module DataMapper
module NestedAttributes
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.
#
# @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.
# These hash keys are nested attributes implementation details.
UNASSIGNABLE_KEYS = [ :id, :_delete ]
##
# 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_to_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.detect { |record| record.id.to_s == attributes[:id].to_s }
assign_to_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_to_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
resource.mark_for_destruction
else
resource.update(attributes.except(*UNASSIGNABLE_KEYS))
end
end
##
# Determines if a hash contains a truthy _delete key.
#
# @param hash [Hash] The hash to test
#
# @return [Boolean]
# true, if hash containts a truthy _delete key
# false, otherwise
def has_delete_flag?(hash)
# TODO find out if this activerecord code needs to be ported
# ConnectionAdapters::Column.value_to_boolean hash['_delete']
hash[:_delete]
end
##
# Determines if a new record should be build by checking for
# has_delete_flag? or if a :reject_if proc exists for this
# association and evaluates to +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 [Boolean]
# true, if the given attributes won't be rejected
# false, otherwise
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
def evaluate_reject_new_record_guard(guard, attributes)
if guard.is_a?(Symbol) || guard.is_a?(String)
send(guard)
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
def normalize_attributes_collection(attributes_collection)
if attributes_collection.is_a?(Hash)
attributes_collection.sort_by { |index, _| index.to_i }.map { |_, attributes| attributes }
else
attributes_collection
end
end
end
module CommonResourceSupport
##
# 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 [Boolean]
# true, if this resource is marked for destruction
# false, otherwise
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