snusnu-dm-accepts_nested_attributes 0.0.6 → 0.10.0

Sign up to get free protection for your applications and to get access to all the features.
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