nested_attributes.rb 13.6 KB
Newer Older
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
module ActiveRecord
  module NestedAttributes #:nodoc:
    def self.included(base)
      base.extend(ClassMethods)
      base.class_inheritable_accessor :reject_new_nested_attributes_procs, :instance_writer => false
      base.reject_new_nested_attributes_procs = {}
    end

    # == Nested Attributes
    #
    # Nested attributes allow you to save attributes on associated records
    # through the parent. By default nested attribute updating is turned off,
    # 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:
    #
44
    #   params = { :member => { :name => 'Jack', :avatar_attributes => { :icon => 'smiling' } } }
45
    #   member = Member.create(params)
46 47
    #   member.avatar.id # => 2
    #   member.avatar.icon # => 'smiling'
48 49 50
    #
    # It also allows you to update the avatar through the member:
    #
51
    #   params = { :member' => { :avatar_attributes => { :id => '2', :icon => 'sad' } } }
52
    #   member.update_attributes params['member']
53
    #   member.avatar.icon # => 'sad'
54 55 56 57 58 59 60 61 62 63 64 65 66 67
    #
    # 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>_delete</tt> key to the attributes hash, with a
    # value that evaluates to +true+, you will destroy the associated model:
    #
68
    #   member.avatar_attributes = { :id => '2', :_delete => '1' }
69 70 71 72 73 74 75 76 77 78 79 80
    #   member.avatar.marked_for_destruction? # => true
    #   member.save
    #   member.avatar #=> nil
    #
    # 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
81
    #     accepts_nested_attributes_for :posts
82 83 84 85 86
    #   end
    #
    # You can now set or update attributes on an associated post model through
    # the attribute hash.
    #
87 88 89 90 91 92 93 94 95 96
    # 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>_delete</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 => '', :_delete => '1' } # this will be ignored
    #     ]
97 98 99
    #   }}
    #
    #   member = Member.create(params['member'])
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
    #   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'
125
    #
126 127
    # If the hash contains an <tt>id</tt> key that matches an already
    # associated record, the matching record will be modified:
128 129
    #
    #   member.attributes = {
130 131 132 133 134
    #     :name => 'Joe',
    #     :posts_attributes => [
    #       { :id => 1, :title => '[UPDATED] An, as of yet, undisclosed awesome Ruby documentation browser!' },
    #       { :id => 2, :title => '[UPDATED] other post' }
    #     ]
135 136
    #   }
    #
137 138
    #   member.posts.first.title # => '[UPDATED] An, as of yet, undisclosed awesome Ruby documentation browser!'
    #   member.posts.second.title # => '[UPDATED] other post'
139
    #
140 141 142 143 144
    # 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>_delete</tt> key to
    # destroy existing records:
145 146 147 148 149 150
    #
    #   class Member < ActiveRecord::Base
    #     has_many :posts
    #     accepts_nested_attributes_for :posts, :allow_destroy => true
    #   end
    #
151 152 153 154
    #   params = { :member => {
    #     :posts_attributes => [{ :id => '2', :_delete => '1' }]
    #   }}
    #
155 156 157 158 159 160 161 162 163 164 165 166 167
    #   member.attributes = params['member']
    #   member.posts.detect { |p| p.id == 2 }.marked_for_destruction? # => true
    #   member.posts.length #=> 2
    #   member.save
    #   member.posts.length # => 1
    #
    # === 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.
    module ClassMethods
168 169 170
      # Defines an attributes writer for the specified association(s). If you
      # are using <tt>attr_protected</tt> or <tt>attr_accessible</tt>, then you
      # will need to add the attribute writer to the allowed list.
171 172 173 174
      #
      # Supported options:
      # [:allow_destroy]
      #   If true, destroys any members from the attributes hash with a
175
      #   <tt>_delete</tt> key and a value that evaluates to +true+
176 177 178 179 180
      #   (eg. 1, '1', true, or 'true'). This option is off by default.
      # [:reject_if]
      #   Allows you to specify a Proc that checks whether a record should be
      #   built for a certain attribute hash. The hash is passed to the Proc
      #   and the Proc should return either +true+ or +false+. When no Proc
181 182
      #   is specified a record will be built for all attribute hashes that
      #   do not have a <tt>_delete</tt> that evaluates to true.
183 184
      #
      # Examples:
