Posted in Martian Chronicles
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 e-commerce 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 own 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 long store 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
Read more dev articles on https://evilmartians.com/chronicles!