Skip to content

Commit

Permalink
Merge pull request rails#1 from SweeD/add_associations
Browse files Browse the repository at this point in the history
[Feature] ActiveResource - Associations through reflections
  • Loading branch information
jeremy committed Mar 14, 2012
2 parents 8226d3c + 1a77002 commit 296dff7
Show file tree
Hide file tree
Showing 13 changed files with 438 additions and 1 deletion.
118 changes: 118 additions & 0 deletions lib/active_resource/associations.rb
@@ -0,0 +1,118 @@
module ActiveResource::Associations

module Builder
autoload :Association, 'active_resource/associations/builder/association'
autoload :HasMany, 'active_resource/associations/builder/has_many'
autoload :HasOne, 'active_resource/associations/builder/has_one'
autoload :BelongsTo, 'active_resource/associations/builder/belongs_to'
end



# Specifies a one-to-many association.
#
# === Options
# [:class_name]
# Specify the class name of the association. This class name would
# be used for resolving the association class.
#
# ==== Example for [:class_name] - option
# GET /posts/123.xml delivers following response body:
# <post>
# <title>ActiveResource now have associations</title>
# <content> ... </content>
# <comments>
# <comment> ... </comment>
# <comment> ... </comment>
# </comments>
# </post>
# ====
#
# <tt>has_many :comments, :class_name => 'myblog/comment'</tt>
# Would resolve those comments into the <tt>Myblog::Comment</tt> class.
def has_many(name, options = {})
Builder::HasMany.build(self, name, options)
end

# Specifies a one-to-one association.
#
# === Options
# [:class_name]
# Specify the class name of the association. This class name would
# be used for resolving the association class.
#
# ==== Example for [:class_name] - option
# GET /posts/123.xml delivers following response body:
# <post>
# <title>ActiveResource now have associations</title>
# <content> ... </content>
# <author>
# <name>caffeinatedBoys</name>
# </author>
# </post>
# ====
#
# <tt>has_one :author, :class_name => 'myblog/author'</tt>
# Would resolve this author into the <tt>Myblog::Author</tt> class.
def has_one(name, options = {})
Builder::HasOne.build(self, name, options)
end

# Specifies a one-to-one association with another class. This class should only be used
# if this class contains the foreign key.
#
# Methods will be added for retrieval and query for a single associated object, for which
# this object holds an id:
#
# [association(force_reload = false)]
# Returns the associated object. +nil+ is returned if the foreign key is +nil+.
# Throws a ActiveResource::ResourceNotFound exception if the foreign key is not +nil+
# and the resource is not found.
#
# (+association+ is replaced with the symbol passed as the first argument, so
# <tt>belongs_to :post</tt> would add among others <tt>post.nil?</tt>.
#
# === Example
#
# A Comment class declaress <tt>belongs_to :post</tt>, which will add:
# * <tt>Comment#post</tt> (similar to <tt>Post.find(post_id)</tt>)
# The declaration can also include an options hash to specialize the behavior of the association.
#
# === Options
# [:class_name]
# Specify the class name for the association. Use it only if that name canÄt be inferred from association name.
# So <tt>belongs_to :post</tt> will by default be linked to the Post class, but if the real class name is Article,
# you'll have to specify it with whis option.
# [:foreign_key]
# Specify the foreign key used for the association. By default this is guessed to be the name
# of the association with an "_id" suffix. So a class that defines a <tt>belongs_to :post</tt>
# association will use "post_id" as the default <tt>:foreign_key</tt>. Similarly,
# <tt>belongs_to :article, :class_name => "Post"</tt> will use a foreign key
# of "article_id".
#
# Option examples:
# <tt>belongs_to :customer, :class_name => 'User'</tt>
# Creates a belongs_to association called customer which is represented through the <tt>User</tt> class.
#
# <tt>belongs_to :customer, :foreign_key => 'user_id'</tt>
# Creates a belongs_to association called customer which would be resolved by the foreign_key <tt>user_id</tt> instead of <tt>customer_id</tt>
#
def belongs_to(name, options={})
Builder::BelongsTo.build(self, name, options)
end

