lib/dm-core/associations/relationship.rb
# TODO: move argument and option validation into the class
module DataMapper
module Associations
# Base class for relationships. Each type of relationship
# (1 to 1, 1 to n, n to m) implements a subclass of this class
# with methods like get and set overridden.
class Relationship
include DataMapper::Assertions
include Subject
OPTIONS = [ :child_repository_name, :parent_repository_name, :child_key, :parent_key, :min, :max, :inverse, :reader_visibility, :writer_visibility, :default ].to_set
# Relationship name
#
# @example for :parent association in
#
# class VersionControl::Commit
# # ...
#
# belongs_to :parent
# end
#
# name is :parent
#
# @api semipublic
attr_reader :name
# Options used to set up association of this relationship
#
# @example for :author association in
#
# class VersionControl::Commit
# # ...
#
# belongs_to :author, :model => 'Person'
# end
#
# options is a hash with a single key, :model
#
# @api semipublic
attr_reader :options
# ivar used to store collection of child options in source
#
# @example for :commits association in
#
# class VersionControl::Branch
# # ...
#
# has n, :commits
# end
#
# instance variable name for source will be @commits
#
# @api semipublic
attr_reader :instance_variable_name
# Repository from where child objects are loaded
#
# @api semipublic
attr_reader :child_repository_name
# Repository from where parent objects are loaded
#
# @api semipublic
attr_reader :parent_repository_name
# Minimum number of child objects for relationship
#
# @example for :cores association in
#
# class CPU::Multicore
# # ...
#
# has 2..n, :cores
# end
#
# minimum is 2
#
# @api semipublic
attr_reader :min
# Maximum number of child objects for
# relationship
#
# @example for :fouls association in
#
# class Basketball::Player
# # ...
#
# has 0..5, :fouls
# end
#
# maximum is 5
#
# @api semipublic
attr_reader :max
# Returns the visibility for the source accessor
#
# @return [Symbol]
# the visibility for the accessor added to the source
#
# @api semipublic
attr_reader :reader_visibility
# Returns the visibility for the source mutator
#
# @return [Symbol]
# the visibility for the mutator added to the source
#
# @api semipublic
attr_reader :writer_visibility
# Returns query options for relationship.
#
# For this base class, always returns query options
# has been initialized with.
# Overriden in subclasses.
#
# @api private
attr_reader :query
# Returns the String the Relationship would use in a Hash
#
# @return [String]
# String name for the Relationship
#
# @api private
def field
name.to_s
end
# Returns a hash of conditions that scopes query that fetches
# target object
#
# @return [Hash]
# Hash of conditions that scopes query
#
# @api private
def source_scope(source)
{ inverse => source }
end
# Creates and returns Query instance that fetches
# target resource(s) (ex.: articles) for given target resource (ex.: author)
#
# @api semipublic
def query_for(source, other_query = nil)
repository_name = relative_target_repository_name_for(source)
DataMapper.repository(repository_name).scope do
query = target_model.query.dup
query.update(self.query)
query.update(:conditions => source_scope(source))
query.update(other_query) if other_query
query.update(:fields => query.fields | target_key)
end
end
# Returns model class used by child side of the relationship
#
# @return [Resource]
# Model for association child
#
# @api private
def child_model
return @child_model if defined?(@child_model)
child_model_name = self.child_model_name
@child_model = DataMapper::Ext::Module.find_const(@parent_model || Object, child_model_name)
rescue NameError
raise NameError, "Cannot find the child_model #{child_model_name} for #{parent_model_name} in #{name}"
end
# @api private
def child_model?
child_model
true
rescue NameError
false
end
# @api private
def child_model_name
@child_model ? child_model.name : @child_model_name
end
# Returns a set of keys that identify the target model
#
# @return [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
# Access Relationship#child_key directly
#
# @api private
alias_method :relationship_child_key, :child_key
private :relationship_child_key
# Returns model class used by parent side of the relationship
#
# @return [Resource]
# Class of association parent
#
# @api private
def parent_model
return @parent_model if defined?(@parent_model)
parent_model_name = self.parent_model_name
@parent_model = DataMapper::Ext::Module.find_const(@child_model || Object, parent_model_name)
rescue NameError
raise NameError, "Cannot find the parent_model #{parent_model_name} for #{child_model_name} in #{name}"
end
# @api private
def parent_model?
parent_model
true
rescue NameError
false
end
# @api private
def parent_model_name
@parent_model ? parent_model.name : @parent_model_name
end
# Returns a set of keys that identify parent model
#
# @return [PropertySet]
# a set of properties that identify parent model
#
# @api private
def parent_key
return @parent_key if defined?(@parent_key)
repository_name = parent_repository_name || child_repository_name
properties = parent_model.properties(repository_name)
@parent_key = if @parent_properties
parent_key = properties.values_at(*@parent_properties)
properties.class.new(parent_key).freeze
else
properties.key
end
end
# Loads and returns "other end" of the association.
# Must be implemented in subclasses.
#
# @api semipublic
def get(resource, other_query = nil)
raise NotImplementedError, "#{self.class}#get not implemented"
end
# Gets "other end" of the association directly
# as @ivar on given resource. Subclasses usually
# use implementation of this class.
#
# @api semipublic
def get!(resource)
resource.instance_variable_get(instance_variable_name)
end
# Sets value of the "other end" of association
# on given resource. Must be implemented in subclasses.
#
# @api semipublic
def set(resource, association)
raise NotImplementedError, "#{self.class}#set not implemented"
end
# Sets "other end" of the association directly
# as @ivar on given resource. Subclasses usually
# use implementation of this class.
#
# @api semipublic
def set!(resource, association)
resource.instance_variable_set(instance_variable_name, association)
end
# Eager load the collection using the source as a base
#
# @param [Collection] source
# the source collection to query with
# @param [Query, Hash] query
# optional query to restrict the collection
#
# @return [Collection]
# the loaded collection for the source
#
# @api private
def eager_load(source, query = nil)
targets = source.model.all(query_for(source, query))
# FIXME: cannot associate targets to m:m collection yet
if source.loaded? && !source.kind_of?(ManyToMany::Collection)
associate_targets(source, targets)
end
targets
end
# Checks if "other end" of association is loaded on given
# resource.
#
# @api semipublic
def loaded?(resource)
resource.instance_variable_defined?(instance_variable_name)
end
# Test the resource to see if it is a valid target
#
# @param [Object] source
# the resource or collection to be tested
#
# @return [Boolean]
# true if the resource is valid
#
# @api semipulic
def valid?(value, negated = false)
case value
when Enumerable then valid_target_collection?(value, negated)
when Resource then valid_target?(value)
when nil then true
else
raise ArgumentError, "+value+ should be an Enumerable, Resource or nil, but was a #{value.class.name}"
end
end
# Compares another Relationship for equality
#
# @param [Relationship] other
# the other Relationship to compare with
#
# @return [Boolean]
# true if they are equal, false if not
#
# @api public
def eql?(other)
return true if equal?(other)
instance_of?(other.class) && cmp?(other, :eql?)
end
# Compares another Relationship for equivalency
#
# @param [Relationship] other
# the other Relationship to compare with
#
# @return [Boolean]
# true if they are equal, false if not
#
# @api public
def ==(other)
return true if equal?(other)
other.respond_to?(:cmp_repository?, true) &&
other.respond_to?(:cmp_model?, true) &&
other.respond_to?(:cmp_key?, true) &&
other.respond_to?(:min) &&
other.respond_to?(:max) &&
other.respond_to?(:query) &&
cmp?(other, :==)
end
# Get the inverse relationship from the target model
#
# @api semipublic
def inverse
return @inverse if defined?(@inverse)
@inverse = options[:inverse]
if kind_of_inverse?(@inverse)
return @inverse
end
relationships = target_model.relationships(relative_target_repository_name)
@inverse = relationships.detect { |relationship| inverse?(relationship) } ||
invert
@inverse.child_key
@inverse
end
# @api private
def relative_target_repository_name
target_repository_name || source_repository_name
end
# @api private
def relative_target_repository_name_for(source)
target_repository_name || if source.respond_to?(:repository)
source.repository.name
else
source_repository_name
end
end
# @api private
def hash
self.class.hash ^
name.hash ^
child_repository_name.hash ^
parent_repository_name.hash ^
child_model.hash ^
parent_model.hash ^
child_properties.hash ^
parent_properties.hash ^
min.hash ^
max.hash ^
query.hash
end
private
# @api private
attr_reader :child_properties
# @api private
attr_reader :parent_properties
# Initializes new Relationship: sets attributes of relationship
# from options as well as conventions: for instance, @ivar name
# for association is constructed by prefixing @ to association name.
#
# Once attributes are set, reader and writer are created for
# the resource association belongs to
#
# @api semipublic
def initialize(name, child_model, parent_model, options = {})
initialize_object_ivar('child_model', child_model)
initialize_object_ivar('parent_model', parent_model)
@name = name
@instance_variable_name = "@#{@name}".freeze
@options = options.dup.freeze
@child_repository_name = @options[:child_repository_name]
@parent_repository_name = @options[:parent_repository_name]
unless @options[:child_key].nil?
@child_properties = DataMapper::Ext.try_dup(@options[:child_key]).freeze
end
unless @options[:parent_key].nil?
@parent_properties = DataMapper::Ext.try_dup(@options[:parent_key]).freeze
end
@min = @options[:min]
@max = @options[:max]
@reader_visibility = @options.fetch(:reader_visibility, :public)
@writer_visibility = @options.fetch(:writer_visibility, :public)
@default = @options.fetch(:default, nil)
# TODO: normalize the @query to become :conditions => AndOperation
# - Property/Relationship/Path should be left alone
# - Symbol/String keys should become a Property, scoped to the target_repository and target_model
# - Extract subject (target) from Operator
# - subject should be processed same as above
# - each subject should be transformed into AbstractComparison
# object with the subject, operator and value
# - transform into an AndOperation object, and return the
# query as :condition => and_object from self.query
# - this should provide the best performance
@query = DataMapper::Ext::Hash.except(@options, *self.class::OPTIONS).freeze
end
# Set the correct ivars for the named object
#
# This method should set the object in an ivar with the same name
# provided, plus it should set a String form of the object in
# a second ivar.
#
# @param [String]
# the name of the ivar to set
# @param [#name, #to_str, #to_sym] object
# the object to set in the ivar
#
# @return [String]
# the String value
#
# @raise [ArgumentError]
# raise when object does not respond to expected methods
#
# @api private
def initialize_object_ivar(name, object)
if object.respond_to?(:name)
instance_variable_set("@#{name}", object)
initialize_object_ivar(name, object.name)
elsif object.respond_to?(:to_str)
instance_variable_set("@#{name}_name", object.to_str.dup.freeze)
elsif object.respond_to?(:to_sym)
instance_variable_set("@#{name}_name", object.to_sym)
else
raise ArgumentError, "#{name} does not respond to #to_str or #name"
end
object
end
# Sets the association targets in the resource
#
# @param [Resource] source
# the source to set
# @param [Array<Resource>] targets
# the targets for the association
# @param [Query, Hash] query
# the query to scope the association with
#
# @return [undefined]
#
# @api private
def eager_load_targets(source, targets, query)
raise NotImplementedError, "#{self.class}#eager_load_targets not implemented"
end
# @api private
def valid_target_collection?(collection, negated)
if collection.kind_of?(Collection)
# TODO: move the check for model_key into Collection#reloadable?
# since what we're really checking is a Collection's ability
# to reload itself, which is (currently) only possible if the
# key was loaded.
model = target_model
model_key = model.key(repository.name)
collection.model <= model &&
(collection.query.fields & model_key) == model_key &&
(collection.loaded? ? (collection.any? || negated) : true)
else
collection.all? { |resource| valid_target?(resource) }
end
end
# @api private
def valid_target?(target)
target.kind_of?(target_model) &&
source_key.valid?(target_key.get(target))
end
# @api private
def valid_source?(source)
source.kind_of?(source_model) &&
target_key.valid?(source_key.get(source))
end
# @api private
def inverse?(other)
return true if @inverse.equal?(other)
other != self &&
kind_of_inverse?(other) &&
cmp_repository?(other, :==, :child) &&
cmp_repository?(other, :==, :parent) &&
cmp_model?(other, :==, :child) &&
cmp_model?(other, :==, :parent) &&
cmp_key?(other, :==, :child) &&
cmp_key?(other, :==, :parent)
# TODO: match only when the Query is empty, or is the same as the
# default scope for the target model
end
# @api private
def inverse_name
inverse = options[:inverse]
if inverse.kind_of?(Relationship)
inverse.name
else
inverse
end
end
# @api private
def invert
inverse_class.new(inverse_name, child_model, parent_model, inverted_options)
end
# @api private
def inverted_options
DataMapper::Ext::Hash.only(options, *OPTIONS - [ :min, :max ]).update(:inverse => self)
end
# @api private
def kind_of_inverse?(other)
other.kind_of?(inverse_class)
end
# @api private
def cmp?(other, operator)
name.send(operator, other.name) &&
cmp_repository?(other, operator, :child) &&
cmp_repository?(other, operator, :parent) &&
cmp_model?(other, operator, :child) &&
cmp_model?(other, operator, :parent) &&
cmp_key?(other, operator, :child) &&
cmp_key?(other, operator, :parent) &&
min.send(operator, other.min) &&
max.send(operator, other.max) &&
query.send(operator, other.query)
end
# @api private
def cmp_repository?(other, operator, type)
# if either repository is nil, then the relationship is relative,
# and the repositories are considered equivalent
return true unless repository_name = send("#{type}_repository_name")
return true unless other_repository_name = other.send("#{type}_repository_name")
repository_name.send(operator, other_repository_name)
end
# @api private
def cmp_model?(other, operator, type)
send("#{type}_model?") &&
other.send("#{type}_model?") &&
send("#{type}_model").base_model.send(operator, other.send("#{type}_model").base_model)
end
# @api private
def cmp_key?(other, operator, type)
property_method = "#{type}_properties"
self_key = send(property_method)
other_key = other.send(property_method)
self_key.send(operator, other_key)
end
def associate_targets(source, targets)
# TODO: create an object that wraps this logic, and when the first
# kicker is fired, then it'll load up the collection, and then
# populate all the other methods
target_maps = Hash.new { |hash, key| hash[key] = [] }
targets.each do |target|
target_maps[target_key.get(target)] << target
end
Array(source).each do |source|
key = source_key.get(source)
eager_load_targets(source, target_maps[key], query)
end
end
end # class Relationship
end # module Associations
end # module DataMapper