lib/ripple/associations.rb
require 'active_support/concern'
require 'active_support/dependencies'
require 'riak/walk_spec'
require 'ripple/translation'
require 'ripple/associations/proxy'
require 'ripple/associations/instantiators'
require 'ripple/associations/linked'
require 'ripple/associations/embedded'
require 'ripple/associations/many'
require 'ripple/associations/one'
require 'ripple/associations/linked'
require 'ripple/associations/one_embedded_proxy'
require 'ripple/associations/many_embedded_proxy'
require 'ripple/associations/one_linked_proxy'
require 'ripple/associations/many_linked_proxy'
require 'ripple/associations/one_stored_key_proxy'
require 'ripple/associations/many_stored_key_proxy'
require 'ripple/associations/one_key_proxy'
require 'ripple/associations/many_reference_proxy'
require 'ripple/associations/one_inverse_proxy'
require 'ripple/associations/many_inverse_proxy'
module Ripple
# Adds associations via links and embedding to {Ripple::Document}
# models. Examples:
#
# # Documents can contain embedded documents, and link to other standalone documents
# # via associations using the many and one class methods.
# class Person
# include Ripple::Document
# property :name, String
# many :addresses
# many :friends, :class_name => "Person"
# one :account
# end
#
# # Account and Address are embeddable documents
# class Account
# include Ripple::EmbeddedDocument
# property :paid_until, Time
# embedded_in :person # Adds "person" method to get parent document
# end
#
# class Address
# include Ripple::EmbeddedDocument
# property :street, String
# property :city, String
# property :state, String
# property :zip, String
# end
#
# person = Person.find("adamhunter")
# person.friends << Person.find("seancribbs") # Links to people/seancribbs with tag "friend"
# person.addresses << Address.new(:street => "100 Main Street") # Adds an embedded address
# person.account.paid_until = 3.months.from_now
module Associations
extend ActiveSupport::Concern
module ClassMethods
include Translation
# @private
def inherited(subclass)
super
subclass.associations.merge!(associations)
end
# Associations defined on the document
def associations
@associations ||= {}.with_indifferent_access
end
# Associations of embedded documents
def embedded_associations
associations.values.select(&:embedded?)
end
# Associations of linked documents
def linked_associations
associations.values.select(&:linked?)
end
# Associations of stored_key documents
def stored_key_associations
associations.values.select(&:stored_key?)
end
# Creates a singular association
def one(name, options={})
configure_for_key_correspondence if options[:using] === :key
create_association(:one, name, options)
end
# Creates a plural association
def many(name, options={})
raise ArgumentError, t('many_key_association') if options[:using] === :key
create_association(:many, name, options)
end
def configure_for_key_correspondence
include Ripple::Associations::KeyDelegator
end
private
def create_association(type, name, options={})
association = associations[name] = Association.new(type, name, options)
association.validate!(self)
association.setup_on(self)
define_method(name) do
get_proxy(association)
end
define_method("#{name}=") do |value|
get_proxy(association).replace(value)
value
end
unless association.many?
define_method("#{name}?") do
get_proxy(association).present?
end
end
end
end
# @private
def get_proxy(association)
unless proxy = instance_variable_get(association.ivar)
proxy = association.proxy_class.new(self, association)
instance_variable_set(association.ivar, proxy)
end
proxy
end
# @private
def reset_associations
self.class.associations.each do |name, assoc_object|
send(name).reset
end
end
# Adds embedded documents to the attributes
# @private
def attributes_for_persistence
self.class.embedded_associations.inject(super) do |attrs, association|
documents = instance_variable_get(association.ivar)
# We must explicitly check #nil? (rather than just saying `if documents`)
# because documents can be an association proxy that is proxying nil.
# In this case ruby treats documents as true because it is not _really_ nil,
# but #nil? will tell us if it is proxying nil.
unless documents.nil?
attrs[association.name] = documents.is_a?(Array) ? documents.map(&:attributes_for_persistence) : documents.attributes_for_persistence
end
attrs
end
end
def propagate_callbacks_to_embedded_associations(name, kind)
self.class.embedded_associations.each do |association|
documents = instance_variable_get(association.ivar)
# We must explicitly check #nil? (rather than just saying `if documents`)
# because documents can be an association proxy that is proxying nil.
# In this case ruby treats documents as true because it is not _really_ nil,
# but #nil? will tell us if it is proxying nil.
next if documents.nil?
Array(documents).each do |doc|
doc.send("_#{name}_callbacks").each do |callback|
next unless callback.kind == kind
doc.send(callback.filter)
end
end
end
end
# Propagates callbacks (save/create/update/destroy) to embedded associated documents.
# This is necessary so that when a parent is saved, the embedded child's before_save
# hooks are run as well.
# @private
def run_callbacks(name, *args, &block)
# validation is already propagated to embedded documents via the
# AssociatedValidator. We don't need to duplicate the propagation here.
return super if name == :validation
propagate_callbacks_to_embedded_associations(name, :before)
return_value = super
propagate_callbacks_to_embedded_associations(name, :after)
return_value
end
end
# The "reflection" for an association - metadata about how it is
# configured.
class Association
include Ripple::Translation
attr_reader :type, :name, :options
# association options :using, :class_name, :class, :extend, :foreign_key, :inverse
# options that may be added :validate
def initialize(type, name, options={})
@type, @name, @options = type, name, options.to_options
end
def validate!(owner)
# TODO: Refactor this into an association subclass. See also GH #284
if @options[:using] == :stored_key || @options.has_key?(:foreign_key)
@options[:using] = :stored_key # Ensure Proxy class
unless prop_name = @options[:foreign_key]
single_name = ActiveSupport::Inflector.singularize(@name.to_s)
prop_name = "#{single_name}_key"
prop_name << "s" if many?
end
unless owner.properties.include?(prop_name)
raise ArgumentError, t('stored_key_requires_property', :name => prop_name)
end
end
end
# @return String The class name of the associated object(s)
def class_name
@class_name ||= case
when @options[:class_name]
@options[:class_name]
when @options[:class]
@options[:class].to_s
when many?
@name.to_s.classify
else
@name.to_s.camelize
end
end
# @return [Class] The class of the associated object(s)
def klass
@klass ||= discover_class
end
# @return [true,false] Is the cardinality of the association > 1
def many?
@type == :many
end
# @return [true,false] Is the cardinality of the association == 1
def one?
@type == :one
end
# @return [true,false] Is the associated class an EmbeddedDocument
def embedded?
klass.embeddable?
end
# TODO: Polymorphic not supported
# @return [true,false] Does the association support more than one associated class
def polymorphic?
false
end
# @return [true,false] Does the association use links
def linked?
using == :linked
end
# @return [true,false] Does the association use stored_key
def stored_key?
using == :stored_key
end
# @return [String] the instance variable in the owner where the association will be stored
def ivar
"@_#{name}"
end
# @return [Class] the association proxy class
def proxy_class
@proxy_class ||= proxy_class_name.constantize
end
# @return [String] the class name of the association proxy
def proxy_class_name
klass_name = (many? ? 'Many' : 'One') + using.to_s.camelize + ('Polymorphic' if polymorphic?).to_s + 'Proxy'
"Ripple::Associations::#{klass_name}"
end
# @return [Proc] a filter proc to be used with Enumerable#select for collecting links that belong to this association (only when #linked? is true)
def link_filter
linked? ? lambda {|link| link.tag == link_tag } : lambda {|_| false }
end
# @return [String,nil] when #linked? is true, the tag for outgoing links
def link_tag
linked? ? Array(link_spec).first.tag : nil
end
def bucket_name
polymorphic? ? '_' : klass.bucket_name
end
# @return [Riak::WalkSpec] when #linked? is true, a specification for which links to follow to retrieve the associated documents
def link_spec
# TODO: support transitive linked associations
if linked?
tag = name.to_s
Riak::WalkSpec.new(:tag => tag, :bucket => bucket_name)
else
nil
end
end
# @return [Symbol] which method is used for representing the association - currently only supports :embedded and :linked
def using
@using ||= options[:using] || (embedded? ? :embedded : :linked)
end
# @raise [ArgumentError] if the value does not match the class of the association
def verify_type!(value, owner)
unless type_matches?(value)
raise ArgumentError.new(t('invalid_association_value',
:name => name,
:owner => owner.inspect,
:klass => polymorphic? ? "<polymorphic>" : klass.name,
:value => value.inspect))
end
end
# @private
def type_matches?(value)
case
when polymorphic?
one? || value.is_a?(Array)
when many?
value.is_a?(Array) && value.all? {|d| (embedded? && d.is_a?(Hash)) || d.kind_of?(klass) }
when one?
value.nil? || (embedded? && value.is_a?(Hash)) || value.kind_of?(klass)
end
end
def uses_search?
options[:using] == :reference
end
def setup_on(model)
@model = model
define_callbacks_on(model)
if uses_search?
klass.before_save do |o|
unless o.class.bucket.is_indexed?
o.class.bucket.enable_index!
end
end
end
end
def define_callbacks_on(klass)
_association = self
klass.before_save do
if _association.linked? && !@_in_save_loaded_documents_callback
@_in_save_loaded_documents_callback = true
begin
send(_association.name).loaded_documents.each do |document|
document.save if document.new? || document.changed?
end
ensure
remove_instance_variable(:@_in_save_loaded_documents_callback)
end
end
end
end
private
def discover_class
options[:class] || (@model && find_class(@model, class_name)) || class_name.constantize
end
def find_class(scope, class_name)
return nil if class_name.include?("::")
class_sym = class_name.to_sym
parent_scope = scope.parents.unshift(scope).find {|s| ActiveSupport::Dependencies.local_const_defined?(s, class_sym) }
parent_scope.const_get(class_sym) if parent_scope
end
end
end