# Defines the belongs_to association finder method
def defines_belongs_to_finder_method(method_name, association_model, finder_key)
ivar_name = :"@#{method_name}"

if method_defined?(method_name)
instance_variable_set(ivar_name, nil)
remove_method(method_name)
end

define_method(method_name) do
instance_variable_defined?(ivar_name) ? instance_variable_get(ivar_name) : instance_variable_set(ivar_name, association_model.find(send(finder_key)))
end
end

end
32 changes: 32 additions & 0 deletions lib/active_resource/associations/builder/association.rb
@@ -0,0 +1,32 @@
module ActiveResource::Associations::Builder
class Association #:nodoc:

# providing a Class-Variable, which will have a different store of subclasses
class_attribute :valid_options
self.valid_options = [:class_name]

# would identify subclasses of association
class_attribute :macro

attr_reader :model, :name, :options, :klass

def self.build(model, name, options)
new(model, name, options).build
end

def initialize(model, name, options)
@model, @name, @options = model, name, options
end

def build
validate_options
model.create_reflection(self.class.macro, name, options)
end

private

def validate_options
options.assert_valid_keys(self.class.valid_options)
end
end
end
14 changes: 14 additions & 0 deletions lib/active_resource/associations/builder/belongs_to.rb
@@ -0,0 +1,14 @@
module ActiveResource::Associations::Builder
class BelongsTo < Association
self.valid_options += [:foreign_key]

self.macro = :belongs_to

def build
validate_options
reflection = model.create_reflection(self.class.macro, name, options)
model.defines_belongs_to_finder_method(reflection.name, reflection.klass, reflection.foreign_key)
return reflection
end
end
end
5 changes: 5 additions & 0 deletions lib/active_resource/associations/builder/has_many.rb
@@ -0,0 +1,5 @@
module ActiveResource::Associations::Builder
class HasMany < Association
self.macro = :has_many
end
end
5 changes: 5 additions & 0 deletions lib/active_resource/associations/builder/has_one.rb
@@ -0,0 +1,5 @@
module ActiveResource::Associations::Builder
class HasOne < Association
self.macro = :has_one
end
end
7 changes: 7 additions & 0 deletions lib/active_resource/base.rb
Expand Up @@ -17,6 +17,8 @@
require 'active_resource/formats'
require 'active_resource/schema'
require 'active_resource/log_subscriber'
require 'active_resource/associations'
require 'active_resource/reflection'

module ActiveResource
# ActiveResource::Base is the main class for mapping RESTful resources as models in a Rails application.
Expand Down Expand Up @@ -1435,6 +1437,7 @@ def response_code_allows_body?(c)

# Tries to find a resource for a given collection name; if it fails, then the resource is created
def find_or_create_resource_for_collection(name)
return reflections[name.to_sym].klass if reflections.key?(name.to_sym)
find_or_create_resource_for(ActiveSupport::Inflector.singularize(name.to_s))
end

Expand All @@ -1455,6 +1458,7 @@ def find_or_create_resource_in_modules(resource_name, module_names)

# Tries to find a resource for a given name; if it fails, then the resource is created
def find_or_create_resource_for(name)
return reflections[name.to_sym].klass if reflections.key?(name.to_sym)
resource_name = name.to_s.camelize

const_args = [resource_name, false]
Expand Down Expand Up @@ -1507,9 +1511,12 @@ def method_missing(method_symbol, *arguments) #:nodoc:

class Base
extend ActiveModel::Naming
extend ActiveResource::Associations

include CustomMethods, Observing, Validations
include ActiveModel::Conversion
include ActiveModel::Serializers::JSON
include ActiveModel::Serializers::Xml
include ActiveResource::Reflection
end
end
77 changes: 77 additions & 0 deletions lib/active_resource/reflection.rb
@@ -0,0 +1,77 @@
require 'active_support/core_ext/class/attribute'
require 'active_support/core_ext/module/deprecation'

