What is a Polymorphic Association?
What is a Polymorphic Association?
In Ruby on Rails, a polymorphic association is an Active Record association that can connect a model to multiple other models. For example, we can use a single association to connect the Review model with the Event
and Restaurant
models, allowing us to connect a review with either an event or a restaurant.
One common use case includes Event
and Restaurant
inheriting from the same ancestor class. This is not necessary, though, and in our example we'll use mixins instead.
Let's now look further into our Review
example.
Diving into the Example
Consider the following situation: we have an application that enables users to review events and restaurants. As we've just seen, this involves associating the Review
model with both the Event
and Restaurant
models using a single, polymorphic association.
First, let's assume that our domain already has Event
, Restaurant
and Review
models. What we now want is for a review to be able to belong to either an event or a restaurant.
The "belongs_to" Side of the Association
Let's head over to our Review
model first. We need to set the kind of entity it belongs to. Since this can be either an event or a restaurant, we're going to need a more generic entity. Let's name it Reviewable
.
The database representation of this polymorphism consists of two columns, which represent the ID and the type of the actual entity that our review will belong to. In our case, these columns will be reviewable_id
(type: integer) and reviewable_type
(type: character varying).
Let's update the Review
spec:
# spec/models/review_spec.rb
require "spec_helper"
RSpec.describe Review, :type => :model do
it { is_expected.to have_db_column(:reviewable_id).of_type(:integer) }
it { is_expected.to have_db_column(:reviewable_type).of_type(:string) }
it { is_expected.to belong_to(:reviewable) }
end
In the spec, we are checking if our new columns exist, and if a review can belong to a reviewable entity. At this point, all of our scenarios should fail.
Let's proceed by altering the database schema. In order to do that, we'll need a migration which expands the reviews
table and adds a reference to the reviewable entity. We'll run the following:
rails generate migration AddReviewableToReviews reviewable:references{polymorphic}
Running this will generate the following migration:
class AddReviewableToReviews < ActiveRecord::Migration
def change
add_reference :reviews, :reviewable, polymorphic: true, index: true
end
end
After we run the migration, our reviews
table will receive the two new columns we mentioned earlier, as well as an index associated to the pair of these new columns.
If we run the Review
specs again, the scenarios testing for the existence of columns should pass. The belongs_to
scenario will still fail, though. Let's fix that in the Review
model:
# app/models/review.rb
class Review < ActiveRecord::Base
belongs_to :reviewable, :polymorphic => true
end
Here, we set that our review belongs to a :reviewable
entity, and that this association is polymorphic.
Running our Review
spec again should result in all scenarios passing.
The "has_many" Side of the Association
Let's turn to the has_many
side of our association now. On that side, we'll abstract the association by creating a concern called Reviewable
.
We'll start by writing a shared spec example for the concern:
# spec/models/concerns/reviewable_spec.rb
shared_examples "reviewable" do
it { is_expected.to have_many(:reviews) }
end
The scenario tests if a reviewable can have many reviews. Let's now include this in the Event
and Restaurant
specs.
# spec/models/event_spec.rb
require "spec_helper"
require "models/concerns/reviewable_spec"
RSpec.describe Event, :type => :model do
it_behaves_like "reviewable"
end
# spec/models/restaurant_spec.rb
require "spec_helper"
require "models/concerns/reviewable_spec"
RSpec.describe Restaurant, :type => :model do
it_behaves_like "reviewable"
end
Running any of these specs at this point will result in a failure. Now, let's implement the actual logic. First, we'll write the concern:
# app/models/concerns/reviewable.rb
module Reviewable
extend ActiveSupport::Concern
included do
has_many :reviews, :as => :reviewable
end
end
Note the :as => :reviewable
option. This is the name that we chose while expanding the Review
model.
We now just need to include the concern in our Event
and Restaurant
models.
# app/models/event.rb
class Event < ActiveRecord::Base
include Reviewable
end
# app/models/restaurant.rb
class Restaurant < ActiveRecord::Base
include Reviewable
end
Run the specs for Event
and Restaurant
now, and they should both pass.
Trying it All Out
With all of our specs passing and our database schema reflecting the new state, we're all done! Let's try out manually our new polymorphic association.
First, let's instantiate a couple of objects:
> event = Event.create
=> #<Event id: 1, ...>
> restaurant = Restaurant.create
=> #<Restaurant id: 1, ...>
> review_1 = Review.create(:reviewable => event)
=> #<Review id: 1, reviewable_id: 1, reviewable_type: "Event", ...>
> review_2 = Review.create(:reviewable => restaurant)
=> #<Review id: 2, reviewable_id: 1, reviewable_type: "Restaurant", ...>
As you can see in the output, we connected our reviews with an event and a restaurant. Note the reviewable_id
and reviewable_type
fields in the reviews. Let's now try out the associations:
> review_1.reviewable
=> #<Event id: 1, ...>
> review_2.reviewable
=> #<Restaurant id: 1, ...>
> event.reviews
=> #<ActiveRecord::Association::CollectionProxy [#<Review id: 1, reviewable_id: 1, reviewable_type: "Event", ...>]>
> restaurant.reviews
=> #<ActiveRecord::Association::CollectionProxy [#<Review id: 2, reviewable_id: 1, reviewable_type: "Restaurant", ...>]>
There it is: our objects are properly associated. Everything is up and working.
As you've now seen, polymorphic associations are a slightly obscure, but a very useful tool, and hopefully this article helped improve your understanding of them.