store_model 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f44d7b9166ab63a3f1ea671f59bb152cea49b935111f57096e8669982063cd3b
4
- data.tar.gz: 21d9661a28011ddca555b1ad36d188888de22d23a56fb3e114d6cc552ab649ca
3
+ metadata.gz: 272a27d0773295eeb4628b0cfd205ecd78bb0d53c9abad4f195a26a00582c465
4
+ data.tar.gz: dd1709b12b83f49f2a67ea0d92bd5db7cb61be8699433630a17c22d6d6fccec2
5
5
  SHA512:
6
- metadata.gz: e091ee24a2d81510188a2d42f02946718105c5a1ce8deab5e22b37cae8ad07f1891f6f2366a47858a6e4117b31ae83286a5bf5c70307d1496dab41129a82b177
7
- data.tar.gz: aef72b9a2fe4fbd0fd23ff31376c44a339becd6176f8a131d4fd798dfddbfdc2527b3f966e5c7628fadb0b1bc3141d13bfe47562686c06def80a2fd58eee6b60
6
+ metadata.gz: 1beb4e1bcc06a983e68db54be1d692023f2e56e82824893d5f918c84056ccf23f49d0768219191fb40869850717ffca8a69d36bc1aa0128056ced6efdeed91c1
7
+ data.tar.gz: c9f82ef6a68c480408cb176fe7817a61b8dd069df808f7450c9df3e54059a62a42866989728685af0cb2cdae8b68eeacfa98fbb1a36d324d6e76e6e76e5fe3d1
data/README.md CHANGED
@@ -1,92 +1,54 @@
1
- [![Gem Version](https://badge.fury.io/rb/store_model.svg)](https://rubygems.org/gems/store_model)
2
- [![Build Status](https://travis-ci.org/DmitryTsepelev/store_model.svg?branch=master)](https://travis-ci.org/DmitryTsepelev/store_model)
3
- [![Coverage Status](https://coveralls.io/repos/github/DmitryTsepelev/store_model/badge.svg?branch=master)](https://coveralls.io/github/DmitryTsepelev/store_model?branch=master)
1
+ # 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)
4
2
 
5
- # StoreModel
3
+ **StoreModel** gem allows you to wrap JSON-backed DB columns with ActiveModel-like classes.
6
4
 
7
- <a href="https://evilmartians.com/?utm_source=store_model">
8
- <img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg" alt="Sponsored by Evil Martians" width="236" height="54"></a>
9
-
10
- 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.
11
-
12
- For instance, imagine that you have a model `Product` with a `jsonb` column called `configuration`. Your usual workflow probably looks like:
13
-
14
- ```ruby
15
- product = Product.find(params[:id])
16
- if product.configuration["model"] == "spaceship"
17
- product.configuration["color"] = "red"
18
- end
19
- product.save
20
- ```
21
-
22
- 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:
23
-
24
- ```ruby
25
- product = Product.find(params[:id])
26
- if product.configuration.model == "spaceship"
27
- product.configuration.color = "red"
28
- end
29
- product.save
30
- ```
31
-
32
- ## Installation
33
-
34
- Add this line to your application's Gemfile:
35
-
36
- ```ruby
37
- gem 'store_model'
38
- ```
39
-
40
- And then execute:
41
- ```bash
42
- $ bundle
43
- ```
44
-
45
- Or install it yourself as:
46
- ```bash
47
- $ gem install store_model
48
- ```
49
-
50
- ## How to register stored model
51
-
52
- Start with creating a class for representing the hash as an object:
5
+ - 💪 **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
6
+ - 🔧 **Works like ActiveModel**. Validations, enums and nested attributes work very similar to APIs provided by Rails
7
+ - 1️⃣ **Follows single responsibility principle**. Keep the logic around the data stored in a JSON column separated from the model
8
+ - 👷‍♂️ **Born in production**.
53
9
 
54
10
  ```ruby
55
11
  class Configuration
56
12
  include StoreModel::Model
57
13
 
58
14
  attribute :model, :string
59
- attribute :color, :string
60
- end
61
- ```
62
-
63
- 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.
15
+ enum :status, %i[active archived], default: :active
64
16
 
65
- Register the field in the ActiveRecord model class:
17
+ validates :model, :status, presence: true
18
+ end
66
19
 
67
- ```ruby
68
20
  class Product < ApplicationRecord
69
21
  attribute :configuration, Configuration.to_type
70
22
  end
71
23
  ```
72
24
 
73
- ## Handling arrays
25
+ <p align="center">
26
+ <a href="https://evilmartians.com/?utm_source=store_model">
27
+ <img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg" alt="Sponsored by Evil Martians" width="236" height="54">
28
+ </a>
29
+ </p>
30
+
31
+ ## Why should I wrap my JSON columns?
74
32
 
75
- Should you store an array of models, you can use `#to_array_type` method:
33
+ Imagine that you have a model `Product` with a `jsonb` column called `configuration`. This is how you likely gonna work with this column:
76
34
 
77
35
  ```ruby
78
- class Product < ApplicationRecord
79
- attribute :configurations, Configuration.to_array_type
36
+ product = Product.find(params[:id])
37
+ if product.configuration["model"] == "spaceship"
38
+ product.configuration["color"] = "red"
80
39
  end
40
+ product.save
81
41
  ```
82
42
 
83
- After that, your attribute will return array of `Configuration` instances.
43
+ 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.
44
+
45
+ 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`)?
84
46
 
85
- > 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
47
+ > 📖 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
86
48
 
87
- ## Validations
49
+ ## Getting started
88
50
 
89
- `StoreModel` supports all the validations shipped with `ActiveModel`. Start with defining validation for the store model:
51
+ Start with creating a class for representing the hash as an object:
90
52
 
91
53
  ```ruby
92
54
  class Configuration
@@ -94,113 +56,38 @@ class Configuration
94
56
 
95
57
  attribute :model, :string
96
58
  attribute :color, :string
97
-
98
- validates :color, presence: true
99
- end
100
- ```
101
-
102
- Then, configure your ActiveRecord model to validates this field as a store model:
103
-
104
- ```ruby
105
- class Product < ApplicationRecord
106
- attribute :configuration, Configuration.to_type
107
-
108
- validates :configuration, store_model: true
109
- end
110
- ```
111
-
112
- When attribute is invalid, errors are not copied to the parent model by default:
113
-
114
- ```ruby
115
- product = Product.new
116
- puts product.valid? # => false
117
- puts product.errors.messages # => { configuration: ["is invalid"] }
118
- puts product.configuration.errors.messages # => { color: ["can't be blank"] }
119
- ```
120
-
121
- You can change this behavior to have these errors on the root level (instead of `["is invalid"]`):
122
-
123
- ```ruby
124
- class Product < ApplicationRecord
125
- attribute :configuration, Configuration.to_type
126
-
127
- validates :configuration, store_model: { merge_errors: true }
128
- end
129
- ```
130
-
131
- In this case errors look this way:
132
-
133
- ```ruby
134
- product = Product.new
135
- puts product.valid? # => false
136
- puts product.errors.messages # => { color: ["can't be blank"] }
137
- ```
138
-
139
- You can change the global behavior using `StoreModel.config`:
140
-
141
- ```ruby
142
- StoreModel.config.merge_errors = true
143
- ```
144
-
145
- > 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"] }`).
146
-
147
- 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_:
148
-
149
- ```ruby
150
- StoreModel.config.merge_errors = lambda do |attribute, base_errors, _store_model_errors| do
151
- base_errors.add(attribute, "cthulhu fhtagn")
152
59
  end
153
60
  ```
154
61
 
155
- If the logic is complex enough - it worth defining a separate class with a `#call` method:
156
-
157
- ```ruby
158
- class FhtagnErrorStrategy
159
- def call(attribute, base_errors, _store_model_errors)
160
- base_errors.add(attribute, "cthulhu fhtagn")
161
- end
162
- end
163
- ```
164
-
165
- You can provide its instance or snake-cased name when configuring global `merge_errors`:
166
-
167
- ```ruby
168
- StoreModel.config.merge_errors = :fhtagn_error_strategy
169
-
170
- class Product < ApplicationRecord
171
- attribute :configuration, Configuration.to_type
172
-
173
- validates :configuration, store_model: { merge_errors: :fhtagn_error_strategy }
174
- end
175
- ```
62
+ 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.
176
63
 
177
- or when calling `validates` method on a class level:
64
+ Register the field in the ActiveRecord model class:
178
65
 
179
66
  ```ruby
180
- StoreModel.config.merge_errors = FhtagnErrorStrategy.new
181
-
182
67
  class Product < ApplicationRecord
183
68
  attribute :configuration, Configuration.to_type
184
-
185
- validates :configuration, store_model: { merge_errors: FhtagnErrorStrategy.new }
186
69
  end
187
70
  ```
188
71
 
189
- **Note**: `:store_model` validator does not allow nils by default, if you want to change this behavior - configure the validation with `allow_nil: true`:
72
+ When you're done, the initial snippet could be rewritten in the following way:
190
73
 
191
74
  ```ruby
192
- class Product < ApplicationRecord
193
- attribute :configuration, Configuration.to_type
194
-
195
- validates :configuration, store_model: true, allow_nil: true
75
+ product = Product.find(params[:id])
76
+ if product.configuration.model == "spaceship"
77
+ product.configuration.color = "red"
196
78
  end
79
+ product.save
197
80
  ```
198
81
 
199
- ## Alternatives
82
+ ## Documentation
200
83
 
201
- - [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)
202
- - [jsonb_accessor](https://github.com/devmynd/jsonb_accessor) - same thing, but with built-in queries
203
- - [attr_json](https://github.com/jrochkind/attr_json) - works like previous one, but using `ActiveModel::Type`
84
+ 1. [Installation](./docs/installation.md)
85
+ 2. `StoreModel::Model` API:
86
+ * [Validations](./docs/validations.md)
87
+ * [Enums](./docs/enums.md)
88
+ * [Nested models](./docs/nested_models.md)
89
+ 3. [Array of stored models](./docs/array_of_stored_models.md)
90
+ 4. [Alternatives](./docs/alternatives.md)
204
91
 
205
92
  ## License
206
93
 
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StoreModel
4
+ module Enum
5
+ def enum(name, values = nil, **kwargs)
6
+ values ||= kwargs[:in] || kwargs
7
+
8
+ ensure_hash(values).tap do |mapping|
9
+ define_attribute(name, mapping, kwargs[:default])
10
+ define_reader(name, mapping)
11
+ define_writer(name, mapping)
12
+ define_method("#{name}_value") { attributes[name.to_s] }
13
+ define_method("#{name}_values") { mapping }
14
+ define_predicate_methods(name, mapping)
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def define_attribute(name, mapping, default)
21
+ attribute name, cast_type(mapping), default: default
22
+ end
23
+
24
+ def define_reader(name, mapping)
25
+ define_method(name) { mapping.key(send("#{name}_value")).to_s }
26
+ end
27
+
28
+ def define_writer(name, mapping)
29
+ type = cast_type(mapping)
30
+ define_method("#{name}=") { |value| super type.cast_value(value) }
31
+ end
32
+
33
+ def define_predicate_methods(name, mapping)
34
+ mapping.each do |label, value|
35
+ define_method("#{label}?") { send(name) == mapping.key(value).to_s }
36
+ end
37
+ end
38
+
39
+ def cast_type(mapping)
40
+ StoreModel::Types::EnumType.new(mapping)
41
+ end
42
+
43
+ def ensure_hash(values)
44
+ return values if values.is_a?(Hash)
45
+
46
+ values.zip(0...values.size).to_h
47
+ end
48
+ end
49
+ end
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "store_model/types"
4
+ require "store_model/enum"
5
+ require "store_model/type_builders"
6
+ require "store_model/nested_attributes"
4
7
 
5
8
  module StoreModel
6
9
  module Model
@@ -8,15 +11,9 @@ module StoreModel
8
11
  base.include ActiveModel::Model
9
12
  base.include ActiveModel::Attributes
10
13
 
11
- base.extend(Module.new do
12
- def to_type
13
- Types::JsonType.new(self)
14
- end
15
-
16
- def to_array_type
17
- Types::ArrayType.new(self)
18
- end
19
- end)
14
+ base.extend StoreModel::Enum
15
+ base.extend StoreModel::TypeBuilders
16
+ base.extend StoreModel::NestedAttributes
20
17
  end
21
18
 
22
19
  def as_json(options = {})
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StoreModel
4
+ module NestedAttributes
5
+ def accepts_nested_attributes_for(*associations)
6
+ associations.each do |association|
7
+ alias_method "#{association}_attributes=", "#{association}="
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StoreModel
4
+ module TypeBuilders
5
+ def to_type
6
+ Types::JsonType.new(self)
7
+ end
8
+
9
+ def to_array_type
10
+ Types::ArrayType.new(self)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ module StoreModel
6
+ module Types
7
+ class EnumType < ActiveModel::Type::Value
8
+ def initialize(mapping)
9
+ @mapping = mapping
10
+ end
11
+
12
+ def type
13
+ :integer
14
+ end
15
+
16
+ def cast_value(value)
17
+ return if value.blank?
18
+
19
+ case value
20
+ when String, Symbol then cast_symbol_value(value.to_sym)
21
+ when Integer then cast_integer_value(value)
22
+ else
23
+ raise StoreModel::Types::CastError,
24
+ "failed casting #{value.inspect}, only String, Symbol or " \
25
+ "Integer instances are allowed"
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def cast_symbol_value(value)
32
+ raise_invalid_value!(value) unless @mapping.key?(value.to_sym)
33
+ @mapping[value.to_sym]
34
+ end
35
+
36
+ def cast_integer_value(value)
37
+ raise_invalid_value!(value) unless @mapping.value?(value)
38
+ value
39
+ end
40
+
41
+ def raise_invalid_value!(value)
42
+ raise ArgumentError, "invalid value '#{value}' is assigned"
43
+ end
44
+ end
45
+ end
46
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "store_model/types/json_type"
4
4
  require "store_model/types/array_type"
5
+ require "store_model/types/enum_type"
5
6
 
6
7
  module StoreModel
7
8
  module Types
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StoreModel
4
- VERSION = "0.3.2"
4
+ VERSION = "0.4.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: store_model
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - DmitryTsepelev
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-06-13 00:00:00.000000000 Z
11
+ date: 2019-07-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -96,9 +96,13 @@ files:
96
96
  - lib/store_model/combine_errors_strategies/mark_invalid_error_strategy.rb
97
97
  - lib/store_model/combine_errors_strategies/merge_error_strategy.rb
98
98
  - lib/store_model/configuration.rb
99
+ - lib/store_model/enum.rb
99
100
  - lib/store_model/model.rb
101
+ - lib/store_model/nested_attributes.rb
102
+ - lib/store_model/type_builders.rb
100
103
  - lib/store_model/types.rb
101
104
  - lib/store_model/types/array_type.rb
105
+ - lib/store_model/types/enum_type.rb
102
106
  - lib/store_model/types/json_type.rb
103
107
  - lib/store_model/version.rb
104
108
  homepage: https://github.com/DmitryTsepelev/store_model