Ruby on Rails
ThroughAssociations (Version #209)

Rails 1.1 introduced some juiced up options for associations, including through associations.

What is it?

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

Example

Given the following associations:

  • Catalogue has_many :catalogue_items
  • CatalogueItems belongs_to :catalogue; belongs_to :product
  • Product has_many :catalogue_items

We would have tables with the following relationships:

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.

  • Catalogue has_many :catalogue_items
  • Catalogue has_many :products, :through => :catalogue_items

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

Additional Resources

Questions/Request

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:

  • Article has_one :author, :through => :edits, :order => ‘edited_on’

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

(Note that final has_many)

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.


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

—Justin

I confirm it should be very useful !
—Renaud


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

If you don’t include the join association you will get an HasManyThroughAssociationNotFoundError error stating: Could not find the association :catalogue_items in model Vendor
I might be an idiot, and it might say this somewhere in the docs, but I struggled with this for a while, and couldn’t find anything on the subject.

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

Rails 1.1 introduced some juiced up options for associations, including through associations.

What is it?

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

Example

Given the following associations:

  • Catalogue has_many :catalogue_items
  • CatalogueItems belongs_to :catalogue; belongs_to :product
  • Product has_many :catalogue_items

We would have tables with the following relationships:

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.

  • Catalogue has_many :catalogue_items
  • Catalogue has_many :products, :through => :catalogue_items

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

Additional Resources

Questions/Request

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:

  • Article has_one :author, :through => :edits, :order => ‘edited_on’

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

(Note that final has_many)

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.


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

—Justin

I confirm it should be very useful !
—Renaud


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

If you don’t include the join association you will get an HasManyThroughAssociationNotFoundError error stating: Could not find the association :catalogue_items in model Vendor
I might be an idiot, and it might say this somewhere in the docs, but I struggled with this for a while, and couldn’t find anything on the subject.

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