aboutsummaryrefslogtreecommitdiffstats
path: root/activerecord/lib/active_record/nested_attributes.rb
blob: 917bc769933fb8a22dd56a91a894a851d788af39 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
require "active_support/core_ext/hash/except"
require "active_support/core_ext/object/try"
require "active_support/core_ext/hash/indifferent_access"

module ActiveRecord
  module NestedAttributes #:nodoc:
    class TooManyRecords < ActiveRecordError
    end

    extend ActiveSupport::Concern

    included do
      class_attribute :nested_attributes_options, instance_writer: false, default: {}
    end

    # = Active Record Nested Attributes
    #
    # Nested attributes allow you to save attributes on associated records
    # through the parent. By default nested attribute updating is turned off
    # and you can enable it using the accepts_nested_attributes_for class
    # method. When you enable nested attributes an attribute writer is
    # defined on the model.
    #
    # The attribute writer is named after the association, which means that
    # in the following example, two new methods are added to your model:
    #
    # <tt>author_attributes=(attributes)</tt> and
    # <tt>pages_attributes=(attributes)</tt>.
    #
    #   class Book < ActiveRecord::Base
    #     has_one :author
    #     has_many :pages
    #
    #     accepts_nested_attributes_for :author, :pages
    #   end
    #
    # Note that the <tt>:autosave</tt> option is automatically enabled on every
    # association that accepts_nested_attributes_for is used for.
    #
    # === One-to-one
    #
    # Consider a Member model that has one Avatar:
    #
    #   class Member < ActiveRecord::Base
    #     has_one :avatar
    #     accepts_nested_attributes_for :avatar
    #   end
    #
    # Enabling nested attributes on a one-to-one association allows you to
    # create the member and avatar in one go:
    #
    #   params = { member: { name: 'Jack', avatar_attributes: { icon: 'smiling' } } }
    #   member = Member.create(params[:member])
    #   member.avatar.id # => 2
    #   member.avatar.icon # => 'smiling'
    #
    # It also allows you to update the avatar through the member:
    #
    #   params = { member: { avatar_attributes: { id: '2', icon: 'sad' } } }
    #   member.update params[:member]
    #   member.avatar.icon # => 'sad'
    #
    # By default you will only be able to set and update attributes on the
    # associated model. If you want to destroy the associated model through the
    # attributes hash, you have to enable it first using the
    # <tt>:allow_destroy</tt> option.
    #
    #   class Member < ActiveRecord::Base
    #     has_one :avatar
    #     accepts_nested_attributes_for :avatar, allow_destroy: true
    #   end
    #
    # Now, when you add the <tt>_destroy</tt> key to the attributes hash, with a
    # value that evaluates to +true+, you will destroy the associated model:
    #
    #   member.avatar_attributes = { id: '2', _destroy: '1' }
    #   member.avatar.marked_for_destruction? # => true
    #   member.save
    #   member.reload.avatar # => nil
    #
    # Note that the model will _not_ be destroyed until the parent is saved.
    #
    # Also note that the model will not be destroyed unless you also specify
    # its id in the updated hash.
    #
    # === One-to-many
    #
    # Consider a member that has a number of posts:
    #
    #   class Member < ActiveRecord::Base
    #     has_many :posts
    #     accepts_nested_attributes_for :posts
    #   end
    #
    # You can now set or update attributes on the associated posts through
    # an attribute hash for a member: include the key +:posts_attributes+
    # with an array of hashes of post attributes as a value.
    #
    # For each hash that does _not_ have an <tt>id</tt> key a new record will
    # be instantiated, unless the hash also contains a <tt>_destroy</tt> key
    # that evaluates to +true+.
    #
    #   params = { member: {
    #     name: 'joe', posts_attributes: [
    #       { title: 'Kari, the awesome Ruby documentation browser!' },
    #       { title: 'The egalitarian assumption of the modern citizen' },
    #       { title: '', _destroy: '1' } # this will be ignored
    #     ]
    #   }}
    #
    #   member = Member.create(params[:member])
    #   member.posts.length # => 2
    #   member.posts.first.title # => 'Kari, the awesome Ruby documentation browser!'
    #   member.posts.second.title # => 'The egalitarian assumption of the modern citizen'
    #
    # You may also set a +:reject_if+ proc to silently ignore any new record
    # hashes if they fail to pass your criteria. For example, the previous
    # example could be rewritten as:
    #
    #   class Member < ActiveRecord::Base
    #     has_many :posts
    #     accepts_nested_attributes_for :posts, reject_if: proc { |attributes| attributes['title'].blank? }
    #   end
    #
    #   params = { member: {
    #     name: 'joe', posts_attributes: [
    #       { title: 'Kari, the awesome Ruby documentation browser!' },
    #       { title: 'The egalitarian assumption of the modern citizen' },
    #       { title: '' } # this will be ignored because of the :reject_if proc
    #     ]
    #   }}
    #
    #   member = Member.create(params[:member])
    #   member.posts.length # => 2
    #   member.posts.first.title # => 'Kari, the awesome Ruby documentation browser!'
    #   member.posts.second.title # => 'The egalitarian assumption of the modern citizen'
    #
    # Alternatively, +:reject_if+ also accepts a symbol for using methods:
    #
    #   class Member < ActiveRecord::Base
    #     has_many :posts
    #     accepts_nested_attributes_for :posts, reject_if: :new_record?
    #   end
    #
    #   class Member < ActiveRecord::Base
    #     has_many :posts
    #     accepts_nested_attributes_for :posts, reject_if: :reject_posts
    #
    #     def reject_posts(attributes)
    #       attributes['title'].blank?
    #     end
    #   end
    #
    # If the hash contains an <tt>id</tt> key that matches an already
    # associated record, the matching record will be modified:
    #
    #   member.attributes = {
    #     name: 'Joe',
    #     posts_attributes: [
    #       { id: 1, title: '[UPDATED] An, as of yet, undisclosed awesome Ruby documentation browser!' },
    #       { id: 2, title: '[UPDATED] other post' }
    #     ]
    #   }
    #
    #   member.posts.first.title # => '[UPDATED] An, as of yet, undisclosed awesome Ruby documentation browser!'
    #   member.posts.second.title # => '[UPDATED] other post'
    #
    # However, the above applies if the parent model is being updated as well.
    # For example, If you wanted to create a +member+ named _joe_ and wanted to
    # update the +posts+ at the same time, that would give an
    # ActiveRecord::RecordNotFound error.
    #
    # By default the associated records are protected from being destroyed. If
    # you want to destroy any of the associated records through the attributes
    # hash, you have to enable it first using the <tt>:allow_destroy</tt>
    # option. This will allow you to also use the <tt>_destroy</tt> key to
    # destroy existing records:
    #
    #   class Member < ActiveRecord::Base
    #     has_many :posts
    #     accepts_nested_attributes_for :posts, allow_destroy: true
    #   end
    #
    #   params = { member: {
    #     posts_attributes: [{ id: '2', _destroy: '1' }]
    #   }}
    #
    #   member.attributes = params[:member]
    #   member.posts.detect { |p| p.id == 2 }.marked_for_destruction? # => true
    #   member.posts.length # => 2
    #   member.save
    #   member.reload.posts.length # => 1
    #
    # Nested attributes for an associated collection can also be passed in
    # the form of a hash of hashes instead of an array of hashes:
    #
    #   Member.create(
    #     name: 'joe',
    #     posts_attributes: {
    #       first:  { title: 'Foo' },
    #       second: { title: 'Bar' }
    #     }
    #   )
    #
    # has the same effect as
    #
    #   Member.create(
    #     name: 'joe',
    #     posts_attributes: [
    #       { title: 'Foo' },
    #       { title: 'Bar' }
    #     ]
    #   )
    #
    # The keys of the hash which is the value for +:posts_attributes+ are
    # ignored in this case.
    # However, it is not allowed to use <tt>'id'</tt> or <tt>:id</tt> for one of
    # such keys, otherwise the hash will be wrapped in an array and
    # interpreted as an attribute hash for a single post.
    #
    # Passing attributes for an associated collection in the form of a hash
    # of hashes can be used with hashes generated from HTTP/HTML parameters,
    # where there may be no natural way to submit an array of hashes.
    #
    # === Saving
    #
    # All changes to models, including the destruction of those marked for
    # destruction, are saved and destroyed automatically and atomically when
    # the parent model is saved. This happens inside the transaction initiated
    # by the parent's save method. See ActiveRecord::AutosaveAssociation.
    #
    # === Validating the presence of a parent model
    #
    # If you want to validate that a child record is associated with a parent
    # record, you can use the +validates_presence_of+ method and the +:inverse_of+
    # key as this example illustrates:
    #
    #   class Member < ActiveRecord::Base
    #     has_many :posts, inverse_of: :member
    #     accepts_nested_attributes_for :posts
    #   end
    #
    #   class Post < ActiveRecord::Base
    #     belongs_to :member, inverse_of: :posts
    #     validates_presence_of :member
    #   end
    #
    # Note that if you do not specify the +:inverse_of+ option, then
    # Active Record will try to automatically guess the inverse association
    # based on heuristics.
    #
    # For one-to-one nested associations, if you build the new (in-memory)
    # child object yourself before assignment, then this module will not
    # overwrite it, e.g.:
    #
    #   class Member < ActiveRecord::Base
    #     has_one :avatar
    #     accepts_nested_attributes_for :avatar
    #
    #     def avatar
    #       super || build_avatar(width: 200)
    #     end
    #   end
    #
    #   member = Member.new
    #   member.avatar_attributes = {icon: 'sad'}
    #   member.avatar.width # => 200
    module ClassMethods
      REJECT_ALL_BLANK_PROC = proc { |attributes| attributes.all? { |key, value| key == "_destroy" || value.blank? } }

      # Defines an attributes writer for the specified association(s).
      #
      # Supported options:
      # [:allow_destroy]
      #   If true, destroys any members from the attributes hash with a
      #   <tt>_destroy</tt> key and a value that evaluates to +true+
      #   (eg. 1, '1', true, or 'true'). This option is off by default.
      # [:reject_if]
      #   Allows you to specify a Proc or a Symbol pointing to a method
      #   that checks whether a record should be built for a certain attribute
      #   hash. The hash is passed to the supplied Proc or the method
      #   and it should return either +true+ or +false+. When no +:reject_if+
      #   is specified, a record will be built for all attribute hashes that
      #   do not have a <tt>_destroy</tt> value that evaluates to true.
      #   Passing <tt>:all_blank</tt> instead of a Proc will create a proc
      #   that will reject a record where all the attributes are blank excluding
      #   any value for +_destroy+.
      # [:limit]
      #   Allows you to specify the maximum number of associated records that
      #   can be processed with the nested attributes. Limit also can be specified
      #   as a Proc or a Symbol pointing to a method that should return a number.
      #   If the size of the nested attributes array exceeds the specified limit,
      #   NestedAttributes::TooManyRecords exception is raised. If omitted, any
      #   number of associations can be processed.
      #   Note that the +:limit+ option is only applicable to one-to-many
      #   associations.
      # [:update_only]
      #   For a one-to-one association, this option allows you to specify how
      #   nested attributes are going to be used when an associated record already
      #   exists. In general, an existing record may either be updated with the
      #   new set of attribute values or be replaced by a wholly new record
      #   containing those values. By default the +:update_only+ option is +false+
      #   and the nested attributes are used to update the existing record only
      #   if they include the record's <tt>:id</tt> value. Otherwise a new
      #   record will be instantiated and used to replace the existing one.
      #   However if the +:update_only+ option is +true+, the nested attributes
      #   are used to update the record's attributes always, regardless of
      #   whether the <tt>:id</tt> is present. The option is ignored for collection
      #   associations.
      #
      # Examples:
      #   # creates avatar_attributes=
      #   accepts_nested_attributes_for :avatar, reject_if: proc { |attributes| attributes['name'].blank? }
      #   # creates avatar_attributes=
      #   accepts_nested_attributes_for :avatar, reject_if: :all_blank
      #   # creates avatar_attributes= and posts_attributes=
      #   accepts_nested_attributes_for :avatar, :posts, allow_destroy: true
      def accepts_nested_attributes_for(*attr_names)
        options = { allow_destroy: false, update_only: false }
        options.update(attr_names.extract_options!)
        options.assert_valid_keys(:allow_destroy, :reject_if, :limit, :update_only)
        options[:reject_if] = REJECT_ALL_BLANK_PROC if options[:reject_if] == :all_blank

        attr_names.each do |association_name|
          if reflection = _reflect_on_association(association_name)
            reflection.autosave = true
            define_autosave_validation_callbacks(reflection)

            nested_attributes_options = self.nested_attributes_options.dup
            nested_attributes_options[association_name.to_sym] = options
            self.nested_attributes_options = nested_attributes_options

            type = (reflection.collection? ? :collection : :one_to_one)
            generate_association_writer(association_name, type)
          else
            raise ArgumentError, "No association found for name `#{association_name}'. Has it been defined yet?"
          end
        end
      end

      private

        # Generates a writer method for this association. Serves as a point for
        # accessing the objects in the association. For example, this method
        # could generate the following:
        #
        #   def pirate_attributes=(attributes)
        #     assign_nested_attributes_for_one_to_one_association(:pirate, attributes)
        #   end
        #
        # This redirects the attempts to write objects in an association through
        # the helper methods defined below. Makes it seem like the nested
        # associations are just regular associations.
        def generate_association_writer(association_name, type)
          generated_association_methods.module_eval <<-eoruby, __FILE__, __LINE__ + 1
            if method_defined?(:#{association_name}_attributes=)
              remove_method(:#{association_name}_attributes=)
            end
            def #{association_name}_attributes=(attributes)
              assign_nested_attributes_for_#{type}_association(:#{association_name}, attributes)
            end
          eoruby
        end
    end

    # Returns ActiveRecord::AutosaveAssociation::marked_for_destruction? It's
    # used in conjunction with fields_for to build a form element for the
    # destruction of this association.
    #
    # See ActionView::Helpers::FormHelper::fields_for for more info.
    def _destroy
      marked_for_destruction?
    end

    private

      # Attribute hash keys that should not be assigned as normal attributes.
      # These hash keys are nested attributes implementation details.
      UNASSIGNABLE_KEYS = %w( id _destroy )

      # Assigns the given attributes to the association.
      #
      # If an associated record does not yet exist, one will be instantiated. If
      # an associated record already exists, the method's behavior depends on
      # the value of the update_only option. If update_only is +false+ and the
      # given attributes include an <tt>:id</tt> that matches the existing record's
      # id, then the existing record will be modified. If no <tt>:id</tt> is provided
      # it will be replaced with a new record. If update_only is +true+ the existing
      # record will be modified regardless of whether an <tt>:id</tt> is provided.
      #
      # If the given attributes include a matching <tt>:id</tt> attribute, or
      # update_only is true, and a <tt>:_destroy</tt> key set to a truthy value,
      # then the existing record will be marked for destruction.
      def assign_nested_attributes_for_one_to_one_association(association_name, attributes)
        options = nested_attributes_options[association_name]
        if attributes.respond_to?(:permitted?)
          attributes = attributes.to_h
        end
        attributes = attributes.with_indifferent_access
        existing_record = send(association_name)

        if (options[:update_only] || !attributes["id"].blank?) && existing_record &&
            (options[:update_only] || existing_record.id.to_s == attributes["id"].to_s)
          assign_to_or_mark_for_destruction(existing_record, attributes, options[:allow_destroy]) unless call_reject_if(association_name, attributes)

        elsif attributes["id"].present?
          raise_nested_attributes_record_not_found!(association_name, attributes["id"])

        elsif !reject_new_record?(association_name, attributes)
          assignable_attributes = attributes.except(*UNASSIGNABLE_KEYS)

          if existing_record && existing_record.new_record?
            existing_record.assign_attributes(assignable_attributes)
            association(association_name).initialize_attributes(existing_record)
          else
            method = "build_#{association_name}"
            if respond_to?(method)
              send(method, assignable_attributes)
            else
              raise ArgumentError, "Cannot build association `#{association_name}'. Are you trying to build a polymorphic one-to-one association?"
            end
          end
        end
      end

      # Assigns the given attributes to the collection association.
      #
      # Hashes with an <tt>:id</tt> value matching an existing associated record
      # will update that record. Hashes without an <tt>:id</tt> value will build
      # a new record for the association. Hashes with a matching <tt>:id</tt>
      # value and a <tt>:_destroy</tt> key set to a truthy value will mark the
      # matched record for destruction.
      #
      # For example:
      #
      #   assign_nested_attributes_for_collection_association(:people, {
      #     '1' => { id: '1', name: 'Peter' },
      #     '2' => { name: 'John' },
      #     '3' => { id: '2', _destroy: true }
      #   })
      #
      # Will update the name of the Person with ID 1, build a new associated
      # person with the name 'John', and mark the associated Person with ID 2
      # for destruction.
      #
      # Also accepts an Array of attribute hashes:
      #
      #   assign_nested_attributes_for_collection_association(:people, [
      #     { id: '1', name: 'Peter' },
      #     { name: 'John' },
      #     { id: '2', _destroy: true }
      #   ])
      def assign_nested_attributes_for_collection_association(association_name, attributes_collection)
        options = nested_attributes_options[association_name]
        if attributes_collection.respond_to?(:permitted?)
          attributes_collection = attributes_collection.to_h
        end

        unless attributes_collection.is_a?(Hash) || attributes_collection.is_a?(Array)
          raise ArgumentError, "Hash or Array expected for attribute `#{association_name}`, got #{attributes_collection.class.name} (#{attributes_collection.inspect})"
        end

        check_record_limit!(options[:limit], attributes_collection)

        if attributes_collection.is_a? Hash
          keys = attributes_collection.keys
          attributes_collection = if keys.include?("id") || keys.include?(:id)
            [attributes_collection]
          else
            attributes_collection.values
          end
        end

        association = association(association_name)

        existing_records = if association.loaded?
          association.target
        else
          attribute_ids = attributes_collection.map { |a| a["id"] || a[:id] }.compact
          attribute_ids.empty? ? [] : association.scope.where(association.klass.primary_key => attribute_ids)
        end

        attributes_collection.each do |attributes|
          if attributes.respond_to?(:permitted?)
            attributes = attributes.to_h
          end
          attributes = attributes.with_indifferent_access

          if attributes["id"].blank?
            unless reject_new_record?(association_name, attributes)
              association.build(attributes.except(*UNASSIGNABLE_KEYS))
            end
          elsif existing_record = existing_records.detect { |record| record.id.to_s == attributes["id"].to_s }
            unless call_reject_if(association_name, attributes)
              # Make sure we are operating on the actual object which is in the association's
              # proxy_target array (either by finding it, or adding it if not found)
              # Take into account that the proxy_target may have changed due to callbacks
              target_record = association.target.detect { |record| record.id.to_s == attributes["id"].to_s }
              if target_record
                existing_record = target_record
              else
                association.add_to_target(existing_record, :skip_callbacks)
              end

              assign_to_or_mark_for_destruction(existing_record, attributes, options[:allow_destroy])
            end
          else
            raise_nested_attributes_record_not_found!(association_name, attributes["id"])
          end
        end
      end

      # Takes in a limit and checks if the attributes_collection has too many
      # records. It accepts limit in the form of symbol, proc, or
      # number-like object (anything that can be compared with an integer).
      #
      # Raises TooManyRecords error if the attributes_collection is
      # larger than the limit.
      def check_record_limit!(limit, attributes_collection)
        if limit
          limit = \
            case limit
            when Symbol
              send(limit)
            when Proc
              limit.call
            else
              limit
            end

          if limit && attributes_collection.size > limit
            raise TooManyRecords, "Maximum #{limit} records are allowed. Got #{attributes_collection.size} records instead."
          end
        end
      end

      # Updates a record with the +attributes+ or marks it for destruction if
      # +allow_destroy+ is +true+ and has_destroy_flag? returns +true+.
      def assign_to_or_mark_for_destruction(record, attributes, allow_destroy)
        record.assign_attributes(attributes.except(*UNASSIGNABLE_KEYS))
        record.mark_for_destruction if has_destroy_flag?(attributes) && allow_destroy
      end

      # Determines if a hash contains a truthy _destroy key.
      def has_destroy_flag?(hash)
        Type::Boolean.new.cast(hash["_destroy"])
      end

      # Determines if a new record should be rejected by checking
      # has_destroy_flag? or if a <tt>:reject_if</tt> proc exists for this
      # association and evaluates to +true+.
      def reject_new_record?(association_name, attributes)
        will_be_destroyed?(association_name, attributes) || call_reject_if(association_name, attributes)
      end

      # Determines if a record with the particular +attributes+ should be
      # rejected by calling the reject_if Symbol or Proc (if defined).
      # The reject_if option is defined by +accepts_nested_attributes_for+.
      #
      # Returns false if there is a +destroy_flag+ on the attributes.
      def call_reject_if(association_name, attributes)
        return false if will_be_destroyed?(association_name, attributes)

        case callback = nested_attributes_options[association_name][:reject_if]
        when Symbol
          method(callback).arity == 0 ? send(callback) : send(callback, attributes)
        when Proc
          callback.call(attributes)
        end
      end

      # Only take into account the destroy flag if <tt>:allow_destroy</tt> is true
      def will_be_destroyed?(association_name, attributes)
        allow_destroy?(association_name) && has_destroy_flag?(attributes)
      end

      def allow_destroy?(association_name)
        nested_attributes_options[association_name][:allow_destroy]
      end

      def raise_nested_attributes_record_not_found!(association_name, record_id)
        model = self.class._reflect_on_association(association_name).klass.name
        raise RecordNotFound.new("Couldn't find #{model} with ID=#{record_id} for #{self.class.name} with ID=#{id}",
                                 model, "id", record_id)
      end
  end
end