snusnu-dm-accepts_nested_attributes 0.0.6 → 0.10.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 (33) hide show
  1. data/Manifest.txt +13 -8
  2. data/Rakefile +2 -3
  3. data/TODO +5 -0
  4. data/lib/dm-accepts_nested_attributes.rb +7 -14
  5. data/lib/dm-accepts_nested_attributes/error_collecting.rb +35 -0
  6. data/lib/dm-accepts_nested_attributes/model.rb +132 -0
  7. data/lib/dm-accepts_nested_attributes/resource.rb +218 -29
  8. data/lib/dm-accepts_nested_attributes/save.rb +13 -0
  9. data/lib/dm-accepts_nested_attributes/transactional_save.rb +15 -0
  10. data/lib/dm-accepts_nested_attributes/version.rb +1 -1
  11. data/spec/fixtures/person.rb +8 -0
  12. data/spec/fixtures/profile.rb +9 -0
  13. data/spec/fixtures/project.rb +8 -0
  14. data/spec/fixtures/project_membership.rb +8 -0
  15. data/spec/fixtures/task.rb +9 -0
  16. data/spec/integration/belongs_to_spec.rb +10 -134
  17. data/spec/integration/has_1_spec.rb +9 -121
  18. data/spec/integration/has_n_spec.rb +10 -149
  19. data/spec/integration/has_n_through_spec.rb +10 -162
  20. data/spec/{shared → lib}/rspec_tmbundle_support.rb +1 -1
  21. data/spec/shared/belongs_to_spec.rb +127 -0
  22. data/spec/shared/has_1_spec.rb +103 -0
  23. data/spec/shared/has_n_spec.rb +114 -0
  24. data/spec/shared/has_n_through_spec.rb +139 -0
  25. data/spec/spec_helper.rb +12 -9
  26. data/spec/unit/accepts_nested_attributes_for_spec.rb +39 -118
  27. data/tasks/changelog.rb +18 -0
  28. data/tasks/spec.rb +0 -1
  29. metadata +19 -14
  30. data/lib/dm-accepts_nested_attributes/association_proxies.rb +0 -55
  31. data/lib/dm-accepts_nested_attributes/association_validation.rb +0 -49
  32. data/lib/dm-accepts_nested_attributes/nested_attributes.rb +0 -350
  33. data/spec/unit/resource_spec.rb +0 -174
@@ -7,25 +7,30 @@ Rakefile
7
7
  TODO
8
8
  CHANGELOG
9
9
  lib/dm-accepts_nested_attributes.rb
10
- lib/dm-accepts_nested_attributes/association_proxies.rb
11
- lib/dm-accepts_nested_attributes/association_validation.rb
12
- lib/dm-accepts_nested_attributes/nested_attributes.rb
10
+ lib/dm-accepts_nested_attributes/error_collecting.rb
11
+ lib/dm-accepts_nested_attributes/model.rb
13
12
  lib/dm-accepts_nested_attributes/resource.rb
13
+ lib/dm-accepts_nested_attributes/save.rb
14
+ lib/dm-accepts_nested_attributes/transactional_save.rb
14
15
  lib/dm-accepts_nested_attributes/version.rb
16
+ spec/spec.opts
17
+ spec/spec_helper.rb
18
+ spec/lib/rspec_tmbundle_support.rb
15
19
  spec/fixtures/person.rb
16
20
  spec/fixtures/profile.rb
17
21
  spec/fixtures/project.rb
18
22
  spec/fixtures/project_membership.rb
19
23
  spec/fixtures/task.rb
24
+ spec/shared/belongs_to_spec.rb
25
+ spec/shared/has_1_spec.rb
26
+ spec/shared/has_n_spec.rb
27
+ spec/shared/has_n_through_spec.rb
28
+ spec/unit/accepts_nested_attributes_for_spec.rb
20
29
  spec/integration/belongs_to_spec.rb
21
30
  spec/integration/has_1_spec.rb
22
31
  spec/integration/has_n_spec.rb
