Rails Techniques: Using Polymorphic Associations

· 16 Aug 2017 · Semaphore Engineering Blog

Rails Techniques: Using Polymorphic Associations

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.

Here's a schematic representation of what we'd like:

Model/DB diagram

Diagram note: for simplicity's sake, let's pretend that a review not being attached to an event or a restaurant somehow makes sense.

Moving on to the implementation. 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.

Note: Had we opted for a superclass instead of a mixin, this entity would've been named something like "Attraction" or "Venue".

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 existance 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.

Note: A superclass for Event and Restaurant is another option here.

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.

If you're already familiar with the topic, you're welcome to share your usage examples in the comments. If you have any questions or other comments, feel free to leave them below. Also, if you found this article useful, you can share it so others can find it as well.

At Semaphore, we're on a mission to make continuous integration fast and easy. If your Rails test suite takes ages to finish, you can now automatically parallelize it with our new feature, Semaphore Boosters, and cut its runtime down to just a few minutes. Spend less time on testing, and focus on shipping.

Happy building!

comments powered by Disqus
Newsletter

Occasional lightweight product and blog updates. Unsubscribe at any time.

© 2009-2017 Rendered Text. All rights reserved. Terms of Service, Privacy policy, Security.