store_model 1.6.1 → 4.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: f4731608dcbfcf457b9e0cce52d04766ccbc7ea38fc061a6af61ef3b6edc2a3c
4
- data.tar.gz: d5a84b08a03420aece94ea78a0c620d3e486ac4046173d6b6091974f94f4c507
3
+ metadata.gz: fe31f43187a97021f54c79cbc8372b7e2b1c5a3c4bcb7b07b13def128bf69f34
4
+ data.tar.gz: '0456813d7c47e3a9888c23bd83dc90c233f8919734766e348c064350c3d502ce'
5
5
  SHA512:
6
- metadata.gz: 9e78d0f1f95f9982ed599f5701e06d48936f2be349189764b5fa7df6fc7a0c9ee71a17d1ae50a74c1a70bcbb037feb8e33db43131de1e880efb74c9a2c0e9c9d
7
- data.tar.gz: 7d5d82c70fe870f3abc767faae918a2a8f11bc6a9d645a8d36d12800da19951b9a8f018cdc336e5c35979a5e21847a034d1d9b76196a87c6bd1a1d0389062a80
6
+ metadata.gz: 54a697b1a4782fa6bf3a6f84972789f6b42aab7ef9702e243d7fa4b9d7a6c035ea1421a560eb848083dd414b9bd594d577ca82a50642718d80607d742db64561
7
+ data.tar.gz: d591bf765bb19d91cc7972762349075edbb5ec9d633f3b0e4b9c290069af132e35b9d011e4537de43c18f3de76ca863e8b0576d93502ac95b24e4abb250bedd3
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # StoreModel [![Gem Version](https://badge.fury.io/rb/store_model.svg)](https://rubygems.org/gems/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) ![](https://ruby-gem-downloads-badge.herokuapp.com/store_model?type=total)
1
+ # StoreModel [![Gem Version](https://badge.fury.io/rb/store_model.svg)](https://rubygems.org/gems/store_model) ![](https://ruby-gem-downloads-badge.herokuapp.com/store_model?type=total)
2
2
 
3
3
  **StoreModel** gem allows you to wrap JSON-backed DB columns with ActiveModel-like classes.
4
4
 
@@ -22,6 +22,8 @@ class Product < ApplicationRecord
22
22
  end
