Combining has_many :through with polymorphic associations in ActiveRecord

Combining has_many :through with polymorphic associations in ActiveRecord

You read the Rails guide and thought you have understood the has_many :through association and polymorphic associations. Your app models needed both. Unfortunately, the guide didn’t explain how to combine these two together.  Wondering why on earth the guide would leave out the most important yet complicated part, you had no choice but try to put them together yourself hoping you would get it right.

Your has_many :through associations seemed right. Your polymorphic associations also seemed identical to the examples in the guide. But ActiveRecord kept throwing errors: Could not find the association :foo in model Bar, Cannot have a has_many :through association ‘Bar#foos’ on the polymorphic object ‘Foo#foo’, or ActiveRecord::HasManyThroughSourceAssociationNotFoundError: Could not find the source association(s).

Now you felt confused and frustrated: “I’m pretty sure I have the tables set up correctly, but why my associations are not working?”

This post answers your question: how to combine has_many :through with polymorphic associations in ActiveRecord.

The post is structured as follows:

  1. What are has_many :through associations and polymorphic associations?
  2. Walking through an example in a semi-TDD fashion.
  3. Quiz: adding another has_many :through and polymorphic association to the example.‌

1. What are has_many :through associations and polymorphic associations?

A has_many :through association is often used to set up a many-to-many connection with another model.

For example, consider a subscription model that connects a podcast model with a user model. A user can subscribe to many podcasts. And a podcast can have many users as its subscribers. We can say a user has many podcasts through subscriptions and a podcast has many users through subscriptions.

With polymorphic associations, a model can belong to more than one other model, on a single association.

For example, a subscription model can belong to either a podcast model or a newspaper model.

Combining has_many :through associations and polymorphic associations allows us to set up many-to-many connections with more than one model.

For example, through the subscription model, a user can be associated with many podcasts and many newspapers.

 

2. Walking through an example in a semi-TDD fashion.

Step 0: Create a new app.

 

rails new polymorphic_has_many_through_example

 

Step 1: Build the polymorphic association between subscriptions and podcasts/newspapers.

Let’s first generate these three models: Subscription, Podcast, and Newspaper.

bin/rails generate model Podcast name:string

bin/rails generate model Newspaper name:string

bin/rails generate model Subscription subscribable_id:integer subscribable_type:string

Notice when we build the polymorphic association, instead of subscribable:references or podcasts:references, we use two columns, subscribable_id and subscribable_type, to store the association. Since it’s a polymorphic association, the subscribable can be more than one type. We use subscribable_type:string to store the type and subscribable_id:integer to store the corresponding id.

Here are the migrations generated by Rails:

class CreatePodcasts < ActiveRecord::Migration[5.1]
  def change
    create_table :podcasts do |t|
      t.string :name

      t.timestamps
    end
  end
end
class CreateNewspapers < ActiveRecord::Migration[5.1]
  def change
    create_table :newspapers do |t|
      t.string :name

      t.timestamps
    end
  end
end
class CreateSubscriptions < ActiveRecord::Migration[5.1]
  def change
    create_table :subscriptions do |t|
      t.integer :subscribable_id
      t.string :subscribable_type

      t.timestamps
    end
  end
end

 

Here are the generated models:

class Subscription < ApplicationRecord
end

class Podcast < ApplicationRecord
end

class Newspaper < ApplicationRecord
end

Let’s run the migrations to update our database.

bin/rails db:migrate

Time to write tests.

We use tests as a way to specify the results we want. The tests will fail at first. But when they pass, we know we achieve what we set out to do.

We use polymorphic associations to give a model the ability to belongs to more than one other model on a single association.

Our test should reflect that.

require 'test_helper'

class SubscriptionTest < ActiveSupport::TestCase
  test "can belong to either a podcast or a newspaper" do
    podcast = Podcast.new
    subscription1 = Subscription.new(subscribable: podcast)

    newspaper = Newspaper.new
    subscription2 = Subscription.new(subscribable: newspaper)

    assert_equal(subscription1.subscribable, podcast)
    assert_equal(subscription2.subscribable, newspaper)
  end
end

Running the test, bin/rails test -b test/models/subscription_test.rb, gives us the following error:

Error:
SubscriptionTest#test_a_subscription_can_belong_to_a_podcast:
ActiveModel::UnknownAttributeError: unknown attribute 'subscribable' for Subscription.

Let’s setup the association in the Subscription model.

class Subscription < ApplicationRecord
  belongs_to :subscribable, :polymorphic => true
end

And the test will pass after this change which indicates the polymorphic association has set up correctly. 🎉🎉

1 runs, 1 assertions, 0 failures, 0 errors, 0 skips

 

Step 2: Build the has_many association between users and subscriptions.

First, create the User model.

bin/rails generate model User name:string

Rails generates the User model as follows:

class User < ApplicationRecord
end

Then we reference user in the subscriptions table.

bin/rails generate migration AddUserToSubscriptions user:references

Rail generates a migration as follows:

class AddUserToSubscriptions < ActiveRecord::Migration[5.1]
  def change
    add_reference :subscriptions, :user, foreign_key: true
  end
end

Run the migration:

bin/rails db:migrate

Then write a spec to assert the result we want, which is a user can have many subscriptions.

