Rails JSON Serialized Fields

Dusty Candland | | rails, rails5, simple_form, factory_bot

Using Rails 5 to serialize objects into a JSON field in the DB. We're gonna assume we want to store some options as a JSON field on a User model. And we want an Options model to work with in code.

The Models

The User model needs to know to serialize #options as Options.

# app/models/user.rb
class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable and :omniauthable
# TODO omniauth
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :trackable, :validatable,
:confirmable, :lockable

serialize :options, Options

def to_s
email
end
end

Create a migration to support the field. Postgres supports JSON, not sure about other DBs.

class AddOptionsToUser < ActiveRecord::Migration[5.2]
def change
add_column :users, :options, :json, default: {}
end
end

Now the fun begins! We need a model that isn't an ActiveRecord, but acts like one. We're only gonna have two options for now.

We'll look at those first two includes in a minute. The others are from ActiveModel. ActiveModel::Attributes allows us to use the attribute method to type the columns. This will help with casting from params and we'll use this for simple_form too.

# app/models/options.rb
class Options
include JsonSerializable
include TypedModel
include ActiveModel::Model
include ActiveModel::Serialization
include ActiveModel::Attributes
include ActiveModel::AttributeMethods
extend ActiveModel::Naming

attribute :newsletter, :boolean, default: false
attribute :opted_at, :datetime, default: false

alias :to_hash :serializable_hash

def persisted?
false
end

def id
nil
end

end

Concerns

The first concern is for serializing to JSON. Rails might not actually need this, but it seems that FactoryBot does. Adapted from json serialized columns with rails

# app/models/concerns/json_serializable.rb
module JsonSerializable
extend ActiveSupport::Concern

class_methods do
def load(json)
return self.new if json.blank?

self.new(JSON.parse(json))
end

def dump(obj)
if obj.respond_to? :to_json
obj.to_json
else
raise StandardError, "Expected #{self}, got #{obj.class}"
end
end
end
end

The second concern is specifically so that simple_form can figure out what type of input to create. There's probably a better way to not def these methods twice, but ActiveRecord has them on both the class and the instance.

# app/models/concerns/typed_model.rb
module TypedModel
extend ActiveSupport::Concern

def type_for_attribute(attr_name, &block)
attr_name = attr_name.to_s
if block
self.class.attribute_types.fetch(attr_name, &block)
else
self.class.attribute_types[attr_name]
end
end
alias :column_for_attribute :type_for_attribute

def has_attribute? attr_name
self.class.attribute_types.include? attr_name.to_s
end

class_methods do
def type_for_attribute(attr_name, &block)
attr_name = attr_name.to_s
if block
attribute_types.fetch(attr_name, &block)
else
attribute_types[attr_name]
end
end
alias :column_for_attribute :type_for_attribute

def has_attribute? attr_name
attribute_types.include? attr_name.to_s
end
end
end

Controller

In the controller, we just need to permit the params.

# app/controllers/user_controller.rb
def user_params
params.require(:user).permit(:email, ..., options: {})
end

Tests

To use a model like this with factory_bot we just need to add a skip_create to the definition.

# test/factories/user.rb
FactoryBot.define do
factory :user do
email { "user@example.com" }
password { "testing" }
password_confirmation { "testing" }
confirmed_at { Time.zone.now }
options
end
end

# test/factories/options.rb
FactoryBot.define do
factory :options do
newsletter { true }
opted_at { DateTime.now }

skip_create
end
end

For good measure, we'll test the model acts like an ActiveModel.

# test/models/options_test.rb
require 'test_helper'

class OptionsTest < ActiveSupport::TestCase
include ActiveModel::Lint::Tests

setup do
@model = Options.new
end
end

In the controller tests, just pass the options as an embedded hash.

The Form

Now we can use the options to the form and have them nicely formatted. Note that we pass the instance to simple_fields_for, not the symbol to options.

-# app/views/users/_form.html.slim
= simple_form_for(@aircraft) do |f|
  = f.error_notification
  = f.error_notification message: f.object.errors[:base].to_sentence if f.object.errors[:base].present?

  .form-inputs
    = f.input :email
    = f.input :etc

    = field_set_tag "Options" do
      = f.simple_fields_for @user.options do |options_form|
        = options_form.input :newsletter
        = options_form.input :opted_at

  .form-actions
    = f.button :submit

Validations

I think validations should work, but I didn't dig into them yet. I might need to setup something on the User model to make sure Options is validated.


Webmentions

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