dm-accepts_nested_attributes_for 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (58) hide show
  1. data/CHANGELOG +970 -0
  2. data/Gemfile +84 -0
  3. data/LICENSE +20 -0
  4. data/README.textile +94 -0
  5. data/Rakefile +25 -0
  6. data/TODO +6 -0
  7. data/VERSION +1 -0
  8. data/dm-accepts_nested_attributes.gemspec +114 -0
  9. data/lib/dm-accepts_nested_attributes.rb +13 -0
  10. data/lib/dm-accepts_nested_attributes/model.rb +144 -0
  11. data/lib/dm-accepts_nested_attributes/relationship.rb +82 -0
  12. data/lib/dm-accepts_nested_attributes/resource.rb +382 -0
  13. data/lib/dm-accepts_nested_attributes/version.rb +7 -0
  14. data/spec/accepts_nested_attributes_for_spec.rb +408 -0
  15. data/spec/assign_nested_attributes_for_spec.rb +101 -0
  16. data/spec/comb/1-1_disjoint_spec.rb +67 -0
  17. data/spec/comb/1-1_overlapping_spec.rb +66 -0
  18. data/spec/comb/1-1_subset_spec.rb +65 -0
  19. data/spec/comb/1-1_superset_spec.rb +67 -0
  20. data/spec/comb/1-m_disjoint_spec.rb +71 -0
  21. data/spec/comb/1-m_overlapping_spec.rb +70 -0
  22. data/spec/comb/1-m_subset_spec.rb +65 -0
  23. data/spec/comb/1-m_superset_spec.rb +71 -0
  24. data/spec/comb/m-1_disjoint_spec.rb +71 -0
  25. data/spec/comb/m-1_overlapping_spec.rb +70 -0
  26. data/spec/comb/m-1_subset_spec.rb +65 -0
  27. data/spec/comb/m-1_superset_spec.rb +71 -0
  28. data/spec/comb/n-m_composite_spec.rb +141 -0
  29. data/spec/comb/n-m_surrogate_spec.rb +154 -0
  30. data/spec/many_to_many_composite_spec.rb +120 -0
  31. data/spec/many_to_many_spec.rb +129 -0
  32. data/spec/many_to_one_composite_spec.rb +120 -0
  33. data/spec/many_to_one_spec.rb +101 -0
  34. data/spec/one_to_many_composite_spec.rb +120 -0
  35. data/spec/one_to_many_spec.rb +100 -0
  36. data/spec/one_to_one_composite_spec.rb +150 -0
  37. data/spec/one_to_one_spec.rb +115 -0
  38. data/spec/rcov.opts +6 -0
  39. data/spec/resource_spec.rb +65 -0
  40. data/spec/shared/many_to_many_composite_spec.rb +149 -0
  41. data/spec/shared/many_to_many_spec.rb +146 -0
  42. data/spec/shared/many_to_one_composite_spec.rb +160 -0
  43. data/spec/shared/many_to_one_spec.rb +130 -0
  44. data/spec/shared/one_to_many_composite_spec.rb +159 -0
  45. data/spec/shared/one_to_many_spec.rb +107 -0
  46. data/spec/shared/one_to_one_composite_spec.rb +114 -0
  47. data/spec/shared/one_to_one_spec.rb +111 -0
  48. data/spec/spec.opts +4 -0
  49. data/spec/spec_helper.rb +50 -0
  50. data/spec/update_dirty_spec.rb +113 -0
  51. data/spec/update_multiple_spec.rb +79 -0
  52. data/tasks/changelog.rake +20 -0
  53. data/tasks/ci.rake +1 -0
  54. data/tasks/local_gemfile.rake +18 -0
  55. data/tasks/spec.rake +22 -0
  56. data/tasks/yard.rake +9 -0
  57. data/tasks/yardstick.rake +19 -0
  58. metadata +216 -0
