Implementing 'has many through' association with where clause in Rails
I was recently working on a many-to-many relations in a Rails project and needed a where condition on has_many X, through: Y but with a where clause on a column on X. In this article, I'll demo that
Hello world,
I recently worked on a Rails project where I had to model an association of nature Many-To-Many through a join table with a where clause. Let’s take a small set of models to understand the requirements and then we shall see how to write those associations in Rails.
Here we have two main models: Movie and Crew. They have many-to-many associations, so we introduce a join table MovieCrew. Each Crew can be associated with a movie by specifying its role. So some crews will be actors, others will be makeup artists, and so on. This gives us the following set of associations:
A Movie has many MovieCrews
A Crew has many MovieCrews
A Movie has many Crews through MovieCrews
A Crew has many Movies through MovieCrews
MovieCrew belongs to a Movie
MovieCrew belongs to a Crew
Now, let’s say we want to access crews from a movie that has a certain role. Such as “Give me all the actors in a movie”.
avatar = Movie.find_by(title: "avatar")
avatar.actors # how do we get this?
To serve this query, we need to add two associations:
A Movie has many MovieCrews whose role is “actor”. Let’s call it “actor_movie_crews”
A Movie has many Crews through “actor_movie_crews”
In the model, we can add these two associations as follows:
Now, we are ready to use actors
association on movies. Similarly, we can define associations for other roles such as directors and writers. Here are two lines for directors:
has_many :director_movie_crews,
-> { where(role: :director) },
class_name: "MovieCrew"
has_many :directors, through: :director_movie_crews, source: :crew
With these associations in place, we can access crews with their roles directly from movies.
matrix = Movie.find_by(title: "Matrix")
matrix.directors
matrix.actors
We can use the meta-programming of Ruby to reduce typing. That will be a topic for another article.
How does this work?
To understand how it works, we need to see the components of how a relation is defined in the model. Let’s look at the first relation that is targeting MovieCrew.
has_many :custom_association, -> { any_clause_here }, class_name: ""
This line has 3 components:
custom association name
lambda for clause
target class name
Here we are creating a has_many association with a custom name. So in our example, “actor_movie_crews” was that custom name.
Then the second argument is a lambda that we can use to put any additional clauses on the target model. So for actors
association, we used
-> { where(role: :actor) }
The third argument is the class name. As we are providing a custom association name as the first argument, we need to inform Rails which model we are targeting. Hence the third argument is provided. In our example, it is “MovieCrew”
With only the first association, we can run queries like the following
godfather = Movie.find_by(title: "Godfather")
godfather.actor_movie_crews
This will return a collection of MovieCrew instances. That is good but not much useful for us as we are more interested in the final Crew instances and not just join table ones.
To get crews of a certain role from a movie, we can utilize this custom association as follows:
has_many :actors, through: :actor_movie_crews, source: :crew
This creates an association named actors
on the movie model. We inform Rails that the source of results is :crew
(another one could be :movie
as the through:
is pointing to a join table). This will give us a result of a collection of Crew instances where the role is “actor”.
For further reading, ActiveRecord documentation has details on how these associations are implemented and what other customizations could be done.
Bonus
If we implement our role column on MovieCrew as an enum, we can use scope instead of complete where clause inside the lambda. Here is how it will look then:
If you are looking to make your VS Code ready for Ruby and Rails programming, check out this article. Only 3 extensions will make your day-to-day programming a joy!
👋🧑💻 That’s a wrap. Let me know how you find this article. Your feedback will help me write better and hopefully provide you with more value. Happy coding.