23
32
  spec/integration/has_n_through_spec.rb
24
- spec/shared/rspec_tmbundle_support.rb
25
- spec/spec.opts
26
- spec/spec_helper.rb
27
- spec/unit/accepts_nested_attributes_for_spec.rb
28
- spec/unit/resource_spec.rb
33
+ tasks/changelog.rb
29
34
  tasks/gemspec.rb
30
35
  tasks/hoe.rb
31
36
  tasks/install.rb
data/Rakefile CHANGED
@@ -1,5 +1,4 @@
1
1
  require 'pathname'
2
- require 'rubygems'
3
2
  require 'rake'
4
3
  require 'rake/rdoctask'
5
4
 
@@ -16,8 +15,8 @@ GEM_NAME = "dm-accepts_nested_attributes"
16
15
  GEM_VERSION = DataMapper::NestedAttributes::VERSION
17
16
 
18
17
  GEM_DEPENDENCIES = [
19
- ["dm-core", '>=0.9.11'],
20
- ['addressable', '~>2.0.2' ]
18
+ ["dm-core", '>=0.10.0'],
19
+ ["dm-validations", '>=0.10.0']
21
20
  ]
22
21
 
23
22
  GEM_CLEAN = %w[ log pkg coverage ]
data/TODO CHANGED
@@ -1,3 +1,8 @@
1
1
  TODO
2
2
  ====
3
3
 
