Profile picture Schedule a Meeting
c a n d l a n d . n e t

Rails Typed JSON Fields

Dusty Candland | | rails, rails6, ruby, postgresql

I've been using Rails JSON Serialized Fields for custom objects stored in Postgresql JSON fields. I found this approach and like it a lot more. It uses ActiveModel::Type::Value to create a custom type mapping for ActiveRecord.

The goal is to store JSON objects in Postgresql and have them strongly typed and validated in Rails.

For this example MyRecord will be the ActiveRecord model. We want to store MyModel as JSON in the database. The my_model attribute will store one MyModel object and the my_models attribute will store an array of MyModel objects.

The Model

This is the ActiveModel object we want to store in a JSON field.

# app/models/my_model.rb
class MyModel
  include ActiveModel::Model
  include ActiveModel::Serialization
  include ActiveModel::Attributes
  include ActiveModel::AttributeMethods
  extend ActiveModel::Naming

  attribute :code, :string
  attribute :severity, :integer

  validates :code, presence: true

  alias to_hash serializable_hash

  def persisted?
    false
  end

  def id
    nil
  end

  def to_s
    code
  end
end

Custom Types

https://www.rubydoc.info/gems/activerecord/ActiveRecord/Type/Json

Create custom types for the MyModel class. More details here ActiveModel::Type::Value. I used the existing ActiveRecord::Type::Json class with an overridden deserialize method to return the MyModel class.

Objects

# app/types/my_model_type.rb
class MyModelType < ActiveRecord::Type::Json
  def deserialize raw_value
    value = super # turns raw json string into array of hashes
    if value.is_a? Hash
      MyModel.new(value)
    else
      value
    end
  end
end

Arrays

Arrays where a bit tricky to figure out. This StackOverflow post helped a ton. That post uses JSONb. I changed to JSON for my needs.

# app/types/my_model_array_type.rb
class MyModelArrayType < ActiveRecord::Type::Json
  def deserialize raw_value
    value = super # turns raw json string into array of hashes
    if value.is_a? Array
      value.map { |h| MyModel.new(h) } # turns array of hashes into array of Variables
    else
      value
    end
  end
end

Type Registration

Starting with Rails 6, there is some autoloading changes that require these be registered in the reloader.to_prepare callback. More discusson

# config/initializers/types.rb
Rails.application.reloader.to_prepare do
  ActiveRecord::Type.register(:my_model, MyModelType)
  ActiveRecord::Type.register(:my_model_array, MyModelArrayType)
end

Using the Types

Now that we have custom types registered we can use them with the ActiveRecord::Attributes api.

# app/models/my_record.rb
class MyRecord < ApplicationRecord
  attribute :my_model, :my_model
  attribute :my_models, :my_model_array, default: []

  validates :my_model, serialized: true
  validates :my_model_array, serialized: true, presence: {message: "Please provide at least one MyModel."}

  # ...
end

The serialized validator is described in Rails JSON Serialized Fields - Validation.

Database Fields

class AddMyModelToMyRecord < ActiveRecord::Migration[6.0]
  def change
    add_column :my_record, :my_model, :json
    add_column :my_record, :my_models, :json, default: []
  end
end

Why I like this

  1. We don't need custom concerns to serialize the object, since it's actually stored as JSON. If you're not using Postgresql, then the serialized approach might be better.
  2. We can rely on more existing Rails code.
  3. Feels a bit cleaner.

Webmentions

These are webmentions via the IndieWeb and webmention.io. Mention this post from your site: