Skip to content

Commit

Permalink
Refactor Associations for Cleanliness(tm)
Browse files Browse the repository at this point in the history
  • Loading branch information
chicks committed Jan 18, 2011
1 parent 0703208 commit 55aef15
Show file tree
Hide file tree
Showing 8 changed files with 208 additions and 87 deletions.
2 changes: 2 additions & 0 deletions lib/sugarcrm/associations.rb
@@ -1,2 +1,4 @@
require 'sugarcrm/associations/association'
require 'sugarcrm/associations/association_cache'
require 'sugarcrm/associations/association_methods'
require 'sugarcrm/associations/association_collection'
102 changes: 102 additions & 0 deletions lib/sugarcrm/associations/association.rb
@@ -0,0 +1,102 @@
module SugarCRM
# Represents an association and it's metadata
class Association
# Returns an array of Association objects
class << self
def register(owner)
associations = []
owner.link_fields.each_pair do |link_field,attributes|
associations << Association.new(owner,link_field,attributes)
end
associations
end
end

attr :owner
attr :target
attr :link_field
attr :attributes
attr :methods

def initialize(owner,link_field,attributes)
@owner = owner
@link_field = link_field
@attributes = attributes
@target = resolve_target
@methods = define_methods
self
end

def to_s
"#{@link_field} => [#{@methods.join ","}] "
end

protected

# Attempts to determine the class of the target in the association
def resolve_target
# Use the link_field name first
klass = @link_field.singularize.camelize
return "SugarCRM::#{klass}".constantize if SugarCRM.const_defined? klass

# Use the link_field attribute "module"
if @attributes["module"].length > 0
module_name = SugarCRM::Module.find(@attributes["module"])
return "SugarCRM::#{module_name.klass}".constantize if SugarCRM.const_defined? module_name.klass
end
# Use the link_field attribute "relationship"
if @attributes["relationship"].length > 0
klass = humanized_link_name(@attributes["relationship"]).singularize.camelize
return "SugarCRM::#{klass}".constantize if SugarCRM.const_defined? klass
end
end

# Generates the association proxy method for related module
def define_method(link_field, pretty_name=nil)
pretty_name ||= link_field
@owner.class.module_eval %Q?
def #{pretty_name}
query_association :#{link_field}
end
?
pretty_name
end

# Defines methods for accessing the association target on the owner class.
# If the link_field name includes the owner class name, it is stripped before
# creating the method. If this occurs, we also create an alias to the stripped
# method using the full link_field name.
def define_methods
methods = []
pretty_name = humanized_link_name(@link_field)
methods << define_method(pretty_name)
if pretty_name != @link_field
@owner.class.module_eval %Q?
alias :#{@link_field} #{pretty_name}
?
methods << @link_field
end
methods
end

# Return the name of the relationship excluding the owner part of the name.
# e.g. if a custom relationship is defined in Studio between Tasks and Documents,
# the link_field will be `tasks_documents` but a human would call the relationship `documents`
def humanized_link_name(link_field)
# Split the relationship name into parts
# "contact_accounts" => ["contact","accounts"]
m = link_field.split(/_/)
# Determine the parts we don't want
# SugarCRM::Contact => ["contacts", "contact"]
o = @owner.class._module.table_name
# Use array subtraction to remove parts representing the owner side of the relationship
# ["contact", "accounts"] - ["contacts", "contact"] => ["accounts"]
t = m - [o, o.singularize]
# Reassemble whatever's left
# "accounts"
t.join('_')
end

end
end

41 changes: 41 additions & 0 deletions lib/sugarcrm/associations/association_cache.rb
@@ -0,0 +1,41 @@
module SugarCRM; module AssociationCache

attr :association_cache, false



# Returns true if an association is cached
def association_cached?(association)
@association_cache.keys.include? association
end

protected

# Returns true if an association collection has changed
def associations_changed?
@association_cache.values.each do |collection|
return true if collection.changed?
end
false
end

def save_modified_associations
@association_cache.values.each do |collection|
if collection.changed?
return false unless collection.save
end
end
true
end

