Double Polymorphic Associations in Rails
100 Days to Offload ChallengeThis is post 5 as part of the #100DaysToOffload challenge. The point is to write 100 posts on a personal blog in a year. Quality isn't as important as quantity so some posts may be a little messy. Read other posts in this challenge.
Polymorphic associations is a common theme among many applications. Things can get complicated, especially as far as naming is concerned, when you consider having a double polymorphic association. Rails provides all the necessary mechanisms by which to manage this in a way that makes sense for most business needs as well as leaving it readable for future programmers that come by in the future.
In programming languages and type theory, polymorphism is the provision of a single interface to entities of different types or the use of a single symbol to represent multiple different types.
The example we'll work with today is one taken from some work I recently did helping to implement a Favorites feature. The requirements for this were:
Usercan have many favorites, which can be a
Teamcan have many favorites, which can be a
This is what I mean by a double polymorphic relationship. One side, favoritor, can be one of a
Team while the other side, the favouritee, can be of the type
Report. The requirements lended itself to building a
Favoritings table and using that as our base. This would have a
favoritee polymorphic columns, which with Rails and ActiveRecord automatically include the
type of each of those. This is what the migration looked like:
class CreateFavoritings < ActiveRecord::Migration[6.1]
create_table(:favoritings) do |t|
t.references(:favoritee, polymorphic: true, index: true)
t.references(:favoritor, polymorphic: true, index: true)
So now comes time to develop the actual relationships to the other models. This is complicated to a degree but you have to consider how your domain is laid out in order to define these relationships as they're needed. For one a Team can have many favourites and a User can have many favourites. Lets solve that first.
class User < ApplicationRecord
has_many :favorites, class_name: 'Favoriting', foreign_key: :favoritor_id, as: :favoritor
While the name of the relationship isn't exact to the model, the domain name of
favorites makes total sense. A User has many favorites. We then go onto define what the class name is since we're not explicitely using the
Favoritings class name. Then we have to tell it the key this relationship uses on that model, as well as the type. A
User has many
favorites of class
Favoritings based on the foreign key
favoritor_id as the type of
favoritor. This makes a well understood API for querying later:
User.find(1).favourites will yield all the favourites. You could also get more specific with:
has_many :favorite_teams, class_name: 'Favoriting', foreign_key: :favoritor_id, as: :favoritor, source_type: 'Team'
This not only defines the relationship more explicitely to the individual type but also builds the query via a join instead of having to call another query to scope it down after the fact. One of the many optimizations ActiveRecord can supply us.
Now lets implement the other side:
Teams as a favouriting.
class Team < ApplicationRecord
has_many :favoritings, as: :favoritee
has_many :user_favoritors, through: :favoritings, source: :favoritor, source_type: 'User'
The first relationship says a
Team has many
favouritings as the
favouritee. So this model can be "favorited." Next we have a
Team has many
Favoritings model which are of the type
Users and the key/type is
favoritor. This will pull all the users that have favorited this team. Just like earlier this allows ActiveRecord to optimize queries for these early on instead of running mulitple or having to manage scopes. This also provides a very readable API for developers down the road.
This is half the aforementioned implementation but it describes the principal enough. Rails and ApplicationRecord provides a great and flexible interface for explicitely defining these types of complex relationships that all flow through the same model.