Active Resource Associations #70

Closed
wants to merge 41 commits into
from
Select commit
+414 −1
Split
View
233 activeresource/lib/active_resource/associations.rb
@@ -0,0 +1,233 @@
+require 'active_resource/associations/association_collection'
+
+module ActiveResource
+ module Associations
+
+ # Active Resource Associations works in the same way than Active Record
+ # associations, it follows the same coventions and method names.
+ # At the moment it support only one-to-one and one-to-many associations,
+ # many-to-many associations are not implemented yet.
+ #
+ # An example of use:
+ #
+ # class Project < ActiveRecord::Base
+ # self.site = "http://37s.sunrise.i:3000"
+ #
+ # belongs_to :portfolio
+ # has_one :project_manager
+ # has_many :milestones
+ # end
+ #
+ # The project class now has the following methods in order to manipulate the relationships:
+ # * <tt>Project#portfolio, Project#portfolio=(portfolio), Project#portfolio.nil?</tt>
+ # * <tt>Project#project_manager, Project#project_manager=(project_manager), Project#project_manager.nil?,</tt>
+ # * <tt>Project#milestones.empty?, Project#milestones.size, Project#milestones, Project#milestones<<(milestone), Project#milestone.delete(milestone)</tt>
+ #
+ #
+ # == Auto-generated methods
+ #
+ # === Singular associations (one-to-one)
+ # | | belongs_to |
+ # generated methods | belongs_to | :polymorphic | has_one
+ # ----------------------------------+------------+--------------+---------
+ # other | X | X | X
+ # other=(other) | X | X | X
+ #
+ # ===Collection associations (one-to-many)
+ #
+ # generated methods (only one-to-many)
+ # --------------------------
+ # others
+ # others=[other,other]
+ # others<<
+ # others.size
+ # others.length
+ # others.empty?
+ # others.clear
+ # others.delete(other)
+ #
+ # === One-to-one
+ #
+ # Use has_one in the base, and belongs_to in the associated model.
+ #
+ # class ProjectManager < ActiveResource::Base
+ # self.site = "http://37s.sunrise.i:3000"
+ # belongs_to :project
+ # end
+ #
+ # class Project < ActiveResource::Base
+ # self.site = "http://37s.sunrise.i:3000"
+ # has_one :project_manager
+ # end
+ #
+ # @project = Project.find(1)
+ # @project.project_manager = ProjectManager.find(3)
+ # @project.project_manager #=> #<ProjectManager:0x7fb91bb05708 @persisted=true,
+ # @attributes={"name"=>"David", "project_id"=>1, "id"=>5}, @prefix_options={}>
+ #
+ #
+ # === One-to-many
+ #
+ # Use has_many in the base, and belongs_to in the associated model.
+ #
+ # class Project < ActiveResource::Base
+ # self.site = "http://37s.sunrise.i:3000"
+ # has_many :milestones
+ # end
+ #
+ # class Milestone < ActiveResource::Base
+ # self.site = "http://37s.sunrise.i:3000"
+ # end
+ #
+ # @milestone = Milestone.find(2)
+ # @project = Project.find(1)
+ # @project.milestones << @milestone
+ #
+ # This will set the @milestone.milestone_id to @project.id
+ # and save @milestone, then when you call @project.milestones
+ # will return an AssociationCollection list with the recently milestone added
+ # included.
+ #
+ # @project.milestones #=>[#<Milestone:0x7f8b3134ac88 @persisted=true,
+ # @attributes={"title"=>"pre", "project_id"=>nil, "id"=>1},
+ # @prefix_options={}>, #<Milestone:0x7f8b31324768 @errors=#<OrderedHash {}>,
+ # @validation_context=nil, @persisted=true, @attributes={"title"=>"rc other",
+ # "project_id"=>nil, "id"=>2}, @remote_errors=nil, @prefix_options={}>]
+ #
+ #
+ # === Collections
+ #
+ # * Adding an object to a collection (+has_many+) automatically saves that resource.
+ #
+ # === Cache
+ #
+ # * Every association set an instance variable over the base resource and works
+ # with a simple cache that keep the result of the last fetched resource
+ # unless you specifically instructed not to.
+ #
+ # project.milestones # fetches milestones resources
+ # project.milestones.size # uses the milestone cache
+ # project.milestones.empty? # uses the milestone cache
+ # project.milestones(true).size # fetches milestones from the database
+ # project.milestones # uses the milestone cache
+ #
+ def self.included(klass)
+ klass.send :include, InstanceMethods
+ klass.extend ClassMethods
+ end
+
+ module InstanceMethods
+ def set_resource_instance_variable(resource, force_reload = false)
+ if !instance_variable_defined?("@#{resource}") or force_reload
+ instance_variable_set("@#{resource}", yield)
+ end
+ instance_variable_get("@#{resource}")
+ end
+ end
+
+ module ClassMethods
+
+ def options(association, resource)
+ o = { :klass => klass_for(association, resource) }
+ o[:host_klass] = self
+
+ case association
+ when :has_many
+ o[:association_col] = o[:host_klass].to_s.singularize
+ when :belongs_to
+ o[:association_col] = o[:klass]
+ when :has_one
+ o[:association_col] = o[:host_klass].to_s
+ end
+ o[:association_col] = "#{o[:association_col].underscore}_id".to_sym
+ o
+ end
+
+ def klass_for(association, resource)
+ resource = resource.to_s
+ resource = resource.singularize if association == :has_many
+ resource.camelize
+ end
+
+ def has_one(resource, opts = {})
+ o = options(:has_one, resource)
+
+ # Define accessor method for resource
+ #
+ define_method(resource) do |*force_reload|
+ force_reload = force_reload.first || false
+
+ set_resource_instance_variable(resource, force_reload) do
+ o[:klass].constantize.find(:first, :params => { o[:association_col] => id })
+ end
+ end
+
+ # Define writter method for resource
+ #
+ define_method("#{resource}=") do |new_resource|
+ if send(resource).blank?
+ new_resource.send("#{o[:association_col]}=", id)
+ instance_variable_set("@#{resource}", new_resource.save)
+ else
+ instance_variable_get("@#{resource}").send(:update_attribute, o[:association_col], id)
+ end
+ end
+ end
+
+ def belongs_to(resource, opts = {})
+ o = options(:belongs_to, resource)
+
+ # Define accessor method for resource
+ #
+ define_method(resource) do |*force_reload|
+ force_reload = force_reload.first || false
+
+ association_col = send o[:association_col]
+ return nil if association_col.nil?
+ set_resource_instance_variable(resource, force_reload){
+ o[:klass].constantize.find(association_col)
+ }
+ end
+
+ # Define writter method for resource
+ #
+ define_method("#{resource}=") do |new_resource|
+ if send(o[:association_col]) != new_resource.id
+ send "#{o[:association_col]}=", new_resource.id
+ end
+ instance_variable_set("@#{resource}", new_resource)
+ end
+ end
+
+ def has_many(resource, opts = {})
+ o = options(:has_many, resource)
+
+ # Define accessor method for resource
+ #
+ define_method(resource) do |*force_reload|
+ force_reload = force_reload.first || false
+
+ set_resource_instance_variable(resource, force_reload) {
+ result = o[:klass].constantize.find(:all,
+ :params => { o[:association_col] => id }) || []
+
+ AssociationCollection.new result, self, o[:association_col]
+ }
+ end
+
+ define_method("#{resource}=") do |new_collection|
+ collection = send(resource)
+ to_remove = collection - new_collection
+ to_remove.each{|m| collection.delete(m)}
+
+ # FIXME should call the old clear
+ collection.clear
+ # FIXME Is this needed?
+ collection.concat new_collection
+ end
+ end
+
+ end
+ end
+
+end
View
31 activeresource/lib/active_resource/associations/association_collection.rb
@@ -0,0 +1,31 @@
+module ActiveResource
+ module Associations
+
+ class AssociationCollection < Array
+
+ def initialize(array, host_resource, association_col)
+ @host_resource = host_resource
+ @association_col = association_col
+ self.concat array
+ end
+
+ def <<(member)
+ member.send "#{@association_col}=", @host_resource.id
+ member.save
+ super(member)
+ end
+
+ def delete(member)
+ member.send "#{@association_col}=", nil
+ member.save
+ super(member)
+ end
+
+ def clear
+ self.each{|member| delete(member)}
+ super
+ end
+
+ end
+ end
+end
View
3 activeresource/lib/active_resource/base.rb
@@ -18,6 +18,7 @@
require 'active_resource/formats'
require 'active_resource/schema'
require 'active_resource/log_subscriber'
+require 'active_resource/associations'
module ActiveResource
# ActiveResource::Base is the main class for mapping RESTful resources as models in a Rails application.
@@ -262,7 +263,7 @@ class Base
# :singleton-method:
# The logger for diagnosing and tracing Active Resource calls.
cattr_accessor :logger
-
+ include Associations
class << self
# Creates a schema for this resource - setting the attributes that are
# known prior to fetching an instance from the remote system.
View
144 activeresource/test/cases/associations_test.rb
@@ -0,0 +1,144 @@
+require 'abstract_unit'
+require "fixtures/project"
+
+class ProjectManager < ActiveResource::Base
+ self.site = "http://37s.sunrise.i:3000"
+ belongs_to :project
+end
+
+class Project < ActiveResource::Base
+ self.site = "http://37s.sunrise.i:3000"
+ has_one :project_manager
+ has_many :milestones
+end
+
+class Milestone < ActiveResource::Base
+ self.site = "http://37s.sunrise.i:3000"
+end
+
+@project = { :id => 1, :name => "Rails"}
+@other_project = { :id => 2, :name => "Ruby"}
+@project_manager = {:id => 5, :name => "David", :project_id =>1}
+@other_project_manager = {:id => 6, :name => "John", :project_id => nil}
+@project_managers = [@project_manager]
+@milestone = { :id => 1, :title => "pre", :project_id => nil}
+@other_milestone = { :id => 2, :title => "rc other", :project_id => nil}
+
+ActiveResource::HttpMock.respond_to do |mock|
+ mock.get "/projects/.xml", {}, @project.to_xml(:root => 'project')
+ mock.get "/projects/1.xml", {}, @project.to_xml(:root => 'project')
+ mock.get "/projects/2.xml", {}, @other_project.to_xml(:root => 'project')
+ mock.get "/project_managers/5.xml", {}, @project_manager.to_xml(:root => 'project_manager')
+ mock.get "/project_managers/6.xml", {}, @other_project_manager.to_xml(:root => 'project_manager')
+ mock.get "/project_managers.xml?project_id=1", {}, @project_managers.to_xml
+ mock.get "/project_managers.xml?project_id=2", {}, [].to_xml
+ mock.get "/milestones.xml", {}, [@milestone].to_xml
+ mock.get "/milestones.xml?project_id=2", {}, [].to_xml
+ mock.get "/milestones.xml?project_id=1", {}, [@milestone].to_xml
+ mock.put "/project_managers/6.xml", {}, nil, 204
+ mock.put "/milestones/2.xml", {}, nil, 204
+ mock.put "/milestones/1.xml", {}, nil, 204
+ mock.get "/milestones/1.xml", {}, @milestone.to_xml(:root => 'milestone')
+ mock.get "/milestones/2.xml", {}, @other_milestone.to_xml(:root => 'milestone')
+end
+
+class AssociationsTest < Test::Unit::TestCase
+
+ def setup
+ @project = Project.find(1)
+ @other_project = Project.find(2)
+ @project_manager = ProjectManager.find(5)
+ @other_project_manager = ProjectManager.find(6)
+ @milestone = Milestone.find(1)
+ @other_milestone = Milestone.find(2)
+ end
+
+ #----------------------------------------------------------------------
+ # has_one association
+ #----------------------------------------------------------------------
+
+ def test_has_one_should_add_a_resource_accessor
+ assert @project.respond_to? :project_manager
+ end
+
+ def test_has_one_accessor_should_return_the_associated_project_manager
+ assert_equal @project_manager, @project.project_manager
+ end
+
+ def test_has_one_accessor_should_return_nil_when_the_does_not_has_an_associated_resource
+ assert_nil @other_project.project_manager
+ end
+
+ def test_has_one_should_assign_a_new_project_manager_when_it_does_not_has_a_project_manager
+ @other_project.project_manager = @other_project_manager
+ assert_equal @other_project.id, @other_project_manager.project_id
+ end
+
+ #----------------------------------------------------------------------
+ # belogns_to association
+ #----------------------------------------------------------------------
+
+ def test_belongs_to_should_add_a_resource_accessor
+ assert @project_manager.respond_to? :project
+ end
+
+ def test_belongs_to_accessor_should_return_the_associated_project
+ assert_equal @project, @project_manager.project
+ end
+
+ def test_belongs_to_accessor_should_return_nil_when_the_does_not_has_an_associated_resource
+ assert_nil @other_project_manager.project
+ end
+
+ def test_has_one_should_assign_a_new_project_manager_when_it_does_not_has_a_project_manager
+ @other_project_manager.project = @other_project
+ assert_equal @other_project_manager.project_id, @other_project.id
+ end
+
+ #----------------------------------------------------------------------
+ # has_many association
+ #----------------------------------------------------------------------
+
+ def test_has_many_should_add_a_resource_accessor
+ assert @project.respond_to? :milestones
+ end
+
+ def test_has_many_accessor_should_return_the_an_array_with_the_associated_milestones
+ assert_equal [@milestone], @project.milestones
+ end
+
+ def test_has_many_accessor_should_return_the_an_empty_array_when_it_does_not_has_milestones
+ assert_equal [], @other_project.milestones
+ end
+
+ def test_has_many_accessor_should_return_the_an_array_including_the_added_obj
+ @project.milestones << @other_milestone
+ assert_equal @other_milestone.project_id, @project.id
+ end
+
+ def test_has_many_accessor_should_return_the_an_array_without_including_the_deleted_obj
+ @project.milestones << @other_milestone
+ @project.milestones.delete(@other_milestone)
+ assert_nil @other_milestone.project_id
+ end
+
+ def test_has_many_accessor_should_return_the_an_empty_array_after_clear
+ @project.milestones << @other_milestone
+ @project.milestones.clear
+
+ assert_equal [], @project.milestones
+ end
+
+ def test_has_many_accessor_should_return_the_new_array_after_assign
+ @project.milestones = [@other_milestone]
+ assert_equal [@other_milestone], @project.milestones
+
+ @project.milestones = []
+ assert_equal [], @project.milestones
+
+ @project.milestones = [@milestone, @other_milestone]
+ assert_equal [@milestone, @other_milestone], @project.milestones
+ end
+
+end
+
View
4 activeresource/test/fixtures/project.rb
@@ -2,6 +2,9 @@
class Project < ActiveResource::Base
self.site = "http://37s.sunrise.i:3000"
+ #----------------------------------------------------------------------
+ # validations
+ #
validates_presence_of :name
validate :description_greater_than_three_letters
@@ -18,6 +21,7 @@ def description_greater_than_three_letters
def name
attributes['name'] || nil
end
+
def description
attributes['description'] || nil
end