Gem Version Build Status Coverage Status

StoreModel

Sponsored by Evil Martians

StoreModel allows to work with JSON-backed database columns in a similar way we work with ActiveRecord models. Supports Ruby >= 2.3 and Rails >= 5.2.

For instance, imagine that you have a model Product with a jsonb column called configuration. Your usual workflow probably looks like:

product = Product.find(params[:id])
if product.configuration["model"] == "spaceship"
  product.configuration["color"] = "red"
end
product.save

This approach works fine when you don't have a lot of keys with logic around them and just read the data. However, when you start working with that data more intensively (for instance, adding some validations around it) - you may find the code a bit verbose and error-prone. With this gem, the snipped above could be rewritten this way:

product = Product.find(params[:id])
if product.configuration.model == "spaceship"
  product.configuration.color = "red"
end
product.save

Installation

Add this line to your application's Gemfile:

gem 'store_model'

And then execute:

$ bundle

Or install it yourself as:

$ gem install store_model

How to register stored model

Start with creating a class for representing the hash as an object:

class Configuration
  include StoreModel::Model

  attribute :model, :string
  attribute :color, :string
end

Attributes should be defined using Rails Attributes API. There is a number of types available out of the box, and you can always extend the type system with your own ones.

Register the field in the ActiveRecord model class:

class Product < ApplicationRecord
  attribute :configuration, Configuration.to_type
end

Handling arrays

Should you store an array of models, you can use #to_array_type method:

class Product < ApplicationRecord
  attribute :configurations, Configuration.to_array_type
end

After that, your attribute will return array of Configuration instances.

Validations

StoreModel supports all the validations shipped with ActiveModel. Start with defining validation for the store model:

class Configuration
  include StoreModel::Model

  attribute :model, :string
  attribute :color, :string

  validates :color, presence: true
end

Then, configure your ActiveRecord model to validates this field as a store model:

class Product < ApplicationRecord
  attribute :configuration, Configuration.to_type

  validates :configuration, store_model: true
end

When attribute is invalid, errors are not copied to the parent model by default:

product = Product.new
puts product.valid? # => false
puts product.errors.messages # => { configuration: ["is invalid"] }
puts product.configuration.errors.messages # => { color: ["can't be blank"] }

You can change this behavior to have these errors on the root level (instead of ["is invalid"]):

class Product < ApplicationRecord
  attribute :configuration, Configuration.to_type

  validates :configuration, store_model: { merge_errors: true }
end

In this case errors look this way:

product = Product.new
puts product.valid? # => false
puts product.errors.messages # => { color: ["can't be blank"] }

You can change the global behavior using StoreModel.config:

StoreModel.config.merge_errors = true

Heads up! Due to the changes of error internals in Rails >= 6.1 it's impossible to add an error with a key that does not have a corresponding attribute with the same name. Because of that, behavior of merge_error strategy will be different - all errors are going to be placed under the attribute name ({ configuration: ["Color can't be blank"] } instead of { color: ["can't be blank"] }).

You can also add your own custom strategies to handle errors. All you need to do is to provide a callable object to StoreModel.config.merge_errors or as value of :merge_errors. It should accept three arguments - attribute, base_errors and store_model_errors:

StoreModel.config.merge_errors = lambda do |attribute, base_errors, _store_model_errors| do
  base_errors.add(attribute, "cthulhu fhtagn")
end

If the logic is complex enough - it worth defining a separate class with a #call method:

class FhtagnErrorStrategy
  def call(attribute, base_errors, _store_model_errors)
    base_errors.add(attribute, "cthulhu fhtagn")
  end
end

You can provide its instance or snake-cased name when configuring global merge_errors:

StoreModel.config.merge_errors = :fhtagn_error_strategy

class Product < ApplicationRecord
  attribute :configuration, Configuration.to_type

  validates :configuration, store_model: { merge_errors: :fhtagn_error_strategy }
end

or when calling validates method on a class level:

StoreModel.config.merge_errors = FhtagnErrorStrategy.new

class Product < ApplicationRecord
  attribute :configuration, Configuration.to_type

  validates :configuration, store_model: { merge_errors: FhtagnErrorStrategy.new }
end

Note: :store_model validator does not allow nils by default, if you want to change this behavior - configure the validation with allow_nil: true:

class Product < ApplicationRecord
  attribute :configuration, Configuration.to_type

  validates :configuration, store_model: true, allow_nil: true
end

Alternatives

  • store_attribute - work with JSON fields as an attributes, defined on the ActiveRecord model (not in the separate class)
  • jsonb_accessor - same thing, but with built-in queries
  • attr_json - works like previous one, but using ActiveModel::Type

License

The gem is available as open source under the terms of the MIT License.