lib/dm-core/associations/many_to_many.rb
module DataMapper
module Associations
module ManyToMany #:nodoc:
class Relationship < Associations::OneToMany::Relationship
extend Chainable
OPTIONS = superclass::OPTIONS.dup << :through << :via
# Returns a set of keys that identify the target model
#
# @return [DataMapper::PropertySet]
# a set of properties that identify the target model
#
# @api semipublic
def child_key
return @child_key if defined?(@child_key)
repository_name = child_repository_name || parent_repository_name
properties = child_model.properties(repository_name)
@child_key = if @child_properties
child_key = properties.values_at(*@child_properties)
properties.class.new(child_key).freeze
else
properties.key
end
end
# @api semipublic
alias_method :target_key, :child_key
# Intermediate association for through model
# relationships
#
# Example: for :bugs association in
#
# class Software::Engineer
# include DataMapper::Resource
#
# has n, :missing_tests
# has n, :bugs, :through => :missing_tests
# end
#
# through is :missing_tests
#
# TODO: document a case when
# through option is a model and
# not an association name
#
# @api semipublic
def through
return @through if defined?(@through)
@through = options[:through]
if @through.kind_of?(Associations::Relationship)
return @through
end
model = source_model
repository_name = source_repository_name
relationships = model.relationships(repository_name)
name = through_relationship_name
@through = relationships[name] ||
DataMapper.repository(repository_name) do
model.has(min..max, name, through_model, one_to_many_options)
end
@through.child_key
@through
end
# @api semipublic
def via
return @via if defined?(@via)
@via = options[:via]
if @via.kind_of?(Associations::Relationship)
return @via
end
name = self.name
through = self.through
repository_name = through.relative_target_repository_name
through_model = through.target_model
relationships = through_model.relationships(repository_name)
singular_name = DataMapper::Inflector.singularize(name.to_s).to_sym
@via = relationships[@via] ||
relationships[name] ||
relationships[singular_name]
@via ||= if anonymous_through_model?
DataMapper.repository(repository_name) do
through_model.belongs_to(singular_name, target_model, many_to_one_options)
end
else
raise UnknownRelationshipError, "No relationships named #{name} or #{singular_name} in #{through_model}"
end
@via.child_key
@via
end
# @api semipublic
def links
return @links if defined?(@links)
@links = []
links = [ through, via ]
while relationship = links.shift
if relationship.respond_to?(:links)
links.unshift(*relationship.links)
else
@links << relationship
end
end
@links.freeze
end
# Initialize the chain for "many to many" relationships
#
# @return [self]
#
# @api public
def finalize
through
via
self
end
# @api private
def source_scope(source)
{ through.inverse => source }
end
# @api private
def query
# TODO: consider making this a query_for method, so that ManyToMany::Relationship#query only
# returns the query supplied in the definition
@many_to_many_query ||= super.merge(:links => links).freeze
end
# Eager load the collection using the source as a base
#
# @param [Resource, Collection] source
# the source to query with
# @param [Query, Hash] other_query
# optional query to restrict the collection
#
# @return [ManyToMany::Collection]
# the loaded collection for the source
#
# @api private
def eager_load(source, other_query = nil)
# FIXME: enable SEL for m:m relationships
source.model.all(query_for(source, other_query))
end
private
# @api private
def through_model
namespace, name = through_model_namespace_name
if namespace.const_defined?(name)
namespace.const_get(name)
else
Model.new(name, namespace) do
# all properties added to the anonymous through model are keys
def property(name, type, options = {})
options[:key] = true
options.delete(:index)
super
end
end
end
end
# @api private
def through_model_namespace_name
target_parts = target_model.base_model.name.split('::')
source_parts = source_model.base_model.name.split('::')
name = [ target_parts.pop, source_parts.pop ].sort.join
namespace = Object
# find the common namespace between the target_model and source_model
target_parts.zip(source_parts) do |target_part, source_part|
break if target_part != source_part
namespace = namespace.const_get(target_part)
end
return namespace, name
end
# @api private
def through_relationship_name
if anonymous_through_model?
namespace = through_model_namespace_name.first
relationship_name = DataMapper::Inflector.underscore(through_model.name.sub(/\A#{namespace.name}::/, '')).tr('/', '_')
DataMapper::Inflector.pluralize(relationship_name).to_sym
else
options[:through]
end
end
# Check if the :through association uses an anonymous model
#
# An anonymous model means that DataMapper creates the model
# in-memory, and sets the relationships to join the source
# and the target model.
#
# @return [Boolean]
# true if the through model is anonymous
#
# @api private
def anonymous_through_model?
options[:through] == Resource
end
# @api private
def nearest_relationship
return @nearest_relationship if defined?(@nearest_relationship)
nearest_relationship = self
while nearest_relationship.respond_to?(:through)
nearest_relationship = nearest_relationship.through
end
@nearest_relationship = nearest_relationship
end
# @api private
def valid_target?(target)
relationship = via
source_key = relationship.source_key
target_key = relationship.target_key
target.kind_of?(target_model) &&
source_key.valid?(target_key.get(target))
end
# @api private
def valid_source?(source)
relationship = nearest_relationship
source_key = relationship.source_key
target_key = relationship.target_key
source.kind_of?(source_model) &&
target_key.valid?(source_key.get(source))
end
chainable do
# @api semipublic
def many_to_one_options
{ :parent_key => target_key.map { |property| property.name } }
end
# @api semipublic
def one_to_many_options
{ :parent_key => source_key.map { |property| property.name } }
end
end
# Returns the inverse relationship class
#
# @api private
def inverse_class
self.class
end
# @api private
def invert
inverse_class.new(inverse_name, parent_model, child_model, inverted_options)
end
# @api private
def inverted_options
links = self.links.dup
through = links.pop.inverse
links.reverse_each do |relationship|
inverse = relationship.inverse
through = self.class.new(
inverse.name,
inverse.child_model,
inverse.parent_model,
inverse.options.merge(:through => through)
)
end
options = self.options
DataMapper::Ext::Hash.only(options, *OPTIONS - [ :min, :max ]).update(
:through => through,
:child_key => options[:parent_key],
:parent_key => options[:child_key],
:inverse => self
)
end
# Returns collection class used by this type of
# relationship
#
# @api private
def collection_class
ManyToMany::Collection
end
end # class Relationship
class Collection < Associations::OneToMany::Collection
# Remove every Resource in the m:m Collection from the repository
#
# This performs a deletion of each Resource in the Collection from
# the repository and clears the Collection.
#
# @return [Boolean]
# true if the resources were successfully destroyed
#
# @api public
def destroy
assert_source_saved 'The source must be saved before mass-deleting the collection'
# make sure the records are loaded so they can be found when
# the intermediaries are removed
lazy_load
unless intermediaries.all(via => self).destroy
return false
end
super
end
# Remove every Resource in the m:m Collection from the repository, bypassing validation
#
# This performs a deletion of each Resource in the Collection from
# the repository and clears the Collection while skipping
# validation.
#
# @return [Boolean]
# true if the resources were successfully destroyed
#
# @api public
def destroy!
assert_source_saved 'The source must be saved before mass-deleting the collection'
model = self.model
key = model.key(repository_name)
conditions = Query.target_conditions(self, key, key)
unless intermediaries.all(via => self).destroy!
return false
end
unless model.all(:repository => repository, :conditions => conditions).destroy!
return false
end
each do |resource|
resource.persistence_state = Resource::PersistenceState::Immutable.new(resource)
end
clear
true
end
# Return the intermediaries linking the source to the targets
#
# @return [Collection]
# the intermediary collection
#
# @api public
def intermediaries
through = self.through
source = self.source
@intermediaries ||= if through.loaded?(source)
through.get_collection(source)
else
reset_intermediaries
end
end
protected
# Map the resources in the collection to the intermediaries
#
# @return [Hash]
# the map of resources to their intermediaries
#
# @api private
def intermediary_for
@intermediary_for ||= {}
end
# @api private
def through
relationship.through
end
# @api private
def via
relationship.via
end
private
# @api private
def _create(attributes, execute_hooks = true)
via = self.via
if via.respond_to?(:resource_for)
resource = super
if create_intermediary(execute_hooks, resource)
resource
end
else
if intermediary = create_intermediary(execute_hooks)
super(attributes.merge(via.inverse => intermediary), execute_hooks)
end
end
end
# @api private
def _save(execute_hooks = true)
via = self.via
if @removed.any?
# delete only intermediaries linked to the removed targets
return false unless intermediaries.all(via => @removed).send(execute_hooks ? :destroy : :destroy!)
# reset the intermediaries so that it reflects the current state of the datastore
reset_intermediaries
end
loaded_entries = self.loaded_entries
if via.respond_to?(:resource_for)
super
loaded_entries.all? { |resource| create_intermediary(execute_hooks, resource) }
else
if loaded_entries.any? && (intermediary = create_intermediary(execute_hooks))
inverse = via.inverse
loaded_entries.each { |resource| inverse.set(resource, intermediary) }
end
super
end
end
# @api private
def create_intermediary(execute_hooks, resource = nil)
intermediary_for = self.intermediary_for
intermediary_resource = intermediary_for[resource]
return intermediary_resource if intermediary_resource
intermediaries = self.intermediaries
method = execute_hooks ? :save : :save!
return unless intermediaries.send(method)
attributes = {}
attributes[via] = resource if resource
intermediary = intermediaries.first_or_new(attributes)
return unless intermediary.__send__(method)
# map the resource, even if it is nil, to the intermediary
intermediary_for[resource] = intermediary
end
# @api private
def reset_intermediaries
through = self.through
source = self.source
through.set_collection(source, through.collection_for(source))
end
# @api private
def inverse_set(*)
# do nothing
end
end # class Collection
end # module ManyToMany
end # module Associations
end # module DataMapper