Rails JSON Serialized Fields
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.
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
Update: Changed from
to_json
toas_json
so that JSON objects end up in the DB.
# app/models/concerns/json_serializable.rb
module JsonSerializable
extend ActiveSupport::Concern
class_methods do
def load(json)
return self.new if json.blank?
# return nil if json.blank? # Use this if nil objects are okay
self.new(json)
end
def dump(obj)
if obj.respond_to? :as_json
obj.as_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: