tallty_duck_record 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +41 -0
  3. data/README.md +82 -0
  4. data/Rakefile +28 -0
  5. data/lib/core_ext/array_without_blank.rb +46 -0
  6. data/lib/duck_record.rb +65 -0
  7. data/lib/duck_record/associations.rb +130 -0
  8. data/lib/duck_record/associations/association.rb +271 -0
  9. data/lib/duck_record/associations/belongs_to_association.rb +71 -0
  10. data/lib/duck_record/associations/builder/association.rb +127 -0
  11. data/lib/duck_record/associations/builder/belongs_to.rb +44 -0
  12. data/lib/duck_record/associations/builder/collection_association.rb +45 -0
  13. data/lib/duck_record/associations/builder/embeds_many.rb +9 -0
  14. data/lib/duck_record/associations/builder/embeds_one.rb +9 -0
  15. data/lib/duck_record/associations/builder/has_many.rb +11 -0
  16. data/lib/duck_record/associations/builder/has_one.rb +20 -0
  17. data/lib/duck_record/associations/builder/singular_association.rb +33 -0
  18. data/lib/duck_record/associations/collection_association.rb +476 -0
  19. data/lib/duck_record/associations/collection_proxy.rb +1160 -0
  20. data/lib/duck_record/associations/embeds_association.rb +92 -0
  21. data/lib/duck_record/associations/embeds_many_association.rb +203 -0
  22. data/lib/duck_record/associations/embeds_many_proxy.rb +892 -0
  23. data/lib/duck_record/associations/embeds_one_association.rb +48 -0
  24. data/lib/duck_record/associations/foreign_association.rb +11 -0
  25. data/lib/duck_record/associations/has_many_association.rb +17 -0
  26. data/lib/duck_record/associations/has_one_association.rb +39 -0
  27. data/lib/duck_record/associations/singular_association.rb +73 -0
  28. data/lib/duck_record/attribute.rb +213 -0
  29. data/lib/duck_record/attribute/user_provided_default.rb +30 -0
  30. data/lib/duck_record/attribute_assignment.rb +118 -0
  31. data/lib/duck_record/attribute_decorators.rb +89 -0
  32. data/lib/duck_record/attribute_methods.rb +325 -0
  33. data/lib/duck_record/attribute_methods/before_type_cast.rb +76 -0
  34. data/lib/duck_record/attribute_methods/dirty.rb +107 -0
  35. data/lib/duck_record/attribute_methods/read.rb +78 -0
  36. data/lib/duck_record/attribute_methods/serialization.rb +66 -0
  37. data/lib/duck_record/attribute_methods/write.rb +70 -0
  38. data/lib/duck_record/attribute_mutation_tracker.rb +108 -0
  39. data/lib/duck_record/attribute_set.rb +98 -0
  40. data/lib/duck_record/attribute_set/yaml_encoder.rb +41 -0
  41. data/lib/duck_record/attributes.rb +262 -0
  42. data/lib/duck_record/base.rb +300 -0
  43. data/lib/duck_record/callbacks.rb +324 -0
  44. data/lib/duck_record/coders/json.rb +13 -0
  45. data/lib/duck_record/coders/yaml_column.rb +48 -0
  46. data/lib/duck_record/core.rb +262 -0
  47. data/lib/duck_record/define_callbacks.rb +23 -0
  48. data/lib/duck_record/enum.rb +139 -0
  49. data/lib/duck_record/errors.rb +71 -0
  50. data/lib/duck_record/inheritance.rb +130 -0
  51. data/lib/duck_record/locale/en.yml +46 -0
  52. data/lib/duck_record/model_schema.rb +71 -0
  53. data/lib/duck_record/nested_attributes.rb +555 -0
  54. data/lib/duck_record/nested_validate_association.rb +262 -0
  55. data/lib/duck_record/persistence.rb +39 -0
  56. data/lib/duck_record/readonly_attributes.rb +36 -0
  57. data/lib/duck_record/reflection.rb +650 -0
  58. data/lib/duck_record/serialization.rb +26 -0
  59. data/lib/duck_record/translation.rb +22 -0
  60. data/lib/duck_record/type.rb +77 -0
  61. data/lib/duck_record/type/array.rb +36 -0
  62. data/lib/duck_record/type/array_without_blank.rb +36 -0
  63. data/lib/duck_record/type/date.rb +7 -0
  64. data/lib/duck_record/type/date_time.rb +7 -0
  65. data/lib/duck_record/type/decimal_without_scale.rb +13 -0
  66. data/lib/duck_record/type/internal/abstract_json.rb +33 -0
  67. data/lib/duck_record/type/internal/timezone.rb +15 -0
  68. data/lib/duck_record/type/json.rb +6 -0
  69. data/lib/duck_record/type/registry.rb +97 -0
  70. data/lib/duck_record/type/serialized.rb +63 -0
  71. data/lib/duck_record/type/text.rb +9 -0
  72. data/lib/duck_record/type/time.rb +19 -0
  73. data/lib/duck_record/type/unsigned_integer.rb +15 -0
  74. data/lib/duck_record/validations.rb +67 -0
  75. data/lib/duck_record/validations/subset.rb +74 -0
  76. data/lib/duck_record/validations/uniqueness_on_real_record.rb +248 -0
  77. data/lib/duck_record/version.rb +3 -0
  78. data/lib/tasks/acts_as_record_tasks.rake +4 -0
  79. metadata +181 -0
