diff --git a/README.md b/README.md index 31ae675..bb48e05 100644 --- a/README.md +++ b/README.md @@ -1,92 +1,54 @@ -[![Gem Version](https://badge.fury.io/rb/store_model.svg)](https://rubygems.org/gems/store_model) -[![Build Status](https://travis-ci.org/DmitryTsepelev/store_model.svg?branch=master)](https://travis-ci.org/DmitryTsepelev/store_model) -[![Coverage Status](https://coveralls.io/repos/github/DmitryTsepelev/store_model/badge.svg?branch=master)](https://coveralls.io/github/DmitryTsepelev/store_model?branch=master) +# StoreModel [![Gem Version](https://badge.fury.io/rb/store_model.svg)](https://rubygems.org/gems/store_model) [![Build Status](https://travis-ci.org/DmitryTsepelev/store_model.svg?branch=master)](https://travis-ci.org/DmitryTsepelev/store_model) [![Coverage Status](https://coveralls.io/repos/github/DmitryTsepelev/store_model/badge.svg?branch=master)](https://coveralls.io/github/DmitryTsepelev/store_model?branch=master) -# StoreModel +**StoreModel** gem allows you to wrap JSON-backed DB columns with ActiveModel-like classes. - - - -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: - -```ruby -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: - -```ruby -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: - -```ruby -gem 'store_model' -``` - -And then execute: -```bash -$ bundle -``` - -Or install it yourself as: -```bash -$ gem install store_model -``` - -## How to register stored model - -Start with creating a class for representing the hash as an object: +- 💪 **Powered with [Attributes API](https://api.rubyonrails.org/classes/ActiveRecord/Attributes/ClassMethods.html)**. You can use a number of familiar types or write your own +- 🔧 **Works like ActiveModel**. Validations, enums and nested attributes work very similar to APIs provided by Rails +- 1️⃣ **Follows single responsibility principle**. Keep the logic around the data stored in a JSON column separated from the model +- 👷♂️ **Born in production**. ```ruby class Configuration include StoreModel::Model attribute :model, :string - attribute :color, :string -end -``` - -Attributes should be defined using [Rails Attributes API](https://api.rubyonrails.org/classes/ActiveRecord/Attributes/ClassMethods.html). There is a number of types available out of the box, and you can always extend the type system with your own ones. + enum :status, %i[active archived], default: :active -Register the field in the ActiveRecord model class: + validates :model, :status, presence: true +end -```ruby 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: +## Why should I wrap my JSON columns? + +Imagine that you have a model `Product` with a `jsonb` column called `configuration`. This is how you likely gonna work with this column: ```ruby -class Product < ApplicationRecord - attribute :configurations, Configuration.to_array_type +product = Product.find(params[:id]) +if product.configuration["model"] == "spaceship" + product.configuration["color"] = "red" end +product.save ``` -After that, your attribute will return array of `Configuration` instances. +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–you may find the code a bit verbose and error-prone. -> **Heads up!** Attribute is not the same as association, in this case–it's just a hash. `assign_attributes` (and similar) is going to _override_ the whole hash, not merge it with a previous value +For instance, try to find a way to validate `:model` value to be required. Despite of the fact, that you'll have to write this validation by hand, it violates single-repsponsibility principle: why parent model (`Product`) should know about the logic related to a child (`Configuration`)? -## Validations +> 📖 Read more about the motivation in the [Wrapping JSON-based ActiveRecord attributes with classes](https://dev.to/evilmartians/wrapping-json-based-activerecord-attributes-with-classes-4apf) post -`StoreModel` supports all the validations shipped with `ActiveModel`. Start with defining validation for the store model: +## Getting started + +Start with creating a class for representing the hash as an object: ```ruby class Configuration @@ -94,171 +56,38 @@ class Configuration attribute :model, :string attribute :color, :string - - validates :color, presence: true -end -``` - -Then, configure your ActiveRecord model to validates this field as a store model: - -```ruby -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: - -```ruby -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"]`): - -```ruby -class Product < ApplicationRecord - attribute :configuration, Configuration.to_type - - validates :configuration, store_model: { merge_errors: true } end ``` -In this case errors look this way: - -```ruby -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`: +Attributes should be defined using [Rails Attributes API](https://api.rubyonrails.org/classes/ActiveRecord/Attributes/ClassMethods.html). There is a number of types available out of the box, and you can always extend the type system. -```ruby -StoreModel.config.merge_errors = true -``` - -> **Heads up!** Due to the [changes](https://github.com/rails/rails/pull/32313) 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_: - -```ruby -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: - -```ruby -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`: - -```ruby -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: - -```ruby -StoreModel.config.merge_errors = FhtagnErrorStrategy.new - -class Product < ApplicationRecord - attribute :configuration, Configuration.to_type - - validates :configuration, store_model: { merge_errors: FhtagnErrorStrategy.new } -end -``` - -> **Heads up!** `:store_model` validator does not allow nils by default, if you want to change this behavior–configure the validation with `allow_nil: true`: +Register the field in the ActiveRecord model class: ```ruby class Product < ApplicationRecord attribute :configuration, Configuration.to_type - - validates :configuration, store_model: true, allow_nil: true end ``` -## Enums - -If you worked with [Rails Enums](https://api.rubyonrails.org/v5.2.3/classes/ActiveRecord/Enum.html) or [enumerize](https://github.com/brainspec/enumerize)–built-in enums should look familiar to you: +When you're done, the initial snippet could be rewritten in the following way: ```ruby -class Configuration - include StoreModel::Model - - enum :status, %i[active archived], default: :active -end - -config = Configuration.new -config.status => # active - -config.status = :archived -config.archived? # => true -config.active? # => false -config.status_value # => 0 - -config.status_values # => { :active => 0, :archived => 1 } -``` - -Under the hood values are stored as integers, according to the index of the element in the array: - -```ruby -Configuration.new.inspect # => #