# Updates an association cache entry if it's been initialized
def update_association_cache_for(association, target)
# only add to the cache if the relationship has been queried
@association_cache[association] << target if association_cached? association
end

# Resets the association cache
def clear_association_cache
@association_cache = {}.with_indifferent_access
end
end; end
110 changes: 42 additions & 68 deletions lib/sugarcrm/associations/association_methods.rb
@@ -1,33 +1,53 @@
module SugarCRM; module AssociationMethods

module ClassMethods
# Returns an array of the module link fields
def associations_from_module_link_fields
self._module.link_fields.keys
end
end

attr :association_cache, false

def association_cached?(association)
@association_cache.keys.include? association.to_sym
end

def associations_changed?
@association_cache.values.each do |collection|
return true if collection.changed?
end
false

# Returns the module link fields hash
def link_fields
self.class._module.link_fields
end

# Creates a relationship between the current object and the target
# The current instance and target records will have a relationship set
# i.e. account.associate!(contact) wyould link account and contact
# i.e. account.associate!(contact) would link account and contact
# In contrast to using account.contacts << contact, this method doesn't load the relationships
# before setting the new relationship.
# This method is useful when certain modules have many links to other modules: not loading the
# relationships allows one ot avoid a Timeout::Error
def associate!(target, target_ids=[], opts={})
def associate!(*targets)
targets.each do |target|
link_field = link_field_for(target)
response = SugarCRM.connection.set_relationship(
self.class._module.name, self.id,
link_field, [target]
)
if response["failed"] > 0
raise AssociationFailed,
"Couldn't associate #{self.class._module.name}: #{self.id} -> #{target.class._module.table_name}: #{target.id}!"
end
update_association_cache_for(link_field, target)
end
true
end

# Returns the link_field name for an object. Uses the following heuristics to determine the link_field name
# for a set_relationship or get_relationship query:
#
# Assuming we want to add a new Contact to an Account, where Account is the parent and Contact is the child.
#
# 1. Module Name Match
# Check the parent record's Module.link_fields hash for a key that matches the child module table_name
#
# 2. Module Name Fuzzy Match
# Check the parent record's Module.link_fields hash for a key that includes the child module table_name
#
# 3. Module Relationship Fuzzy Match
# Check the parent record's Module.link_fields hash for a sub-key (aptly named "relationship") that includes
# part of the child module table_name
#
def link_field_for(target)

if self.class._module.custom_module? || target.class._module.custom_module?
link_field = get_link_field(target)
else
Expand All @@ -39,56 +59,17 @@ def associate!(target, target_ids=[], opts={})
link_field = get_link_field(target)
end

target_ids = [target.id] if target_ids.size < 1
response = SugarCRM.connection.set_relationship(
self.class._module.name, self.id,
link_field, target_ids,
opts
)
raise AssociationFailed,
"Couldn't associate #{self.class._module.name}: #{self.id} -> #{target.class._module.table_name}:#{target.id}!" if response["failed"] > 0
@association_cache[link_field.to_sym] << target if @association_cache[link_field.to_sym] # only add to the cache if the relationship has been queried
true
end

protected

def save_modified_associations
@association_cache.values.each do |collection|
if collection.changed?
return false unless collection.save
end
end
true
end

def clear_association_cache
@association_cache = {}
end


# Generates the association proxy methods for related modules
def define_association_methods
return if association_methods_generated?
@associations.each do |k|
define_association_method(k) # register method with original link_field name

# if a human would call the association differently, register that name, too
humanized_link_name = get_humanized_link_name(k)
define_association_method(k, humanized_link_name) unless k == humanized_link_name
end
@associations = Association.register(self)
self.class.association_methods_generated = true
end

# Generates the association proxy method for related module
def define_association_method(link_field, pretty_name=nil)
pretty_name ||= link_field
self.class.module_eval %Q?
def #{pretty_name}
query_association :#{link_field}
end
?
end

# Returns the records from the associated module or returns the cached copy if we've already
# loaded it. Force a reload of the records with reload=true
#
Expand Down Expand Up @@ -123,13 +104,6 @@ def get_link_field(other)
link_field
end