@@ -0,0 +1,555 @@
1
+ require "active_support/core_ext/hash/except"
2
+ require "active_support/core_ext/object/try"
3
+ require "active_support/core_ext/hash/indifferent_access"
4
+
5
+ module DuckRecord
6
+ module NestedAttributes #:nodoc:
7
+ class TooManyRecords < DuckRecordError
8
+ end
9
+
10
+ extend ActiveSupport::Concern
11
+
12
+ included do
13
+ class_attribute :nested_attributes_options, instance_writer: false
14
+ self.nested_attributes_options = {}
15
+ end
16
+
17
+ # = Active Record Nested Attributes
18
+ #
19
+ # Nested attributes allow you to save attributes on associated records
20
+ # through the parent. By default nested attribute updating is turned off
21
+ # and you can enable it using the accepts_nested_attributes_for class
22
+ # method. When you enable nested attributes an attribute writer is
23
+ # defined on the model.
24
+ #
25
+ # The attribute writer is named after the association, which means that
26
+ # in the following example, two new methods are added to your model:
27
+ #
28
+ # <tt>author_attributes=(attributes)</tt> and
29
+ # <tt>pages_attributes=(attributes)</tt>.
30
+ #
31
+ # class Book < ActiveRecord::Base
32
+ # has_one :author
33
+ # has_many :pages
34
+ #
35
+ # accepts_nested_attributes_for :author, :pages
36
+ # end
37
+ #
38
+ # Note that the <tt>:autosave</tt> option is automatically enabled on every
39
+ # association that accepts_nested_attributes_for is used for.
40
+ #
41
+ # === One-to-one
42
+ #
43
+ # Consider a Member model that has one Avatar:
44
+ #
45
+ # class Member < ActiveRecord::Base
46
+ # has_one :avatar
47
+ # accepts_nested_attributes_for :avatar
48
+ # end
49
+ #
50
+ # Enabling nested attributes on a one-to-one association allows you to
51
+ # create the member and avatar in one go:
52
+ #
53
+ # params = { member: { name: 'Jack', avatar_attributes: { icon: 'smiling' } } }
54
+ # member = Member.create(params[:member])
55
+ # member.avatar.id # => 2
56
+ # member.avatar.icon # => 'smiling'
57
+ #
58
+ # It also allows you to update the avatar through the member:
59
+ #
60
+ # params = { member: { avatar_attributes: { id: '2', icon: 'sad' } } }
61
+ # member.update params[:member]
62
+ # member.avatar.icon # => 'sad'
63
+ #
64
+ # By default you will only be able to set and update attributes on the
65
+ # associated model. If you want to destroy the associated model through the
66
+ # attributes hash, you have to enable it first using the
67
+ # <tt>:allow_destroy</tt> option.
68
+ #
69
+ # class Member < ActiveRecord::Base
70
+ # has_one :avatar
71
+ # accepts_nested_attributes_for :avatar, allow_destroy: true
72
+ # end
73
+ #
74
+ # Now, when you add the <tt>_destroy</tt> key to the attributes hash, with a
75
+ # value that evaluates to +true+, you will destroy the associated model:
76
+ #
77
+ # member.avatar_attributes = { id: '2', _destroy: '1' }
78
+ # member.avatar.marked_for_destruction? # => true
79
+ # member.save
80
+ # member.reload.avatar # => nil
81
+ #
82
+ # Note that the model will _not_ be destroyed until the parent is saved.
83
+ #
84
+ # Also note that the model will not be destroyed unless you also specify
85
+ # its id in the updated hash.
86
+ #
87
+ # === One-to-many
88
+ #
89
+ # Consider a member that has a number of posts:
90
+ #
91
+ # class Member < ActiveRecord::Base
92
+ # has_many :posts
93
+ # accepts_nested_attributes_for :posts
94
+ # end
95
+ #
96
+ # You can now set or update attributes on the associated posts through
97
+ # an attribute hash for a member: include the key +:posts_attributes+
98
+ # with an array of hashes of post attributes as a value.
99
+ #
100
+ # For each hash that does _not_ have an <tt>id</tt> key a new record will
101
+ # be instantiated, unless the hash also contains a <tt>_destroy</tt> key
102
+ # that evaluates to +true+.
103
+ #
104
+ # params = { member: {
105
+ # name: 'joe', posts_attributes: [
106
+ # { title: 'Kari, the awesome Ruby documentation browser!' },
107
+ # { title: 'The egalitarian assumption of the modern citizen' },
108
+ # { title: '', _destroy: '1' } # this will be ignored
109
+ # ]
110
+ # }}
111
+ #
112
+ # member = Member.create(params[:member])
113
+ # member.posts.length # => 2
114
+ # member.posts.first.title # => 'Kari, the awesome Ruby documentation browser!'
115
+ # member.posts.second.title # => 'The egalitarian assumption of the modern citizen'
116
+ #
117
+ # You may also set a +:reject_if+ proc to silently ignore any new record
118
+ # hashes if they fail to pass your criteria. For example, the previous
119
+ # example could be rewritten as:
120
+ #
121
+ # class Member < ActiveRecord::Base
122
+ # has_many :posts
123
+ # accepts_nested_attributes_for :posts, reject_if: proc { |attributes| attributes['title'].blank? }
124
+ # end
125
+ #
126
+ # params = { member: {
127
+ # name: 'joe', posts_attributes: [
128
+ # { title: 'Kari, the awesome Ruby documentation browser!' },
129
+ # { title: 'The egalitarian assumption of the modern citizen' },
130
+ # { title: '' } # this will be ignored because of the :reject_if proc
131
+ # ]
132
+ # }}
133
+ #
134
+ # member = Member.create(params[:member])
135
+ # member.posts.length # => 2
136
+ # member.posts.first.title # => 'Kari, the awesome Ruby documentation browser!'
137
+ # member.posts.second.title # => 'The egalitarian assumption of the modern citizen'
138
+ #
139
+ # Alternatively, +:reject_if+ also accepts a symbol for using methods:
140
+ #
141
+ # class Member < ActiveRecord::Base
142
+ # has_many :posts
143
+ # accepts_nested_attributes_for :posts, reject_if: :new_record?
144
+ # end
145
+ #
146
+ # class Member < ActiveRecord::Base
147
+ # has_many :posts
148
+ # accepts_nested_attributes_for :posts, reject_if: :reject_posts
149
+ #
150
+ # def reject_posts(attributes)
151
+ # attributes['title'].blank?
152
+ # end
153
+ # end
154
+ #
155
+ # If the hash contains an <tt>id</tt> key that matches an already
156
+ # associated record, the matching record will be modified:
157
+ #
158
+ # member.attributes = {
159
+ # name: 'Joe',
160
+ # posts_attributes: [
161
+ # { id: 1, title: '[UPDATED] An, as of yet, undisclosed awesome Ruby documentation browser!' },
162
+ # { id: 2, title: '[UPDATED] other post' }
163
+ # ]
164
+ # }
165
+ #
166
+ # member.posts.first.title # => '[UPDATED] An, as of yet, undisclosed awesome Ruby documentation browser!'
167
+ # member.posts.second.title # => '[UPDATED] other post'
168
+ #
169
+ # However, the above applies if the parent model is being updated as well.
170
+ # For example, If you wanted to create a +member+ named _joe_ and wanted to
171
+ # update the +posts+ at the same time, that would give an
172
+ # ActiveRecord::RecordNotFound error.
173
+ #
174
+ # By default the associated records are protected from being destroyed. If
175
+ # you want to destroy any of the associated records through the attributes
176
+ # hash, you have to enable it first using the <tt>:allow_destroy</tt>
177
+ # option. This will allow you to also use the <tt>_destroy</tt> key to
178
+ # destroy existing records:
179
+ #
180
+ # class Member < ActiveRecord::Base
181
+ # has_many :posts
182
+ # accepts_nested_attributes_for :posts, allow_destroy: true
183
+ # end
184
+ #
185
+ # params = { member: {
186
+ # posts_attributes: [{ id: '2', _destroy: '1' }]
187
+ # }}
188
+ #
189
+ # member.attributes = params[:member]
190
+ # member.posts.detect { |p| p.id == 2 }.marked_for_destruction? # => true
191
+ # member.posts.length # => 2
192
+ # member.save
193
+ # member.reload.posts.length # => 1
194
+ #
195
+ # Nested attributes for an associated collection can also be passed in
196
+ # the form of a hash of hashes instead of an array of hashes:
197
+ #
198
+ # Member.create(
199
+ # name: 'joe',
200
+ # posts_attributes: {
201
+ # first: { title: 'Foo' },
202
+ # second: { title: 'Bar' }
203
+ # }
204
+ # )
205
+ #
206
+ # has the same effect as
207
+ #
208
+ # Member.create(
209
+ # name: 'joe',
210
+ # posts_attributes: [
211
+ # { title: 'Foo' },
212
+ # { title: 'Bar' }
213
+ # ]
214
+ # )
215
+ #
216
+ # The keys of the hash which is the value for +:posts_attributes+ are
217
+ # ignored in this case.
218
+ # However, it is not allowed to use <tt>'id'</tt> or <tt>:id</tt> for one of
219
+ # such keys, otherwise the hash will be wrapped in an array and
220
+ # interpreted as an attribute hash for a single post.
221
+ #
222
+ # Passing attributes for an associated collection in the form of a hash
223
+ # of hashes can be used with hashes generated from HTTP/HTML parameters,
224
+ # where there may be no natural way to submit an array of hashes.
225
+ #
226
+ # === Saving
227
+ #
228
+ # All changes to models, including the destruction of those marked for
229
+ # destruction, are saved and destroyed automatically and atomically when
230
+ # the parent model is saved. This happens inside the transaction initiated
231
+ # by the parent's save method. See ActiveRecord::AutosaveAssociation.
232
+ #
233
+ # === Validating the presence of a parent model
234
+ #
235
+ # If you want to validate that a child record is associated with a parent
236
+ # record, you can use the +validates_presence_of+ method and the +:inverse_of+
237
+ # key as this example illustrates:
238
+ #
239
+ # class Member < ActiveRecord::Base
240
+ # has_many :posts, inverse_of: :member
241
+ # accepts_nested_attributes_for :posts
242
+ # end
243
+ #
244
+ # class Post < ActiveRecord::Base
245
+ # belongs_to :member, inverse_of: :posts
246
+ # validates_presence_of :member
247
+ # end
248
+ #
249
+ # Note that if you do not specify the +:inverse_of+ option, then
250
+ # Active Record will try to automatically guess the inverse association
251
+ # based on heuristics.
252
+ #
253
+ # For one-to-one nested associations, if you build the new (in-memory)
254
+ # child object yourself before assignment, then this module will not
255
+ # overwrite it, e.g.:
256
+ #
257
+ # class Member < ActiveRecord::Base
258
+ # has_one :avatar
259
+ # accepts_nested_attributes_for :avatar
260
+ #
261
+ # def avatar
262
+ # super || build_avatar(width: 200)
263
+ # end
264
+ # end
265
+ #
266
+ # member = Member.new
267
+ # member.avatar_attributes = {icon: 'sad'}
268
+ # member.avatar.width # => 200
269
+ module ClassMethods
270
+ REJECT_ALL_BLANK_PROC = proc { |attributes| attributes.all? { |key, value| key == "_destroy" || value.blank? } }
271
+
272
+ # Defines an attributes writer for the specified association(s).
273
+ #
274
+ # Supported options:
275
+ # [:allow_destroy]
276
+ # If true, destroys any members from the attributes hash with a
277
+ # <tt>_destroy</tt> key and a value that evaluates to +true+
278
+ # (eg. 1, '1', true, or 'true'). This option is off by default.
279
+ # [:reject_if]
280
+ # Allows you to specify a Proc or a Symbol pointing to a method
281
+ # that checks whether a record should be built for a certain attribute
282
+ # hash. The hash is passed to the supplied Proc or the method
283
+ # and it should return either +true+ or +false+. When no +:reject_if+
284
+ # is specified, a record will be built for all attribute hashes that
285
+ # do not have a <tt>_destroy</tt> value that evaluates to true.
286
+ # Passing <tt>:all_blank</tt> instead of a Proc will create a proc
287
+ # that will reject a record where all the attributes are blank excluding
288
+ # any value for +_destroy+.
289
+ # [:limit]
290
+ # Allows you to specify the maximum number of associated records that
291
+ # can be processed with the nested attributes. Limit also can be specified
292
+ # as a Proc or a Symbol pointing to a method that should return a number.
293
+ # If the size of the nested attributes array exceeds the specified limit,
294
+ # NestedAttributes::TooManyRecords exception is raised. If omitted, any
295
+ # number of associations can be processed.
296
+ # Note that the +:limit+ option is only applicable to one-to-many
297
+ # associations.
298
+ # [:update_only]
299
+ # For a one-to-one association, this option allows you to specify how
300
+ # nested attributes are going to be used when an associated record already
301
+ # exists. In general, an existing record may either be updated with the
302
+ # new set of attribute values or be replaced by a wholly new record
303
+ # containing those values. By default the +:update_only+ option is +false+
304
+ # and the nested attributes are used to update the existing record only
305
+ # if they include the record's <tt>:id</tt> value. Otherwise a new
306
+ # record will be instantiated and used to replace the existing one.
307
+ # However if the +:update_only+ option is +true+, the nested attributes
308
+ # are used to update the record's attributes always, regardless of
309
+ # whether the <tt>:id</tt> is present. The option is ignored for collection
310
+ # associations.
311
+ #
312
+ # Examples:
313
+ # # creates avatar_attributes=
314
+ # accepts_nested_attributes_for :avatar, reject_if: proc { |attributes| attributes['name'].blank? }
315
+ # # creates avatar_attributes=
316
+ # accepts_nested_attributes_for :avatar, reject_if: :all_blank
317
+ # # creates avatar_attributes= and posts_attributes=
318
+ # accepts_nested_attributes_for :avatar, :posts, allow_destroy: true
319
+ def accepts_nested_attributes_for(*attr_names)
320
+ options = { allow_destroy: false }
321
+ options.update(attr_names.extract_options!)
322
+ options.assert_valid_keys(:reject_if, :limit, :allow_destroy)
323
+ options[:reject_if] = REJECT_ALL_BLANK_PROC if options[:reject_if] == :all_blank
324
+
325
+ attr_names.each do |association_name|
326
+ if reflection = _reflect_on_association(association_name)
327
+ add_nested_validate_association_callbacks(reflection)
328
+
329
+ nested_attributes_options = self.nested_attributes_options.dup
330
+ nested_attributes_options[association_name.to_sym] = options
331
+ self.nested_attributes_options = nested_attributes_options
332
+
333
+ type = (reflection.collection? ? :collection : :one_to_one)
334
+ generate_association_writer(association_name, type)
335
+ else
336
+ raise ArgumentError, "No association found for name `#{association_name}'. Has it been defined yet?"
337
+ end
338
+ end
339
+ end
340
+
341
+ private
342
+
343
+ # Generates a writer method for this association. Serves as a point for
344
+ # accessing the objects in the association. For example, this method
345
+ # could generate the following:
346
+ #
347
+ # def pirate_attributes=(attributes)
348
+ # assign_nested_attributes_for_one_to_one_association(:pirate, attributes)
349
+ # end
350
+ #
351
+ # This redirects the attempts to write objects in an association through
352
+ # the helper methods defined below. Makes it seem like the nested
353
+ # associations are just regular associations.
354
+ def generate_association_writer(association_name, type)
355
+ generated_association_methods.module_eval <<-eoruby, __FILE__, __LINE__ + 1
356
+ if method_defined?(:#{association_name}_attributes=)
357
+ remove_method(:#{association_name}_attributes=)
358
+ end
359
+ def #{association_name}_attributes=(attributes)
360
+ assign_nested_attributes_for_#{type}_association(:#{association_name}, attributes)
361
+ end
362
+ eoruby
363
+ end
364
+ end
365
+
366
+ # Marks this record to be destroyed as part of the parent's save transaction.
367
+ # This does _not_ actually destroy the record instantly, rather child record will be destroyed
368
+ # when <tt>parent.save</tt> is called.
369
+ #
370
+ # Only useful if the <tt>:autosave</tt> option on the parent is enabled for this associated model.
371
+ def mark_for_destruction
372
+ @marked_for_destruction = true
373
+ end
374
+
375
+ # Returns whether or not this record will be destroyed as part of the parent's save transaction.
376
+ #
377
+ # Only useful if the <tt>:autosave</tt> option on the parent is enabled for this associated model.
378
+ def marked_for_destruction?
379
+ @marked_for_destruction
380
+ end
381
+
382
+ # Returns ActiveRecord::AutosaveAssociation::marked_for_destruction? It's
383
+ # used in conjunction with fields_for to build a form element for the
384
+ # destruction of this association.
385
+ #
386
+ # See ActionView::Helpers::FormHelper::fields_for for more info.
387
+ def _destroy
388
+ marked_for_destruction?
389
+ end
390
+
391
+ private
392
+
393
+ # Attribute hash keys that should not be assigned as normal attributes.
394
+ # These hash keys are nested attributes implementation details.
395
+ UNASSIGNABLE_KEYS = %w( _destroy )
396
+
397
+ # Assigns the given attributes to the association.
398
+ #
399
+ # If an associated record does not yet exist, one will be instantiated. If
400
+ # an associated record already exists, the method's behavior depends on
401
+ # the value of the update_only option. If update_only is +false+ and the
402
+ # given attributes include an <tt>:id</tt> that matches the existing record's
403
+ # id, then the existing record will be modified. If no <tt>:id</tt> is provided
404
+ # it will be replaced with a new record. If update_only is +true+ the existing
405
+ # record will be modified regardless of whether an <tt>:id</tt> is provided.
406
+ #
407
+ # If the given attributes include a matching <tt>:id</tt> attribute, or
408
+ # update_only is true, and a <tt>:_destroy</tt> key set to a truthy value,
409
+ # then the existing record will be marked for destruction.
410
+ def assign_nested_attributes_for_one_to_one_association(association_name, attributes)
411
+ if attributes.respond_to?(:permitted?)
412
+ attributes = attributes.to_h
413
+ end
414
+ attributes = attributes.with_indifferent_access
415
+ existing_record = send(association_name)
416
+
417
+ if reject_new_record?(association_name, attributes)
418
+ send("#{association_name}=", nil)
419
+ else
420
+ assignable_attributes = attributes.except(*UNASSIGNABLE_KEYS)
421
+
422
+ if existing_record
423
+ existing_record.assign_attributes(assignable_attributes)
424
+ else
425
+ method = "build_#{association_name}"
426
+ if respond_to?(method)
427
+ send(method, assignable_attributes)
428
+ else
429
+ raise ArgumentError, "Cannot build association `#{association_name}'. Are you trying to build a polymorphic one-to-one association?"
430
+ end
431
+ end
432
+ end
433
+ end
434
+
435
+ # Assigns the given attributes to the collection association.
436
+ #
437
+ # Hashes with an <tt>:id</tt> value matching an existing associated record
438
+ # will update that record. Hashes without an <tt>:id</tt> value will build
439
+ # a new record for the association. Hashes with a matching <tt>:id</tt>
440
+ # value and a <tt>:_destroy</tt> key set to a truthy value will mark the
441
+ # matched record for destruction.
442
+ #
443
+ # For example:
444
+ #
445
+ # assign_nested_attributes_for_collection_association(:people, {
446
+ # '1' => { id: '1', name: 'Peter' },
447
+ # '2' => { name: 'John' },
448
+ # '3' => { id: '2', _destroy: true }
449
+ # })
450
+ #
451
+ # Will update the name of the Person with ID 1, build a new associated
452
+ # person with the name 'John', and mark the associated Person with ID 2
453
+ # for destruction.
454
+ #
455
+ # Also accepts an Array of attribute hashes:
456
+ #
457
+ # assign_nested_attributes_for_collection_association(:people, [
458
+ # { id: '1', name: 'Peter' },
459
+ # { name: 'John' },
460
+ # { id: '2', _destroy: true }
461
+ # ])
462
+ def assign_nested_attributes_for_collection_association(association_name, attributes_collection)
463
+ options = nested_attributes_options[association_name]
464
+ if attributes_collection.respond_to?(:permitted?)
465
+ attributes_collection = attributes_collection.to_h
466
+ end
467
+
468
+ unless attributes_collection.is_a?(Hash) || attributes_collection.is_a?(Array)
469
+ raise ArgumentError, "Hash or Array expected, got #{attributes_collection.class.name} (#{attributes_collection.inspect})"
470
+ end
471
+
472
+ check_record_limit!(options[:limit], attributes_collection)
473
+
474
+ if attributes_collection.is_a? Hash
475
+ attributes_collection = attributes_collection.values
476
+ end
477
+
478
+ association = association(association_name)
479
+
480
+ association.delete_all
481
+
482
+ attributes_collection.each do |attributes|
483
+ if attributes.respond_to?(:permitted?)
484
+ attributes = attributes.to_h
485
+ end
486
+ attributes = attributes.with_indifferent_access
487
+
488
+ unless reject_new_record?(association_name, attributes)
489
+ association.build(attributes.except(*UNASSIGNABLE_KEYS))
490
+ end
491
+ end
492
+ end
493
+
494
+ # Takes in a limit and checks if the attributes_collection has too many
495
+ # records. It accepts limit in the form of symbol, proc, or
496
+ # number-like object (anything that can be compared with an integer).
497
+ #
498
+ # Raises TooManyRecords error if the attributes_collection is
499
+ # larger than the limit.
500
+ def check_record_limit!(limit, attributes_collection)
501
+ if limit
502
+ limit = \
503
+ case limit
504
+ when Symbol
505
+ send(limit)
506
+ when Proc
507
+ limit.call
508
+ else
509
+ limit
510
+ end
511
+
512
+ if limit && attributes_collection.size > limit
513
+ raise TooManyRecords, "Maximum #{limit} records are allowed. Got #{attributes_collection.size} records instead."
514
+ end
515
+ end
516
+ end
517
+
518
+ # Determines if a hash contains a truthy _destroy key.
519
+ def has_destroy_flag?(hash)
520
+ Type::Boolean.new.cast(hash["_destroy"])
521
+ end
522
+
523
+ # Determines if a new record should be rejected by checking
524
+ # has_destroy_flag? or if a <tt>:reject_if</tt> proc exists for this
525
+ # association and evaluates to +true+.
526
+ def reject_new_record?(association_name, attributes)
527
+ will_be_destroyed?(association_name, attributes) || call_reject_if(association_name, attributes)
528
+ end
529
+
530
+ # Determines if a record with the particular +attributes+ should be
531
+ # rejected by calling the reject_if Symbol or Proc (if defined).
532
+ # The reject_if option is defined by +accepts_nested_attributes_for+.
533
+ #
534
+ # Returns false if there is a +destroy_flag+ on the attributes.
535
+ def call_reject_if(association_name, attributes)
536
+ return false if will_be_destroyed?(association_name, attributes)
537
+
538
+ case callback = nested_attributes_options[association_name][:reject_if]
539
+ when Symbol
540
+ method(callback).arity == 0 ? send(callback) : send(callback, attributes)
541
+ when Proc
542
+ callback.call(attributes)
543
+ end
544
+ end
545
+
546
+ # Only take into account the destroy flag if <tt>:allow_destroy</tt> is true
547
+ def will_be_destroyed?(association_name, attributes)
548
+ allow_destroy?(association_name) && has_destroy_flag?(attributes)
549
+ end
550
+
551
+ def allow_destroy?(association_name)
552
+ nested_attributes_options[association_name][:allow_destroy]
553
+ end
554
+ end
555
+ end