store_model 0.3.2 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
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