StoreModel
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. # => { configuration: ["is invalid"] }
puts product.configuration.errors. # => { 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. # => { 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.