duck_record 0.0.3 → 0.0.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -285,6 +285,10 @@ module DuckRecord #:nodoc:
285
285
  include DefineCallbacks
286
286
  include AttributeMethods
287
287
  include Callbacks
288
+ include Associations
289
+ include NestedValidateAssociation
290
+ include NestedAttributes
291
+ include Reflection
288
292
  include Serialization
289
293
 
290
294
  def persisted?
@@ -14,6 +14,14 @@ module DuckRecord
14
14
  class DangerousAttributeError < DuckRecordError
15
15
  end
16
16
 
17
+ # Raised when association is being configured improperly or user tries to use
18
+ # offset and limit together with
19
+ # {ActiveRecord::Base.has_many}[rdoc-ref:Associations::ClassMethods#has_many] or
20
+ # {ActiveRecord::Base.has_and_belongs_to_many}[rdoc-ref:Associations::ClassMethods#has_and_belongs_to_many]
21
+ # associations.
22
+ class ConfigurationError < DuckRecordError
23
+ end
24
+
17
25
  # Raised when unknown attributes are supplied via mass assignment.
18
26
  UnknownAttributeError = ActiveModel::UnknownAttributeError
19
27
 
@@ -11,13 +11,9 @@ en:
11
11
  taken: "has already been taken"
12
12
 
13
13
  # Active Record models configuration
14
- activerecord:
14
+ duck_record:
15
15
  errors:
16
16
  messages:
17
- record_invalid: "Validation failed: %{errors}"
18
- restrict_dependent_destroy:
19
- has_one: "Cannot delete record because a dependent %{record} exists"
20
- has_many: "Cannot delete record because dependent %{record} exist"
21
17
  # Append your own errors here or at the model/attributes scope.
22
18
 
23
19
  # You can define own errors for models or model attributes.