4
+ * add specs for :reject_if => :foo option, but think about _where_ first!
5
+ * think about supporting :reject_unless in addition to :reject_if
6
+ * think about generalizing :reject_if to not only work for new? resources
7
+ * think about :allow_destroy accepting the same parameters like :reject_if
8
+ (Symbol, String, #call)
@@ -1,20 +1,13 @@
1
- # Needed to import datamapper and other gems
2
- require 'rubygems'
3
1
  require 'pathname'
4
-
5
- # Add all external dependencies for the plugin here
6
- gem 'dm-core', '>=0.9.11'
7
- gem 'dm-validations', '>=0.9.11'
8
-
9
2
  require 'dm-core'
10
- require 'dm-validations'
11
3
 
12
- # Require plugin-files
13
4
  dir = Pathname(__FILE__).dirname.expand_path / 'dm-accepts_nested_attributes'
5
+
6
+ require dir / 'model'
7
+ #require dir / 'save'
14
8
  require dir / 'resource'
15
- require dir / 'association_proxies'
16
- require dir / 'nested_attributes'
17
9
 
18
- # Include the plugin in Model
19
- DataMapper::Model.append_extensions DataMapper::NestedAttributes::ClassMethods
20
- DataMapper::Resource.append_inclusions DataMapper::NestedAttributes::CommonInstanceMethods
10
+ # Activate the plugin
11
+ DataMapper::Model.append_extensions(DataMapper::NestedAttributes::Model)
12
+ #DataMapper::Model.append_inclusions(DataMapper::NestedAttributes::Save)
13
+ DataMapper::Model.append_inclusions(DataMapper::NestedAttributes::CommonResourceSupport)
@@ -0,0 +1,35 @@
1
+ module DataMapper
2
+ module NestedAttributes
3
+
4
+ module ValidationErrorCollecting
5
+
6
+ # collect errors on parent associations
7
+ def before_save_parent_association(association, context)
8
+ if association.respond_to?(:each)
9
+ association.each do |r|
10
+ unless r.valid?(context)
11
+ r.errors.each { |e| self.errors.add(:general, e) }
12
+ end
13
+ end
14
+ else
15
+ unless association.valid?(context)
16
+ association.errors.each { |e| self.errors.add(:general, e) }
17
+ end
18
+ end
19
+ end
20
+
21
+ # collect errors on child associations
22
+ def before_save_child_association(association, context)
23
+ if association.respond_to?(:valid?)
24
+ unless association.valid?(context)
25
+ association.errors.each { |e| self.errors.add(:general, e) }
26
+ end
27
+ else
28
+ self.errors.add(:general, "child association is missing")
29
+ end
30
+ end
31
+
32
+ end
33
+
34
+ end
35
+ end
@@ -0,0 +1,132 @@
1
+ module DataMapper
2
+ module NestedAttributes
3
+
4
+ ##
5
+ # raised by accepts_nested_attributes_for
6
+ # if the passed options don't make sense
7
+ class InvalidOptions < ArgumentError; end
8
+
9
+ module Model
10
+
11
+ ##
12
+ # Allows any association to accept nested attributes.
13
+ #
14
+ # @param association_name [Symbol, String]
15
+ # The name of the association that should accept nested attributes
16
+ #
17
+ # @param options [Hash, nil]
18
+ # List of resources to initialize the Collection with
19
+ #
20
+ # @option :reject_if [Symbol, String, #call]
21
+ # An instance method name or an object that respond_to?(:call), which
22
+ # stops a new record from being created, if it evaluates to true.
23
+ #
24
+ # @option :allow_destroy [true, false]
25
+ # If true, allow destroying the association via the generated writer
26
+ # If false, prevent destroying the association via the generated writer
27
+ # defaults to false
28
+ #
29
+ # @return nil
30
+ def accepts_nested_attributes_for(association_name, options = {})
31
+
32
+ # ----------------------------------------------------------------------------------
33
+ # try to fail as early as possible
34
+ # ----------------------------------------------------------------------------------
35
+
36
+ unless relationship = relationships(repository_name)[association_name]
37
+ raise(ArgumentError, "No relationship #{name.inspect} for #{self.name} in #{repository_name}")
38
+ end
39
+
40
+ # raise InvalidOptions if the given options don't make sense
41
+ assert_valid_options_for_nested_attributes(options)
42
+
43
+ # by default, nested attributes can't be destroyed
44
+ options = { :allow_destroy => false }.update(options)
45
+
46
+ # ----------------------------------------------------------------------------------
47
+ # should be safe to go from here
48
+ # ----------------------------------------------------------------------------------
49
+
50
+ options_for_nested_attributes[relationship] = options
51
+
52
+ include ::DataMapper::NestedAttributes::Resource
53
+
54
+ add_save_behavior
55
+
56
+ # TODO i wonder if this is the best place here?
57
+ # the transactional save behavior is definitely not needed for all resources,
58
+ # but it's necessary for resources that accept nested attributes
59
+ # FIXME this leads to weird "no such table" errors when specs are run
60
+ add_transactional_save_behavior # TODO if repository.adapter.supports_transactions?
61
+
62
+ # TODO make this do something
63
+ # it's only here now to remind me that this is probably the best place to put it
64
+ add_error_collection_behavior if DataMapper.const_defined?('Validate')
65
+
66
+ type = relationship.max > 1 ? :collection : :resource
67
+
68
+ define_method "#{association_name}_attributes" do
69
+ instance_variable_get("@#{association_name}_attributes")
70
+ end
71
+
72
+ define_method "#{association_name}_attributes=" do |attributes|
73
+ attributes = sanitize_nested_attributes(attributes)
74
+ instance_variable_set("@#{association_name}_attributes", attributes)
75
+ send("assign_nested_attributes_for_related_#{type}", relationship, attributes)
76
+ end
77
+
78
+ end
79
+
80
+ ##
81
+ # The options given to the accepts_nested_attributes method
82
+ # They are guaranteed to be valid if they made it this far.
83
+ #
84
+ # @return [Hash] The options given to the accepts_nested_attributes method
85
+ # @see accepts_nested_attributes
86
+ def options_for_nested_attributes
87
+ @options_for_nested_attributes ||= {}
88
+ end
89
+
90
+ private
91
+
92
+ def add_save_behavior
93
+ require Pathname(__FILE__).dirname.expand_path + 'save'
94
+ include ::DataMapper::NestedAttributes::Save
95
+ end
96
+
97
+ def add_transactional_save_behavior
98
+ require Pathname(__FILE__).dirname.expand_path + 'transactional_save'
99
+ include ::DataMapper::NestedAttributes::TransactionalSave
100
+ end
101
+
102
+ def add_error_collection_behavior
103
+ require Pathname(__FILE__).dirname.expand_path + 'error_collecting'
104
+ include ::DataMapper::NestedAttributes::ValidationErrorCollecting
105
+ end
106
+
107
+
108
+ def assert_valid_options_for_nested_attributes(options)
109
+
110
+ assert_kind_of 'options', options, Hash
111
+
112
+ valid_options = [ :allow_destroy, :reject_if ]
113
+
114
+ unless options.all? { |k,v| valid_options.include?(k) }
115
+ raise InvalidOptions, 'options must be one of :allow_destroy or :reject_if'
116
+ end
117
+
118
+ guard = options[:reject_if]
119
+ if guard.is_a?(Symbol) || guard.is_a?(String)
120
+ msg = ":reject_if => #{guard.inspect}, but there is no instance method #{guard.inspect} in #{self.name}"
121
+ raise InvalidOptions, msg unless instance_methods.include?(options[:reject_if].to_s)
122
+ else
123
+ msg = ":reject_if must be a Symbol|String or respond_to?(:call) "
124
+ raise InvalidOptions, msg unless guard.nil? || guard.respond_to?(:call)
125
+ end
126
+
127
+ end
128
+
129
+ end
130
+
131
+ end
132
+ end
@@ -1,42 +1,231 @@
1
1
  module DataMapper
2
- module Resource
3
-
4
- # basic extract method refactorings to work around a bug in extlib
5
- # see http://sick.snusnu.info/2009/04/29/extlibhook-breaks-if-hooked-method-is-redefined/
6
- # maybe they are worth considering even when the bug in extlib (hopefully) gets fixed
2
+ module NestedAttributes
7
3
 
8
- def save(context = :default)
4
+ module Resource
9
5
 
10
- associations_saved = false
11
- associations_saved = save_child_associations(associations_saved, context)
12
-
13
- saved = save_self
14
-
15
- if saved
16
- original_values.clear
6
+ ##
7
+ # Can be used to remove ambiguities from the passed attributes.
8
+ # Consider a situation with a belongs_to association where both a valid value
9
+ # for the foreign_key attribute *and* nested_attributes for a new record are
10
+ # present (i.e. item_type_id and item_type_attributes are present).
11
+ # Also see http://is.gd/sz2d on the rails-core ml for a discussion on this.
12
+ # The basic idea is, that there should be a well defined behavior for what
13
+ # exactly happens when such a situation occurs. I'm currently in favor for
14
+ # using the foreign_key if it is present, but this probably needs more thinking.
15
+ # For now, this method basically is a no-op, but at least it provides a hook where
16
+ # everyone can perform it's own sanitization by overwriting this method.
17
+ #
18
+ # @return [Hash] The sanitized attributes
19
+ def sanitize_nested_attributes(attributes)
20
+ attributes # noop
17
21
  end
18
22
 
19
- associations_saved = save_parent_associations(associations_saved, context)
23
+ private
24
+
25
+ # Attribute hash keys that should not be assigned as normal attributes.
26
+ # These hash keys are nested attributes implementation details.
27
+ UNASSIGNABLE_KEYS = [ :id, :_delete ]
28
+
29
+
30
+ ##
31
+ # Assigns the given attributes to the resource association.
32
+ #
33
+ # If the given attributes include an <tt>:id</tt> that matches the existing
34
+ # record’s id, then the existing record will be modified. Otherwise a new
35
+ # record will be built.
36
+ #
37
+ # If the given attributes include a matching <tt>:id</tt> attribute _and_ a
38
+ # <tt>:_delete</tt> key set to a truthy value, then the existing record
39
+ # will be marked for destruction.
40
+ #
41
+ # @param relationship [DataMapper::Associations::Relationship]
42
+ # The relationship backing the association.
43
+ # Assignment will happen on the target end of the relationship
44
+ #
45
+ # @param attributes [Hash]
46
+ # The attributes to assign to the relationship's target end
47
+ # All attributes except @see UNASSIGNABLE_KEYS will be assigned
48
+ #
49
+ # @return nil
50
+ def assign_nested_attributes_for_related_resource(relationship, attributes)
51
+ if attributes[:id].blank?
52
+ return if reject_new_record?(relationship, attributes)
53
+ new_record = relationship.target_model.new(attributes.except(*UNASSIGNABLE_KEYS))
54
+ relationship.set(self, new_record)
55
+ else
56
+ existing_record = relationship.get(self)
57
+ if existing_record && existing_record.id.to_s == attributes[:id].to_s
58
+ assign_to_or_mark_for_destruction(relationship, existing_record, attributes)
59
+ end
60
+ end
61
+ end
62
+
63
+ ##
64
+ # Assigns the given attributes to the collection association.
65
+ #
66
+ # Hashes with an <tt>:id</tt> value matching an existing associated record
67
+ # will update that record. Hashes without an <tt>:id</tt> value will build
68
+ # a new record for the association. Hashes with a matching <tt>:id</tt>
69
+ # value and a <tt>:_delete</tt> key set to a truthy value will mark the
70
+ # matched record for destruction.
71
+ #
72
+ # For example:
73
+ #
74
+ # assign_nested_attributes_for_collection_association(:people, {
75
+ # '1' => { :id => '1', :name => 'Peter' },
76
+ # '2' => { :name => 'John' },
77
+ # '3' => { :id => '2', :_delete => true }
78
+ # })
79
+ #
80
+ # Will update the name of the Person with ID 1, build a new associated
81
+ # person with the name `John', and mark the associatied Person with ID 2
82
+ # for destruction.
83
+ #
84
+ # Also accepts an Array of attribute hashes:
85
+ #
86
+ # assign_nested_attributes_for_collection_association(:people, [
87
+ # { :id => '1', :name => 'Peter' },
88
+ # { :name => 'John' },
89
+ # { :id => '2', :_delete => true }
90
+ # ])
91
+ #
92
+ # @param relationship [DataMapper::Associations::Relationship]
93
+ # The relationship backing the association.
94
+ # Assignment will happen on the target end of the relationship
95
+ #
96
+ # @param attributes [Hash]
97
+ # The attributes to assign to the relationship's target end
98
+ # All attributes except @see UNASSIGNABLE_KEYS will be assigned
99
+ #
100
+ # @return nil
101
+ def assign_nested_attributes_for_related_collection(relationship, attributes_collection)
20
102
 
21
- # We should return true if the model (or any of its associations) were saved.
22
- (saved | associations_saved) == true
103
+ normalize_attributes_collection(attributes_collection).each do |attributes|
104
+
105
+ if attributes[:id].blank?
106
+ next if reject_new_record?(relationship, attributes)
107
+ relationship.get(self).new(attributes.except(*UNASSIGNABLE_KEYS))
108
+ else
109
+ collection = relationship.get(self)
110
+ if existing_record = collection.detect { |record| record.id.to_s == attributes[:id].to_s }
111
+ assign_to_or_mark_for_destruction(relationship, existing_record, attributes)
112
+ end
113
+ end
114
+
115
+ end
23
116
 
24
- end
25
-
117
+ end
26
118
 
27
- def save_child_associations(saved, context)
28
- child_associations.each { |a| saved |= a.save }
29
- saved
30
- end
119
+ ##
120
+ # Updates a record with the +attributes+ or marks it for destruction if
121
+ # +allow_destroy+ is +true+ and has_delete_flag? returns +true+.
122
+ #
123
+ # @param relationship [DataMapper::Associations::Relationship]
124
+ # The relationship backing the association.
125
+ # Assignment will happen on the target end of the relationship
126
+ #
127
+ # @param attributes [Hash]
128
+ # The attributes to assign to the relationship's target end
129
+ # All attributes except @see UNASSIGNABLE_KEYS will be assigned
130
+ #
131
+ # @return nil
132
+ def assign_to_or_mark_for_destruction(relationship, resource, attributes)
133
+ allow_destroy = self.class.options_for_nested_attributes[relationship][:allow_destroy]
134
+ if has_delete_flag?(attributes) && allow_destroy
135
+ resource.mark_for_destruction
136
+ else
137
+ resource.update(attributes.except(*UNASSIGNABLE_KEYS))
138
+ end
139
+ end
140
+
141
+ ##
142
+ # Determines if a hash contains a truthy _delete key.
143
+ #
144
+ # @param hash [Hash] The hash to test
145
+ #
146
+ # @return [Boolean]
147
+ # true, if hash containts a truthy _delete key
148
+ # false, otherwise
149
+ def has_delete_flag?(hash)
150
+ # TODO find out if this activerecord code needs to be ported
151
+ # ConnectionAdapters::Column.value_to_boolean hash['_delete']
152
+ hash[:_delete]
153
+ end
31
154
 
32
- def save_self
33
- new_record? ? create : update
155
+ ##
156
+ # Determines if a new record should be build by checking for
157
+ # has_delete_flag? or if a <tt>:reject_if</tt> proc exists for this
158
+ # association and evaluates to +true+.
159
+ #
160
+ # @param relationship [DataMapper::Associations::Relationship]
161
+ # The relationship backing the association.
162
+ # Assignment will happen on the target end of the relationship
163
+ #
164
+ # @param attributes [Hash]
165
+ # The attributes to assign to the relationship's target end
166
+ # All attributes except @see UNASSIGNABLE_KEYS will be assigned
167
+ #
168
+ # @return [Boolean]
169
+ # true, if the given attributes won't be rejected
170
+ # false, otherwise
171
+ def reject_new_record?(relationship, attributes)
172
+ guard = self.class.options_for_nested_attributes[relationship][:reject_if]
173
+ return false if guard.nil? # if relationship guard is nil, nothing will be rejected
174
+ has_delete_flag?(attributes) || evaluate_reject_new_record_guard(guard, attributes)
175
+ end
176
+
177
+ def evaluate_reject_new_record_guard(guard, attributes)
178
+ if guard.is_a?(Symbol) || guard.is_a?(String)
179
+ send(guard)
180
+ elsif guard.respond_to?(:call)
181
+ guard.call(attributes)
182
+ else
183
+ # never reached when called from inside the plugin
184
+ raise ArgumentError, "guard must be a Symbol, a String, or respond_to?(:call)"
185
+ end
186
+ end
187
+
188
+ def normalize_attributes_collection(attributes_collection)
189
+ if attributes_collection.is_a?(Hash)
190
+ attributes_collection.sort_by { |index, _| index.to_i }.map { |_, attributes| attributes }
191
+ else
192
+ attributes_collection
193
+ end
194
+ end
195
+
34
196
  end
35
197
 
36
- def save_parent_associations(saved, context)
37
- parent_associations.each { |a| saved |= a.save }
38
- saved
198
+ module CommonResourceSupport
199
+
200
+ ##
201
+ # remove mark for destruction if present
202
+ # before delegating reload behavior to super
203
+ #
204
+ # @return The same value that super returns
205
+ def reload
206
+ @marked_for_destruction = false
207
+ super
208
+ end
209
+
210
+ ##
211
+ # Test if this resource is marked for destruction
212
+ #
213
+ # @return [Boolean]
214
+ # true, if this resource is marked for destruction
215
+ # false, otherwise
216
+ def marked_for_destruction?
217
+ @marked_for_destruction
218
+ end
219
+
220
+ ##
221
+ # Mark this resource for destruction
222
+ #
223
+ # @return true
224
+ def mark_for_destruction
225
+ @marked_for_destruction = true
226
+ end
227
+
39
228
  end
40
-
229
+
41
230
  end
42
- end
231
+ end