Skip to main content
blog

Interacting with JSONB columns like an Active Record-like resource

By May 17, 2024No Comments

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:

  1. it enforces that each stored value is valid according to the JSON rules
  2. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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:

  1. sub-class from ActiveRecord::Base or ApplicationRecord
  2. remove the Active Model modules that were included/extended
  3. convert the company attr_accessor into a belongs_to association
  4. convert the attr_accessors into database table columns
  5. optionally, convert the constants into an ActiveRecord::Enum using the enum class method
  6. remove the define_model_callbacks declaration
  7. 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.

Explore Other Resources

February 14, 2024 in Case Studies, Product Engineering

CLAS – A system that integrates Hubspot, Stripe, Canvas

About This Project Ziplines is a series A-funded ed-tech startup with one goal—helping students attain the real-world skills they need to thrive in careers they love by partnering with universities.…
Read More
September 9, 2024 in blog

Gentle Introduction to Elasticsearch

Elasticsearch is a search engine based on the Lucene library. It provides a distributed, multitenant-capable full-text search engine with an HTTP web interface and schema-free JSON documents. Elasticsearch is developed…
Read More
May 17, 2024 in blog

How to fix “OAuth out-of-band (OOB) flow will be deprecated” error for Google apps API access.

Migrate your OAuth out-of-band flow to an alternative method. Google has announced that they will block the usage of OOB based OAuth starting from January 31, 2023. This has forced…
Read More