diff --git a/activerecord/CHANGELOG b/activerecord/CHANGELOG
index be8765366c5e5f93d74b7a644aefc65e3ebeb022..b85cccc78e76e6eafc1468a68ee6864acb672954 100644
--- a/activerecord/CHANGELOG
+++ b/activerecord/CHANGELOG
@@ -1,5 +1,7 @@
*SVN*
+* Add has_one :through support. #4756 [thechrisoshow]
+
* Migrations: create_table supports primary_key_prefix_type. #10314 [student, thechrisoshow]
* Added logging for dependency load errors with fixtures #11056 [stuthulhu]
diff --git a/activerecord/lib/active_record/association_preload.rb b/activerecord/lib/active_record/association_preload.rb
index cc657dc4338c9b49a387157dc57a0d34ef729c19..0c31c85bb0178cf6fdc0fb1b8dc78698f0e07636 100644
--- a/activerecord/lib/active_record/association_preload.rb
+++ b/activerecord/lib/active_record/association_preload.rb
@@ -48,6 +48,14 @@ def add_preloaded_records_to_collection(parent_records, reflection_name, associa
association_proxy.target.push(*[associated_record].flatten)
end
end
+
+ def add_preloaded_record_to_collection(parent_records, reflection_name, associated_record)
+ parent_records.each do |parent_record|
+ association_proxy = parent_record.send(reflection_name)
+ association_proxy.loaded
+ association_proxy.target = associated_record
+ end
+ end
def set_association_collection_records(id_to_record_map, reflection_name, associated_records, key)
associated_records.each do |associated_record|
@@ -97,11 +105,27 @@ def preload_has_and_belongs_to_many_association(records, reflection, preload_opt
end
def preload_has_one_association(records, reflection, preload_options={})
- id_to_record_map, ids = construct_id_map(records)
- records.each {|record| record.send("set_#{reflection.name}_target", nil)}
+ id_to_record_map, ids = construct_id_map(records)
+ options = reflection.options
+ if options[:through]
+ records.each {|record| record.send(reflection.name) && record.send(reflection.name).loaded}
+ through_records = preload_through_records(records, reflection, options[:through])
+ through_reflection = reflections[options[:through]]
+ through_primary_key = through_reflection.primary_key_name
+ unless through_records.empty?
+ source = reflection.source_reflection.name
+ through_records.first.class.preload_associations(through_records, source)
+ through_records.compact.each do |through_record|
+ add_preloaded_record_to_collection(id_to_record_map[through_record[through_primary_key].to_i],
+ reflection.name, through_record.send(source))
+ end
+ end
+ else
+ records.each {|record| record.send("set_#{reflection.name}_target", nil)}
- set_association_single_records(id_to_record_map, reflection.name, find_associated_records(ids, reflection, preload_options),
- reflection.primary_key_name)
+
+ set_association_single_records(id_to_record_map, reflection.name, find_associated_records(ids, reflection, preload_options), reflection.primary_key_name)
+ end
end
def preload_has_many_association(records, reflection, preload_options={})
@@ -126,7 +150,7 @@ def preload_has_many_association(records, reflection, preload_options={})
reflection.primary_key_name)
end
end
-
+
def preload_through_records(records, reflection, through_association)
through_reflection = reflections[through_association]
through_primary_key = through_reflection.primary_key_name
diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb
index d7d5d9b3123bdef61d40f818798844588cd03ea0..0e07ee49138e4382e9fea23cf3abc1dcc869f679 100755
--- a/activerecord/lib/active_record/associations.rb
+++ b/activerecord/lib/active_record/associations.rb
@@ -6,6 +6,7 @@
require 'active_record/associations/has_many_association'
require 'active_record/associations/has_many_through_association'
require 'active_record/associations/has_and_belongs_to_many_association'
+require 'active_record/associations/has_one_through_association'
module ActiveRecord
class HasManyThroughAssociationNotFoundError < ActiveRecordError #:nodoc:
@@ -737,6 +738,12 @@ def has_many(association_id, options = {}, &extension)
# as the default +foreign_key+.
# * :include - specify second-order associations that should be eager loaded when this object is loaded.
# * :as: Specifies a polymorphic interface (See #belongs_to).
+ # * :through: Specifies a Join Model through which to perform the query. Options for :class_name and :foreign_key
+ # are ignored, as the association uses the source reflection. You can only use a :through query through a
+ # has_one or belongs_to association on the join model.
+ # * :source: Specifies the source association name used by has_one :through queries. Only use it if the name cannot be
+ # inferred from the association. has_one :favorite, :through => :favorites will look for a
+ # :favorite on +Favorite+, unless a :source is given.
# * :readonly - if set to +true+, the associated object is readonly through the association.
#
# Option examples:
@@ -746,27 +753,34 @@ def has_many(association_id, options = {}, &extension)
# has_one :project_manager, :class_name => "Person", :conditions => "role = 'project_manager'"
# has_one :attachment, :as => :attachable
# has_one :boss, :readonly => :true
+ # has_one :club, :through => :membership
+ # has_one :primary_address, :through => :addressables, :conditions => ["addressable.primary = ?", true], :source => :addressable
def has_one(association_id, options = {})
- reflection = create_has_one_reflection(association_id, options)
+ if options[:through]
+ reflection = create_has_one_through_reflection(association_id, options)
+ association_accessor_methods(reflection, ActiveRecord::Associations::HasOneThroughAssociation)
+ else
+ reflection = create_has_one_reflection(association_id, options)
- ivar = "@#{reflection.name}"
+ ivar = "@#{reflection.name}"
- method_name = "has_one_after_save_for_#{reflection.name}".to_sym
- define_method(method_name) do
- association = instance_variable_get("#{ivar}") if instance_variable_defined?("#{ivar}")
+ method_name = "has_one_after_save_for_#{reflection.name}".to_sym
+ define_method(method_name) do
+ association = instance_variable_get("#{ivar}") if instance_variable_defined?("#{ivar}")
- if !association.nil? && (new_record? || association.new_record? || association["#{reflection.primary_key_name}"] != id)
- association["#{reflection.primary_key_name}"] = id
- association.save(true)
+ if !association.nil? && (new_record? || association.new_record? || association["#{reflection.primary_key_name}"] != id)
+ association["#{reflection.primary_key_name}"] = id
+ association.save(true)
+ end
end
- end
- after_save method_name
+ after_save method_name
- association_accessor_methods(reflection, HasOneAssociation)
- association_constructor_method(:build, reflection, HasOneAssociation)
- association_constructor_method(:create, reflection, HasOneAssociation)
+ association_accessor_methods(reflection, HasOneAssociation)
+ association_constructor_method(:build, reflection, HasOneAssociation)
+ association_constructor_method(:create, reflection, HasOneAssociation)
- configure_dependency_for_has_one(reflection)
+ configure_dependency_for_has_one(reflection)
+ end
end
# Adds the following methods for retrieval and query for a single associated object for which this object holds an id:
@@ -1058,7 +1072,12 @@ def association_accessor_methods(reflection, association_proxy_class)
association = association_proxy_class.new(self, reflection)
end
- association.replace(new_value)
+ if association_proxy_class == HasOneThroughAssociation
+ association.create_through_record(new_value)
+ self.send(reflection.name, new_value)
+ else
+ association.replace(new_value)
+ end
instance_variable_set(ivar, new_value.nil? ? nil : association)
end
@@ -1300,6 +1319,13 @@ def create_has_one_reflection(association_id, options)
create_reflection(:has_one, association_id, options, self)
end
+
+ def create_has_one_through_reflection(association_id, options)
+ options.assert_valid_keys(
+ :class_name, :foreign_key, :remote, :conditions, :order, :include, :dependent, :counter_cache, :extend, :as, :through, :source
+ )
+ create_reflection(:has_one, association_id, options, self)
+ end
def create_belongs_to_reflection(association_id, options)
options.assert_valid_keys(
diff --git a/activerecord/lib/active_record/associations/has_one_through_association.rb b/activerecord/lib/active_record/associations/has_one_through_association.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ecf9d8208d1c603e4841b2b8f422df30c6adb777
--- /dev/null
+++ b/activerecord/lib/active_record/associations/has_one_through_association.rb
@@ -0,0 +1,28 @@
+module ActiveRecord
+ module Associations
+ class HasOneThroughAssociation < ActiveRecord::Associations::HasManyThroughAssociation
+
+ def create_through_record(new_value) #nodoc:
+ klass = @reflection.through_reflection.klass
+
+ current_object = @owner.send(@reflection.through_reflection.name)
+
+ if current_object
+ klass.destroy(current_object)
+ @owner.clear_association_cache
+ end
+
+ @owner.send(@reflection.through_reflection.name, klass.send(:create, construct_join_attributes(new_value)))
+ end
+
+ private
+ def find(*args)
+ super(args.merge(:limit => 1))
+ end
+
+ def find_target
+ super.first
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/associations/join_model_test.rb b/activerecord/test/cases/associations/join_model_test.rb
index 1034cb67e9407ad6d89bdd4a9b05195b3a0a0a7a..3d59b97f702668e81ed6ecfd4481c5be4fb572c3 100644
--- a/activerecord/test/cases/associations/join_model_test.rb
+++ b/activerecord/test/cases/associations/join_model_test.rb
@@ -631,7 +631,7 @@ def test_belongs_to_shared_parent
assert_equal comments.first.post, comments[1].post
end
end
-
+
private
# create dynamic Post models to allow different dependency options
def find_post_with_dependency(post_id, association, association_name, dependency)
diff --git a/activerecord/test/cases/associations_test.rb b/activerecord/test/cases/associations_test.rb
index 50643066b9fec1a09dd2f3d60c78a0a1a9077cb6..768d2b2600245030119a922dce3bb7084d1fe140 100755
--- a/activerecord/test/cases/associations_test.rb
+++ b/activerecord/test/cases/associations_test.rb
@@ -20,6 +20,10 @@
require 'models/pirate'
require 'models/treasure'
require 'models/price_estimate'
+require 'models/club'
+require 'models/member'
+require 'models/membership'
+require 'models/sponsor'
class AssociationsTest < ActiveRecord::TestCase
fixtures :accounts, :companies, :developers, :projects, :developers_projects,
@@ -186,7 +190,7 @@ def test_has_one
assert_equal companies(:first_firm).account, Account.find(1)
assert_equal Account.find(1).credit_limit, companies(:first_firm).account.credit_limit
end
-
+
def test_has_one_cache_nils
firm = companies(:another_firm)
assert_queries(1) { assert_nil firm.account }
@@ -476,6 +480,63 @@ def test_cant_save_readonly_association
end
+class HasOneThroughAssociationsTest < ActiveRecord::TestCase
+ fixtures :members, :clubs, :memberships, :sponsors
+
+ def setup
+ @member = members(:groucho)
+ end
+
+ def test_has_one_through_with_has_one
+ assert_equal clubs(:boring_club), @member.club
+ end
+
+ def test_has_one_through_with_has_many
+ assert_equal clubs(:moustache_club), @member.favourite_club
+ end
+
+ def test_creating_association_creates_through_record
+ new_member = Member.create(:name => "Chris")
+ new_member.club = Club.create(:name => "LRUG")
+ assert_not_nil new_member.current_membership
+ assert_not_nil new_member.club
+ end
+
+ def test_replace_target_record
+ new_club = Club.create(:name => "Marx Bros")
+ @member.club = new_club
+ @member.reload
+ assert_equal new_club, @member.club
+ end
+
+ def test_replacing_target_record_deletes_old_association
+ assert_no_difference "Membership.count" do
+ new_club = Club.create(:name => "Bananarama")
+ @member.club = new_club
+ @member.reload
+ end
+ end
+
+ def test_has_one_through_polymorphic
+ assert_equal clubs(:moustache_club), @member.sponsor_club
+ end
+
+ def has_one_through_to_has_many
+ assert_equal 2, @member.fellow_members.size
+ end
+
+ def test_has_one_through_eager_loading
+ members = Member.find(:all, :include => :club)
+ assert_equal 2, members.size
+ assert_not_nil assert_no_queries {members[0].club}
+ end
+
+ def test_has_one_through_eager_loading_through_polymorphic
+ members = Member.find(:all, :include => :sponsor_club)
+ assert_equal 2, members.size
+ assert_not_nil assert_no_queries {members[0].sponsor_club}
+ end
+end
class HasManyAssociationsTest < ActiveRecord::TestCase
fixtures :accounts, :companies, :developers, :projects,
diff --git a/activerecord/test/fixtures/clubs.yml b/activerecord/test/fixtures/clubs.yml
new file mode 100644
index 0000000000000000000000000000000000000000..1986d28229b1d767c756fb59d56223585faf90ad
--- /dev/null
+++ b/activerecord/test/fixtures/clubs.yml
@@ -0,0 +1,6 @@
+boring_club:
+ name: Banana appreciation society
+moustache_club:
+ name: Moustache and Eyebrow Fancier Club
+crazy_club:
+ name: Skull and bones
\ No newline at end of file
diff --git a/activerecord/test/fixtures/members.yml b/activerecord/test/fixtures/members.yml
new file mode 100644
index 0000000000000000000000000000000000000000..67a6cc459a5bb987aa4c619aeb62e5c95857f392
--- /dev/null
+++ b/activerecord/test/fixtures/members.yml
@@ -0,0 +1,4 @@
+groucho:
+ name: Groucho Marx
+some_other_guy:
+ name: Englebert Humperdink
\ No newline at end of file
diff --git a/activerecord/test/fixtures/memberships.yml b/activerecord/test/fixtures/memberships.yml
new file mode 100644
index 0000000000000000000000000000000000000000..99fbe46d9bd23d18f0c4b758f23e68c19cc92dbb
--- /dev/null
+++ b/activerecord/test/fixtures/memberships.yml
@@ -0,0 +1,20 @@
+membership_of_boring_club:
+ joined_on: <%= 3.weeks.ago.to_s(:db) %>
+ club: boring_club
+ member: groucho
+ favourite: false
+ type: CurrentMembership
+
+membership_of_favourite_club:
+ joined_on: <%= 3.weeks.ago.to_s(:db) %>
+ club: moustache_club
+ member: groucho
+ favourite: true
+ type: Membership
+
+other_guys_membership:
+ joined_on: <%= 4.weeks.ago.to_s(:db) %>
+ club: boring_club
+ member: some_other_guy
+ favourite: false
+ type: CurrentMembership
diff --git a/activerecord/test/fixtures/sponsors.yml b/activerecord/test/fixtures/sponsors.yml
new file mode 100644
index 0000000000000000000000000000000000000000..c116df05345a6f4c5c344426ec2f7a06a8bc0120
--- /dev/null
+++ b/activerecord/test/fixtures/sponsors.yml
@@ -0,0 +1,3 @@
+moustache_club_sponsor_for_groucho:
+ sponsor_club: moustache_club
+ sponsorable: groucho (Member)
\ No newline at end of file
diff --git a/activerecord/test/models/club.rb b/activerecord/test/models/club.rb
new file mode 100644
index 0000000000000000000000000000000000000000..171445df3ee5a80fabfbe7d4e4ae7b21c2609aa8
--- /dev/null
+++ b/activerecord/test/models/club.rb
@@ -0,0 +1,6 @@
+class Club < ActiveRecord::Base
+ has_many :memberships
+ has_many :members, :through => :memberships
+ has_many :current_memberships
+ has_many :sponsors
+end
\ No newline at end of file
diff --git a/activerecord/test/models/member.rb b/activerecord/test/models/member.rb
new file mode 100644
index 0000000000000000000000000000000000000000..688725f200754df91a33e8eb2c0cd1ef6d6a6fa1
--- /dev/null
+++ b/activerecord/test/models/member.rb
@@ -0,0 +1,9 @@
+class Member < ActiveRecord::Base
+ has_one :current_membership
+ has_many :memberships
+ has_many :fellow_members, :through => :club, :source => :members
+ has_one :club, :through => :current_membership
+ has_one :favourite_club, :through => :memberships, :conditions => ["memberships.favourite = ?", true], :source => :club
+ has_one :sponsor, :as => :sponsorable
+ has_one :sponsor_club, :through => :sponsor
+end
\ No newline at end of file
diff --git a/activerecord/test/models/membership.rb b/activerecord/test/models/membership.rb
new file mode 100644
index 0000000000000000000000000000000000000000..905f948c37ed1f7c8e88e84788d835ea7553a09f
--- /dev/null
+++ b/activerecord/test/models/membership.rb
@@ -0,0 +1,9 @@
+class Membership < ActiveRecord::Base
+ belongs_to :member
+ belongs_to :club
+end
+
+class CurrentMembership < Membership
+ belongs_to :member
+ belongs_to :club
+end
diff --git a/activerecord/test/models/sponsor.rb b/activerecord/test/models/sponsor.rb
new file mode 100644
index 0000000000000000000000000000000000000000..50c2c2d76c63e9c5bbb110d268c4f4d984bba5cc
--- /dev/null
+++ b/activerecord/test/models/sponsor.rb
@@ -0,0 +1,4 @@
+class Sponsor < ActiveRecord::Base
+ belongs_to :sponsor_club, :class_name => "Club", :foreign_key => "club_id"
+ belongs_to :sponsorable, :polymorphic => true
+end
\ No newline at end of file
diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb
index 33aa6e2ea45b22e0ebedd7e0b490442227091abf..724226c35943190de90ae41595885c5caeef0fa9 100644
--- a/activerecord/test/schema/schema.rb
+++ b/activerecord/test/schema/schema.rb
@@ -49,6 +49,10 @@
t.integer :category_id, :null => false
t.integer :post_id, :null => false
end
+
+ create_table :clubs, :force => true do |t|
+ t.string :name
+ end
create_table :colnametests, :force => true do |t|
t.integer :references, :null => false
@@ -117,6 +121,17 @@
t.integer :version, :null => false, :default => 0
end
+ create_table :members, :force => true do |t|
+ t.string :name
+ end
+
+ create_table :memberships, :force => true do |t|
+ t.datetime :joined_on
+ t.integer :club_id, :member_id
+ t.boolean :favourite, :default => false
+ t.string :type
+ end
+
create_table :minimalistics, :force => true do |t|
end
@@ -177,6 +192,12 @@
t.integer :post_id, :null => false
t.integer :person_id, :null => false
end
+
+ create_table :sponsors, :force => true do |t|
+ t.integer :club_id
+ t.integer :sponsorable_id
+ t.integer :sponsorable_type
+ end
create_table :subscribers, :force => true, :id => false do |t|
t.string :nick, :null => false