185 186 187 188
      #   # creates avatar_attributes=
      #   accepts_nested_attributes_for :avatar, :reject_if => proc { |attributes| attributes['name'].blank? }
      #   # creates avatar_attributes= and posts_attributes=
      #   accepts_nested_attributes_for :avatar, :posts, :allow_destroy => true
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
      def accepts_nested_attributes_for(*attr_names)
        options = { :allow_destroy => false }
        options.update(attr_names.extract_options!)
        options.assert_valid_keys(:allow_destroy, :reject_if)

        attr_names.each do |association_name|
          if reflection = reflect_on_association(association_name)
            type = case reflection.macro
            when :has_one, :belongs_to
              :one_to_one
            when :has_many, :has_and_belongs_to_many
              :collection
            end

            reflection.options[:autosave] = true
            self.reject_new_nested_attributes_procs[association_name.to_sym] = options[:reject_if]

            # def pirate_attributes=(attributes)
            #   assign_nested_attributes_for_one_to_one_association(:pirate, attributes, false)
            # end
            class_eval %{
              def #{association_name}_attributes=(attributes)
                assign_nested_attributes_for_#{type}_association(:#{association_name}, attributes, #{options[:allow_destroy]})
              end
            }, __FILE__, __LINE__
          else
            raise ArgumentError, "No association found for name `#{association_name}'. Has it been defined yet?"
          end
        end
      end
    end

221 222 223
    # 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.
224 225 226 227 228 229 230 231
    #
    # See ActionView::Helpers::FormHelper::fields_for for more info.
    def _delete
      marked_for_destruction?
    end

    private

232 233 234 235 236 237 238 239 240 241 242 243 244
    # Attribute hash keys that should not be assigned as normal attributes.
    # These hash keys are nested attributes implementation details.
    UNASSIGNABLE_KEYS = %w{ id _delete }

    # Assigns the given attributes to the association.
    #
    # If the given attributes include an <tt>:id</tt> that matches the existing
    # record’s id, then the existing record will be modified. Otherwise a new
    # record will be built.
    #
    # If the given attributes include a matching <tt>:id</tt> attribute _and_ a
    # <tt>:_delete</tt> key set to a truthy value, then the existing record
    # will be marked for destruction.
245
    def assign_nested_attributes_for_one_to_one_association(association_name, attributes, allow_destroy)
246 247 248 249 250 251 252 253
      attributes = attributes.stringify_keys

      if attributes['id'].blank?
        unless reject_new_record?(association_name, attributes)
          send("build_#{association_name}", attributes.except(*UNASSIGNABLE_KEYS))
        end
      elsif (existing_record = send(association_name)) && existing_record.id.to_s == attributes['id'].to_s
        assign_to_or_mark_for_destruction(existing_record, attributes, allow_destroy)
254 255 256 257 258
      end
    end

    # Assigns the given attributes to the collection association.
    #
259 260 261 262 263
    # 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>:_delete</tt> key set to a truthy value will mark the
    # matched record for destruction.
264 265 266 267
    #
    # For example:
    #
    #   assign_nested_attributes_for_collection_association(:people, {
268 269 270
    #     '1' => { :id => '1', :name => 'Peter' },
    #     '2' => { :name => 'John' },
    #     '3' => { :id => '2', :_delete => true }
271 272
    #   })
    #
273 274 275 276 277 278 279 280 281 282 283 284 285 286
    # Will update the name of the Person with ID 1, build a new associated
    # person with the name `John', and mark the associatied 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', :_delete => true }
    #   ])
    def assign_nested_attributes_for_collection_association(association_name, attributes_collection, allow_destroy)
      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})"
287 288
      end

289 290
      if attributes_collection.is_a? Hash
        attributes_collection = attributes_collection.sort_by { |index, _| index.to_i }.map { |_, attributes| attributes }
291 292
      end

293 294
      attributes_collection.each do |attributes|
        attributes = attributes.stringify_keys
295

296 297 298 299 300 301 302
        if attributes['id'].blank?
          unless reject_new_record?(association_name, attributes)
            send(association_name).build(attributes.except(*UNASSIGNABLE_KEYS))
          end
        elsif existing_record = send(association_name).detect { |record| record.id.to_s == attributes['id'].to_s }
          assign_to_or_mark_for_destruction(existing_record, attributes, allow_destroy)
        end
303 304 305
      end
    end

306 307 308 309
    # Updates a record with the +attributes+ or marks it for destruction if
    # +allow_destroy+ is +true+ and has_delete_flag? returns +true+.
    def assign_to_or_mark_for_destruction(record, attributes, allow_destroy)
      if has_delete_flag?(attributes) && allow_destroy
310 311
        record.mark_for_destruction
      else
312
        record.attributes = attributes.except(*UNASSIGNABLE_KEYS)
313 314
      end
    end
315 316 317 318 319 320 321 322 323 324 325 326 327

    # Determines if a hash contains a truthy _delete key.
    def has_delete_flag?(hash)
      ConnectionAdapters::Column.value_to_boolean hash['_delete']
    end

    # Determines if a new record should be build by checking for
    # has_delete_flag? or if a <tt>:reject_if</tt> proc exists for this
    # association and evaluates to +true+.
    def reject_new_record?(association_name, attributes)
      has_delete_flag?(attributes) ||
        self.class.reject_new_nested_attributes_procs[association_name].try(:call, attributes)
    end
328
  end
329
end