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 +4 -4
- data/README.md +42 -155
- data/lib/store_model/enum.rb +49 -0
- data/lib/store_model/model.rb +6 -9
- data/lib/store_model/nested_attributes.rb +11 -0
- data/lib/store_model/type_builders.rb +13 -0
- data/lib/store_model/types/enum_type.rb +46 -0
- data/lib/store_model/types.rb +1 -0
- data/lib/store_model/version.rb +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 272a27d0773295eeb4628b0cfd205ecd78bb0d53c9abad4f195a26a00582c465
|
4
|
+
data.tar.gz: dd1709b12b83f49f2a67ea0d92bd5db7cb61be8699433630a17c22d6d6fccec2
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1beb4e1bcc06a983e68db54be1d692023f2e56e82824893d5f918c84056ccf23f49d0768219191fb40869850717ffca8a69d36bc1aa0128056ced6efdeed91c1
|
7
|
+
data.tar.gz: c9f82ef6a68c480408cb176fe7817a61b8dd069df808f7450c9df3e54059a62a42866989728685af0cb2cdae8b68eeacfa98fbb1a36d324d6e76e6e76e5fe3d1
|
data/README.md
CHANGED
@@ -1,92 +1,54 @@
|
|
1
|
-
[](https://rubygems.org/gems/store_model)
|
2
|
-
[](https://travis-ci.org/DmitryTsepelev/store_model)
|
3
|
-
[](https://coveralls.io/github/DmitryTsepelev/store_model?branch=master)
|
1
|
+
# StoreModel [](https://rubygems.org/gems/store_model) [](https://travis-ci.org/DmitryTsepelev/store_model) [](https://coveralls.io/github/DmitryTsepelev/store_model?branch=master)
|
4
2
|
|
5
|
-
|
3
|
+
**StoreModel** gem allows you to wrap JSON-backed DB columns with ActiveModel-like classes.
|
6
4
|
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
79
|
-
|
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
|
-
|
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
|
-
>
|
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
|
-
##
|
49
|
+
## Getting started
|
88
50
|
|
89
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
72
|
+
When you're done, the initial snippet could be rewritten in the following way:
|
190
73
|
|
191
74
|
```ruby
|
192
|
-
|
193
|
-
|
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
|
-
##
|
82
|
+
## Documentation
|
200
83
|
|
201
|
-
|
202
|
-
|
203
|
-
|
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
|
data/lib/store_model/model.rb
CHANGED
@@ -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
|
12
|
-
|
13
|
-
|
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,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
|
data/lib/store_model/types.rb
CHANGED
data/lib/store_model/version.rb
CHANGED
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.
|
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-
|
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
|