module ActiveResource
# = Active Resource reflection
#
# Associations in ActiveResource would be used to resolve nested attributes
# in a response with correct classes.
# Now they could be specified over Associations with the options :class_name
module Reflection # :nodoc:
extend ActiveSupport::Concern

included do
class_attribute :reflections
self.reflections = {}
end

module ClassMethods
def create_reflection(macro, name, options)
reflection = AssociationReflection.new(macro, name, options)
self.reflections = self.reflections.merge(name => reflection)
reflection
end
end


class AssociationReflection

def initialize(macro, name, options)
@macro, @name, @options = macro, name, options
end

# Returns the name of the macro.
#
# <tt>has_many :clients</tt> returns <tt>:clients</tt>
attr_reader :name

# Returns the macro type.
#
# <tt>has_many :clients</tt> returns <tt>:has_many</tt>
attr_reader :macro

# Returns the hash of options used for the macro.
#
# <tt>has_many :clients</tt> returns +{}+
attr_reader :options

# Returns the class for the macro.
#
# <tt>has_many :clients</tt> returns the Client class
def klass
@klass ||= class_name.constantize
end

# Returns the class name for the macro.
#
# <tt>has_many :clients</tt> returns <tt>'Client'</tt>
def class_name
@class_name ||= derive_class_name
end

# Returns the foreign_key for the macro.
def foreign_key
@foreign_key ||= self.options[:foreign_key] || "#{self.name.to_s.downcase}_id"
end

private
def derive_class_name
return (options[:class_name] ? options[:class_name].to_s : name.to_s).classify
end

def derive_foreign_key
return options[:foreign_key] ? options[:foreign_key].to_s : "#{name.to_s.downcase}_id"
end
end
end
end
4 changes: 3 additions & 1 deletion test/abstract_unit.rb
Expand Up @@ -73,7 +73,9 @@ def setup_response
:children => []
}
]
}]
}],
:enemies => [{:name => 'Joker'}],
:mother => {:name => 'Ingeborg'}
}
}.to_json
# - resource with yaml array of strings; for ARs using serialize :bar, Array
Expand Down
61 changes: 61 additions & 0 deletions test/cases/association_test.rb
@@ -0,0 +1,61 @@
require 'abstract_unit'

require 'fixtures/person'
require 'fixtures/beast'
require 'fixtures/customer'


class AssociationTest < ActiveSupport::TestCase
def setup
@klass = ActiveResource::Associations::Builder::Association
end


def test_validations_for_instance
object = @klass.new(Person, :customers, {})
assert_equal({}, object.send(:validate_options))
end

def test_instance_build
object = @klass.new(Person, :customers, {})
assert_kind_of ActiveResource::Reflection::AssociationReflection, object.build
end

def test_valid_options
assert @klass.build(Person, :customers, {:class_name => 'Client'})

assert_raise ArgumentError do
@klass.build(Person, :customers, {:soo_invalid => true})
end
end

def test_association_class_build
assert_kind_of ActiveResource::Reflection::AssociationReflection, @klass.build(Person, :customers, {})
end

def test_has_many
External::Person.send(:has_many, :people)
assert_equal 1, External::Person.reflections.select{|name, reflection| reflection.macro.eql?(:has_many)}.count
end

def test_has_one
External::Person.send(:has_one, :customer)
assert_equal 1, External::Person.reflections.select{|name, reflection| reflection.macro.eql?(:has_one)}.count
end

def test_belongs_to
External::Person.belongs_to(:Customer)
assert_equal 1, External::Person.reflections.select{|name, reflection| reflection.macro.eql?(:belongs_to)}.count
end

def test_defines_belongs_to_finder_method_with_instance_variable_cache
Person.defines_belongs_to_finder_method(:customer, Customer, 'customer_id')

person = Person.new
assert !person.instance_variable_defined?(:@customer)
person.stubs(:customer_id).returns(2)
Customer.expects(:find).with(2).once()
2.times{person.customer}
assert person.instance_variable_defined?(:@customer)
end
end

0 comments on commit 296dff7

Please sign in to comment.