nested_attributes.rb 22.1 KB
Newer Older
J
Jeremy Kemper 已提交
1 2
require 'active_support/core_ext/hash/except'
require 'active_support/core_ext/object/try'
3
require 'active_support/core_ext/hash/indifferent_access'
J
Jeremy Kemper 已提交
4

5 6
module ActiveRecord
  module NestedAttributes #:nodoc:
7 8 9
    class TooManyRecords < ActiveRecordError
    end

10
    extend ActiveSupport::Concern
11 12

    included do
J
Jon Leighton 已提交
13 14
      class_attribute :nested_attributes_options, instance_writer: false
      self.nested_attributes_options = {}
15 16
    end

R
Rizwan Reza 已提交
17
    # = Active Record Nested Attributes
18 19
    #
    # Nested attributes allow you to save attributes on associated records
H
Hrvoje Šimić 已提交
20 21 22 23
    # 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.
24 25 26
    #
    # The attribute writer is named after the association, which means that
    # in the following example, two new methods are added to your model:
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
    # <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:
    #
A
AvnerCohen 已提交
53
    #   params = { member: { name: 'Jack', avatar_attributes: { icon: 'smiling' } } }
P
Pratik Naik 已提交
54
    #   member = Member.create(params[:member])
55 56
    #   member.avatar.id # => 2
    #   member.avatar.icon # => 'smiling'
57 58 59
    #
    # It also allows you to update the avatar through the member:
    #
A
AvnerCohen 已提交
60
    #   params = { member: { avatar_attributes: { id: '2', icon: 'sad' } } }
61
    #   member.update params[:member]
62
    #   member.avatar.icon # => 'sad'
63 64 65 66 67 68 69 70
    #
    # 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
A
AvnerCohen 已提交
71
    #     accepts_nested_attributes_for :avatar, allow_destroy: true
72 73
    #   end
    #
74
    # Now, when you add the <tt>_destroy</tt> key to the attributes hash, with a
75 76
    # value that evaluates to +true+, you will destroy the associated model:
    #
A
AvnerCohen 已提交
77
    #   member.avatar_attributes = { id: '2', _destroy: '1' }
78 79
    #   member.avatar.marked_for_destruction? # => true
    #   member.save
80
    #   member.reload.avatar # => nil
81 82 83 84 85 86 87 88 89
    #
    # Note that the model will _not_ be destroyed until the parent is saved.
    #
    # === One-to-many
    #
    # Consider a member that has a number of posts:
    #
    #   class Member < ActiveRecord::Base
    #     has_many :posts
90
    #     accepts_nested_attributes_for :posts
91 92
    #   end
    #
93 94 95
    # 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.
96
    #
97
    # For each hash that does _not_ have an <tt>id</tt> key a new record will
98
    # be instantiated, unless the hash also contains a <tt>_destroy</tt> key
99 100
    # that evaluates to +true+.
    #
A
AvnerCohen 已提交
101 102 103 104 105
    #   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
106
    #     ]
107 108
    #   }}
    #
109
    #   member = Member.create(params[:member])
110 111 112 113 114 115 116 117
    #   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:
    #
A
Alexey Muranov 已提交
118 119 120 121
    #   class Member < ActiveRecord::Base
    #     has_many :posts
    #     accepts_nested_attributes_for :posts, reject_if: proc { |attributes| attributes['title'].blank? }
    #   end
122
    #
A
AvnerCohen 已提交
123 124 125 126 127
    #   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
128 129 130
    #     ]
    #   }}
    #
131
    #   member = Member.create(params[:member])
132 133 134
    #   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'
135
    #
136
    # Alternatively, :reject_if also accepts a symbol for using methods:
137
    #
A
Alexey Muranov 已提交
138 139 140 141
    #   class Member < ActiveRecord::Base
    #     has_many :posts
    #     accepts_nested_attributes_for :posts, reject_if: :new_record?
    #   end