23
23
  ```
24
24
 
25
+ You can support my open–source work [here](https://boosty.to/dmitry_tsepelev).
26
+
25
27
  ## Why should I wrap my JSON columns?
26
28
 
27
29
  Imagine that you have a model `Product` with a `jsonb` column called `configuration`. This is how you likely gonna work with this column:
@@ -76,20 +78,66 @@ product.save
76
78
  _Usage note: Rails and assigning Arrays/Hashes to records_
77
79
 
78
80
  - Assigned attributes must be a String, Hash, Array of Hashes, or StoreModel. For example, if the attributes are coming from a controller, be sure to convert any ActionController::Parameters as needed.
79
- - Any changes made to a StoreModel instance requires the attribute be re-assigned; Rails doesn't track mutations of objects. For example: `self.my_stored_models = my_stored_models.map(&:as_json)`
81
+ - Any changes made to a StoreModel instance requires the attribute be flagged as dirty, either by reassignment (`self.my_stored_models = my_stored_models.map(&:as_json)`) or by `will_change!` (`self.my_stored_models_will_change!`)
82
+ - Mixing `StoreModel::NestedAttributes` into your model will allow you to use `accepts_nested_attributes_for` in the same way as ActiveRecord.
83
+
84
+ ```ruby
85
+ class Supplier < ActiveRecord::Base
86
+ include StoreModel::NestedAttributes
87
+
88
+ has_many :bicycles, dependent: :destroy
89
+
90
+ attribute :products, Product.to_array_type
91
+
92
+ accepts_nested_attributes_for :bicycles, :products, allow_destroy: true
93
+ end
94
+ ```
95
+
96
+ This will allow the form builders to work their magic:
97
+
98
+ ```erb
99
+ <%= form_with model: @supplier do |form| %>
100
+ <%= form.fields_for :products do |product_fields| %>
101
+ <%= product_fields.text_field :name %>
102
+ <% end %>
103
+ <% end %>
104
+ ```
105
+
106
+ Resulting in:
107
+ ```html
108
+ <input type="text" name="supplier[products_attributes][0][name]" id="supplier_products_attributes_0_name">
109
+ ```
110
+
111
+ In the controller:
112
+ ```ruby
113
+ def create
114
+ @supplier = Supplier.from_value(supplier_params)
115
+ @supplier.save
116
+ end
117
+
118
+ private
119
+
120
+ def supplier_params
121
+ params.require(:supplier).permit(products_attributes: [:name])
122
+ end
123
+ ```
80
124
 
81
125
  ## Documentation
82
126
 
83
127
  1. [Installation](./docs/installation.md)
84
- 2. StoreModel::Model API:
128
+ 2. `StoreModel::Model` API:
129
+ * [Instantiation](./docs/instantiation.md)
85
130
  * [Validations](./docs/validations.md)
86
131
  * [Enums](./docs/enums.md)
87
132
  * [Nested models](./docs/nested_models.md)
88
133
  * [Unknown attributes](./docs/unknown_attributes.md)
134
+ * [Empty attributes](./docs/empty_attributes.md)
89
135
  3. [Array of stored models](./docs/array_of_stored_models.md)
90
- 4. [One of](./docs/one_of.md)
91
- 4. [Alternatives](./docs/alternatives.md)
92
- 5. [Defining custom types](./docs/defining_custom_types.md)
136
+ 4. [Hash of stored models](./docs/hash_of_stored_models.md)
137
+ 5. [One of](./docs/one_of.md)
138
+ 6. [Alternatives](./docs/alternatives.md)
139
+ 7. [Defining custom types](./docs/defining_custom_types.md)
140
+ 8. [Disabling Parent Tracking](./docs/enable_parent_assignment.md)
93
141
 
94
142
  ## Credits
95
143
 
@@ -14,6 +14,7 @@ module ActiveModel
14
14
  # @param record [ApplicationRecord] object to validate
15
15
  # @param attribute [String] name of the validated attribute
16
16
  # @param value [Object] value of the validated attribute
17
+ # rubocop:disable Metrics/MethodLength
17
18
  def validate_each(record, attribute, value)
18
19
  if value.nil?
19
20
  record.errors.add(attribute, :blank)
@@ -25,8 +26,11 @@ module ActiveModel
25
26
  call_json_strategy(record, attribute, value)
26
27
  when :array, :polymorphic_array
27
28
  call_array_strategy(record, attribute, value)
29
+ when :hash, :polymorphic_hash
30
+ call_hash_strategy(record, attribute, value)
28
31
  end
29
32
  end
33
+ # rubocop:enable Metrics/MethodLength
30
34
 
31
35
  private
32
36
 
@@ -39,6 +43,11 @@ module ActiveModel
39
43
  array_strategy.call(attribute, record.errors, value) if any_invalid
40
44
  end
41
45
 
46
+ def call_hash_strategy(record, attribute, value)
47
+ invalid_values = value.select { |_k, v| v.invalid?(record.validation_context) }
48
+ hash_strategy.call(attribute, record.errors, invalid_values) if invalid_values.present?
49
+ end
50
+
42
51
  def strategy
43
52
  @strategy ||= StoreModel::CombineErrorsStrategies.configure(options)
44
53
  end
@@ -46,6 +55,10 @@ module ActiveModel
46
55
  def array_strategy
47
56
  @array_strategy ||= StoreModel::CombineErrorsStrategies.configure_array(options)
48
57
  end
58
+
59
+ def hash_strategy
60
+ @hash_strategy ||= StoreModel::CombineErrorsStrategies.configure_hash(options)
61
+ end
49
62
  end
50
63
  end
51
64
  end
@@ -10,8 +10,8 @@ module StoreModel
10
10
  # @param base_errors [ActiveModel::Errors] errors object of the parent record
11
11
  # @param _store_model_errors [ActiveModel::Errors] errors object of the
12
12
  # StoreModel::Model attribute
13
- def call(attribute, base_errors, _store_model_errors)
14
- base_errors.add(attribute, :invalid)
13
+ def call(attribute, base_errors, store_model_errors)
14
+ base_errors.add(attribute, :invalid, errors: store_model_errors)
15
15
  end
16
16
  end
17
17
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StoreModel
4
+ module CombineErrorsStrategies
5
+ # +MergeHashErrorStrategy+ copies errors from the StoreModel::Model to the parent
6
+ # record attribute errors with hash key prefixes.
7
+ class MergeHashErrorStrategy
8
+ # Merges errors on +attribute+ from the child model with parent errors.
9
+ #
10
+ # @param attribute [String] name of the validated attribute
11
+ # @param base_errors [ActiveModel::Errors] errors object of the parent record
12
+ # @param store_models [Hash] a hash of store_models that have been validated
13
+ def call(attribute, base_errors, store_models)
14
+ store_models.each do |key, store_model|
15
+ store_model.errors.full_messages.each do |full_message|
16
+ base_errors.add(attribute, :invalid, message: "[#{key}] #{full_message}")
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -3,6 +3,7 @@
3
3
  require "store_model/combine_errors_strategies/mark_invalid_error_strategy"
4
4
  require "store_model/combine_errors_strategies/merge_error_strategy"
5
5
  require "store_model/combine_errors_strategies/merge_array_error_strategy"
6
+ require "store_model/combine_errors_strategies/merge_hash_error_strategy"
6
7
 
7
8
  module StoreModel
8
9
  # Module with built-in strategies for combining errors.
@@ -37,6 +38,20 @@ module StoreModel
37
38
  )
38
39
  end
39
40
 
41
+ # Finds a hash strategy based on +options+ and global config.
42
+ #
43
+ # @param options [Hash]
44
+ #
45
+ # @return [Object] strategy
46
+ def configure_hash(options)
47
+ configured_strategy = options[:merge_hash_errors] || StoreModel.config.merge_hash_errors
48
+
49
+ get_configured_strategy(
50
+ configured_strategy,
51
+ StoreModel::CombineErrorsStrategies::MergeHashErrorStrategy
52
+ )
53
+ end
54
+
40
55
  def get_configured_strategy(configured_strategy, true_strategy_class)
41
56
  if configured_strategy.respond_to?(:call)
42
57
  configured_strategy
@@ -11,12 +11,32 @@ module StoreModel
11
11
  # @return [Boolean]
12
12
  attr_accessor :merge_array_errors
13
13
 
14
+ # Controls usage of MergeHashErrorStrategy
15
+ # @return [Boolean]
16
+ attr_accessor :merge_hash_errors
17
+
14
18
  # Controls if the result of `as_json` will contain the unknown attributes of the model
15
19
  # @return [Boolean]
16
20
  attr_accessor :serialize_unknown_attributes
17
21
 
22
+ # Controls if the result of `as_json` will contain the nulls attributes of the model
23
+ # @return [Boolean]
24
+ attr_accessor :serialize_empty_attributes
25
+
26
+ # Controls if the result of `as_json` will serialize enum fields using `as_json`
27
+ # @return [Boolean]
28
+ attr_accessor :serialize_enums_using_as_json
29
+
30
+ # Controls if parent tracking functionality is enabled.
31
+ # Default: true
32
+ # @return [Boolean]
33
+ attr_accessor :enable_parent_assignment
34
+
18
35
  def initialize
19
36
  @serialize_unknown_attributes = true
37
+ @enable_parent_assignment = true
38
+ @serialize_enums_using_as_json = true
39
+ @serialize_empty_attributes = true
20
40
  end
21
41
  end
22
42
  end
@@ -10,12 +10,12 @@ module StoreModel
10
10
  # @param kwargs [Object]
11
11
  def enum(name, values = nil, **kwargs)
12
12
  values ||= kwargs[:in] || kwargs
13
- options = kwargs.slice(:_prefix, :_suffix, :default)
13
+ options = retrieve_options(kwargs)
14
14
 
15
15
  ensure_hash(values).tap do |mapping|
16
- define_attribute(name, mapping, options[:default])
16
+ define_attribute(name, mapping, options)
17
17
  define_reader(name, mapping)
18
- define_writer(name, mapping)
18
+ define_writer(name, mapping, options[:raise_on_invalid_values])
19
19
  define_method("#{name}_value") { attributes[name.to_s] }
20
20
  define_map_readers(name, mapping)
21
21
  define_predicate_methods(name, mapping, options)
@@ -24,16 +24,25 @@ module StoreModel
24
24
 
25
25
  private
26
26
 
27
- def define_attribute(name, mapping, default)
28
- attribute name, cast_type(mapping), default: default
27
+ def retrieve_options(options)
28
+ default_options = { raise_on_invalid_values: true }
29
+ options.slice(:_prefix, :_suffix, :default, :raise_on_invalid_values)
30
+ .reverse_merge(default_options)
31
+ end
32
+
33
+ def define_attribute(name, mapping, options)
34
+ attribute name, cast_type(mapping, options[:raise_on_invalid_values]), default: options[:default]
29
35
  end
30
36
 
31
37
  def define_reader(name, mapping)
32
- define_method(name) { mapping.key(send("#{name}_value")).to_s }
38
+ define_method(name) do
39
+ raw_value = send("#{name}_value")
40
+ (mapping.key(raw_value) || raw_value).to_s
41
+ end
33
42
  end
34
43
 
35
- def define_writer(name, mapping)
36
- type = cast_type(mapping)
44
+ def define_writer(name, mapping, raise_on_invalid_values)
45
+ type = cast_type(mapping, raise_on_invalid_values)
37
46
  define_method("#{name}=") { |value| super type.cast_value(value) }
38
47
  end
39
48
 
@@ -50,8 +59,8 @@ module StoreModel
50
59
  singleton_class.alias_method(ActiveSupport::Inflector.pluralize(name), "#{name}_values")
51
60
  end
52
61
 
53
- def cast_type(mapping)
54
- StoreModel::Types::EnumType.new(mapping)
62
+ def cast_type(mapping, raise_on_invalid_values)
63
+ StoreModel::Types::EnumType.new(mapping, raise_on_invalid_values)
55
64
  end
56
65
 
57
66
  def ensure_hash(values)
@@ -7,9 +7,9 @@ module StoreModel
7
7
 
8
8
  def assign_parent_to_store_model_relation(attribute)
9
9
  assign_parent_to_singular_store_model(attribute)
10
- return unless attribute.is_a?(Array)
10
+ return if !attribute.is_a?(Array) && !attribute.is_a?(Hash)
11
11
 
12
- attribute.each(&method(:assign_parent_to_singular_store_model))
12
+ (attribute.try(:values) || attribute).each(&method(:assign_parent_to_singular_store_model))
13
13
  end
14
14
 
15
15
  def assign_parent_to_singular_store_model(item)
@@ -7,7 +7,8 @@ require "store_model/nested_attributes"
7
7
 
8
8
  module StoreModel
9
9
  # When included into class configures it to handle JSON column
10
- module Model
10
+ module Model # rubocop:disable Metrics/ModuleLength
11
+ # rubocop:disable Metrics/MethodLength
11
12
  def self.included(base) # :nodoc:
12
13
  base.include ActiveModel::Model
13
14
  base.include ActiveModel::Attributes
@@ -15,13 +16,40 @@ module StoreModel
15
16
  base.include ActiveModel::AttributeMethods
16
17
  base.include StoreModel::NestedAttributes
17
18
 
19
+ if ActiveModel::VERSION::MAJOR >= 8 && ActiveModel::VERSION::MINOR >= 1
20
+ base.include ActiveModel::Attributes::Normalization
21
+ end
22
+
18
23
  base.extend StoreModel::Enum
19
24
  base.extend StoreModel::TypeBuilders
20
25
 
21
26
  base.attribute_method_suffix "?"
27
+
28
+ base.extend(ClassMethods)
29
+ end
30
+ # rubocop:enable Metrics/MethodLength
31
+
32
+ # Class methods for StoreModel::Model
33
+ module ClassMethods
34
+ def from_value(value)
35
+ to_type.cast_value(value)
36
+ end
37
+
38
+ def from_values(values)
39
+ to_array_type.cast_value(values)
40
+ end
41
+
42
+ # Defines a discriminator attribute with a value
43
+ # @param discriminator [Symbol, String] attribute name (default: :type)
44
+ # @param type [Symbol, String] attribute type (default: :string)
45
+ # @param value [String] the discriminator value
46
+ def discriminator_attribute(discriminator = "type", value:, type: :string)
47
+ attribute discriminator, type, default: value
48
+ end
22
49
  end
23
50
 
24
51
  attr_accessor :parent
52
+ attr_writer :serialize_unknown_attributes, :serialize_enums_using_as_json, :serialize_empty_attributes
25
53
 
26
54
  delegate :each_value, to: :attributes
27
55
 
@@ -31,16 +59,39 @@ module StoreModel
31
59
  # @param options [Hash]
32
60
  #
33
61
  # @return [Hash]
34
- def as_json(options = {})
62
+ def as_json(options = {}) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
35
63
  serialize_unknown_attributes = if options.key?(:serialize_unknown_attributes)
36
64
  options[:serialize_unknown_attributes]
37
65
  else
38
66
  StoreModel.config.serialize_unknown_attributes
39
67
  end
40
68
 
41
- result = attributes.with_indifferent_access
69
+ serialize_enums_using_as_json = if options.key?(:serialize_enums_using_as_json)
70
+ options[:serialize_enums_using_as_json]
71
+ else
72
+ StoreModel.config.serialize_enums_using_as_json
73
+ end
74
+
75
+ serialize_empty_attributes = if options.key?(:serialize_empty_attributes)
76
+ options[:serialize_empty_attributes]
77
+ else
78
+ StoreModel.config.serialize_empty_attributes
79
+ end
80
+
81
+ # If the model is nested, we need to ensure that the serialization
82
+
83
+ result = @attributes.keys.each_with_object({}) do |key, values|
84
+ attr = @attributes.fetch(key)
85
+ assign_serialization_options(attr, serialize_unknown_attributes, serialize_enums_using_as_json,
86
+ serialize_empty_attributes)
87
+ serialized = serialized_attribute(attr)
88
+ values[key] = serialized if serialize_empty_attributes || !serialized.nil?
89
+ end.with_indifferent_access
90
+
42
91
  result.merge!(unknown_attributes) if serialize_unknown_attributes
43
- result.as_json(options)
92
+ result.as_json(options).tap do |json|
93
+ serialize_enums!(json) if serialize_enums_using_as_json
94
+ end
44
95
  end
45
96
 
46
97
  # Returns an Object, similar to Hash#fetch, raises
@@ -107,7 +158,7 @@ module StoreModel
107
158
  #
108
159
  # @return [String]
109
160
  def inspect
110
- attribute_string = attributes.map { |name, value| "#{name}: #{value.nil? ? 'nil' : value}" }
161
+ attribute_string = attributes.map { |name, value| "#{name}: #{value.inspect}" }
111
162
  .join(", ")
112
163
  "#<#{self.class.name} #{attribute_string}>"
113
164
  end
@@ -167,6 +218,55 @@ module StoreModel
167
218
  @unknown_attributes ||= {}
168
219
  end
169
220
 
221
+ # Returns the value of the `@serialize_unknown_attributes` instance
222
+ # variable. In the current specification, unknown attributes must be
223
+ # persisted in the database regardless of the globally configured
224
+ # `serialize_unknown_attributes` option. Therefore, it returns the
225
+ # default value `true` if the instance variable is `nil`.
226
+ #
227
+ # This method is used to ensure that the `serialize_unknown_attributes`
228
+ # option is correctly applied to nested StoreModel::Model objects when
229
+ # the `as_json` method is called.
230
+ #
231
+ # @return [Boolean]
232
+ def serialize_unknown_attributes?
233
+ @serialize_unknown_attributes.nil? ? true : @serialize_unknown_attributes
234
+ end
235
+
236
+ # Returns the value of the `@serialize_enums_using_as_json` instance
237
+ # variable. The default value is the value of the globally configured
238
+ # `serialize_enums_using_as_json` option.
239
+ #
240
+ # This method is used to determine whether enums should be serialized
241
+ # when the `as_json` method is called in nested StoreModel::Model
242
+ # objects.
243
+ #
244
+ # @return [Boolean]
245
+ def serialize_enums_using_as_json?
246
+ if @serialize_enums_using_as_json.nil?
247
+ StoreModel.config.serialize_enums_using_as_json || false
248
+ else
249
+ @serialize_enums_using_as_json
250
+ end
251
+ end
252
+
253
+ # Returns the value of the `@serialize_empty_attributes` instance
254
+ # variable. The default value is the value of the globally configured
255
+ # `serialize_empty_attributes` option.
256
+ #
257
+ # This method is used to determine whether empty values should be serialized
258
+ # when the `as_json` method is called in nested StoreModel::Model
259
+ # objects.
260
+ #
261
+ # @return [Boolean]
262
+ def serialize_empty_attributes?
263
+ if @serialize_empty_attributes.nil?
264
+ StoreModel.config.serialize_empty_attributes || true
265
+ else
266
+ @serialize_empty_attributes
267
+ end
268
+ end
269
+
170
270
  private
171
271
 
172
272
  def attribute?(attribute)
@@ -175,5 +275,48 @@ module StoreModel
175
275
  else value.present?
176
276
  end
177
277
  end
278
+
279
+ def serialize_enums!(json)
280
+ enum_mappings =
281
+ self.class
282
+ .attribute_types
283
+ .select { |_, type| type.is_a?(StoreModel::Types::EnumType) }
284
+
285
+ enum_mappings.each_key do |name|
286
+ next unless json.key?(name)
287
+
288
+ json[name] = public_send(name).as_json unless json[name].nil?
289
+ end
290
+ end
291
+
292
+ def serialized_attribute(attr)
293
+ if attr.value.is_a? StoreModel::Model
294
+ Types::RawJSONEncoder.new(attr.value_for_database)
295
+ elsif attr.value.is_a? Array
296
+ serialize_array_attribute(attr.value)
297
+ else
298
+ attr.value_for_database
299
+ end
300
+ end
301
+
302
+ def serialize_array_attribute(array)
303
+ return array.as_json unless array.any? && array.all? { |value| value.is_a?(StoreModel::Model) }
304
+
305
+ array.as_json(
306
+ serialize_unknown_attributes: array.first.serialize_unknown_attributes?,
307
+ serialize_enums_using_as_json: array.first.serialize_enums_using_as_json?,
308
+ serialize_empty_attributes: array.first.serialize_empty_attributes?
309
+ )
310
+ end
311
+
312
+ def assign_serialization_options(attr, serialize_unknown_attributes, serialize_enums_using_as_json, serialize_empty_attributes) # rubocop:disable Layout/LineLength
313
+ return unless Array(attr.value).all? { |value| value.is_a?(StoreModel::Model) }
314
+
315
+ Array(attr.value).each do |value|
316
+ value.serialize_unknown_attributes = serialize_unknown_attributes
317
+ value.serialize_enums_using_as_json = serialize_enums_using_as_json
318
+ value.serialize_empty_attributes = serialize_empty_attributes
319
+ end
320
+ end
178
321
  end
179
322
  end
@@ -8,36 +8,96 @@ module StoreModel
8
8
  end
9
9
 
10
10
  module ClassMethods # :nodoc:
11
+ # gather storemodel attribute types on the class-level
12
+ def store_model_attribute_types
13
+ @store_model_attribute_types ||= {}
14
+ end
15
+
16
+ # add storemodel type of attribute if it is storemodel type
17
+ def attribute(name, type = nil, **)
18
+ store_model_attribute_types[name.to_s] = type if type.is_a?(Types::Base)
19
+ super
20
+ end
21
+
11
22
  # Enables handling of nested StoreModel::Model attributes
12
23
  #
13
24
  # @param associations [Array] list of associations and options to define attributes, for example:
14
25
  # accepts_nested_attributes_for [:suppliers, allow_destroy: true]
15
26
  #
27
+ # Alternatively, use the standard Rails syntax:
28
+ #
29
+ # @param associations [Array] list of associations and attributes to define getters and setters.
30
+ #
31
+ # @param options [Hash] options not supported by StoreModel will still be passed to ActiveRecord.
32
+ #
16
33
  # Supported options:
17
34
  # [:allow_destroy]
18
35
  # If true, destroys any members from the attributes hash with a
19
36
  # <tt>_destroy</tt> key and a value that evaluates to +true+
20
37
  # (e.g. 1, '1', true, or 'true'). This option is off by default.
21
- def accepts_nested_attributes_for(*associations)
22
- associations.each do |association, options|
23
- case attribute_types[association.to_s]
24
- when Types::One
25
- define_association_setter_for_single(association, options)
26
- alias_method "#{association}_attributes=", "#{association}="
27
- when Types::Many
28
- define_association_setter_for_many(association, options)
29
- end
38
+ #
39
+ # [:reject_if]
40
+ # Allows you to specify a Proc or a Symbol pointing to a method that
41
+ # checks whether a record should be built for a certain attribute hash.
42
+ # The hash is passed to the supplied Proc or the method and it should
43
+ # return either true or false. Passing <tt>:all_blank</tt> instead of a Proc
44
+ # will create a proc that will reject a record where all the attributes
45
+ # are blank excluding any value for <tt>_destroy</tt>.
46
+ #
47
+ # See https://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html#method-i-accepts_nested_attributes_for
48
+ def accepts_nested_attributes_for(*attributes)
49
+ options = attributes.extract_options!
30
50
 
31
- define_attr_accessor_for_destroy(association, options)
51
+ attributes.each do |attribute|
52
+ if nested_attribute_type(attribute).is_a?(Types::Base)
53
+ options.reverse_merge!(allow_destroy: false, update_only: false)
54
+ define_store_model_attr_accessors(attribute, options)
55
+ else
56
+ super(*attribute, options)
57
+ end
32
58
  end
33
59
  end
34
60
 
35
61
  private
36
62
 
63
+ # when db connection is not available, it becomes impossible to read attributes types from
64
+ # ActiveModel::AttributeRegistration::ClassMethods.attribute_types, because activerecord
65
+ # overrides _default_attributes and triggers db connection.
66
+ # for activerecord model only use attribute_types if it has db connected
67
+ #
68
+ # @param attribute [String, Symbol]
69
+ # @return [StoreModel::Types::Base, nil]
70
+ def nested_attribute_type(attribute)
71
+ if self < ActiveRecord::Base && !schema_loaded?
72
+ store_model_attribute_types[attribute.to_s]
73
+ else
74
+ attribute_types[attribute.to_s]
75
+ end
76
+ end
77
+
78
+ def define_store_model_attr_accessors(attribute, options) # rubocop:disable Metrics/MethodLength
79
+ case nested_attribute_type(attribute)
80
+ when Types::OneBase
81
+ define_association_setter_for_single(attribute, options)
82
+ define_method "#{attribute}_attributes=" do |*args, **kwargs|
83
+ existing_model = send(attribute)
84
+ if existing_model && options[:update_only]
85
+ existing_model.assign_attributes(*args, **kwargs)
86
+ else
87
+ send("#{attribute}=", *args, **kwargs)
88
+ end
89
+ end
90
+ when Types::ManyBase
91
+ define_association_setter_for_many(attribute, options)
92
+ end
93
+
94
+ define_attr_accessor_for_destroy(attribute, options)
95
+ end
96
+
37
97
  def define_attr_accessor_for_destroy(association, options)
38
98
  return unless options&.dig(:allow_destroy)
39
99
 
40
- attribute_types[association.to_s].model_klass.class_eval do
100
+ nested_attribute_type(association).model_klass.class_eval do
41
101
  attr_accessor :_destroy
42
102
  end
43
103
  end
@@ -52,7 +112,7 @@ module StoreModel
52
112
  return unless options&.dig(:allow_destroy)
53
113
 
54
114
  define_method "#{association}=" do |attributes|
55
- if ActiveRecord::Type::Boolean.new.cast(attributes.stringify_keys.dig("_destroy"))
115
+ if ActiveRecord::Type::Boolean.new.cast(attributes.stringify_keys["_destroy"])
56
116
  super(nil)
57
117
  else
58
118
  super(attributes)
@@ -61,18 +121,32 @@ module StoreModel
61
121
  end
62
122
  end
63
123
 
64
- private
124
+ # Base
125
+ def assign_nested_attributes_for_collection_association(association, attributes, options = nil)
126
+ return super(association, attributes) unless options
65
127
 
66
- def assign_nested_attributes_for_collection_association(association, attributes, options)
67
128
  attributes = attributes.values if attributes.is_a?(Hash)
68
129
 
69
130
  if options&.dig(:allow_destroy)
70
131
  attributes.reject! do |attribute|
71
- ActiveRecord::Type::Boolean.new.cast(attribute.stringify_keys.dig("_destroy"))
132
+ ActiveRecord::Type::Boolean.new.cast(attribute.stringify_keys["_destroy"])
72
133
  end
73
134
  end
74
135
 
136
+ attributes.reject! { call_store_model_reject_if(_1, options[:reject_if]) } if options&.dig(:reject_if)
137
+
75
138
  send("#{association}=", attributes)
76
139
  end
140
+
141
+ def call_store_model_reject_if(attributes, callback)
142
+ callback = ActiveRecord::NestedAttributes::ClassMethods::REJECT_ALL_BLANK_PROC if callback == :all_blank
143
+
144
+ case callback
145
+ when Symbol
146
+ method(callback).arity.zero? ? send(callback) : send(callback, attributes)
147
+ when Proc
148
+ callback.call(attributes)
149
+ end
150
+ end
77
151
  end
78
152
  end