Many to Many Relationships
Learning Goals
- Describe the relationship between a foreign key on one table and a primary key on another table
- Diagram a one-to-many relationship
- Diagram a many-to-many relationship
- Understand what a join table is and why we need one
- Create many-to-many relationships in Rails
- Interpret
schema.rb
Vocabulary
- Foreign Key
- Primary Key
- One-to-Many Relationship
- Many-to-Many Relationship
- Join Table
- Migration
- Schema
Setup
You can begin this lesson on the many-to-many-starter
branch of the Set List Tutorial Repo here.
Warm-Up
In SetList:
- What is the relationship between Songs and Artists?
- Diagram the database tables for Songs and Artists.
One-to-Many Relationships
- Primary Key - a key in a relational database that is unique for each record. This is also known as an
id
. It is a unique identifier, such as a driver’s license, or the VIN on a car. You may have one and only one primary key value per table. - Foreign Key - a foreign key is a field in one table that uniquely identifies a row of another table. A foreign key is defined in a second table, but it refers to the primary key in the first table.
- The relationship between
songs
andartists
is a one-to-many relationship. songs
has a column calledartist_id
which refers to the primary key of theartist
table.artist_id
is the foreign key
Songs Table
id | title | length | play_count | artist_id |
---|---|---|---|---|
1 | This Must Be the Place | 345 | 23 | 1 |
2 | Aint No Bread in the Breadbox | 432 | 12 | 1 |
3 | Reuben and Cherise | 367 | 45 | 2 |
4 | Purple Rain | 183 | 49 | 3 |
Artists Table
id | name |
---|---|
1 | Talking Heads |
2 | Jerry Garcia Band |
3 | Prince |
Independent Practice - Students and Modules
Think about the relationship between students and modules (i.e. “Mod 1: Fundamentals of Programming”, “Mod 2: Web Application Development”).
Diagram what the database would look like.
Many-to-Many Relationships
Now, we’re going to add playlists to our app.
Let’s think about the relationship between songs and playlists.
A playlist can have many songs. A song can be in many playlists.
If we have Songs and Playlists tables that look like this:
Songs Table
id | title | length | play_count | artist_id |
---|---|---|---|---|
1 | This Must Be the Place | 345 | 23 | 1 |
2 | Aint No Bread in the Breadbox | 432 | 12 | 1 |
3 | Reuben and Cherise | 367 | 45 | 2 |
4 | Purple Rain | 183 | 49 | 3 |
Playlists Table
id | name |
---|---|
1 | Classic Rock |
2 | Uplifting Sound |
3 | Jerry Jams |
We can imagine that the “Classic Rock” playlist could include all of our current songs (a playlist has many songs). We can also imagine that the song “Reuben and Cherise” could be in both the “Classic Rock” and “Jerry Jams” playlists (a song has many playlists).
So far, we have used foreign keys to create relationships. The problem is that a foreign key can identify a single record from another table, but in a many-to-many both sides of the relationship need to reference multiple records. This means that we’re going to need more than just foreign keys.
One solution that might come to mind is, instead of our foreign keys storing a single id, we could store an array of ids. This is a good guess, but in a database there is no concept of an array. The reason is that, for the database to be efficient, it needs to know the exact size of the datatype it is storing. For example, our DB knows exactly how much space an integer takes up (8 bytes).
But what about strings? They can vary in length. The Database handles strings by giving them a maximum length, which is 255 bytes by default. If we tried to do the same thing with arrays, we would be limiting how many relationships we could create, which is a bigger problem than limiting how long a string can be.
The key takeaway here is a database shouldn’t store an array of ids, so we’re going to need something else.
Join Tables
Since we can’t achieve the many-to-many relationship with our given tables, we are going to add a third table to manage this relationship. This is called a join table. (These can also be referred to as linking tables, to disambiguate them from an SQL Join, which we will talk about later.)
Songs Table
id | title | length | play_count | artist_id |
---|---|---|---|---|
1 | This Must Be the Place | 345 | 23 | 1 |
2 | Aint No Bread in the Breadbox | 432 | 12 | 1 |
3 | Reuben and Cherise | 367 | 45 | 2 |
4 | Purple Rain | 183 | 49 | 3 |
PlaylistSongs Table
id | playlist_id | song_id |
---|---|---|
1 | 1 | 1 |
2 | 1 | 2 |
3 | 1 | 3 |
4 | 2 | 4 |
5 | 3 | 2 |
6 | 3 | 4 |
Playlists Table
id | name |
---|---|
1 | Classic Rock |
2 | Uplifting Sound |
3 | Jerry Jams |
Note: Join tables are just ordinary tables with a unique purpose. Each row of our join table relates a row of one table to a row of another table.
The join table’s name doesn’t really matter. We could call it song_playlists
or playlist_songs
, it’s really up to you as the developer. You could even choose to name it happy_fun_times
but that would be confusing.
When you’re thinking about what to call this table, think about how you’re likely to use it most within your application. Since our app’s goal will be to show a playlist of songs more often, we’re going to call it playlist_songs
.
Note: Don’t confuse join table with a sql joins operation. They are two different things.
Independent Practice
Diagram the DB tables you would need to create a many-to-many relationship between two tables that you think up on your own. Include some example data in your tables. If you can’t come up with an example on your own, use Photographs and Albums.
Many-to-Many Relationships in Rails
Adding Playlists
We’re now going to add playlists to our SetList app. We are going to work bottom-up in this case by starting with a model test:
spec/models/playlist_spec.rb
require "rails_helper"
RSpec.describe Playlist, type: :model do
describe "relationships" do
it { should have_many :playlist_songs}
end
end
The first thing we need to set up is the connection between our model and our table. Running this gives us this error.
NameError:
uninitialized constant Playlist
Let’s go create our Playlist model.
app/models/playlist.rb
class Playlist < ApplicationRecord
end
And when we run our tests once again.
ActiveRecord::StatementInvalid:
PG::UndefinedTable: ERROR: relation "playlists" does not exist
So let’s create a migration to create that.
$ rails g migration CreatePlaylists name:string
Let’s migrate that in.
$ rails db:migrate
Now when we run our tests, we should get this failure.
Failure/Error: it { should have_many :playlist_songs}
Expected Playlist to have a has_many association called playlist_songs (no association called playlist_songs)
Let’s go into our model and build out that association.
app/models/playlist.rb
class Playlist < ApplicationRecord
has_many :playlist_songs
end
Running the tests again we get an other error.
Failure/Error: it { should have_many :playlist_songs}
Expected Playlist to have a has_many association called playlist_songs (PlaylistSong does not exist)
It says our join table doesn’t exist, so we need to go create it.
Creating the PlaylistSongs Join Table
Let’s start with a test. First we’ll think about what a record in the join table should do. Looking back at our diagram of this table, it should relate a single song with a single playlist:
spec/models/playlist_song_spec.rb
require "rails_helper"
RSpec.describe PlaylistSong, type: :model do
describe "relationships" do
it {should belong_to :playlist}
it {should belong_to :song}
end
end
Run this test and we are going to get an error.
NameError:
uninitialized constant PlaylistSong
Let’s go and create a model.
app/models/playlist_song.rb
class PlaylistSong < ApplicationRecord
end
We run our tests again and we get a new error.
ActiveRecord::StatementInvalid:
PG::UndefinedTable: ERROR: relation "playlist_songs" does not exist
So without it being in the database, we need to generate the migration to get it in our database.
$ rails g migration CreatePlaylistSongs song:references playlist:references
Let’s migrate.
$ rails db:migrate
When we run our tests now, we are seeing that we are failing because we’re missing associations.
Expected PlaylistSong to have a belongs_to association called playlist (no association called playlist)
Expected PlaylistSong to have a belongs_to association called song (no association called song)
And so we should make them.
app/models/playlist_song.rb
class PlaylistSong < ApplicationRecord
belongs_to :playlist
belongs_to :song
end
At this point, all of our tests should be passing. Our joins table is ready to go.
A Playlist has many Songs
Run the playlist_spec
again and it passes! Now that our join table is set up, the connection between the playlist
and playlist_songs
is working. Now we can set up the has_many relationship between songs and playlists. Let’s add to our playlist_spec.rb
.
spec/models/playlist_spec.rb
require "rails_helper"
RSpec.describe Playlist, type: :model do
describe "relationships" do
it { should have_many :playlist_songs}
it { should have_many(:songs).through(:playlist_songs) }
end
end
We are using the ShouldaMatchers through
method to test that we can access a Playlist’s songs through the join table.
Running this test gives us:
Failure/Error: it { should have_many(:songs).through(:playlist_songs) }
Expected Playlist to have a has_many association called songs (no association called songs)
So we need to add the association to our Playlist model.
app/models/playlist.rb
class Playlist < ApplicationRecord
has_many :playlist_songs
has_many :songs
end
Running our test now gives us this error:
Failure/Error: it { should have_many(:songs).through(:playlist_songs) }
Expected Playlist to have a has_many association called songs (Song does not have a playlist_id foreign key.)
The error is telling us that our songs table doesn’t have a foreign key for playlists. We could be very literal and add a foreign key to songs, but this won’t work per our previous discussion of many to many relationships. Instead, we want to access the songs through the join table.
app/models/playlist.rb
class Playlist < ApplicationRecord
has_many :playlist_songs
has_many :songs, through: :playlist_songs
end
Run this test and it passes!
It is very important that for a model with a many to many relationship, that you set up both the has_many, through:
relationship and the has_many
with the join table. If we take out the connection to the join table like so:
class Playlist < ApplicationRecord
has_many :songs, through: :playlist_songs
end
When we run our test, we will get:
NoMethodError:
undefined method `klass' for nil:NilClass
Unfortunately, if you do see this, TDD will let you down a bit since this error is entirely unhelpful. While it may be unintuitive, if you see the undefined method 'klass' for nil:NilClass
, remember to check that your model associations are set up properly.
app/models/playlist.rb
class Playlist < ApplicationRecord
has_many :playlist_songs
has_many :songs, through: :playlist_songs
end
Run the test again to make sure our Playlist is still working properly.
We’ve now set up one end of the many-to-many, but what about the other?
A Song Has Many Playlists
Let’s add our song many to many tests.
spec/models/song_spec.rb
require "rails_helper"
RSpec.describe Song, type: :model do
it { should belong_to :artist }
it { should have_many :playlist_songs }
it { should have_many(:playlists).through(:playlist_songs) }
end
This is very similar to what we have for our Playlist model tests. When we run these tests, we’ll see two failures.
Failure/Error: it { should have_many :playlist_songs }
Expected Song to have a has_many association called playlist_songs (no association called playlist_songs)
Failure/Error: it { should have_many(:playlists).through(:playlist_songs) }
Expected Song to have a has_many association called playlists (no association called playlists)
So now we can go into our Song model and add those relationships.
app/models/song.rb
class Song < ApplicationRecord
belongs_to :artist
has_many :playlist_songs
has_many :playlists, through: :playlist_songs
end
Run the test again and it passes.
Check Schema
Open up schema.rb
. Compare what is in this file with our original diagram of the many-to-many relationship.
Playlist Endpoint
Now that we have playlists, let’s add an endpoint to view all our playlists:
spec/requests/api/v1/playlists/playlists_request_spec.rb
require "rails_helper"
RSpec.describe "Playlist Endpoints" do
it "can return all playlists" do
talking_heads = Artist.create!(name: "Talking Heads")
jgb = Artist.create!(name: "Jerry Garcia Band")
prince = Artist.create!(name: "Prince")
place = talking_heads.songs.create!(title: "This Must Be The Place", length: 832, play_count: 83209)
breadbox = jgb.songs.create!(title: "Aint No Bread in the Breadbox", length: 832, play_count: 83209)
r_and_c = jgb.songs.create!(title: "Reuben and Cherise", length: 832, play_count: 83209)
# Creates a playlist
uplifting_sound = Playlist.create!(name: "Uplifting Sound")
jams = Playlist.create!(name: "Jerry Jams")
# Creates playlist and associates it with a song. Under the hood, creates a row in the playlist_songs table
rock = breadbox.playlists.create!(name: "Classic Rock")
# Creates song and associates it with a playlist. Under the hood, creates a row in the playlist_songs table
purple = uplifting_sound.songs.create!(title: "Purple Rain", length: 4378, play_count: 7453689, artist: prince)
# Creates a row in the playlist_songs table. Associates a playlist with a song
PlaylistSong.create!(song: purple, playlist: jams)
PlaylistSong.create!(song: place, playlist: jams)
# Creates a row in the playlist_songs table. Associates a playlist with a song
rock.songs << place
# Creates a row in the playlist_songs table. Associates a playlist with a song
r_and_c.playlists << rock
get api_v1_playlists_path
# expect(response).to be_successful
playlists = JSON.parse(response.body, symbolize_names: true)
binding.pry
# expect the correct number of playlists
expect(playlists[:data].count).to eq(3)
expect(playlists[:data][0][:attributes][:name]).to eq("Uplifting Sound")
expect(playlists[:data][1][:attributes][:name]).to eq("Jerry Jams")
expect(playlists[:data][2][:attributes][:name]).to eq("Classic Rock")
end
end
In the setup portion of the test, we are creating the relationships in a couple different ways. Normally, you would want to be more consistent with your syntax, but in this case we want to show a couple different ways to create relationships. Take a minute to review all these different strategies.
Run this test and you should get an error for a missing route. Add it to our routes.
config/routes.rb
Rails.application.routes.draw do
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
# Defines the root path route ("/")
# root "articles#indexget "/songs", to: "songs#index"
namespace :api do
namespace :v1 do
resources :songs, except: [:new, :edit]
resources :artists, only: [:index, :show] do
resources :songs, only: [:index, :create], controller: "artist_songs"
end
resources :playlists, only: [:index]
resources :images, only: :index, action: "show"
end
end
end
Run the test again and you’ll see an error because we don’t have a playlists controller, so we’ll add the controller with the index action.
app/controllers/api/v1/playlists_controller.rb
class Api::V1::PlaylistsController < ApplicationController
def index
render json: Playlist.all
end
end
Now, we should hit the pry
in our test, and we can finally see what this data looks like! One thing that you’ll notice is that we get ALL the data about a playlist, including some data we may not care about. Let’s clean that up with a serializer:
app/serializers/playlist_serializer.rb
class PlaylistSerializer
include JSONAPI::Serializer
attributes :name
end
app/controllers/api/v1/playlists_controller.rb
class Api::V1::PlaylistsController < ApplicationController
def index
render json: PlaylistSerializer.new(Playlist.all)
end
end
Now, when we take a look at the json result in our test, you might be thinking “wait, what about songs?”. With our serializer configured as is, we don’t see the representation of the relationship between songs and playlists. Fortunately, we can fix that with some additional functionality from JSONAPI::Serializer.
class PlaylistSerializer
include JSONAPI::Serializer
attributes :name
has_many :songs
end
Note: this does require that a serializer is created for songs!
We also have the ability to add our own custom attributes. What if we wanted an attribute that told us how many songs each playlist had?
class PlaylistSerializer
include JSONAPI::Serializer
attributes :name
has_many :songs
attribute :num_songs do |object|
object.songs.count
end
end
This syntax is a bit different from what we are used to. We use attribute
singular, and then as a symbol we pick the name of what we want our attribute to be. We use a do end block similar to an enumerable with a block parameter. Now the block parameter, object is a lot like self
. We get to use it for each single thing of a collection we pass to the serializer. We are essentially saying for each thing you serialize, grab the songs and count them too. In this manner we can add a custom generated value for each playlist.
What about MVC?
Think about how the current version of our serializer does and does not strictly adhere to MVC design.
- Where does this serializer fall in the MVC structure?
- Are there ways that we could refactor our code to better adhere to MVC?
Additional Practice
🌶️ TDD an endpoint for a single playlist’s songs.
🌶️🌶️ Include the name of the playlist in the endpoint you just created!
🌶️🌶️🌶️ Add a genre to the playlist. This should be the genre that is most represented in the songs in that playlist. hint: you will need to update another model. rule: do not use .each
or any enumerable in your playlist serializer.
Checks for Understanding
- How is a one-to-many relationship set up in a database?
- What does a join table do? Why would we need one?
- How do we test many-to-many relationships?
- What migrations do we need to create to set up a many-to-many?
- What do we need to add to our models to set up a many-to-many?
- What is the relationship between Songs and Playlists?