Wednesday, October 10, 2007

Convert has_and_belongs_to_many to a has_many :through association

So there are plenty of resources out there to learn how to use has_many :through associations.

I followed them over and over again but couldn't get my code to work. I knew I had the basic structure setup correctly, since the examples are pretty straightforward, and the concept is not difficult. My has_and_belongs_to_many code originally looked like this:

class Soda < ActiveRecord::Base
has_and_belongs_to_many :distributors
end

class Distributor < ActiveRecord::Base
has_and_belongs_to_many :sodas
end

Of course there was also a many-to-many join table migration:

class DistributorsSodasJoinTable < ActiveRecord::Migration
def self.up
create_table :distributors_sodas, :id => false do |t|
t.column :soda_id, :int
t.column :distributor_id, :int
end
end

def self.down
drop_table :distributors_sodas
end
end

This works quite nicely:

>> Soda.find(1).distributors
=> []

Later found that I needed to add attributes in the join table to associate extra fields on the Distributors <-> Sodas relationship. has_and_belongs_to_many does not have a Rails way to access those extra fields in the join table. I've successfully done it through SQL, but much guilt and remorse lead me to finally learn has_many :through.

This was my best initial attempt:

class Soda < ActiveRecord::Base
has_many :distributors_sodas
has_many :distributors, :through =>; :distributors_sodas
end

class Distributor < ActiveRecord::Base
has_many :distributors_sodas
has_many :distributors, :through => :distributors_sodas
end

class DistributorsSodas < ActiveRecord::Base
belongs_to :soda
belongs_to :distributor
end

All goes well until I try to do a quick test:

>> Soda.find(1).distributors
NameError: uninitialized constant Soda::DistributorsSoda
...
from (irb):4

Umm... what? I never tried to instantiate an object of the type Soda::DistributorsSoda. Instead, I was simply trying to use the DistributorsSodas ActiveRecord object, right?

It turns out that has_many :through (apparently) can't handle using the join tables created by has_and_belongs_to_many. Its just a naming issue - has_many :through will work fine using a one-to-many join table like distributor_sodas (note the missing 's' on distributor). If you need a many-to-many join, you have to rename the table to fix the (pluralization?) problem. I deleted the DistributorsSodas model and created the Store model.

class Soda < ActiveRecord::Base
has_many :stores
has_many :distributors, :through => :stores
end

class Distributor < ActiveRecord::Base
has_many :stores
has_many :sodas, :through => :stores
end

class Store < ActiveRecord::Base
belongs_to :soda
belongs_to :distributor
end

This proved a much better result:

>> Soda.find(1).distributors
=> []

In the end, the association naming convention actually make more sense. I was bummed to have to change the table/model names though.

Please comment if you know how to create the association without changing the model name.

4 comments:

  1. I had been searching for this for 2 days. I kept getting the "naming bug", uninitialized error on the join table. Thanks so much for posting. I did the rename, and was all set afterwards.

    ReplyDelete
  2. Glad to hear the post was helpful.

    ReplyDelete
  3. I just figured it out. You have to specify :class_name, and :foreign_key on the join :belongs_to join model, even when the naming seems "inflectable"(is that a word?).

    ReplyDelete
  4. Guys,

    I have tripped over this myself, and I think I have a tenuous grasp on what's going on that may help. I think the original example above would work like this:

    class Soda ; :distributor_sodas
    end

    class Distributor :distributor_sodas
    end

    class DistributorSoda < ActiveRecord::Base
    belongs_to :soda
    belongs_to :distributor
    end


    First, the class name: DistributorSoda is always singular. The part I find confusing is that you (might) expect the underlying join table, and therefore the name used for the has_many declaration to be "distributors_sodas", as it would in a HABTM relationship, but instead its "distributor_sodas". In other words, its treated as one word pluralized instead of two words split by the underscore, with each pluralized. This makes sense when you consider that many join tables that are full fledged models go by a better name, like "store" in your example, or "membership" to give another popular example. I think this also explains why specifying :class_name and :foreign_key will also work; its not helping Rails figure out the proper pluralizatio so much as it is bypassing the whole pluralization issue by naming the join table/class/foreign keys explicitly.

    ReplyDelete