module DataMapper module NestedAttributes ## # Extensions and customizations for @see DataMapper::Resource # that are needed if the @see DataMapper::Resource wants to # accept nested attributes for any given relationship. # Basically, this module provides functionality that allows # either assignment or marking for destruction of related parent # and child associations, based on the given attributes and what # kind of relationship should be altered. module Resource ## # Can be used to remove ambiguities from the passed attributes. # Consider a situation with a belongs_to association where both a valid value # for the foreign_key attribute *and* nested_attributes for a new record are # present (i.e. item_type_id and item_type_attributes are present). # Also see http://is.gd/sz2d on the rails-core ml for a discussion on this. # The basic idea is, that there should be a well defined behavior for what # exactly happens when such a situation occurs. I'm currently in favor for # using the foreign_key if it is present, but this probably needs more thinking. # For now, this method basically is a no-op, but at least it provides a hook where # everyone can perform it's own sanitization by overwriting this method. # # @param attributes [Hash] # The attributes to sanitize # # @return [Hash] # The sanitized attributes # def sanitize_nested_attributes(attributes) attributes # noop end private ## # Attribute hash keys that should not be assigned as normal attributes. # # @return [#each] # The model key and :_delete, the latter being a special value # used to mark a resource for destruction def unassignable_keys model.key.to_a << :_delete end ## # Assigns the given attributes to the resource association. # # If the given attributes include an :id that matches the existing # record’s id, then the existing record will be modified. Otherwise a new # record will be built. # # If the given attributes include a matching :id attribute _and_ a # :_delete key set to a truthy value, then the existing record # will be marked for destruction. # # @param relationship [DataMapper::Associations::Relationship] # The relationship backing the association. # Assignment will happen on the target end of the relationship # # @param attributes [Hash] # The attributes to assign to the relationship's target end # All attributes except @see UNASSIGNABLE_KEYS will be assigned # # @return nil def assign_nested_attributes_for_related_resource(relationship, attributes) if attributes[:id].blank? return if reject_new_record?(relationship, attributes) new_record = relationship.target_model.new(attributes.except(*unassignable_keys)) relationship.set(self, new_record) else existing_record = relationship.get(self) if existing_record && existing_record.id.to_s == attributes[:id].to_s assign_or_mark_for_destruction(relationship, existing_record, attributes) end end end ## # Assigns the given attributes to the collection association. # # Hashes with an :id value matching an existing associated record # will update that record. Hashes without an :id value will build # a new record for the association. Hashes with a matching :id # value and a :_delete key set to a truthy value will mark the # matched record for destruction. # # For example: # # assign_nested_attributes_for_collection_association(:people, { # '1' => { :id => '1', :name => 'Peter' }, # '2' => { :name => 'John' }, # '3' => { :id => '2', :_delete => true } # }) # # Will update the name of the Person with ID 1, build a new associated # person with the name `John', and mark the associatied Person with ID 2 # for destruction. # # Also accepts an Array of attribute hashes: # # assign_nested_attributes_for_collection_association(:people, [ # { :id => '1', :name => 'Peter' }, # { :name => 'John' }, # { :id => '2', :_delete => true } # ]) # # @param relationship [DataMapper::Associations::Relationship] # The relationship backing the association. # Assignment will happen on the target end of the relationship # # @param attributes [Hash] # The attributes to assign to the relationship's target end # All attributes except @see UNASSIGNABLE_KEYS will be assigned # # @return nil def assign_nested_attributes_for_related_collection(relationship, attributes_collection) normalize_attributes_collection(attributes_collection).each do |attributes| if attributes[:id].blank? next if reject_new_record?(relationship, attributes) relationship.get(self).new(attributes.except(*unassignable_keys)) else collection = relationship.get(self) if existing_record = collection.get(attributes[:id]) assign_or_mark_for_destruction(relationship, existing_record, attributes) end end end end ## # Updates a record with the +attributes+ or marks it for destruction if # +allow_destroy+ is +true+ and has_delete_flag? returns +true+. # # @param relationship [DataMapper::Associations::Relationship] # The relationship backing the association. # Assignment will happen on the target end of the relationship # # @param attributes [Hash] # The attributes to assign to the relationship's target end # All attributes except @see UNASSIGNABLE_KEYS will be assigned # # @return nil def assign_or_mark_for_destruction(relationship, resource, attributes) allow_destroy = self.class.options_for_nested_attributes[relationship][:allow_destroy] if has_delete_flag?(attributes) && allow_destroy if relationship.is_a?(DataMapper::Associations::ManyToMany::Relationship) target_query = relationship.target_key.zip(resource.key).to_hash target_collection = relationship.get(self, target_query) unless target_collection.empty? target_collection.send(:intermediaries, target_collection).each do |intermediary| intermediary.mark_for_destruction end target_collection.each { |r| r.mark_for_destruction } end else resource.mark_for_destruction end else resource.attributes = attributes.except(*unassignable_keys) resource.save end end ## # Determines if the given attributes hash contains a truthy :_delete key. # # @param attributes [Hash] The attributes to test # # @return [TrueClass, FalseClass] # true, if attributes contains a truthy :_delete key def has_delete_flag?(attributes) !!attributes[:_delete] end ## # Determines if a new record should be built with the given attributes. # Rejects a new record if @see has_delete_flag? returns true for the given attributes, # or if a :reject_if guard exists for the passed relationship that evaluates to +true+. # # @param relationship [DataMapper::Associations::Relationship] # The relationship backing the association. # # @param attributes [Hash] # The attributes to test with @see has_delete_flag? # # @return [TrueClass, FalseClass] # true, if the given attributes will be rejected def reject_new_record?(relationship, attributes) guard = self.class.options_for_nested_attributes[relationship][:reject_if] return false if guard.nil? # if relationship guard is nil, nothing will be rejected has_delete_flag?(attributes) || evaluate_reject_new_record_guard(guard, attributes) end ## # Evaluates the given guard by calling it with the given attributes # # @param [Symbol, String, #call] guard # An instance method name or an object that respond_to?(:call), which # would stop a new record from being created, if it evaluates to true. # # @param [Hash] attributes # The attributes to pass to the guard for evaluating if it should reject # the creation of a new resource # # @raise ArgumentError # If the given guard doesn't match [Symbol, String, #call] # # @return [true, false] # The value returned by evaluating the guard def evaluate_reject_new_record_guard(guard, attributes) if guard.is_a?(Symbol) || guard.is_a?(String) send(guard, attributes) elsif guard.respond_to?(:call) guard.call(attributes) else # never reached when called from inside the plugin raise ArgumentError, "guard must be a Symbol, a String, or respond_to?(:call)" end end ## # Make sure to return a collection of attribute hashes. # If passed an attributes hash, map it to its attributes # # @param attributes [Hash, #each] # An attributes hash or a collection of attribute hashes # # @return [#each] # A collection of attribute hashes def normalize_attributes_collection(attributes) if attributes.is_a?(Hash) attributes.map { |_, attributes| attributes } else attributes end end end ## # This module provides basic support for accepting nested attributes, # that every @see DataMapper::Resource must include. It includes methods # that allow a resource to be marked for destruction and it provides an # overwritten version of @see DataMapper::Resource#save_self that either # destroys a resource if it's @see marked_for_destruction? or performs # an ordinary save by delegating to super # module CommonResourceSupport ## # If self is marked for destruction, destroy self # else, save self by delegating to super method. # # @return The same value that super returns def save_self if marked_for_destruction? saved? ? destroy : true else super end end ## # remove mark for destruction if present # before delegating reload behavior to super # # @return The same value that super returns def reload @marked_for_destruction = false super end ## # Test if this resource is marked for destruction # # @return [true, false] # true if this resource is marked for destruction def marked_for_destruction? !!@marked_for_destruction end ## # Mark this resource for destruction # # @return true def mark_for_destruction @marked_for_destruction = true end end end end