142
    #
A
Alexey Muranov 已提交
143 144 145
    #   class Member < ActiveRecord::Base
    #     has_many :posts
    #     accepts_nested_attributes_for :posts, reject_if: :reject_posts
146
    #
A
Alexey Muranov 已提交
147 148 149 150
    #     def reject_posts(attributed)
    #       attributed['title'].blank?
    #     end
    #   end
151
    #
152 153
    # If the hash contains an <tt>id</tt> key that matches an already
    # associated record, the matching record will be modified:
154 155
    #
    #   member.attributes = {
A
AvnerCohen 已提交
156 157 158 159
    #     name: 'Joe',
    #     posts_attributes: [
    #       { id: 1, title: '[UPDATED] An, as of yet, undisclosed awesome Ruby documentation browser!' },
    #       { id: 2, title: '[UPDATED] other post' }
160
    #     ]
161 162
    #   }
    #
163 164
    #   member.posts.first.title # => '[UPDATED] An, as of yet, undisclosed awesome Ruby documentation browser!'
    #   member.posts.second.title # => '[UPDATED] other post'
165
    #
166 167 168
    # 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>
169
    # option. This will allow you to also use the <tt>_destroy</tt> key to
170
    # destroy existing records:
171 172 173
    #
    #   class Member < ActiveRecord::Base
    #     has_many :posts
A
AvnerCohen 已提交
174
    #     accepts_nested_attributes_for :posts, allow_destroy: true
175 176
    #   end
    #
A
AvnerCohen 已提交
177 178
    #   params = { member: {
    #     posts_attributes: [{ id: '2', _destroy: '1' }]
179 180
    #   }}
    #
181
    #   member.attributes = params[:member]
182
    #   member.posts.detect { |p| p.id == 2 }.marked_for_destruction? # => true
183
    #   member.posts.length # => 2
184
    #   member.save
P
Pratik Naik 已提交
185
    #   member.reload.posts.length # => 1
186
    #
187 188 189 190 191 192 193 194 195 196 197 198 199 200
    # 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
A
Alexey Muranov 已提交
201
    # ignored in this case.
202 203 204 205 206 207 208 209
    # However, it is not allowed to use +'id'+ or +:id+ 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 maybe no natural way to submit an array of hashes.
    #
210 211 212 213 214 215
    # === 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 parents save method. See ActiveRecord::AutosaveAssociation.
216
    #
217 218 219 220 221 222 223
    # === 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 <tt>validates_presence_of</tt> and
    # <tt>inverse_of</tt> as this example illustrates:
    #
    #   class Member < ActiveRecord::Base
A
AvnerCohen 已提交
224
    #     has_many :posts, inverse_of: :member
225 226 227 228
    #     accepts_nested_attributes_for :posts
    #   end
    #
    #   class Post < ActiveRecord::Base
A
AvnerCohen 已提交
229
    #     belongs_to :member, inverse_of: :posts
230 231
    #     validates_presence_of :member
    #   end
232
    module ClassMethods
233
      REJECT_ALL_BLANK_PROC = proc { |attributes| attributes.all? { |key, value| key == '_destroy' || value.blank? } }
234

235
      # Defines an attributes writer for the specified association(s).
236 237 238 239
      #
      # Supported options:
      # [:allow_destroy]
      #   If true, destroys any members from the attributes hash with a
240
      #   <tt>_destroy</tt> key and a value that evaluates to +true+
241 242
      #   (eg. 1, '1', true, or 'true'). This option is off by default.
      # [:reject_if]
243 244 245 246 247
      #   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
248
      #   do not have a <tt>_destroy</tt> value that evaluates to true.
249
      #   Passing <tt>:all_blank</tt> instead of a Proc will create a proc
250 251
      #   that will reject a record where all the attributes are blank excluding
      #   any value for _destroy.
252 253
      # [:limit]
      #   Allows you to specify the maximum number of the associated records that