@@ -0,0 +1,531 @@
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
+ private
367
+
368
+ # Attribute hash keys that should not be assigned as normal attributes.
369
+ # These hash keys are nested attributes implementation details.
370
+ UNASSIGNABLE_KEYS = %w( _destroy )
371
+
372
+ # Assigns the given attributes to the association.
373
+ #
374
+ # If an associated record does not yet exist, one will be instantiated. If
375
+ # an associated record already exists, the method's behavior depends on
376
+ # the value of the update_only option. If update_only is +false+ and the
377
+ # given attributes include an <tt>:id</tt> that matches the existing record's
378
+ # id, then the existing record will be modified. If no <tt>:id</tt> is provided
379
+ # it will be replaced with a new record. If update_only is +true+ the existing
380
+ # record will be modified regardless of whether an <tt>:id</tt> is provided.
381
+ #
382
+ # If the given attributes include a matching <tt>:id</tt> attribute, or
383
+ # update_only is true, and a <tt>:_destroy</tt> key set to a truthy value,
384
+ # then the existing record will be marked for destruction.
385
+ def assign_nested_attributes_for_one_to_one_association(association_name, attributes)
386
+ if attributes.respond_to?(:permitted?)
387
+ attributes = attributes.to_h
388
+ end
389
+ attributes = attributes.with_indifferent_access
390
+ existing_record = send(association_name)
391
+
392
+ if reject_new_record?(association_name, attributes)
393
+ send("#{association_name}=", nil)
394
+ else
395
+ assignable_attributes = attributes.except(*UNASSIGNABLE_KEYS)
396
+
397
+ if existing_record
398
+ existing_record.assign_attributes(assignable_attributes)
399
+ association(association_name).initialize_attributes(existing_record)
400
+ else
401
+ method = "build_#{association_name}"
402
+ if respond_to?(method)
403
+ send(method, assignable_attributes)
404
+ else
405
+ raise ArgumentError, "Cannot build association `#{association_name}'. Are you trying to build a polymorphic one-to-one association?"
406
+ end
407
+ end
408
+ end
409
+ end
410
+
411
+ # Assigns the given attributes to the collection association.
412
+ #
413
+ # Hashes with an <tt>:id</tt> value matching an existing associated record
414
+ # will update that record. Hashes without an <tt>:id</tt> value will build
415
+ # a new record for the association. Hashes with a matching <tt>:id</tt>
416
+ # value and a <tt>:_destroy</tt> key set to a truthy value will mark the
417
+ # matched record for destruction.
418
+ #
419
+ # For example:
420
+ #
421
+ # assign_nested_attributes_for_collection_association(:people, {
422
+ # '1' => { id: '1', name: 'Peter' },
423
+ # '2' => { name: 'John' },
424
+ # '3' => { id: '2', _destroy: true }
425
+ # })
426
+ #
427
+ # Will update the name of the Person with ID 1, build a new associated
428
+ # person with the name 'John', and mark the associated Person with ID 2
429
+ # for destruction.
430
+ #
431
+ # Also accepts an Array of attribute hashes:
432
+ #
433
+ # assign_nested_attributes_for_collection_association(:people, [
434
+ # { id: '1', name: 'Peter' },
435
+ # { name: 'John' },
436
+ # { id: '2', _destroy: true }
437
+ # ])
438
+ def assign_nested_attributes_for_collection_association(association_name, attributes_collection)
439
+ options = nested_attributes_options[association_name]
440
+ if attributes_collection.respond_to?(:permitted?)
441
+ attributes_collection = attributes_collection.to_h
442
+ end
443
+
444
+ unless attributes_collection.is_a?(Hash) || attributes_collection.is_a?(Array)
445
+ raise ArgumentError, "Hash or Array expected, got #{attributes_collection.class.name} (#{attributes_collection.inspect})"
446
+ end
447
+
448
+ check_record_limit!(options[:limit], attributes_collection)
449
+
450
+ if attributes_collection.is_a? Hash
451
+ attributes_collection = [attributes_collection]
452
+ end
453
+
454
+ association = association(association_name)
455
+
456
+ association.delete_all
457
+
458
+ attributes_collection.each do |attributes|
459
+ if attributes.respond_to?(:permitted?)
460
+ attributes = attributes.to_h
461
+ end
462
+ attributes = attributes.with_indifferent_access
463
+
464
+ unless reject_new_record?(association_name, attributes)
465
+ association.build(attributes.except(*UNASSIGNABLE_KEYS))
466
+ end
467
+ end
468
+ end
469
+
470
+ # Takes in a limit and checks if the attributes_collection has too many
471
+ # records. It accepts limit in the form of symbol, proc, or
472
+ # number-like object (anything that can be compared with an integer).
473
+ #
474
+ # Raises TooManyRecords error if the attributes_collection is
475
+ # larger than the limit.
476
+ def check_record_limit!(limit, attributes_collection)
477
+ if limit
478
+ limit = \
479
+ case limit
480
+ when Symbol
481
+ send(limit)
482
+ when Proc
483
+ limit.call
484
+ else
485
+ limit
486
+ end
487
+
488
+ if limit && attributes_collection.size > limit
489
+ raise TooManyRecords, "Maximum #{limit} records are allowed. Got #{attributes_collection.size} records instead."
490
+ end
491
+ end
492
+ end
493
+
494
+ # Determines if a hash contains a truthy _destroy key.
495
+ def has_destroy_flag?(hash)
496
+ Type::Boolean.new.cast(hash["_destroy"])
497
+ end
498
+
499
+ # Determines if a new record should be rejected by checking
500
+ # has_destroy_flag? or if a <tt>:reject_if</tt> proc exists for this
501
+ # association and evaluates to +true+.
502
+ def reject_new_record?(association_name, attributes)
503
+ will_be_destroyed?(association_name, attributes) || call_reject_if(association_name, attributes)
504
+ end
505
+
506
+ # Determines if a record with the particular +attributes+ should be
507
+ # rejected by calling the reject_if Symbol or Proc (if defined).
508
+ # The reject_if option is defined by +accepts_nested_attributes_for+.
509
+ #
510
+ # Returns false if there is a +destroy_flag+ on the attributes.
511
+ def call_reject_if(association_name, attributes)
512
+ return false if will_be_destroyed?(association_name, attributes)
513
+
514
+ case callback = nested_attributes_options[association_name][:reject_if]
515
+ when Symbol
516
+ method(callback).arity == 0 ? send(callback) : send(callback, attributes)
517
+ when Proc
518
+ callback.call(attributes)
519
+ end
520
+ end
521
+
522
+ # Only take into account the destroy flag if <tt>:allow_destroy</tt> is true
523
+ def will_be_destroyed?(association_name, attributes)
524
+ allow_destroy?(association_name) && has_destroy_flag?(attributes)
525
+ end
526
+
527
+ def allow_destroy?(association_name)
528
+ nested_attributes_options[association_name][:allow_destroy]
529
+ end
530
+ end
531
+ end