@@ -0,0 +1,82 @@
1
+ module DataMapper
2
+ module NestedAttributes
3
+
4
+ # Extensions for {DataMapper::Associations::Relationship}.
5
+ module Relationship
6
+ # Extracts the primary key values necessary to retrieve or update a nested
7
+ # model when using {Model#accepts_nested_attributes_for}. Values are taken from
8
+ # the specified model and attribute hash with the former having priority.
9
+ # Values for properties in the primary key that are *not* included in the
10
+ # foreign key must be specified in the attributes hash.
11
+ #
12
+ # @param model [DataMapper::Model]
13
+ # The model that accepts nested attributes.
14
+ #
15
+ # @param attributes [Hash]
16
+ # The attributes assigned to the nested attribute setter on the
17
+ # +model+.
18
+ #
19
+ # @return [Array]
20
+ def extract_keys_for_nested_attributes(model, attributes)
21
+ raise NotImplementedError, "extract_keys must be overridden in a derived class"
22
+ end
23
+ end
24
+
25
+ # Extensions for {DataMapper::Associations::ManyToMany::Relationship}.
26
+ module ManyToMany
27
+ def extract_keys_for_nested_attributes(model, attributes)
28
+ keys = self.child_key.map do |key|
29
+ attributes[key.name]
30
+ end
31
+
32
+ keys.any? { |key| DataMapper::Ext.blank?(key) } ? nil : keys
33
+ end
34
+ end
35
+
36
+ # Extensions for {DataMapper::Associations::OneToMany::Relationship}.
37
+ module OneToMany
38
+ def extract_keys_for_nested_attributes(model, attributes)
39
+ keys = self.child_model.key.to_enum(:each_with_index).map do |key, idx|
40
+ if parent_idx = self.child_key.to_a.index(key)
41
+ model[self.parent_key.to_a.at(parent_idx).name]
42
+ else
43
+ attributes[key.name]
44
+ end
45
+ end
46
+
47
+ keys.any? { |key| DataMapper::Ext.blank?(key) } ? nil : keys
48
+ end
49
+ end
50
+
51
+ # Extensions for {DataMapper::Associations::ManyToOne::Relationship}.
52
+ module ManyToOne
53
+ def extract_keys_for_nested_attributes(model, attributes)
54
+ keys = self.parent_model.key.to_enum(:each_with_index).map do |key, idx|
55
+ if child_idx = self.parent_key.to_a.index(key)
56
+ model[self.child_key.to_a.at(child_idx).name]
57
+ else
58
+ attributes[key.name]
59
+ end
60
+ end
61
+
62
+ keys.any? { |key| DataMapper::Ext.blank?(key) } ? nil : keys
63
+ end
64
+ end
65
+
66
+ # Extensions for {DataMapper::Associations::OneToOne::Relationship}.
67
+ module OneToOne
68
+ def extract_keys_for_nested_attributes(model, attributes)
69
+ keys = self.child_model.key.to_enum(:each_with_index).map do |key, idx|
70
+ if parent_idx = self.child_key.to_a.index(key)
71
+ model[self.parent_key.to_a.at(parent_idx).name]
72
+ else
73
+ attributes[key.name]
74
+ end
75
+ end
76
+
77
+ keys.any? { |key| DataMapper::Ext.blank?(key) } ? nil : keys
78
+ end
79
+ end
80
+
81
+ end
82
+ end
@@ -0,0 +1,382 @@
1
+ module DataMapper
2
+ module NestedAttributes
3
+
4
+ ##
5
+ # Extensions and customizations for a {DataMapper::Resource}
6
+ # that are needed if the {DataMapper::Resource} wants to
7
+ # accept nested attributes for any given relationship.
8
+ # Basically, this module provides functionality that allows
9
+ # either assignment or marking for destruction of related parent
10
+ # and child associations, based on the given attributes and what
11
+ # kind of relationship should be altered.
12
+ module Resource
13
+ # Truthy values for the +:_delete+ flag.
14
+ TRUE_VALUES = [true, 1, '1', 't', 'T', 'true', 'TRUE'].to_set
15
+
16
+ ##
17
+ # Can be used to remove ambiguities from the passed attributes.
18
+ # Consider a situation with a belongs_to association where both a valid value
19
+ # for the foreign_key attribute *and* nested_attributes for a new record are
20
+ # present (i.e. item_type_id and item_type_attributes are present).
21
+ # Also see http://is.gd/sz2d on the rails-core ml for a discussion on this.
22
+ # The basic idea is, that there should be a well defined behavior for what
23
+ # exactly happens when such a situation occurs. I'm currently in favor for
24
+ # using the foreign_key if it is present, but this probably needs more thinking.
25
+ # For now, this method basically is a no-op, but at least it provides a hook where
26
+ # everyone can perform it's own sanitization by overwriting this method.
27
+ #
28
+ # @param attributes [Hash]
29
+ # The attributes to sanitize.
30
+ #
31
+ # @return [Hash]
32
+ # The sanitized attributes.
33
+ #
34
+ def sanitize_nested_attributes(attributes)
35
+ attributes # noop
36
+ end
37
+
38
+ ##
39
+ # Saves the resource and destroys nested resources marked for destruction.
40
+ def save(*)
41
+ saved = super
42
+ remove_destroyables
43
+ saved
44
+ end
45
+
46
+ private
47
+
48
+ ##
49
+ # Attribute hash keys that are excluded when creating a nested resource.
50
+ # Excluded attributes include +:_delete+, a special value used to mark a
51
+ # resource for destruction.
52
+ #
53
+ # @return [Array<Symbol>] Excluded attribute names.
54
+ def uncreatable_keys
55
+ [:_delete]
56
+ end
57
+
58
+ ##
59
+ # Attribute hash keys that are excluded when updating a nested resource.
60
+ # Excluded attributes include the model key and :_delete, a special value
61
+ # used to mark a resource for destruction.
62
+ #
63
+ # @return [Array<Symbol>] Excluded attribute names.
64
+ def unupdatable_keys
65
+ model.key.to_a.map { |property| property.name } << :_delete
66
+ end
67
+
68
+
69
+ ##
70
+ # Assigns the given attributes to the resource association.
71
+ #
72
+ # If the given attributes include the primary key values that match the
73
+ # existing record’s keys, then the existing record will be modified.
74
+ # Otherwise a new record will be built.
75
+ #
76
+ # If the given attributes include matching primary key values _and_ a
77
+ # <tt>:_delete</tt> key set to a truthy value, then the existing record
78
+ # will be marked for destruction.
79
+ #
80
+ # The names of the primary key values required depend on the configuration
81
+ # of the association. It is not necessary to specify values for attributes
82
+ # that exist on this resource as they are inferred.
83
+ #
84
+ # @param relationship [DataMapper::Associations::Relationship]
85
+ # The relationship backing the association.
86
+ # Assignment will happen on the target end of the relationship
87
+ #
88
+ # @param attributes [Hash{Symbol => Object}]
89
+ # The attributes to assign to the relationship's target end.
90
+ # All attributes except {#uncreatable_keys} (for new resources) and
91
+ # {#unupdatable_keys} (when updating an existing resource) will be
92
+ # assigned.
93
+ #
94
+ # @return [void]
95
+ def assign_nested_attributes_for_related_resource(relationship, attributes)
96
+ assert_kind_of 'attributes', attributes, Hash
97
+
98
+ if keys = extract_keys(relationship, attributes)
99
+ existing_record = relationship.get(self)
100
+ if existing_record && existing_record.key == keys
101
+ update_or_mark_as_destroyable(relationship, existing_record, attributes)
102
+ return
103
+ end
104
+ end
105
+
106
+ return if reject_new_record?(relationship, attributes)
107
+
108
+ attributes = DataMapper::Ext::Hash.except(attributes, *uncreatable_keys)
109
+ new_record = relationship.target_model.new(attributes)
110
+ relationship.set(self, new_record)
111
+ end
112
+
113
+ ##
114
+ # Assigns the given attributes to the collection association.
115
+ #
116
+ # Hashes with primary key values matching an existing associated record
117
+ # will update that record. Hashes without primary key values (or only
118
+ # values for a partial primary key), or if no existing associated record
119
+ # exists, will build a new record for the association. Hashes with
120
+ # matching primary key values and a <tt>:_delete</tt> key set to a truthy
121
+ # value will mark the matched record for destruction.
122
+ #
123
+ # The names of the primary key values required depend on the configuration
124
+ # of the association. It is not necessary to specify values for attributes
125
+ # that exist on this resource as they are inferred.
126
+ #
127
+ # For example:
128
+ #
129
+ # assign_nested_attributes_for_collection_association(:people, {
130
+ # '1' => { :id => '1', :name => 'Peter' },
131
+ # '2' => { :name => 'John' },
132
+ # '3' => { :id => '2', :_delete => true }
133
+ # })
134
+ #
135
+ # Will update the name of the Person with ID 1, build a new associated
136
+ # person with the name 'John', and mark the associatied Person with ID 2
137
+ # for destruction.
138
+ #
139
+ # assign_nested_attributes_for_collection_association(:people, {
140
+ # '1' => { :person_id => '1', :audit_id => 2, :name => 'Peter' },
141
+ # '2' => { :audit_id => 2, :name => 'John' },
142
+ # '3' => { :person_id => '2', :audit_id => 3, :_delete => true }
143
+ # })
144
+ #
145
+ # Will update the name of the Person with `(person_id, audit_id) = (1, 2)`,
146
+ # build a new associated person with the name 'John', and mark the
147
+ # associatied Person with key `(2, 3)` for destruction.
148
+ #
149
+ # Also accepts an Array of attribute hashes:
150
+ #
151
+ # assign_nested_attributes_for_collection_association(:people, [
152
+ # { :id => '1', :name => 'Peter' },
153
+ # { :name => 'John' },
154
+ # { :id => '2', :_delete => true }
155
+ # ])
156
+ #
157
+ # @param relationship [DataMapper::Associations::Relationship]
158
+ # The relationship backing the association.
159
+ # Assignment will happen on the target end of the relationship
160
+ #
161
+ # @param attributes_collection [Hash{Integer=>Hash}, Array<Hash>]
162
+ # The attributes to assign to the relationship's target end.
163
+ # All attributes except {#uncreatable_keys} (for new resources) and
164
+ # {#unupdatable_keys} (when updating an existing resource) will be
165
+ # assigned.
166
+ #
167
+ # @return [void]
168
+ def assign_nested_attributes_for_related_collection(relationship, attributes_collection)
169
+ assert_hash_or_array_of_hashes("attributes_collection", attributes_collection)
170
+
171
+ normalize_attributes_collection(attributes_collection).each do |attributes|
172
+ if keys = extract_keys(relationship, attributes)
173
+ collection = relationship.get(self)
174
+ if existing_record = collection.get(*keys)
175
+ update_or_mark_as_destroyable(relationship, existing_record, attributes)
176
+ next
177
+ end
178
+ end
179
+
180
+ next if reject_new_record?(relationship, attributes)
181
+
182
+ attributes = DataMapper::Ext::Hash.except(attributes, *uncreatable_keys)
183
+ relationship.get(self).new(attributes)
184
+ end
185
+
186
+ nil
187
+ end
188
+
189
+ ##
190
+ # Extracts the primary key values necessary to retrieve or update a nested
191
+ # model when using +accepts_nested_attributes_for+. Values are taken from
192
+ # this model instance and attribute hash with the former having priority.
193
+ # Values for properties in the primary key that are *not* included in the
194
+ # foreign key must be specified in the attributes hash.
195
+ #
196
+ # @param relationship [DataMapper::Association::Relationship]
197
+ # The relationship backing the association.
198
+ #
199
+ # @param attributes [Hash{Symbol => Object}]
200
+ # The attributes assigned to the nested attribute setter on the
201
+ # +model+.
202
+ #
203
+ # @return [Array]
204
+ def extract_keys(relationship, attributes)
205
+ relationship.extract_keys_for_nested_attributes(self, attributes)
206
+ end
207
+
208
+ ##
209
+ # Updates a record with the +attributes+ or marks it for destruction if
210
+ # the +:allow_destroy+ option is +true+ and {#has_delete_flag?} returns
211
+ # +true+.
212
+ #
213
+ # @param relationship [DataMapper::Associations::Relationship]
214
+ # The relationship backing the association.
215
+ # Assignment will happen on the target end of the relationship
216
+ #
217
+ # @param attributes [Hash{Symbol => Object}]
218
+ # The attributes to assign to the relationship's target end.
219
+ # All attributes except {#unupdatable_keys} will be assigned.
220
+ #
221
+ # @return [void]
222
+ def update_or_mark_as_destroyable(relationship, resource, attributes)
223
+ allow_destroy = self.class.options_for_nested_attributes[relationship.name][:allow_destroy]
224
+ if has_delete_flag?(attributes) && allow_destroy
225
+ if relationship.is_a?(DataMapper::Associations::ManyToMany::Relationship)
226
+ intermediaries = relationship.through.get(self).all(relationship.via => resource)
227
+ intermediaries.each { |intermediate| destroyables << intermediate }
228
+ end
229
+ destroyables << resource
230
+ else
231
+ assert_nested_update_clean_only(resource)
232
+ resource.attributes = DataMapper::Ext::Hash.except(attributes, *unupdatable_keys)
233
+ resource.save
234
+ end
235
+ end
236
+
237
+ ##
238
+ # Determines whether the given attributes hash contains a truthy :_delete key.
239
+ #
240
+ # @param attributes [Hash{Symbol => Object}] The attributes to test.
241
+ #
242
+ # @return [Boolean]
243
+ # +true+ if attributes contains a truthy :_delete key.
244
+ #
245
+ # @see TRUE_VALUES
246
+ def has_delete_flag?(attributes)
247
+ value = attributes[:_delete]
248
+ if value.is_a?(String) && value !~ /\S/
249
+ nil
250
+ else
251
+ TRUE_VALUES.include?(value)
252
+ end
253
+ end
254
+
255
+ ##
256
+ # Determines if a new record should be built with the given attributes.
257
+ # Rejects a new record if {#has_delete_flag?} returns +true+ for the given
258
+ # attributes, or if a +:reject_if+ guard exists for the passed relationship
259
+ # that evaluates to +true+.
260
+ #
261
+ # @param relationship [DataMapper::Associations::Relationship]
262
+ # The relationship backing the association.
263
+ #
264
+ # @param attributes [Hash{Symbol => Object}]
265
+ # The attributes to test with {#has_delete_flag?}.
266
+ #
267
+ # @return [Boolean]
268
+ # +true+ if the given attributes will be rejected.
269
+ def reject_new_record?(relationship, attributes)
270
+ guard = self.class.options_for_nested_attributes[relationship.name][:reject_if]
271
+ return false if guard.nil? # if relationship guard is nil, nothing will be rejected
272
+ has_delete_flag?(attributes) || evaluate_reject_new_record_guard(guard, attributes)
273
+ end
274
+
275
+ ##
276
+ # Evaluates the given guard by calling it with the given attributes.
277
+ #
278
+ # @param [Symbol, String, #call] guard
279
+ # An instance method name or an object that respond_to?(:call), which
280
+ # would stop a new record from being created, if it evaluates to true.
281
+ #
282
+ # @param [Hash{Symbol => Object}] attributes
283
+ # The attributes to pass to the guard for evaluating if it should reject
284
+ # the creation of a new resource
285
+ #
286
+ # @raise [ArgumentError]
287
+ # If the given guard doesn't match [Symbol, String, #call]
288
+ #
289
+ # @return [Boolean]
290
+ # The value returned by evaluating the guard
291
+ def evaluate_reject_new_record_guard(guard, attributes)
292
+ if guard.is_a?(Symbol) || guard.is_a?(String)
293
+ send(guard, attributes)
294
+ elsif guard.respond_to?(:call)
295
+ guard.call(attributes)
296
+ else
297
+ # never reached when called from inside the plugin
298
+ raise ArgumentError, "guard must be a Symbol, a String, or respond_to?(:call)"
299
+ end
300
+ end
301
+
302
+ ##
303
+ # Raises an exception if the specified resource is dirty or has dirty
304
+ # children.
305
+ #
306
+ # @param [DataMapper::Resource] resource
307
+ # The resource to check.
308
+ #
309
+ # @return [void]
310
+ #
311
+ # @raise [UpdateConflictError]
312
+ # If the resource is dirty.
313
+ #
314
+ # @api private
315
+ def assert_nested_update_clean_only(resource)
316
+ if resource.send(:dirty_self?) || resource.send(:dirty_children?)
317
+ raise UpdateConflictError, "#{model}#update cannot be called on a #{new? ? 'new' : 'dirty'} nested resource"
318
+ end
319
+ end
320
+
321
+ ##
322
+ # Asserts that the specified parameter value is a Hash of Hashes, or an
323
+ # Array of Hashes and raises an ArgumentError if value does not conform.
324
+ #
325
+ # @param param_name [String]
326
+ # The parameter name included in the raised ArgumentError.
327
+ #
328
+ # @param value
329
+ # The value to check.
330
+ #
331
+ # @return [void]
332
+ def assert_hash_or_array_of_hashes(param_name, value)
333
+ if value.is_a?(Hash)
334
+ unless value.values.all? { |a| a.is_a?(Hash) }
335
+ raise ArgumentError,
336
+ "+#{param_name}+ should be a Hash of Hashes or Array " +
337
+ "of Hashes, but was a Hash with #{value.values.map { |a| a.class }.uniq.inspect}"
338
+ end
339
+ elsif value.is_a?(Array)
340
+ unless value.all? { |a| a.is_a?(Hash) }
341
+ raise ArgumentError,
342
+ "+#{param_name}+ should be a Hash of Hashes or Array " +
343
+ "of Hashes, but was an Array with #{value.map { |a| a.class }.uniq.inspect}"
344
+ end
345
+ else
346
+ raise ArgumentError,
347
+ "+#{param_name}+ should be a Hash of Hashes or Array of " +
348
+ "Hashes, but was #{value.class}"
349
+ end
350
+ end
351
+
352
+ ##
353
+ # Make sure to return a collection of attribute hashes.
354
+ # If passed an attributes hash, map it to its attributes.
355
+ #
356
+ # @param attributes [Hash, #each]
357
+ # An attributes hash or a collection of attribute hashes.
358
+ #
359
+ # @return [#each]
360
+ # A collection of attribute hashes.
361
+ def normalize_attributes_collection(attributes)
362
+ if attributes.is_a?(Hash)
363
+ attributes.map { |_, attributes| attributes }
364
+ else
365
+ attributes
366
+ end
367
+ end
368
+
369
+
370
+ def destroyables
371
+ @destroyables ||= []
372
+ end
373
+
374
+ def remove_destroyables
375
+ destroyables.each { |r| r.destroy if r.saved? }
376
+ @destroyables.clear
377
+ end
378
+
379
+ end
380
+
381
+ end
382
+ end