# return the name of the relationship as a human would call it
# e.g. if a custom relationship is defined in Studio between Tasks and Documents,
# the link_field will be `tasks_documents` but a human would call the relationship `documents`
# (does the opposite of get_link_field)
def get_humanized_link_name(link_field)
return link_field unless link_field.to_s =~ /((.*)_)?#{Regexp.quote(self.class._module.name.downcase)}(_(.*))?/
$2 || $4
end


end; end
4 changes: 2 additions & 2 deletions lib/sugarcrm/attributes/attribute_methods.rb
Expand Up @@ -2,7 +2,7 @@ module SugarCRM; module AttributeMethods

module ClassMethods
# Returns a hash of the module fields from the module
def attributes_from_module_fields
def attributes_from_module
fields = {}.with_indifferent_access
self._module.fields.keys.sort.each do |k|
fields[k] = nil
Expand Down Expand Up @@ -74,7 +74,7 @@ def required_attributes
# royally screws up our typecasting code, so we handle it here.
def merge_attributes(attrs={})
# copy attributes from the parent module fields array
@attributes = self.class.attributes_from_module_fields
@attributes = self.class.attributes_from_module
# populate the attributes with values from the attrs provided to init.
@attributes.keys.each do |name|
write_attribute name, attrs[name] if attrs[name]
Expand Down
4 changes: 2 additions & 2 deletions lib/sugarcrm/base.rb
Expand Up @@ -250,7 +250,7 @@ def self.#{method_id}(*args)
end

def all_attributes_exists?(attribute_names)
attribute_names.all? { |name| attributes_from_module_fields.include?(name) }
attribute_names.all? { |name| attributes_from_module.include?(name) }
end

def construct_attributes_from_arguments(attribute_names, arguments)
Expand All @@ -273,7 +273,6 @@ def initialize(attributes={})
@modified_attributes = {}
merge_attributes(attributes.with_indifferent_access)
clear_association_cache
@associations = self.class.associations_from_module_link_fields
define_attribute_methods
define_association_methods
typecast_attributes
Expand Down Expand Up @@ -371,6 +370,7 @@ def association_methods_generated?
include AttributeSerializers
include AssociationMethods
extend AssociationMethods::ClassMethods
include AssociationCache
end

end; end
30 changes: 16 additions & 14 deletions lib/sugarcrm/module.rb
Expand Up @@ -7,6 +7,7 @@ class Module
attr :klass, true
attr :fields, true
attr :link_fields, true
alias :bean :klass

# Dynamically register objects based on Module name
# I.e. a SugarCRM Module named Users will generate
Expand All @@ -15,27 +16,28 @@ def initialize(name)
@name = name
@klass = name.classify
@table_name = name.tableize

# set table name for custom attibutes
# custom attributes are contained in a table named after the module, with a '_cstm' suffix
# the module's table name must be tableized for the modules that ship with SugarCRM
# for custom modules (created in the Studio), table name don't need to be tableized: the name passed to the constructor is already tableized
unless self.custom_module?
@custom_table_name = @table_name + "_cstm"
else
@custom_table_name = name + "_cstm"
end

@custom_table_name = resolve_custom_table_name
@fields = {}
@link_fields = {}
@fields_registered = false
self
end

# return true if this module was created in the SugarCRM Studio (i.e. it is not part of the modules that
# ship in the dfault SugarCRM configuration
# Return true if this module was created in the SugarCRM Studio (i.e. it is not part of the modules that
# ship in the default SugarCRM configuration)
def custom_module?
name.downcase == name # custom module names are all lower_case, whereas SugarCRM modules are CamelCase
# custom module names are all lower_case, whereas SugarCRM modules are CamelCase
@name.downcase == @name
end

# Set table name for custom attibutes
# Custom attributes are contained in a table named after the module, with a '_cstm' suffix.
# The module's table name must be tableized for the modules that ship with SugarCRM.
# For custom modules (created in the Studio), table name don't need to be tableized since
# the name passed to the constructor is already tableized
def resolve_custom_table_name
@custom_table_name = @table_name + "_cstm"
@custom_table_name = @name + "_cstm" if custom_module?
end

# Returns the fields associated with the module
Expand Down

0 comments on commit 55aef15

Please sign in to comment.