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.