Many-to-Many Relationships in Ruby on Rails


Many-to-many relationships in Rails can be confusing at first. There are a couple different ways to handle them, depending on what you need. Let’s say you want to organize your mp3 collection. Artists obviously have multiple songs, but songs also have multiple artists.

has_and_belongs_to_many

The simpler way to do this is using HABTM (has_and_belongs_to_many) to define your relationships. Here are the models:

# app/models/song.rb
class Song < ActiveRecord::Base
  has_and_belongs_to_many :artists
end

# app/models/artist.rb
class Artist < ActiveRecord::Base
  has_and_belongs_to_many :songs
end

And here are the migrations:

# db/migrate/create_artists.rb
class CreateArtists < ActiveRecord::Migration
  def self.up
    create_table :artists do |t|
      t.string :name
      t.timestamps
    end
  end

  def self.down
    drop_table :artists
  end
end

# db/migrate/create_songs.rb
class CreateSongs < ActiveRecord::Migration
  def self.up
    create_table :songs do |t|
      t.string :name
      t.timestamps
    end
  end

  def self.down
    drop_table :songs
  end
end

# db/migrate/create_artists_songs.rb
class CreateArtistsSongs < ActiveRecord::Migration
  def self.up
    create_table :artists_songs, :id => false do |t|
      t.integer :artist_id
      t.integer :song_id
    end
  end

  def self.down
    drop_table :artists_songs
  end
end

Notice a couple things about the migrations. First, I had to manually create a join table migration. Its name must be a combination of the two tables being joined, in alphabetical order. This is how Rails is able to figure out the join table’s name automatically. Also notice that I had to specify :id => false in the join table definition. Primary keys in join tables cause big problems.

Now, if I have an object song that is an instance of the Song class, I can say song.artists and get a list of the artists associated with this song. I can do the same for artists. Yay!

has_many :through

What if we wanted to track what instrument the artist played on a given song? This doesn’t go in the artists table, because it’s different for every song. It doesn’t go in the songs table either, because each artist probably played something different. It needs to go in the join table, but there’s a problem. There’s no way in your Rails code to access the information stored in the join table itself.

What we really have here is more than just a join table – it’s another model all its own. Let’s call it a contribution. Each artist’s contribution to a song is unique. I might play nose flute in a rendition of Herbie Hancock’s Watermelon Man, but I play the slide whistle when recreating the theme song from the Krusty the Klown show.

Recreating the models from scratch looks like this:

# app/models/artist.rb
class Artist  < ActiveRecord::Base
  has_many :contributions
  has_many :songs, :through => :contributions
end

# app/models/contribution.rb
class Contribution < ActiveRecord::Base
  belongs_to :artist
  belongs_to :song
end

# app/models/song.rb
class Song < ActiveRecord::Base
  has_many :contributions
  has_many :artists, :through => :contributions
end

The migrations look like this:

# db/migrate/create_artists.rb
class CreateArtists < ActiveRecord::Migration
  def self.up
    create_table :artists do |t|
      t.string :name
      t.timestamps
    end
  end

  def self.down
    drop_table :artists
  end
end

# db/migrate/create_contributions.rb
class CreateContributions < ActiveRecord::Migration
  def self.up
    create_table :contributions do |t|
      t.references :artist
      t.references :song
      t.string :instrument
      t.timestamps
    end
  end

  def self.down
    drop_table :contributions
  end
end

# db/migrate/create_songs.rb
class CreateSongs < ActiveRecord::Migration
  def self.up
    create_table :songs do |t|
      t.string :name
      t.timestamps
    end
  end

  def self.down
    drop_table :songs
  end
end

I didn’t have to manually create the join table this time. I created the three models calling script/generate, and the tables were created automatically. Now, with an object artist that is an instance of the Artist class, I can get a list of contributions with artist.contributions. I can get a list of songs just as easily with artist.songs, and Rails will find them through the contributions table. If I want the instrument the artist used on the first song, I can say artist.songs[0].contribution.instrument, and so on.

Deciding Which to Use

If two models have a many-to-many relationship and you need to track info about the relationship itself, use has_many :through. In fact, sometimes it’s wise to do this anyway, if you anticipate needing that later. HABTM should only be used when the relationship itself will never need to track data of its own.

Advertisements

Tags: , , , , , , ,

9 Responses to “Many-to-Many Relationships in Ruby on Rails”

  1. Eric Says:

    Interesting, however, wouldn’t you have a problem if you don’t know what someone played on an album? It looks to me like you basically made the join table more useful and put in detailed info about the album. But there still needs to be something in the contributions table regardless to link up everything.

    So, basically, its the same thing isn’t it? There will be data in contributions regardless of whether or not you actually know what they played.

  2. jbellmyer Says:

    If you saw my post when it first came out, some of the model/migration definitions were a little messed up due to wordpress “sanitizing” some of the code.

    You don’t have to fill in the instrument info for every record. And you’re right, the contributions table is still acts as a join table, albeit with more info. But if you want to *use* the extra fields in the join table, you have to use the has_many :through approach. Otherwise there’s no way to set or get the instrument info within Rails, aside from hard-coding SQL.

  3. Eric Says:

    Also this is a little strange:

    class CreateArtistsSongs false do |t|
    t.integer :artist_id
    t.integer :song_id
    end
    end

    Wouldn’t it be:

    class CreateArtistsSongs < ActiveRecord::Migration
    def self.up
    create_table :artists_songs do |t|
    t.references :artists
    t.references :songs
    end
    end

  4. jbellmyer Says:

    Yeah, that was the mess-up I mentioned before. It’s fixed now.

  5. Eric Says:

    No no, in the first example. Wouldn’t you use t.references rather than integer?

    create_table :artists_songs, :id => false do |t|
    t.integer :artist_id
    t.integer :song_id
    end

    But rather use t.references instead? And would you need to disable the :id still?

  6. jbellmyer Says:

    They’re interchangeable. Honestly, I’ll probably keep using “t.integer whatever_id” because it’s more explicit. All t.references does is isave you from typing “_id”.

  7. Eric Says:

    Ok, but do you need to still do that :id => false thing? I mean, if there are t.references to the linking tables……

  8. jbellmyer Says:

    The *only* thing that “t.references :something” does is convert it to “t.integer :something_id” – it’s merely shorthand, and not that short. It doesn’t do anything else.

    You still need “:id => false”, to keep the join table from having a primary key. If you leave the primary key in (which is the default) and you’re using a version of Rails older than 2.3.4, it will fail with a cryptic message several inserts in. As of 2.3.4, I actually contributed a change to the Rails core so that it tells you the primary key is the culprit.

    Migrations merely define tables in a database – they don’t actually create relationships between models. That’s what the ActiveRecord methods (has_one, has_many, has_and_belongs_to_many, and belongs_to) are doing for you in the model itself. But the foreign keys do have to be in the right places, and all tables involved have to have the correct names and settings.

  9. Zdeny Says:

    OK your article is nice. However I can not find anywhere how to set the extra field. E.g. artist.songs[0].contribution.instrument = “piano” does not return any error but the field stays nil.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s


%d bloggers like this: