dm-accepts_nested_attributes_for 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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