rails / rails

Ruby on Rails

rails / activerecord / lib / active_record / nested_attributes.rb
e8550ee0 » jeremy 2009-05-13 Cherry-pick core extensions 1 require 'active_support/core_ext/hash/except'
2 require 'active_support/core_ext/object/try'
3
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 4 module ActiveRecord
5 module NestedAttributes #:nodoc:
4e50a35f » josh 2009-05-28 Break up DependencyModule's... 6 extend ActiveSupport::Concern
a2875bec » brynary 2009-05-11 Use DependencyModule for in... 7
8 included do
9 class_inheritable_accessor :reject_new_nested_attributes_procs, :instance_writer => false
10 self.reject_new_nested_attributes_procs = {}
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 11 end
12
13 # == Nested Attributes
14 #
15 # Nested attributes allow you to save attributes on associated records
16 # through the parent. By default nested attribute updating is turned off,
17 # you can enable it using the accepts_nested_attributes_for class method.
18 # When you enable nested attributes an attribute writer is defined on
19 # the model.
20 #
21 # The attribute writer is named after the association, which means that
22 # in the following example, two new methods are added to your model:
23 # <tt>author_attributes=(attributes)</tt> and
24 # <tt>pages_attributes=(attributes)</tt>.
25 #
26 # class Book < ActiveRecord::Base
27 # has_one :author
28 # has_many :pages
29 #
30 # accepts_nested_attributes_for :author, :pages
31 # end
32 #
33 # Note that the <tt>:autosave</tt> option is automatically enabled on every
34 # association that accepts_nested_attributes_for is used for.
35 #
36 # === One-to-one
37 #
38 # Consider a Member model that has one Avatar:
39 #
40 # class Member < ActiveRecord::Base
41 # has_one :avatar
42 # accepts_nested_attributes_for :avatar
43 # end
44 #
45 # Enabling nested attributes on a one-to-one association allows you to
46 # create the member and avatar in one go:
47 #
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 48 # params = { :member => { :name => 'Jack', :avatar_attributes => { :icon => 'smiling' } } }
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 49 # member = Member.create(params)
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 50 # member.avatar.id # => 2
51 # member.avatar.icon # => 'smiling'
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 52 #
53 # It also allows you to update the avatar through the member:
54 #
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 55 # params = { :member' => { :avatar_attributes => { :id => '2', :icon => 'sad' } } }
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 56 # member.update_attributes params['member']
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 57 # member.avatar.icon # => 'sad'
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 58 #
59 # By default you will only be able to set and update attributes on the
60 # associated model. If you want to destroy the associated model through the
61 # attributes hash, you have to enable it first using the
62 # <tt>:allow_destroy</tt> option.
63 #
64 # class Member < ActiveRecord::Base
65 # has_one :avatar
66 # accepts_nested_attributes_for :avatar, :allow_destroy => true
67 # end
68 #
69 # Now, when you add the <tt>_delete</tt> key to the attributes hash, with a
70 # value that evaluates to +true+, you will destroy the associated model:
71 #
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 72 # member.avatar_attributes = { :id => '2', :_delete => '1' }
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 73 # member.avatar.marked_for_destruction? # => true
74 # member.save
75 # member.avatar #=> nil
76 #
77 # Note that the model will _not_ be destroyed until the parent is saved.
78 #
79 # === One-to-many
80 #
81 # Consider a member that has a number of posts:
82 #
83 # class Member < ActiveRecord::Base
84 # has_many :posts
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 85 # accepts_nested_attributes_for :posts
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 86 # end
87 #
88 # You can now set or update attributes on an associated post model through
89 # the attribute hash.
90 #
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 91 # For each hash that does _not_ have an <tt>id</tt> key a new record will
92 # be instantiated, unless the hash also contains a <tt>_delete</tt> key
93 # that evaluates to +true+.
94 #
95 # params = { :member => {
96 # :name => 'joe', :posts_attributes => [
97 # { :title => 'Kari, the awesome Ruby documentation browser!' },
98 # { :title => 'The egalitarian assumption of the modern citizen' },
99 # { :title => '', :_delete => '1' } # this will be ignored
100 # ]
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 101 # }}
102 #
103 # member = Member.create(params['member'])
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 104 # member.posts.length # => 2
105 # member.posts.first.title # => 'Kari, the awesome Ruby documentation browser!'
106 # member.posts.second.title # => 'The egalitarian assumption of the modern citizen'
107 #
108 # You may also set a :reject_if proc to silently ignore any new record
109 # hashes if they fail to pass your criteria. For example, the previous
110 # example could be rewritten as:
111 #
112 # class Member < ActiveRecord::Base
113 # has_many :posts
114 # accepts_nested_attributes_for :posts, :reject_if => proc { |attributes| attributes['title'].blank? }
115 # end
116 #
117 # params = { :member => {
118 # :name => 'joe', :posts_attributes => [
119 # { :title => 'Kari, the awesome Ruby documentation browser!' },
120 # { :title => 'The egalitarian assumption of the modern citizen' },
121 # { :title => '' } # this will be ignored because of the :reject_if proc
122 # ]
123 # }}
124 #
125 # member = Member.create(params['member'])
126 # member.posts.length # => 2
127 # member.posts.first.title # => 'Kari, the awesome Ruby documentation browser!'
128 # member.posts.second.title # => 'The egalitarian assumption of the modern citizen'
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 129 #
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 130 # If the hash contains an <tt>id</tt> key that matches an already
131 # associated record, the matching record will be modified:
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 132 #
133 # member.attributes = {
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 134 # :name => 'Joe',
135 # :posts_attributes => [
136 # { :id => 1, :title => '[UPDATED] An, as of yet, undisclosed awesome Ruby documentation browser!' },
137 # { :id => 2, :title => '[UPDATED] other post' }
138 # ]
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 139 # }
140 #
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 141 # member.posts.first.title # => '[UPDATED] An, as of yet, undisclosed awesome Ruby documentation browser!'
142 # member.posts.second.title # => '[UPDATED] other post'
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 143 #
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 144 # By default the associated records are protected from being destroyed. If
145 # you want to destroy any of the associated records through the attributes
146 # hash, you have to enable it first using the <tt>:allow_destroy</tt>
147 # option. This will allow you to also use the <tt>_delete</tt> key to
148 # destroy existing records:
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 149 #
150 # class Member < ActiveRecord::Base
151 # has_many :posts
152 # accepts_nested_attributes_for :posts, :allow_destroy => true
153 # end
154 #
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 155 # params = { :member => {
156 # :posts_attributes => [{ :id => '2', :_delete => '1' }]
157 # }}
158 #
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 159 # member.attributes = params['member']
160 # member.posts.detect { |p| p.id == 2 }.marked_for_destruction? # => true
161 # member.posts.length #=> 2
162 # member.save
163 # member.posts.length # => 1
164 #
165 # === Saving
166 #
167 # All changes to models, including the destruction of those marked for
168 # destruction, are saved and destroyed automatically and atomically when
169 # the parent model is saved. This happens inside the transaction initiated
170 # by the parents save method. See ActiveRecord::AutosaveAssociation.
171 module ClassMethods
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 172 # Defines an attributes writer for the specified association(s). If you
173 # are using <tt>attr_protected</tt> or <tt>attr_accessible</tt>, then you
174 # will need to add the attribute writer to the allowed list.
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 175 #
176 # Supported options:
177 # [:allow_destroy]
178 # If true, destroys any members from the attributes hash with a
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 179 # <tt>_delete</tt> key and a value that evaluates to +true+
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 180 # (eg. 1, '1', true, or 'true'). This option is off by default.
181 # [:reject_if]
182 # Allows you to specify a Proc that checks whether a record should be
183 # built for a certain attribute hash. The hash is passed to the Proc
184 # and the Proc should return either +true+ or +false+. When no Proc
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 185 # is specified a record will be built for all attribute hashes that
186 # do not have a <tt>_delete</tt> that evaluates to true.
9010ed27 » hardbap 2009-05-09 Allow you to pass :all_blan... Comment 187 # Passing <tt>:all_blank</tt> instead of a Proc will create a proc
188 # that will reject a record where all the attributes are blank.
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 189 #
190 # Examples:
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 191 # # creates avatar_attributes=
192 # accepts_nested_attributes_for :avatar, :reject_if => proc { |attributes| attributes['name'].blank? }
9010ed27 » hardbap 2009-05-09 Allow you to pass :all_blan... Comment 193 # # creates avatar_attributes=
194 # accepts_nested_attributes_for :avatar, :reject_if => :all_blank
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 195 # # creates avatar_attributes= and posts_attributes=
196 # accepts_nested_attributes_for :avatar, :posts, :allow_destroy => true
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 197 def accepts_nested_attributes_for(*attr_names)
198 options = { :allow_destroy => false }
199 options.update(attr_names.extract_options!)
200 options.assert_valid_keys(:allow_destroy, :reject_if)
201
202 attr_names.each do |association_name|
203 if reflection = reflect_on_association(association_name)
204 type = case reflection.macro
205 when :has_one, :belongs_to
206 :one_to_one
207 when :has_many, :has_and_belongs_to_many
208 :collection
209 end
210
211 reflection.options[:autosave] = true
9010ed27 » hardbap 2009-05-09 Allow you to pass :all_blan... Comment 212
213 self.reject_new_nested_attributes_procs[association_name.to_sym] = if options[:reject_if] == :all_blank
214 proc { |attributes| attributes.all? {|k,v| v.blank?} }
215 else
216 options[:reject_if]
217 end
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 218
219 # def pirate_attributes=(attributes)
220 # assign_nested_attributes_for_one_to_one_association(:pirate, attributes, false)
221 # end
222 class_eval %{
223 def #{association_name}_attributes=(attributes)
224 assign_nested_attributes_for_#{type}_association(:#{association_name}, attributes, #{options[:allow_destroy]})
225 end
226 }, __FILE__, __LINE__
227 else
228 raise ArgumentError, "No association found for name `#{association_name}'. Has it been defined yet?"
229 end
230 end
231 end
232 end
233
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 234 # Returns ActiveRecord::AutosaveAssociation::marked_for_destruction? It's
235 # used in conjunction with fields_for to build a form element for the
236 # destruction of this association.
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 237 #
238 # See ActionView::Helpers::FormHelper::fields_for for more info.
239 def _delete
240 marked_for_destruction?
241 end
242
243 private
244
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 245 # Attribute hash keys that should not be assigned as normal attributes.
246 # These hash keys are nested attributes implementation details.
247 UNASSIGNABLE_KEYS = %w{ id _delete }
248
249 # Assigns the given attributes to the association.
250 #
251 # If the given attributes include an <tt>:id</tt> that matches the existing
252 # record’s id, then the existing record will be modified. Otherwise a new
253 # record will be built.
254 #
255 # If the given attributes include a matching <tt>:id</tt> attribute _and_ a
256 # <tt>:_delete</tt> key set to a truthy value, then the existing record
257 # will be marked for destruction.
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 258 def assign_nested_attributes_for_one_to_one_association(association_name, attributes, allow_destroy)
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 259 attributes = attributes.stringify_keys
260
261 if attributes['id'].blank?
262 unless reject_new_record?(association_name, attributes)
263 send("build_#{association_name}", attributes.except(*UNASSIGNABLE_KEYS))
264 end
265 elsif (existing_record = send(association_name)) && existing_record.id.to_s == attributes['id'].to_s
266 assign_to_or_mark_for_destruction(existing_record, attributes, allow_destroy)
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 267 end
268 end
269
270 # Assigns the given attributes to the collection association.
271 #
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 272 # Hashes with an <tt>:id</tt> value matching an existing associated record
273 # will update that record. Hashes without an <tt>:id</tt> value will build
274 # a new record for the association. Hashes with a matching <tt>:id</tt>
275 # value and a <tt>:_delete</tt> key set to a truthy value will mark the
276 # matched record for destruction.
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 277 #
278 # For example:
279 #
280 # assign_nested_attributes_for_collection_association(:people, {
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 281 # '1' => { :id => '1', :name => 'Peter' },
282 # '2' => { :name => 'John' },
283 # '3' => { :id => '2', :_delete => true }
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 284 # })
285 #
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 286 # Will update the name of the Person with ID 1, build a new associated
287 # person with the name `John', and mark the associatied Person with ID 2
288 # for destruction.
289 #
290 # Also accepts an Array of attribute hashes:
291 #
292 # assign_nested_attributes_for_collection_association(:people, [
293 # { :id => '1', :name => 'Peter' },
294 # { :name => 'John' },
295 # { :id => '2', :_delete => true }
296 # ])
297 def assign_nested_attributes_for_collection_association(association_name, attributes_collection, allow_destroy)
298 unless attributes_collection.is_a?(Hash) || attributes_collection.is_a?(Array)
299 raise ArgumentError, "Hash or Array expected, got #{attributes_collection.class.name} (#{attributes_collection.inspect})"
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 300 end
301
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 302 if attributes_collection.is_a? Hash
303 attributes_collection = attributes_collection.sort_by { |index, _| index.to_i }.map { |_, attributes| attributes }
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 304 end
305
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 306 attributes_collection.each do |attributes|
307 attributes = attributes.stringify_keys
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 308
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 309 if attributes['id'].blank?
310 unless reject_new_record?(association_name, attributes)
311 send(association_name).build(attributes.except(*UNASSIGNABLE_KEYS))
312 end
313 elsif existing_record = send(association_name).detect { |record| record.id.to_s == attributes['id'].to_s }
314 assign_to_or_mark_for_destruction(existing_record, attributes, allow_destroy)
315 end
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 316 end
317 end
318
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 319 # Updates a record with the +attributes+ or marks it for destruction if
320 # +allow_destroy+ is +true+ and has_delete_flag? returns +true+.
321 def assign_to_or_mark_for_destruction(record, attributes, allow_destroy)
322 if has_delete_flag?(attributes) && allow_destroy
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 323 record.mark_for_destruction
324 else
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 325 record.attributes = attributes.except(*UNASSIGNABLE_KEYS)
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 326 end
327 end
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 328
329 # Determines if a hash contains a truthy _delete key.
330 def has_delete_flag?(hash)
331 ConnectionAdapters::Column.value_to_boolean hash['_delete']
332 end
333
334 # Determines if a new record should be build by checking for
335 # has_delete_flag? or if a <tt>:reject_if</tt> proc exists for this
336 # association and evaluates to +true+.
337 def reject_new_record?(association_name, attributes)
338 has_delete_flag?(attributes) ||
339 self.class.reject_new_nested_attributes_procs[association_name].try(:call, attributes)
340 end
ec8f0458 » alloy 2009-01-31 Add support for nested obje... Comment 341 end
5dbc9d40 » cainlevy 2009-02-08 Changed API of NestedAttrib... 342 end