254 255
      #   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 number. If the size of the
256 257 258
      #   nested attributes array exceeds the specified limit, NestedAttributes::TooManyRecords
      #   exception is raised. If omitted, any number associations can be processed.
      #   Note that the :limit option is only applicable to one-to-many associations.
259
      # [:update_only]
260 261 262 263 264 265 266 267 268 269 270 271
      #   For a one-to-one association, this option allows you to specify how
      #   nested attributes are 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.
272 273
      #
      # Examples:
274
      #   # creates avatar_attributes=
A
AvnerCohen 已提交
275
      #   accepts_nested_attributes_for :avatar, reject_if: proc { |attributes| attributes['name'].blank? }
276
      #   # creates avatar_attributes=
A
AvnerCohen 已提交
277
      #   accepts_nested_attributes_for :avatar, reject_if: :all_blank
278
      #   # creates avatar_attributes= and posts_attributes=
A
AvnerCohen 已提交
279
      #   accepts_nested_attributes_for :avatar, :posts, allow_destroy: true
280
      def accepts_nested_attributes_for(*attr_names)
281
        options = { :allow_destroy => false, :update_only => false }
282
        options.update(attr_names.extract_options!)
283
        options.assert_valid_keys(:allow_destroy, :reject_if, :limit, :update_only)
284
        options[:reject_if] = REJECT_ALL_BLANK_PROC if options[:reject_if] == :all_blank
285 286 287 288

        attr_names.each do |association_name|
          if reflection = reflect_on_association(association_name)
            reflection.options[:autosave] = true
289
            add_autosave_association_callbacks(reflection)
290 291

            nested_attributes_options = self.nested_attributes_options.dup
292
            nested_attributes_options[association_name.to_sym] = options
293 294
            self.nested_attributes_options = nested_attributes_options

295
            type = (reflection.collection? ? :collection : :one_to_one)
296
            generate_association_writer(association_name, type)
297 298 299 300 301
          else
            raise ArgumentError, "No association found for name `#{association_name}'. Has it been defined yet?"
          end
        end
      end
302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325

      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_feature_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
326 327
    end

328 329 330
    # 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.
331 332
    #
    # See ActionView::Helpers::FormHelper::fields_for for more info.
333
    def _destroy
334 335 336 337 338
      marked_for_destruction?
    end

    private

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

    # Assigns the given attributes to the association.
    #
345 346 347 348 349 350 351
    # 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.
352
    #
353 354 355
    # 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.
356
    def assign_nested_attributes_for_one_to_one_association(association_name, attributes)
357
      options = self.nested_attributes_options[association_name]
358
      attributes = attributes.with_indifferent_access
359

360
      if (options[:update_only] || !attributes['id'].blank?) && (record = send(association_name)) &&
361
          (options[:update_only] || record.id.to_s == attributes['id'].to_s)
362
        assign_to_or_mark_for_destruction(record, attributes, options[:allow_destroy]) unless call_reject_if(association_name, attributes)
363

364
      elsif attributes['id'].present?
365
        raise_nested_attributes_record_not_found!(association_name, attributes['id'])
366

367 368 369
      elsif !reject_new_record?(association_name, attributes)
        method = "build_#{association_name}"
        if respond_to?(method)
370
          send(method, attributes.except(*UNASSIGNABLE_KEYS))
371
        else
372
          raise ArgumentError, "Cannot build association `#{association_name}'. Are you trying to build a polymorphic one-to-one association?"
373
        end
374 375 376 377 378
      end
    end

    # Assigns the given attributes to the collection association.
    #
379 380 381
    # 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>
382
    # value and a <tt>:_destroy</tt> key set to a truthy value will mark the
383
    # matched record for destruction.
384 385 386 387
    #
    # For example:
    #
    #   assign_nested_attributes_for_collection_association(:people, {
