module DataMapper
module NestedAttributes
module ClassMethods
def self.extended(base)
base.class_inheritable_accessor :autosave_associations
base.autosave_associations = {}
end
# Defines an attributes reader and writer for the specified association(s).
# If you are using attr_protected or attr_accessible,
# then you will need to add the attribute writer to the allowed list.
#
# After any params are passed to the attributes writer they are available
# via the attributes reader (they are stored in an instance variable of
# the same name). The attributes reader returns nil if the attributes
# writer has not been called.
#
# Supported options:
# [:allow_destroy]
# If true, destroys any members from the attributes hash with a
# _delete key and a value that evaluates to +true+
# (eg. 1, '1', true, or 'true'). This option is off by default.
# [:reject_if]
# Allows you to specify a Proc that checks whether a record should be
# built for a certain attribute hash. The hash is passed to the Proc
# and the Proc should return either +true+ or +false+. When no Proc
# is specified a record will be built for all attribute hashes that
# do not have a _delete that evaluates to true.
#
# Examples:
# # creates avatar_attributes
# # creates avatar_attributes=
# accepts_nested_attributes_for :avatar, :reject_if => proc { |attributes| attributes['name'].blank? }
# # creates avatar_attributes and posts_attributes
# # creates avatar_attributes= and posts_attributes=
# accepts_nested_attributes_for :avatar, :posts, :allow_destroy => true
def accepts_nested_attributes_for(association_name, options = {})
assert_kind_of 'association_name', association_name, Symbol, String
assert_kind_of 'options', options, Hash
options = { :allow_destroy => false }.update(options)
# raises if the specified option keys aren't valid
assert_valid_autosave_options(options)
# raises if the specified association doesn't exist
# we don't need the return value here, just the check
# ------------------------------------------------------
# also, when using the return value from this call to
# replace association_name with association.name,
# has(1, :through) are broken, because they seem to have
# a different name
association_for_name(association_name)
# should be safe to go on
include InstanceMethods
if ::DataMapper.const_defined?('Validate')
require Pathname(__FILE__).dirname.expand_path + 'association_validation'
include AssociationValidation
end
autosave_associations[association_name] = options
type = nr_of_possible_child_instances(association_name) > 1 ? :collection : :one_to_one
class_eval %{
def save(context = :default)
saved = false # preserve Resource#save api contract
transaction { |t| t.rollback unless saved = super }
saved
end
def #{association_name}_attributes
@#{association_name}_attributes
end
def #{association_name}_attributes=(attributes)
attributes = sanitize_nested_attributes(attributes)
@#{association_name}_attributes = attributes
assign_nested_attributes_for_#{type}_association(:#{association_name}, attributes, #{options[:allow_destroy]})
end
if association_type(:#{association_name}) == :many_to_one || association_type(:#{association_name}) == :one_to_one
def get_#{association_name}
#{association_name.to_s} || self.class.associated_model_for_name(:#{association_name}).new
end
end
}, __FILE__, __LINE__ + 1
end
def reject_new_nested_attributes_proc_for(association_name)
autosave_associations[association_name] ? autosave_associations[association_name][:reject_if] : nil
end
# utility methods
def nr_of_possible_child_instances(association_name, repository = :default)
# belongs_to seems to generate no options[:max]
association_for_name(association_name, repository).options[:max] || 1
end
# i have the feeling this should be refactored
def associated_model_for_name(association_name, repository = :default)
a = association_for_name(association_name, repository)
case association_type(association_name)
when :many_to_one
a.parent_model
when :one_to_one
a.child_model
when :one_to_many
a.child_model
when :many_to_many
Object.full_const_get(a.options[:child_model])
else
raise ArgumentError, "Unknown association type #{a.inspect}"
end
end
# maybe this should be provided by dm-core somehow
# DataMapper::Association::Relationship would be a place maybe?
def association_type(association_name)
a = association_for_name(association_name)
if a.options[:max].nil? # belongs_to
:many_to_one
elsif a.options[:max] == 1 # has(1)
:one_to_one
elsif a.options[:max] > 1 && !a.is_a?(DataMapper::Associations::RelationshipChain) # has(n)
:one_to_many
elsif a.is_a?(DataMapper::Associations::RelationshipChain) # has(n, :through) MUST be checked after has(n) here
:many_to_many
else
raise ArgumentError, "Unknown association type #{a.inspect}"
end
end
# avoid nil access by always going through this
# this method raises if the association named name is not established in this model
def association_for_name(name, repository = :default)
association = self.relationships(repository)[name]
# TODO think about using a specific Error class like UnknownAssociationError
raise(ArgumentError, "Relationship #{name.inspect} does not exist in \#{model}") unless association
association
end
private
# think about storing valid options in a classlevel constant
def assert_valid_autosave_options(options)
unless options.all? { |k,v| [ :allow_destroy, :reject_if ].include?(k) }
raise ArgumentError, 'accepts_nested_attributes_for only takes :allow_destroy and :reject_if as options'
end
end
end
module InstanceMethods
# This method 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 (just overwrite this method)
def sanitize_nested_attributes(attrs)
attrs
end
# returns nil if no resource has been associated yet
def associated_instance_get(association_name, repository = :default)
send(self.class.association_for_name(association_name, repository).name)
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 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.
def assign_nested_attributes_for_one_to_one_association(association_name, attributes, allow_destroy)
if attributes[:id].blank?
unless reject_new_record?(association_name, attributes)
model = self.class.associated_model_for_name(association_name)
send("#{association_name}=", model.new(attributes.except(*UNASSIGNABLE_KEYS)))
end
else (existing_record = associated_instance_get(association_name)) && existing_record.id.to_s == attributes[:id].to_s
assign_to_or_mark_for_destruction(association_name, existing_record, attributes, allow_destroy)
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 }
# ])
def assign_nested_attributes_for_collection_association(association_name, attributes_collection, allow_destroy)
assert_kind_of 'association_name', association_name, Symbol
assert_kind_of 'attributes_collection', attributes_collection, Hash, Array
if attributes_collection.is_a? Hash
attributes_collection = attributes_collection.sort_by { |index, _| index.to_i }.map { |_, attributes| attributes }
end
attributes_collection.each do |attributes|
if attributes[:id].blank?
unless reject_new_record?(association_name, attributes)
case self.class.association_type(association_name)
when :one_to_many
build_new_has_n_association(association_name, attributes)
when :many_to_many
build_new_has_n_through_association(association_name, attributes)
end
end
elsif existing_record = send(association_name).detect { |record| record.id.to_s == attributes[:id].to_s }
assign_to_or_mark_for_destruction(association_name, existing_record, attributes, allow_destroy)
end
end
end
def build_new_has_n_association(association_name, attributes)
send(association_name).build(attributes.except(*UNASSIGNABLE_KEYS))
end
def build_new_has_n_through_association(association_name, attributes)
# fetch the association to have the information ready
association = self.class.association_for_name(association_name)
# do what's done in dm-core/specs/integration/association_through_spec.rb
# explicitly build the join entry and assign it to the join association
join_entry = self.class.associated_model_for_name(association.name).new
self.send(association.name) << join_entry
self.save
# explicitly build the child entry and assign the join entry to its join association
child_entry = self.class.associated_model_for_name(association_name).new(attributes)
child_entry.send(association.name) << join_entry
child_entry.save
end
# Updates a record with the +attributes+ or marks it for destruction if
# +allow_destroy+ is +true+ and has_delete_flag? returns +true+.
def assign_to_or_mark_for_destruction(association_name, record, attributes, allow_destroy)
if has_delete_flag?(attributes) && allow_destroy
if self.class.association_type(association_name) == :many_to_many
# destroy the join record
record.send(self.class.association_for_name(association_name).name).destroy!
# destroy the child record
record.destroy
else
record.mark_for_destruction
end
else
record.attributes = attributes.except(*UNASSIGNABLE_KEYS)
if self.class.association_type(association_name) == :many_to_many
record.save
end
end
end
# Determines if a hash contains a truthy _delete key.
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+.
def reject_new_record?(association_name, attributes)
guard = self.class.reject_new_nested_attributes_proc_for(association_name)
has_delete_flag?(attributes) || (guard.respond_to?(:call) && guard.call(attributes))
end
end
module CommonInstanceMethods
# Reloads the attributes of the object as usual and removes a mark for destruction.
def reload
@marked_for_destruction = false
super
end
def marked_for_destruction?
@marked_for_destruction
end
def mark_for_destruction
@marked_for_destruction = true
end
end
end
end