Working with JSONB columns in your Active Record models with Active Model
At times our Active Record models need to store configuration along with the usual data that mutates regularly and has searches to be executed against. This configuration however won’t be searched against so adding it as a JSONB column in your Active Record model makes sense. Using the Active Model API can help simplify how we interact with this new JSONB attribute of our model.
What is JSONB
JSON is JavaScript Object Notation as defined in this RFC. JSONB is a binary data type supported by PostgreSQL that allows us to store our JSON data as a column in our tables. It also gives us two major benefits:
- it enforces that each stored value is valid according to the JSON rules
- processing JSONB is much faster since the it doesn’t need to store insignificant whitespace, duplicate keys and preserve key order
A brief introduction to Active Model
Active Model is the backbone of Active Record, which is the defacto ORM that ships with Rails, comes with. It may be used to build custom ORMs for use outside of the Rails framework. Including the ActiveModel::API
module into our classes gives us features present on Active Record to be used as instance/class methods.
An example would be:
require 'active_model'
require 'active_support/core_ext/integer/time'
class Dog
include ActiveModel::API
attr_accessor :name, :age, :breed
BREEDS = %i[corgi labrador maltese].freeze
validates :name, :breed, :age, presence: true
validates :breed, inclusion: { in: BREEDS }
validates :age, numericality: { only_integer: true },
inclusion: { in: 1.year..12.years }
end
And then in IRB:
irb> dog = Dog.new name: "Milo",
breed: :corgi,
age: 2.years
irb> dog.name
=> "Milo"
irb> dog.breed
=> :corgi
irb> dog.valid?
=> true
The important thing to note from Rails docs is this:
Any class that includes ActiveModel::API
can be used with form_with
, render
and any other Action View helper methods, just like Active Record objects.
Using Active Model with JSONB
Before diving into the Active Model modules, a brief discussion of the end-result that we want to achieve is required.
We have a Company
Active Record model for which we want to store some Pricing related configuration such as the Software Edition they want, their preferred Billing Cycle and a custom Software Edition Price that they have negotiated.
Since this is just configuration we add a pricing
JSONB column to the companies
table and create a Pricing
class that we shall use to interact with the column in a resourceful manner.
ActiveModel::API
When ActiveModel::API
is included in our class, we get access to Active Record’s validations DSL. We also get access to model name introspection, which will be useful later when we write forms using the form_with
helper.
Some other goodies we get with this module are listed in this section of the guides.
We can now add validations to the JSON keys’ values similar to Active Record.
class Pricing
include ActiveModel::API
attr_accessor :software_edition,
:billing_cycle,
:software_edition_price,
:company
SOFTWARE_EDITION = [
:demo, 'demo',
:basic, 'basic',
:team, 'team',
:enterprise, 'enterprise'
].freeze
BILLING_CYCLE = [
:monthly, 'monthly',
:yearly, 'yearly'
].freeze
validates :software_edition, :presence: true, inclusion: { in: SOFTWARE_EDITION }
validates :billing_cycle, presence: true, inclusion: { in: BILLING_CYCLE }
validates :software_edition_price, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :company, presence: true
def initialize(args = {})
super
@errors = ActiveModel::Errors.new(self)
end
end
We override the initialize
method so that we can keep track of the model errors in the @errors
instance variable.
We can now also use the valid?
method to verify that our Pricing
instances are valid. We shall use it later when we attempt to persist the changes back the Company
model’s pricing
column.
ActiveModel::Serialization
Including the ActiveModel::Serialization
module gives us access to the serializable_hash
, which we can use to access a Hash of only those attributes that we want to save. We need to define an attributes
that returns a hash which contains the attributes we want to serialize/save.
class Pricing
.
.
.
include ActiveModel::Serialization
def attributes
{
'software_edition' => nil,
'billing_cycle' => nil,
'software_edition_price' => nil
}
end
end
Note how we skipped declaring the company
as a key in the attributes
Hash since we won’t be saving that.
ActiveModel::Callbacks
ActiveModel::Callbacks
module needs to be extended which gives us access to the define_model_callbacks
class method that we can use to configure any sort of callback. We can mimic the before_save
callback like so:
class Pricing
.
.
.
extend ActiveModel::Callbacks
define_model_callbacks :save
before_save :normalize_software_edition_price
def save
if valid?
run_callbacks(:save) do
# run code to persist the data into the `pricing` JSONB column
end
else
false
end
end
private
def normalize_software_edition_price
self.software_edition_price = software_edition_price.to_f.round 2
end
end
Here, we introduced the save
method into our Pricing
class. We could have configured callbacks with define_model_callbacks :foo
which would have created the before_foo
, after_foo
and around_foo
class methods to register the callback methods.
You would still need to use the run_callbacks(:foo)
method somewhere in your class otherwise no registered callback method will be called.
ActiveModel::Dirty
The ActiveModel::Dirty
module helps us track changes done to our attribute relative to their initial state. We can use this to implement business logic in our Pricing
class.
For example, we can now implement a business logic where we only allow Billing Cycle to be changed on the 1st of every month. It will require some changes to how we define the specific instance attribute’s getter and setter methods.
class Pricing
attr_accessor :software_edition,
:software_edition_price,
:company
.
.
.
include ActiveModel::Dirty
attr_reader :billing_cycle
define_attribute_methods :billing_cycle
validate :billing_cycle_not_changed_before_or_after_1st
def initialize(args = {})
super
@errors = ActiveModel::Errors.new(self)
clear_attribute_changes(['billing_cycle']) # since this will be initialized we don't want to track changes yet
end
def billing_cycle=(value)
billing_cycle_will_change! if value != @billing_cycle
@billing_cycle = value
end
private
def billing_cycle_not_changed_before_or_after_1st
if billing_cycle_changed? &&
Date.today != Date.today.beginning_of_month
errors.add(:billing_cycle, :cant_be_changed_today, message: "can't be changed today")
end
end
end
We also had to use the clear_attribute_changes
method to track changes to billing_cycle
only AFTER initializing the class.
Bringing it all together
Finally, our class would look something like this:
class Pricing
include ActiveModel::API
include ActiveModel::Serialization
extend ActiveModel::Callbacks
include ActiveModel::Dirty
attr_accessor :software_edition,
:software_edition_price,
:company
attr_reader :billing_cycle
define_attribute_methods :billing_cycle
SOFTWARE_EDITION = [
:demo, 'demo',
:basic, 'basic',
:team, 'team',
:enterprise, 'enterprise'
].freeze
BILLING_CYCLE = [
:monthly, 'monthly',
:yearly, 'yearly'
].freeze
validates :software_edition, :presence: true, inclusion: { in: SOFTWARE_EDITION }
validates :billing_cycle, presence: true, inclusion: { in: BILLING_CYCLE }
validates :software_edition_price, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :company, presence: true
define_model_callbacks :save
before_save :normalize_software_edition_price
def initialize(args = {})
super
@errors = ActiveModel::Errors.new(self)
clear_attribute_changes(['billing_cycle']) # since this will be initialized we don't want to track changes yet
end
def save
if valid?
run_callbacks(:save) do
company.pricing = serializable_hash
company.save
end
else
false
end
end
def update(attributes = {})
assign_attributes(attributes) if attributes
save
end
def attributes
{
'software_edition' => nil,
'billing_cycle' => nil,
'software_edition_price' => nil
}
end
private
def normalize_software_edition_price
self.software_edition_price = software_edition_price.to_f.round 2
end
end
Note the update
method depends on the save
method.
We may now use this to interact with our Company
model’s pricing
column in a resourceful manner.
We add an instance method to Company
to interact with the pricing
column:
class Company < ApplicationRecord
.
.
.
def pricing_details
default_pricing = {
software_edition: nil,
billing_cycle: nil,
software_edition_price: nil
}
Pricing.new({
company: self
}.merge(pricing.reverse_merge(default_pricing)))
end
end
An example implementation of the controller for the Pricing
class:
class PricingController < ApplicationController
before_action :set_company
def show
@pricing = @company.pricing_details
end
def edit
@pricing = @company.pricing_details
end
def update
@pricing = @company.pricing_details
if @pricing.update pricing_params
flash[:success] = 'Pricing was successfully updated.'
redirect_to pricing_company_url(@company)
else
render :edit, status: :unprocessable_entity
end
end
private
def set_company
@company = Company.find params[:id]
end
def pricing_params
params.require(:pricing).permit :software_edition,
:billing_cycle,
:software_edition_price
end
end
And the form, made with the help of form_with
Action View helper:
<%= form_with model: @pricing, url: update_pricing_company_path(@company), method: :put do |f| %>
<% if @pricing.errors.any? %>
<ul>
<% @plan.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
<% end %>
<%= f.label :software_edition, "Software Edition:" %>
<%= f.select :software_edition, Pricing::SOFTWARE_EDITION.filter { |software_edition| software_edition.is_a?(String) }.map { |software_edition| [ software_edition.humanize.titleize, software_edition ] }, include_blank: true %>
<%= f.label :billing_cycle, "Billing Cycle:" %>
<%= f.select :billing_cycle, Pricing::BILLING_CYCLE.filter { |billing_cycle| billing_cycle.is_a?(String) }.map { |billing_cycle| [ billing_cycle.humanize.titleize, billing_cycle ] }, include_blank: true %>
<%= f.label :software_edition_price, 'Software Edition Price:' %>
<%= f.text_field :software_edition_price %>
<%= link_to 'Cancel', pricing_company_path(@company) %>
<%= f.submit 'Update' %>
<% end %>
If you inspect the rendered HTML, you will see that the input fields and drop-downs have the name as “pricing[<attribute name>]” which is similar to what we get with an Active Record model too!
Promotion to a fully-fledged Active Record model/resource
Using Active Model gives us a path to later on promote our Pricing
class as an Active Record model if it makes sense, for example if we want to search against any of the fields in the JSONB columns. We might have to do the following changes:
- sub-class from
ActiveRecord::Base
orApplicationRecord
- remove the Active Model modules that were included/extended
- convert the
company
attr_accessor
into abelongs_to
association - convert the
attr_accessors
into database table columns - optionally, convert the constants into an
ActiveRecord::Enum
using theenum
class method - remove the
define_model_callbacks
declaration - remove the
initialize
,save
,update
,attributes
methods since those will be handled now by Active Record
The rest of the controller and view code should not require too much change.
Conclusion
Using Active Model as a tool to DRY up code and use familiar DSL can be a really awesome way to keep the controllers small and simple to reason about. Using already built tools in the Rails toolbox certainly follows the Rails doctrine of optimizing for programmer happiness.