store_model 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1f5848ec2d1688ab965fe1c960c300ef7834270c433555cd51b09969a1b84ac2
4
+ data.tar.gz: 399e0250321b8ac5d470a0c52ae7ca9ca50a125b2a96e06c7eb2dd322a76baeb
5
+ SHA512:
6
+ metadata.gz: 4638217fbbbb70dd9a649942dd7d3f28d0bc9b3c5a6e7fa3461e8d77b4f8c7575b566c4f525bd71bf6e05fc4e993a7dd08bc6fc49e9c52afed900d4f45bf2f83
7
+ data.tar.gz: bebdc47d1101a98e7c49bab39c18e973b3f88818860ab37deabb5f0c54beba04c5322167c680504b68c1551390022f5d853f8eb7395895a8c0073a9493c4f045
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2019 DmitryTsepelev
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,181 @@
1
+ # Disclaimer
2
+
3
+ **This gem hasn't been released yet! I'm going to force push a lot, be careful**
4
+
5
+ [![Gem Version](https://badge.fury.io/rb/store_model.svg)](https://rubygems.org/gems/store_model)
6
+ [![Build Status](https://travis-ci.org/DmitryTsepelev/store_model.svg?branch=master)](https://travis-ci.org/DmitryTsepelev/store_model)
7
+ [![Coverage Status](https://coveralls.io/repos/github/DmitryTsepelev/store_model/badge.svg?branch=master)](https://coveralls.io/github/DmitryTsepelev/store_model?branch=master)
8
+
9
+ # StoreModel
10
+
11
+ <a href="https://evilmartians.com/?utm_source=store_model">
12
+ <img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg" alt="Sponsored by Evil Martians" width="236" height="54"></a>
13
+
14
+ 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.
15
+
16
+ For instance, imagine that you have a model `Product` with a `jsonb` column called `configuration`. Your usual workflow probably looks like:
17
+
18
+ ```ruby
19
+ product = Product.find(params[:id])
20
+ if product.configuration["model"] = "spaceship"
21
+ product.configuration["color"] = "red"
22
+ end
23
+ product.save
24
+ ```
25
+
26
+ 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:
27
+
28
+ ```ruby
29
+ product = Product.find(params[:id])
30
+ if product.configuration.model = "spaceship"
31
+ product.configuration.color = "red"
32
+ end
33
+ product.save
34
+ ```
35
+
36
+ > **Note**: if you want to work with JSON fields as an attributes, defined on the ActiveRecord model (not in the separate class) - consider using [store_attribute](https://github.com/palkan/store_attribute) or [jsonb_accessor](https://github.com/devmynd/jsonb_accessor).
37
+
38
+ ## Installation
39
+
40
+ Add this line to your application's Gemfile:
41
+
42
+ ```ruby
43
+ gem 'store_model'
44
+ ```
45
+
46
+ And then execute:
47
+ ```bash
48
+ $ bundle
49
+ ```
50
+
51
+ Or install it yourself as:
52
+ ```bash
53
+ $ gem install store_model
54
+ ```
55
+
56
+ ## How to register stored model
57
+
58
+ Start with creating a class for representing the hash as an object:
59
+
60
+ ```ruby
61
+ class Configuration
62
+ include StoreModel::Model
63
+
64
+ attribute :model, :string
65
+ attribute :color, :string
66
+ end
67
+ ```
68
+
69
+ Attributes shoould 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.
70
+
71
+ Register the field in the ActiveRecord model class:
72
+
73
+ ```ruby
74
+ class Product < ApplicationRecord
75
+ attribute :configuration, Configuration.to_type
76
+ end
77
+ ```
78
+
79
+ ## Validations
80
+
81
+ `StoreModel` supports all the validations shipped with `ActiveModel`. Start with defining validation for the store model:
82
+
83
+ ```ruby
84
+ class Configuration
85
+ include StoreModel::Model
86
+
87
+ attribute :model, :string
88
+ attribute :color, :string
89
+
90
+ validates :color, presence: true
91
+ end
92
+ ```
93
+
94
+ Then, configure your ActiveRecord model to validates this field as a store model:
95
+
96
+ ```ruby
97
+ class Product < ApplicationRecord
98
+ attribute :configuration, Configuration.to_type
99
+
100
+ validates :configuration, store_model: true
101
+ end
102
+ ```
103
+
104
+ When attribute is invalid, errors are not copied to the parent model by default:
105
+
106
+ ```ruby
107
+ product = Product.new
108
+ puts product.valid? # => false
109
+ puts product.errors.messages # => { configuration: ["is invalid"] }
110
+ puts product.configuration.errors.messages # => { color: ["can't be blank"] }
111
+ ```
112
+
113
+ You can change this behavior to have these errors on the root level (instead of `["is invalid"]`):
114
+
115
+ ```ruby
116
+ class Product < ApplicationRecord
117
+ attribute :configuration, Configuration.to_type
118
+
119
+ validates :configuration, store_model: { merge_errors: true }
120
+ end
121
+ ```
122
+
123
+ In this case errors look this way:
124
+
125
+ ```ruby
126
+ product = Product.new
127
+ puts product.valid? # => false
128
+ puts product.errors.messages # => { color: ["can't be blank"] }
129
+ ```
130
+
131
+ You can change the global behavior using `StoreModel.config`:
132
+
133
+ ```ruby
134
+ StoreModel.config.merge_errors = true
135
+ ```
136
+
137
+ 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_:
138
+
139
+ ```ruby
140
+ StoreModel.config.merge_errors = lambda do |attribute, base_errors, _store_model_errors| do
141
+ base_errors.add(attribute, "cthulhu fhtagn")
142
+ end
143
+ ```
144
+
145
+ If the logic is complex enough - it worth defining a separate class with a `#call` method:
146
+
147
+ ```ruby
148
+ class FhtagnErrorStrategy
149
+ def call(attribute, base_errors, _store_model_errors)
150
+ base_errors.add(attribute, "cthulhu fhtagn")
151
+ end
152
+ end
153
+ ```
154
+
155
+ You can provide its instance or snake-cased name when configuring global `merge_errors`:
156
+
157
+ ```ruby
158
+ StoreModel.config.merge_errors = :fhtagn_error_strategy
159
+
160
+ class Product < ApplicationRecord
161
+ attribute :configuration, Configuration.to_type
162
+
163
+ validates :configuration, store_model: { merge_errors: :fhtagn_error_strategy }
164
+ end
165
+ ```
166
+
167
+ or when calling `validates` method on a class level:
168
+
169
+ ```ruby
170
+ StoreModel.config.merge_errors = FhtagnErrorStrategy.new
171
+
172
+ class Product < ApplicationRecord
173
+ attribute :configuration, Configuration.to_type
174
+
175
+ validates :configuration, store_model: { merge_errors: FhtagnErrorStrategy.new }
176
+ end
177
+ ```
178
+
179
+ ## License
180
+
181
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+ require "rubocop/rake_task"
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+ RuboCop::RakeTask.new
7
+
8
+ task default: [:rubocop, :spec]
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "store_model/combine_errors_strategies"
5
+
6
+ module ActiveModel
7
+ module Validations
8
+ class StoreModelValidator < ActiveModel::Validator
9
+ def validate(record)
10
+ options[:attributes].each do |attribute|
11
+ attribute_value = record.send(attribute)
12
+ combine_errors(record, attribute) unless attribute_value.validate
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def combine_errors(record, attribute)
19
+ base_errors = record.errors
20
+ store_model_errors = record.send(attribute).errors
21
+
22
+ base_errors.delete(attribute)
23
+
24
+ strategy = StoreModel::CombileErrorsStrategies.configure(options)
25
+ strategy.call(attribute, base_errors, store_model_errors)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "store_model/model"
4
+ require "store_model/configuration"
5
+ require "active_model/validations/store_model_validator"
6
+
7
+ module StoreModel
8
+ class << self
9
+ def config
10
+ @config ||= Configuration.new
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "store_model/combine_errors_strategies/mark_invalid_error_strategy"
4
+ require "store_model/combine_errors_strategies/merge_error_strategy"
5
+
6
+ module StoreModel
7
+ module CombileErrorsStrategies
8
+ module_function
9
+
10
+ # Finds a strategy based on options and global config
11
+ def configure(options)
12
+ configured_strategy = options[:merge_errors] || StoreModel.config.merge_errors
13
+
14
+ if configured_strategy.respond_to?(:call)
15
+ configured_strategy
16
+ elsif configured_strategy == true
17
+ StoreModel::CombileErrorsStrategies::MergeErrorStrategy.new
18
+ elsif configured_strategy.nil?
19
+ StoreModel::CombileErrorsStrategies::MarkInvalidErrorStrategy.new
20
+ else
21
+ const_get(configured_strategy.to_s.camelize).new
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StoreModel
4
+ module CombileErrorsStrategies
5
+ class MarkInvalidErrorStrategy
6
+ def call(attribute, base_errors, _store_model_errors)
7
+ base_errors.add(attribute, I18n.translate("invalid", scope: "errors.messages"))
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StoreModel
4
+ module CombileErrorsStrategies
5
+ class MergeErrorStrategy
6
+ def call(_attribute, base_errors, store_model_errors)
7
+ base_errors.copy!(store_model_errors)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StoreModel
4
+ # StoreModel configuration:
5
+ #
6
+ # - `merge_errors` - set up to `true` to merge errors or specify your
7
+ # own strategy
8
+ #
9
+ class Configuration
10
+ attr_accessor :merge_errors
11
+ end
12
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ module StoreModel
6
+ class JsonModelType < ActiveModel::Type::Value
7
+ def initialize(model_klass)
8
+ @model_klass = model_klass
9
+ end
10
+
11
+ def type
12
+ :json
13
+ end
14
+
15
+ # rubocop:disable Style/RescueModifier
16
+ def cast_value(value)
17
+ case value
18
+ when String
19
+ decoded = ActiveSupport::JSON.decode(value) rescue nil
20
+ @model_klass.new(decoded) unless decoded.nil?
21
+ when Hash
22
+ @model_klass.new(value)
23
+ when @model_klass
24
+ value
25
+ end
26
+ end
27
+ # rubocop:enable Style/RescueModifier
28
+
29
+ def serialize(value)
30
+ case value
31
+ when Hash, @model_klass
32
+ ActiveSupport::JSON.encode(value)
33
+ else
34
+ super
35
+ end
36
+ end
37
+
38
+ def changed_in_place?(raw_old_value, new_value)
39
+ cast_value(raw_old_value) != new_value
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "store_model/json_model_type"
4
+
5
+ module StoreModel
6
+ module Model
7
+ def self.included(base)
8
+ base.include ActiveModel::Model
9
+ base.include ActiveModel::Attributes
10
+
11
+ base.extend(Module.new do
12
+ def to_type
13
+ JsonModelType.new(self)
14
+ end
15
+ end)
16
+ end
17
+
18
+ def as_json(options = {})
19
+ attributes.with_indifferent_access.as_json(options)
20
+ end
21
+
22
+ def ==(other)
23
+ return super unless other.is_a?(self.class)
24
+
25
+ attributes.all? { |name, value| value == other.send(name) }
26
+ end
27
+
28
+ # Allows to call :presence validation on the association itself
29
+ def blank?
30
+ attributes.values.all?(&:blank?)
31
+ end
32
+
33
+ def inspect
34
+ attribute_string = attributes.map { |name, value| "#{name}: #{value || 'nil'}" }.join(", ")
35
+ "#<#{self.class.name} #{attribute_string}>"
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StoreModel
4
+ VERSION = "0.1.0"
5
+ end
metadata ADDED
@@ -0,0 +1,126 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: store_model
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - DmitryTsepelev
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2019-02-20 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '5.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '5.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec-rails
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: coveralls
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ description: Gem for working with JSON-backed attributes as ActiveRecord models
84
+ email:
85
+ - dmitry.a.tsepelev@gmail.com
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - MIT-LICENSE
91
+ - README.md
92
+ - Rakefile
93
+ - lib/active_model/validations/store_model_validator.rb
94
+ - lib/store_model.rb
95
+ - lib/store_model/combine_errors_strategies.rb
96
+ - lib/store_model/combine_errors_strategies/mark_invalid_error_strategy.rb
97
+ - lib/store_model/combine_errors_strategies/merge_error_strategy.rb
98
+ - lib/store_model/configuration.rb
99
+ - lib/store_model/json_model_type.rb
100
+ - lib/store_model/model.rb
101
+ - lib/store_model/version.rb
102
+ homepage: https://github.com/DmitryTsepelev/store_model
103
+ licenses:
104
+ - MIT
105
+ metadata: {}
106
+ post_install_message:
107
+ rdoc_options: []
108
+ require_paths:
109
+ - lib
110
+ required_ruby_version: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ version: '2.3'
115
+ required_rubygems_version: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: '0'
120
+ requirements: []
121
+ rubyforge_project:
122
+ rubygems_version: 2.7.6
123
+ signing_key:
124
+ specification_version: 4
125
+ summary: Gem for working with JSON-backed attributes as ActiveRecord models
126
+ test_files: []