Rails 1.1 introduced some juiced up options for associations, including through associations.
Through associations allow for one model to be accessed through an intermediary association. Often used in conjunction with PolymorphicAssociations.
Official documentation: see the section titled “Association Join Models” in the ActiveRecord Association API
Given the following associations:
catalogues catalogue_items products
---------- --------------- --------
id <------. id ,----> id
name `---*> catalogue_id ,' name
product_id <--' price
position
Under this setup, a Catalogue object will have a number of CatalogueItems, each of which refers to a Product. There is no direct association between a Catalogue and the Products listed in it. Each CatalogueItem represents an instance of a Product in a Catalogue.
A through association will allow us to access a Product that belongs to a Catalogue “through” or via that Catalogue’s CatalogueItems. We simply add a :through associations that references an existing association.
Doing so allows our Catalogue objects to have direct access to their products.
Getting @catalogue’s products goes from:
@catalogue.catalogue_items.collect{|item| item.product}
To:
@catalogue.products
Wish: I wish they would do this for has_one associations as well. While has_one is not used as often, it has just as much need for the functionality. For example, a way to access the initial creator of a page without an extra field in a Wiki:
has_one :through is now in Edge Rails: see a peek here.
Until that glorious day:
class Article < AR::Base
has_many :authors, :through => :edits, :order => 'edited_on'
end
@article.authors.first
—-
or simply something like (with a less complex example):
class Article < AR::Base
belongs_to :book
# Fake has_one :author, :through => :book
def author
book.author
end
def author=(a)
book.author=(a)
end
end
You could make this even sweeter using delegate (new in Rails 1.1):
class Article < AR::Base
belongs_to :book
# Fake has_one :author, :through => :book
delegate :author, :author=, :to => :book
end
—-
Here is an implementation that adds methods to add basic “has_one” support through a habtm association.
# This defines a new type of association that is currently
# missing in rails. There is currently no way to have a
# has_one association when a join table is used.
#
# This association requires that a has_and_belongs_to_many
# association exists and adds methods to access the first
# element of the resulting array.
#
# Example: Enrollments have one registration, but is
# modeled through the enrollments_registrations join table.
# There is already a “has_and_belongs_to_many :registrations”
# and the following operations are added (and perform as expected):
# * enrollment.registration => enrollments.registrations.first
# * enrollment.registration_id => enrollments.registrations.first.id
# * enrollment.registration= will set the assocation, erasing any
# existing registration. This method will
# take an id or a registration object
# * enrollment.registration_id= like registration=, but takes an id
# * enrollment.has_registration? => true or false as the case may be
#
def self.has_one_through_join_table(class_name)
class_name = class_name.to_s
has_many_association_name = class_name.pluralize
# def registration
define_method(class_name) do
send(has_many_association_name).first
end
#def registration_id
define_method(class_name+"_id") do
send(class_name) ? send(class_name).id : nil
end
# def registration=(id_or_object)
define_method(class_name+"=") do |id_or_object|
clazz = eval(class_name.classify)
habtm = send(class_name.pluralize) # the has_and_belong_to_many association (i.e. registrations)
if id_or_object.nil?
habtm.clear
return
end
id_or_object = clazz.find(id_or_object) unless id_or_object.is_a?(clazz)
if id_or_object
habtm.clear # can only have one object
habtm << id_or_object
end
end
#def registration_id=(id)
define_method(class_name+"_id=") do |id|
send(class_name+"=", id)
end
# def has_registration?
define_method(“has_#{class_name}?”) do
! send(class_name).nil?
end
end # has_many_through_join_table
end
—garrett snider
Garrett, this implementation calls the original has_many association method and then uses the first element of that array:
send(has_many_association_name).first
I’m wondering if there is a way to do this so that instead of pulling all the records out from the database, you just pull out the only record that you need with a LIMIT 1.
-tung nguyen
—-
Another Wish: Is there any way to go deeper than one table away? I have code where I’d like to have something like this:
class Manufacturer < ActiveRecord::Base
has_many :products
has_many :reviews, :through => :products
has_many :review_comments, :through => :reviews
It becomes clear in script/console that :through is unable to parse what it is I’m wanting – it’ll include the review table and the review_comments table, but it’ll assume that the review table has a manufacturer_id (which it doesn’t, as it’s only tied to the product).
I can certainly get around this, but it’s damned annoying.
—-
There is a nested_has_many_through plugin that’s been floating around for awhile now to achieve this, and some have vied for adding it to core. I’ve had some success with this version that is an effort to keep up with Edge Rails post-2.0.2.
—-
I think the syntax that would be nice to see instead of
has_many :review_comments, :through => :reviews
it should be:
class Manufacturer
# using an array specifies the order in which the relationships occur.
has_many :review_comments, :through => [:products, :reviews]
# For that matter, we should be able to string together as many relationships as we like.
# For example, for intergalactic commerce, we might want to find out which moons are associated with a given manufacturer.
has_many :moons, :through => [:distributors, :space_stations, :planets, :moons]
end
and to access (in, say, the controller) the ReviewComment instances associated with a given Manufacturer you would do something like:
@manufacturer = Manufacturer.find(1)
@comments = @manufacturer.review_comments
# and to access the moons associated with a given manufacturer you would do this:
@moons = @manufacturer.moons
d.beckwith
—-
Please Clarify: Using your example, how do you access the “position” value from “catalogue_items” in code? What’s the syntax for getting the “position” for a particular catalogue and a particular product? Or say the positionS for all of the productS in a certain catalogue? Thanks.
Second to the Please Clarify Above -Dave
Third to the Please Clarify Above – Andrew
(Answer to question above)
some_products = Product.find_by_critera('critera')
some_products.catalog_items.each do |item|
item.position
end
You can include the position field from catalog_items by using the :select option for has_many, so that when you do some_catalog.products, position shows up as an attribute. — Jeremy
Just to clarify Jeremy’s suggestion a little further: you need to use the :select option on the same has_many line that you’ve put the :through option. And since you’re essentially overwriting the existing SELECT statement, you need to make sure you include the original SELECT clause as well (generally just column_name.*).
Here’s how you would find the position:
class Product < ActiveRecord::Base
has_many :catalogue_items
has_many :catalogues, :through => :catalogue_items, :select => "catalogue_items.position, catalogues.*"
end
I confirm it should be very useful !
— Renaud
Seems like a kludge. Why isn’t the Select above the default?
— Paul
—-
Self Referral?
Is it possible to use :through to form an association between two entries in the same table?
Example: Customers are rewarded for referring people to a service. A table is kept that keeps track of the referring client, the “referree”, the date, comments that were made, etc etc.
class Client < ActiveRecord::Base
has_many :clients, :through => :referrals
end
How should the fields in the bridging table be handled? Two “client_id” fields are not an option, obviously… Any insight?
Solution: http://blog.hasmanythrough.com/2007/10/30/self-referential-has-many-through
—-
Self Referral?
It should be made clear that the models for using through need to include an association for the through join, ie:
class Product < ActiveRecord::Base
has_many :catalogue_items
has_many :catalogues, :through => :catalogue_items
end
This wasted my entire day as well. Despite reading the above message, I didn’t figure it out: Make sure to define the has_many relationship to the JOIN TABLE before the has_many :through => line. It looks to make sure you have a regular has_many on the join table before it lets you add it. Terrible error message
—-
Are there any way to declare the :through clause when you have namespaced models?:
class Site::User < ActiveRecord::Base
has_many :groups, :through => 'site/membership'
end
class Site::Group < ActiveRecord::Base
has_many :users, :through => 'site/membership'
end
class Site::Membership < ActiveRecord::Base
belongs_to :users, :class_name => 'site/user'
belongs_to :groups :class_name => 'site/group'
end
This sample code doesn’t seem to work.
Solution:
This is the code that works:
class Site::User < ActiveRecord::Base
has_many :memberships, :class_name => 'Site::Membership'
has_many :groups, :through => :memberships
end
class Site::Group < ActiveRecord::Base
has_many :memberships, :class_name => 'Site::Membership'
has_many :users, :through => memberships
end
class Site::Membership < ActiveRecord::Base
belongs_to :users, :class_name => 'Site::User'
belongs_to :groups :class_name => 'Site::Group'
end