require 'test_helper'

class UserTest < ActiveSupport::TestCase
  test "has many subscriptions" do
    user = User.new
    subscription1 = user.subscriptions.new
    subscription2 = user.subscriptions.new

    assert_equal(user.subscriptions.length, 2)
    assert_equal(user.subscriptions[0], subscription1)
    assert_equal(user.subscriptions[1], subscription2) 
  end
end

The test fails because we have not set up the has_many association yet.

Error:
UserTest#test_has_many_subscriptions:
NoMethodError: undefined method `subscriptions' for #<User id: nil, name: nil, created_at: nil, updated_at: nil>

We set up the has_many association as follows:

class User < ApplicationRecord
  has_many :subscriptions
end

The test should pass now.

We also want a subscription to belong to a user. So we write a test to assert that:

test "belongs to a user" do
  user = User.new
  subscription = Subscription.new(user: user)

  assert_equal(subscription.user, user)
end

Again, the test will fail.

Error:
SubscriptionTest#test_a_subscription_belongs_to_a_user:
ActiveModel::UnknownAttributeError: unknown attribute 'user' for Subscription.

Then we can add the association:

class Subscription < ApplicationRecord
  belongs_to :subscriptable, :polymorphic => true
  belongs_to :user
end

And the test will pass which indicates the has_many association has set up correctly. 🎉🎉🎉

2 runs, 3 assertions, 0 failures, 0 errors, 0 skips

 

Step 3: Combining has_many :through associations with polymorphic associations.

Finally, it’s time to combine has_many :through associations with polymorphic associations.

A user can have many podcasts through subscriptions. A user can also have many newspapers through subscriptions.

Let’s add tests to user_test.rb to reflect that.

test "has many podcasts through subscriptions" do
  user = User.create
  podcast1 = Podcast.create
  subscription1 = user.subscriptions.create(subscriptable: podcast1)
  podcast2 = Podcast.create
  subscription2 = user.subscriptions.create(subscriptable: podcast2)

  assert_equal(user.podcasts.length, 2)
  assert_equal(user.podcasts[0], podcast1)
  assert_equal(user.podcasts[1], podcast2) 
end

test "has many newspapers through subscriptions" do
  user = User.create
  newspaper1 = Newspaper.create
  subscription1 = user.subscriptions.create(subscriptable: newspaper1)
  newspaper2 = Newspaper.create
  subscription2 = user.subscriptions.create(subscriptable: newspaper2)

  assert_equal(user.newspapers.length, 2)
  assert_equal(user.newspapers[0], newspaper1)
  assert_equal(user.newspapers[1], newspaper2) 
end

Obviously, these tests will fail.

NoMethodError: undefined method `newspapers' for #<User id: nil, name: nil, created_at: nil, updated_at: nil>
NoMethodError: undefined method `podcasts' for #<User id: nil, name: nil, created_at: nil, updated_at: nil>

Here is how we add the associations:

class User < ApplicationRecord
  has_many :subscriptions

  has_many :podcasts, through: :subscriptions, source: :subscribable, source_type: 'Podcast'
  has_many :newspapers, through: :subscriptions, source: :subscribable, source_type: 'Newspaper'
end

We need to specify source and source_type because Podcast and Newspaper have polymorphic associations with Subscription.

has_many :podcasts, through: :subscriptions, source: :subscribable, source_type: 'Podcast' in English is a user has many podcasts through subscriptions, and the way to find these podcasts is to look for subscribables from subscriptions belong to the user with type Podcast.

With that, our tests should pass.

We have successfully combined has_many :through associations with polymorphic associations! 🎉🎉

You can find the code in here.

 

3. Quiz: adding another polymorphic association to the example.

What if not only users but also bots can subscribe to podcasts and newspapers through subscriptions?

We need to create a bot model and create polymorphic associations between Subscription and User/Bot.

Then a podcast can has many user subscribers and many bot subscribers.

 

Check out this branch to complete the exercise.

Your goal is to make the following tests pass.

class PodcastTest < ActiveSupport::TestCase
  test "has many user subscribers" do
    podcast = Podcast.create
    user_subscriber1 = User.create
    podcast.subscriptions.create(subscriber: user_subscriber1)
    user_subscriber2 = User.create
    podcast.subscriptions.create(subscriber: user_subscriber2)

    assert_equal(podcast.user_subscribers.length, 2)
    assert_equal(podcast.user_subscribers[0], user_subscriber1)
    assert_equal(podcast.user_subscribers[1], user_subscriber2)
  end

  test "has many bot subscribers" do
    podcast = Podcast.create
    bot_subscriber1 = Bot.create
    podcast.subscriptions.create(subscriber: bot_subscriber1)
    bot_subscriber2 = Bot.create
    podcast.subscriptions.create(subscriber: bot_subscriber2)

    assert_equal(podcast.bot_subscribers.length, 2)
    assert_equal(podcast.bot_subscribers[0], bot_subscriber1)
    assert_equal(podcast.bot_subscribers[1], bot_subscriber2)
  end  
end

The answer is in this branch.
Share your answer below.


Thanks for reading.

Please share the post if you find it helpful! 🙃

Don’t forget to subscribe.

Leave a Comment