Permalink
Browse files

Merge pull request #1 from SweeD/add_associations

[Feature] ActiveResource - Associations through reflections
  • Loading branch information...
2 parents 8226d3c + 1a77002 commit 296dff7a0d341d11876e6468424389981e6c104d @jeremy jeremy committed Mar 14, 2012
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
+module ActiveResource::Associations::Builder
+ class HasMany < Association
+ self.macro = :has_many
+ end
+end
@@ -0,0 +1,5 @@
+module ActiveResource::Associations::Builder
+ class HasOne < Association
+ self.macro = :has_one
+ end
+end
@@ -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.
@@ -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
@@ -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]
@@ -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
@@ -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
View
@@ -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
@@ -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
Oops, something went wrong.

0 comments on commit 296dff7

Please sign in to comment.