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: