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 +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
|
-
[![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
|
-
|
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
|