Martian Chronicles
Evil Martians’ team blog
Back-end

Wrapping JSON-based ActiveRecord attributes with classes

This post will teach you how to wrap JSON-based DB columns with ActiveModel-like classes easily.

Have you ever tried to denormalize your DB structure a little bit by using a JSON-based column? It goes without saying that great power flexibility comes with great responsibility, but in some cases, this approach does a good job and simplifies things a lot.

For instance, imagine that you are building a feature for yet another ecommerce site, which allows users to filter products by a set of rules and order them automatically each night. Obviously, we want to specify the count of products to order, but how can we represent the filtering rules (e.g., min/max price, publication date, etc.)? We could have a column for each rule, but JSON column looks more promising. We are not planning to filter by any of these columns; however, there is a big chance that we will add more rules in future and JSON column allows us to avoid migrating our database each time.

This is how we usually add JSONB column to the PostgreSQL table:

create_table :auto_buyers do |t|
  t.integer :count, null: false
  t.jsonb :rules, null: false, default: {}
end

This attribute is just a hash:

buyer = AutoBuyer.create(count: 1, rules: { max_price: 10 })

if item.price < buyer.rules["max_price"]
  # buy item...
end

What if we want something more complex, for instance, validate such fields? This looks a bit messy:

class AutoBuyer < ApplicationRecord
  validate do
    if rules["max_price"] && rules["min_price"] && rules["min_price"] > rules["max_price"]
      errors.add(:max_price, "should be greater then min price")
    end
  end
end

Imagine that ActiveRecord model has its behavior, and we are adding more complexity with such validations—in this case, it might make sense to move the logic around the JSON attribute to the separate class, and it would also be nice to work with attributes, not the hash. Attributes API can save us!

However, the documentation is confusing enough, and it’s hard to get things right from the first attempt. Let’s try to do it start with defining the class for representing our hash as an object:

class AutoBuyerRules
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :min_price, :integer
  attribute :max_price, :integer
end

After that, we need to define the attribute class to wrap up the hash to this class:

class AutoBuyerRulesType < ActiveModel::Type::Value
  def type
    :jsonb
  end

  # rubocop:disable Style/RescueModifier
  def cast_value(value)
    case value
    when String
      decoded = ActiveSupport::JSON.decode(value) rescue nil
      AutoBuyerRules.new(decoded) unless decoded.nil?
    when Hash
      AutoBuyerRules.new(value)
    when AutoBuyerRules
      value
    end
  end
  # rubocop:enable Style/RescueModifier

  def serialize(value)
    case value
    when Hash, AutoBuyerRules
      ActiveSupport::JSON.encode(value)
    else
      super
    end
  end

  def changed_in_place?(raw_old_value, new_value)
    cast_value(raw_old_value) != new_value
  end
end

And finally, let’s tell our ActiveRecord model to use this new attribute and try it out:

class AutoBuyer < ApplicationRecord
  attribute :rules, AutoBuyerRulesType.new
end

buyer = AutoBuyer.create(count: 1, rules: { max_price: 10 })

if item.price < buyer.rules.max_price
  # buy item...
end

It looks way better, but should we use so much code each time we want to get this behavior? Not really, please welcome store_model gem!

To make the long story short (you can find the full story at the README) let’s take a look at the same example:

class AutoBuyerRules
  include StoreModel::Model

  attribute :min_price, :integer
  attribute :max_price, :integer

  validate :min_price, presence: true
end

class AutoBuyer < ApplicationRecord
  attribute :rules, AutoBuyerRules.to_type

  validate :rules, store_model: { merge_errors: true }
end

buyer = AutoBuyer.new
puts buyer.valid? # => false
puts buyer.errors.messages # => { min_price: ["can't be blank"] }

That’s it, you don’t need to include a ton of modules or define any custom types by hand! It also comes with validation support—you can validate store model fields and then merge these errors to the parent record (or define any custom strategy).

Let’s recap:

  • JSON columns are good for the data that change often.
  • You can handle them as hashes, but it can be verbose.
  • You can use Attribute API to wrap them with objects or use store_model gem.
  • If you want to do the same thing, but have attributes defined on the ActiveRecord model itself—consider using store_attribute or jsonb_accessor.
Humans! We come in peace and bring cookies. We also care about your privacy: if you want to know more or withdraw your consent, please see the Privacy Policy.