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. - -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: - -```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 +

+ + Sponsored by Evil Martians + +

-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 # => # -``` - -You can specify values explicitly using the `:in` kwarg: - -```ruby -class Review - include StoreModel::Model - - enum :rating, in: { excellent: 100, okay: 50, bad: 25, awful: 10 }, default: :okay -end -``` - -## Nested Models - -In some cases you might need to have a stored model as an attribute of another one: - -```ruby -class Supplier - include StoreModel::Model - - attribute :title, :string -end - -class Configuration - include StoreModel::Model - - attribute :supplier, Supplier.to_type +product = Product.find(params[:id]) +if product.configuration.model == "spaceship" + product.configuration.color = "red" end +product.save ``` -In order to make it work with nested Rails forms (i.e. define a method called `#{attribute_name}_attributes=`) you should add `accepts_nested_attributes_for :supplier`, which works in the exact same way as the [built-in Rails method](https://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html), to the parent model. - -## Alternatives +## Documentation -- [store_attribute](https://github.com/palkan/store_attribute)–work with JSON fields as an attributes, defined on the ActiveRecord model (not in the separate class) -- [jsonb_accessor](https://github.com/devmynd/jsonb_accessor)–same thing, but with built-in queries -- [attr_json](https://github.com/jrochkind/attr_json)–works like previous one, but using `ActiveModel::Type` +1. [Installation](./docs/installation.md) +2. `StoreModel::Model` API: + * [Validations](./docs/validations.md) + * [Enums](./docs/enums.md) + * [Nested models](./docs/nested_models.md) +3. [Array of stored models](./docs/array_of_stored_models.md) +4. [Alternatives](./docs/alternatives.md) ## License diff --git a/docs/alternatives.md b/docs/alternatives.md new file mode 100644 index 0000000..a3db78d --- /dev/null +++ b/docs/alternatives.md @@ -0,0 +1,5 @@ +## Alternatives + +- [store_attribute](https://github.com/palkan/store_attribute)–work with JSON fields as an attributes, defined on the ActiveRecord model (not in the separate class) +- [jsonb_accessor](https://github.com/devmynd/jsonb_accessor)–same thing, but with built-in queries +- [attr_json](https://github.com/jrochkind/attr_json)–works like previous one, but using `ActiveModel::Type` diff --git a/docs/array_of_stored_models.md b/docs/array_of_stored_models.md new file mode 100644 index 0000000..4fa4199 --- /dev/null +++ b/docs/array_of_stored_models.md @@ -0,0 +1,13 @@ +## Array of stored models + +Should you store an array of stored models, use `#to_array_type` method: + +```ruby +class Product < ApplicationRecord + attribute :configurations, Configuration.to_array_type +end +``` + +After that, your attribute will return an array of `Configuration` instances. + +> **Heads up!** The attribute is not the same as the 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. diff --git a/docs/enums.md b/docs/enums.md new file mode 100644 index 0000000..df9323c --- /dev/null +++ b/docs/enums.md @@ -0,0 +1,37 @@ +## 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: + +```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 # => # +``` + +You can specify values explicitly using the `:in` kwarg: + +```ruby +class Review + include StoreModel::Model + + enum :rating, in: { excellent: 100, okay: 50, bad: 25, awful: 10 }, default: :okay +end +``` diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..54048f9 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,17 @@ +## 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 +``` diff --git a/docs/nested_models.md b/docs/nested_models.md new file mode 100644 index 0000000..241e5dc --- /dev/null +++ b/docs/nested_models.md @@ -0,0 +1,19 @@ +## Nested Models + +In some cases, you might need to have a stored model as an attribute of another one: + +```ruby +class Supplier + include StoreModel::Model + + attribute :title, :string +end + +class Configuration + include StoreModel::Model + + attribute :supplier, Supplier.to_type +end +``` + +To make it work with nested Rails forms (i.e., define a method called `#{attribute_name}_attributes=`) you should add `accepts_nested_attributes_for :supplier`, which works in the same way as the [built-in Rails method](https://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html), to the parent model. diff --git a/docs/validations.md b/docs/validations.md new file mode 100644 index 0000000..8945387 --- /dev/null +++ b/docs/validations.md @@ -0,0 +1,111 @@ +## Validations + +`StoreModel` supports all the validations shipped with `ActiveModel`. Start with defining validation for the store model: + +```ruby +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: + +```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`: + +```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, the 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`: + +```ruby +class Product < ApplicationRecord + attribute :configuration, Configuration.to_type + + validates :configuration, store_model: true, allow_nil: true +end +```