Lesson Tuesday

So far, we've focused on building one-to-many relationships with Rails and Active Record. In this lesson, we'll cover the basics of setting up a many-to-many relationship. We'll revisit the many-to-many relationships between Albums and Artists that we covered in the last course section.

Active Record has two associations for handling many-to-many relationships: has_and_belongs_to_many and has_many :through. Let's explore these two associations in the context of artists and albums. The methods available in our actual application will be the same as they are with a one-to-many relationship. The one exception: there is an Artist#albums method and an Album#artists method — both methods are pluralized (in contrast to Song#album, which is singular).

has_and_belongs_to_many Association


We would choose a has_and_belongs_to_many relationship for artists and albums if we only wanted a join table that linked artist_ids to album_ids.

Models

Our updated models would look like this:

app/models/artist.rb
class Artist < ApplicationRecord
  has_and_belongs_to_many(:albums)
end
app/models/album.rb
class Album < ApplicationRecord
  has_and_belongs_to_many(:artists)
  ...
end

Note that we do not need to add a class for the join table. There will be a join table in the database but it's only there for making associations between songs and artists.

Migration

When the has_and_belongs_to_many relationship is declared between two classes, Active Record expects a join table to exist in the database with the name of the two classes being joined in alphabetical order and separated by an underscore. Let's create a new migration that includes an artists table and a join table for albums_artists:

add_artists_and_join_table.rb
class AddArtistsAndJoinTable < ActiveRecord::Migration[5.2]
  def change
    create_table :artists do |t|
      t.string :name
      t.timestamps
    end

    create_table :albums_artists, id: false do |t|
      t.belongs_to :artist, index: true
      t.belongs_to :album, index: true
    end
  end
end

The table for artists just has one field for adding a name. One thing to note. We can use t.string instead of t.column. This means we don't need to do this: t.column (:name, :string). It's another way Rails is flexible but both ways of adding a column are fine.

Our join table has a few new things. First, we'll emphasize once again that the names of the tables must be in alphabetical order. This relationship will not work correctly if we named the join table artists_albums instead.

Next, we add id: false. This is because our join table does not have a primary key. We will never try to find a row on the join table by its id. Instead, the point of the join table is to find relationships between artists and albums.

We could create a column that looks like this: t.column :artist_id, :integer. However, Rails has a built-in belongs_to method that takes care of this for us. We also add index: true. Indexing a column makes it faster to look up rows in a database. We won't get into all the scenarios in which a column should be indexed. There are plenty of situations where indexing can even slow down database queries or take up too much memory. However, foreign keys should always be indexed. Unlike with a primary key, our database won't stop looking for results as soon as it finds a specific id. Instead, the database potentially has to look through every row in the database in order to find all the necessary associations. In this case, indexing both foreign keys makes these database queries more efficient.

We recommend reading the following article on indexing a database to learn more about when database columns should be indexed.

Tests

Next, we need to test our associations with Shoulda Matchers. It may be tempting to skip this step because Active Record does so much for us. However, it's very easy to make a mistake when setting up an association, such as with a typo in the join table.

spec/models/album_spec.rb
describe Album do
  ...
  it { should have_and_belong_to_many :artists }
  ...
end
spec/models/artist_spec.rb
describe Artist do
  it { should have_and_belong_to_many :albums }
end

has_many :through Association


Note: Do not try setting up a has_many :through association until you are comfortable setting up a has_and_belongs_to_many association. You are not expected to set up a has_many :through association for this course section's independent project. However, it is a best practice to use has_many :through instead of has_and_belongs_to_many relationships.

If we want to add additional attributes, validations, callbacks or other code to the join table between artists and albums, we should make a has_many :through association between the two classes. Many developers believe that using a has_many :through association is always preferable to using a has_and_belongs_to_many association even if there are no fields on the join table. Projects often change over time in ways we can't anticipate. A has_many :through association gives us additional flexibility if we do decide we need fields on the join table later. It's much easier to set up a has_many :through association at the beginning of a project than it is to change a has_and_belongs_to_many association when a project is well under way.

That being said, the naming convention is very tricky if we still want our join table to combine the names of the two classes. It would be easier if the join table between artists and albums was named something like sessions. However, we will stick with the combination of artists and albums because there is no obvious name for the join table and because of this tricky naming.

Here's how we'd set up a has_many :through relationship between artists and albums.

Models

We'll start with the models. Note that this time around we need to have an AlbumArtist class for the join table.

app/models/artist.rb
class Artist < ApplicationRecord
  has_many :album_artists
  has_many :albums, :through => :album_artists
end
app/models/album.rb
class Album < ApplicationRecord
  has_many :album_artists
  has_many :artists, :through => :album_artists
end
app/models/album_artist.rb
class AlbumArtist < ApplicationRecord
  belongs_to :artist
  belongs_to :album
end

As we can see here, an artist has many album_artists and also has many albums through album_artists. Both of these lines of code need to exist in the class in order for this to work. This same code needs to be in album.rb as well — reversed so that an album has many artists instead.

Astute observers may have already noticed a naming gotcha. We cannot name this join table albums_artists because it is a has many through relationship. Instead, the first class must be singular while the second must be pluralized. (They should still be alphabetical and include underscores between each word.) Unfortunately, the documentation isn't very clear on this topic.

Next, our join table must have belongs to relationships with both of the classes being joined. Note that the name of our file is album_artist.rb while the name of the class is AlbumArtist. We should never add underscores to a class. The name of the file is a bit confusing considering that the join table must be named album_artists.

Migration

class AddArtistsAndHasManyThroughTable < ActiveRecord::Migration[5.2]
  def change
    create_table :album_artists do |t|
      t.belongs_to :artist, index: true
      t.belongs_to :album, index: true
      t.timestamps
    end
  end
end

This migration just shows the join table, not the code for adding an artists table. Once again, note that the table is named album_artists. Other than that, the code is the same as our previous join table and we make sure to index our foreign keys.

Tests

Finally, here are the tests for the has many through relationship. As always, testing is especially important — in this case, the possibility of typos and naming mishaps is even higher than usual.

spec/models/album_spec.rb
describe Album do
  ...
  it { should have_many(:artists).through(:album_artists) }
  ...
end
spec/models/artist_spec.rb
describe Artist do
  it { should have_many(:albums).through(:album_artists) }
end
spec/models/album_artist_spec.rb
require 'rails_helper'

describe AlbumArtist do
  it { should belong_to(:album) }
  it { should belong_to(:artist) }
end

If things are not named correctly, you will likely get an error like this when you run your tests: undefined method 'klass' for nil:NilClass. It isn't a helpful error and you'll just need to check the naming of your classes and tables.

We recommend trying out both types of relationships outlined in this lesson. In general, though, using a has many through relationship is more flexible.

Lesson 23 of 34
Last updated August 7, 2022