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:
- What are has_many :through associations and polymorphic associations?
- Walking through an example in a semi-TDD fashion.
- 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.
was very helpful.thank you
Thank you so much for this. I kept messing this up and couldn’t figure this out. Now it works! Woo hoo! Thank you soo much!
Glad to hear you found the article helpful! 🙂
Pingback: Polymorphic through – Biased Noob Tech Blog
Thanks! Great article
Very nice writeup. Thank you!
Extremely helpful! I could not get this to work, but I tried your example code and saw the tests were passing, so I took a step back and thought about how I had framed my issue. I realized I did not need a “through” at all – I’ve got an “Actionable” and created a view of them which merges the tables into one for convenience of indexing the set together, “v_actionables”
So I realized I don’t have a through-table. When I took “through” away from my has_many relations, I realized then with some help from the Rails error messages I got, that I just needed to add a primary_key, foreign_key, and foreign_type. If I had just named my view “Actionables” I would not have needed any of that. But regardless, your writeup definitely helped me come to the conclusion I needed, so I owe you a beer! Thanks
Thanks for useful article, but you, obviously forgot to add relation to Newspaper and Podcasts models.
Thanks, was very helpful!
Hello everyone !
First thanks for this post, there are not many about has_many through and polymophic on the web.
I’ve tried on your app and I have some questions.
I’m able to save records for all models and then query User.first.podcasts, and it works fine. Then I want to do the same on the other side and do Podcast.first.users but it doesn’t work. so I modify Podcast class like so :
class Podcast < ApplicationRecord
has_many :subscriptions
has_many :users, through: :subscriptions, as: :subscribable
end
but it still doesn't work.
Does someone know how to query Podcast.first and get all users that have Subscription to that podcast ??
thanks a lot 🙂
flip
I’ve found my answer.
I made a mistake on Podcast class. I need to do this :
class Podcast < ApplicationRecord
has_many :subscriptions, as: :subscribable
has_many :users, through: :subscriptions
end
the as: :subscribable line goes with has_many :subscriptions and not has_many :users. So that rails now knows wich records of the Subscription class is a Podcast or a Newspapper.
This way you can query Podcast.first.users 🙂
Hope it might hemp someone.
flip
Hi,
Similar to what the User class has:
“`
has_many :podcasts, through: :subscriptions, source: :subscribable, source_type: ‘Podcast’
has_many :newspapers, through: :subscriptions, source: :subscribable, source_type: ‘Newspaper’
“`
What if we wanted to have these associations on the Subscription class? So we can do subscription.podcasts and subscription.newspapers.
*I meant it in plural
Ant I got it, I think I’d be something like this:
has_one :podcasts, class_name: ‘Podcast’, foreign_key: ‘subscriptable_id’, conditions: “subscriptable_type = ‘Podcast'”
has_one :scope, class_name: ‘Newspaper’, foreign_key: ‘subscriptable_id’, conditions: “subscriptable_type = ‘Newspaper'”