A
AvnerCohen 已提交
388 389 390
    #     '1' => { id: '1', name: 'Peter' },
    #     '2' => { name: 'John' },
    #     '3' => { id: '2', _destroy: true }
391 392
    #   })
    #
393
    # Will update the name of the Person with ID 1, build a new associated
394
    # person with the name 'John', and mark the associated Person with ID 2
395 396 397 398 399
    # for destruction.
    #
    # Also accepts an Array of attribute hashes:
    #
    #   assign_nested_attributes_for_collection_association(:people, [
A
AvnerCohen 已提交
400 401 402
    #     { id: '1', name: 'Peter' },
    #     { name: 'John' },
    #     { id: '2', _destroy: true }
403
    #   ])
404
    def assign_nested_attributes_for_collection_association(association_name, attributes_collection)
405
      options = self.nested_attributes_options[association_name]
406

407 408
      unless attributes_collection.is_a?(Hash) || attributes_collection.is_a?(Array)
        raise ArgumentError, "Hash or Array expected, got #{attributes_collection.class.name} (#{attributes_collection.inspect})"
409 410
      end

411
      check_record_limit!(options[:limit], attributes_collection)
412

413
      if attributes_collection.is_a? Hash
414 415
        keys = attributes_collection.keys
        attributes_collection = if keys.include?('id') || keys.include?(:id)
416
          [attributes_collection]
417
        else
A
Aaron Patterson 已提交
418
          attributes_collection.values
419
        end
420 421
      end

422
      association = association(association_name)
423 424

      existing_records = if association.loaded?
425
        association.target
426 427
      else
        attribute_ids = attributes_collection.map {|a| a['id'] || a[:id] }.compact
J
Jon Leighton 已提交
428
        attribute_ids.empty? ? [] : association.scope.where(association.klass.primary_key => attribute_ids)
429 430
      end

431
      attributes_collection.each do |attributes|
432
        attributes = attributes.with_indifferent_access
433

434 435
        if attributes['id'].blank?
          unless reject_new_record?(association_name, attributes)
436
            association.build(attributes.except(*UNASSIGNABLE_KEYS))
437
          end
438
        elsif existing_record = existing_records.detect { |record| record.id.to_s == attributes['id'].to_s }
439 440 441
          unless association.loaded? || 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)
442
            target_record = association.target.detect { |record| record == existing_record }
443 444 445 446

            if target_record
              existing_record = target_record
            else
447
              association.add_to_target(existing_record)
448
            end
449
          end
450

451
          if !call_reject_if(association_name, attributes)
452
            assign_to_or_mark_for_destruction(existing_record, attributes, options[:allow_destroy])
453
          end
454
        else
455
          raise_nested_attributes_record_not_found!(association_name, attributes['id'])
456
        end
457 458 459
      end
    end

460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482
    # Takes in a limit and checks if the attributes_collection has too many
    # records. The method will take limits in the form of symbols, procs, and
    # number-like objects (anything that can be compared with an integer).
    #
    # Will raise an 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

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

490 491
    # Determines if a hash contains a truthy _destroy key.
    def has_destroy_flag?(hash)
492
      ConnectionAdapters::Column.value_to_boolean(hash['_destroy'])
493 494
    end

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

502 503 504 505 506
    # 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.
507
    def call_reject_if(association_name, attributes)
508
      return false if has_destroy_flag?(attributes)
509
      case callback = self.nested_attributes_options[association_name][:reject_if]
510 511 512
      when Symbol
        method(callback).arity == 0 ? send(callback) : send(callback, attributes)
      when Proc
513
        callback.call(attributes)
514
      end
515
    end
516

517
    def raise_nested_attributes_record_not_found!(association_name, record_id)
E
Emilio Tagua 已提交
518
      raise RecordNotFound, "Couldn't find #{self.class.reflect_on_association(association_name).klass.name} with ID=#{record_id} for #{self.class.name} with ID=#{id}"
519
    end
520
  end
521
end