Joining polymorphic associations in ActiveRecord

9 minutes read

Polymorphic associations in ActiveRecord allow to belong to more than one model on a single associations. The mechanism is very powerful because helps to DRY the code and make the database schema clean. Let’s have a quick example:

class Payment < ApplicationRecord
  belongs_to :subject, polymorphic: true
end

class User < ApplicationRecord
  has_many :payments, as: :subject
end

class Artist < ApplicationRecord
  has_many :payments, as: :subject
end

So the payment can belong to multiple type of models marked as subject and to distinguish them by subject_id and subject_type columns at the database level. In our simple example, we have two different entities who can create multiple payments: User and Artist.

db-schema

Keep in mind, that this is not the payment between User and Artist, these are payments created by users or by artists. And such records are going to be stored in a single table payments. The first question which comes to the mind, what would happen if we try to join the polymorphic association to the Payment?

Join polymorphic association

> Payment.joins(:subject).last

ActiveRecord::EagerLoadPolymorphicError: Cannot eagerly load the polymorphic association :subject

The error says it is not able to eagerly load the polymorphic association. And that is reasonable because subject is a general name for our association and ActiveRecord doesn’t know what table to join on. If we try to construct a SQL query by hand we can end up by something like this:

SELECT payments.* FROM payments
  INNER JOIN users ON users.id = payments.subject_id

but here we join on a specific table users and payment actually can belong to artists so we miss some data we want. The solution might be to do multiple queries to join on multiple tables the payment belongs to.

Include polymorphic association

And that is what the ActiveRecord’s includes method does. It performs multiple queries to fetch the data. In the example below, if we change joins to includes the error is gone, however if we look closely to the explanation, we can see that ActiveRecord does as many extra queries to the database as the number of different types of models the polymorphic association has. In our case 2 queries: to table users and to table artists.

> Payment.includes(:subject).last #=> <Payment...>
> Payment.includes(:subject).map(&:subject_id) #=> [2, 1, 2]

> Payment.includes(:subject).explain
=> EXPLAIN for: SELECT "payments".* FROM "payments"
2|0|0|SCAN TABLE payments

EXPLAIN for: SELECT "users".* FROM "users" WHERE "users"."id" = ? [["id", 2]]
2|0|0|SEARCH TABLE users USING INTEGER PRIMARY KEY (rowid=?)

EXPLAIN for: SELECT "artists".* FROM "artists" WHERE "artists"."id" IN (?, ?) [["id", 1], ["id", 2]]
2|0|0|SEARCH TABLE artists USING INTEGER PRIMARY KEY (rowid=?)

Define the association with a scope

What if our polymorphic association belongs to too many different type of models and we want to efficiently query by single association? A solution might be to define an extra association for this specific type of model:

class Payment < ApplicationRecord
  belongs_to :subject, polymorphic: true
+ belongs_to :user, -> { where(payments: { subject_type: 'User' }) }, foreign_key: 'subject_id'
end

In this example we defined a user association with an extra scope on it, so ActiveRecord can properly made the join and filter the associated records by subject_type:

> Payment.joins(:user).explain

=> EXPLAIN for: SELECT "payments".* FROM "payments"
   INNER JOIN "users" ON "users"."id" = "payments"."subject_id"
   AND "payments"."subject_type" = ? [["subject_type", "User"]]

4|0|0|SEARCH TABLE payments USING INDEX index_payments_on_subject_type_and_subject_id (subject_type=?)
11|0|0|SEARCH TABLE users USING INTEGER PRIMARY KEY (rowid=?)

Define the association through the self ref

There is another possible way to let the join work, but i find it a bit tricky:

class Payment < ApplicationRecord
  belongs_to :subject, polymorphic: true
+
+ has_one :self_ref, class_name: 'Payment', foreign_key: :id
+ has_one :user, through: :self_ref, source: :subject, source_type: 'User'
end

Here we define a self_ref association to have a relationship to self and then define the needed association to the user through self. Looks a bit hacky, right? But anyway it still works, even if it has one extra join to the self table:

> Payment.joins(:user).explain

=> EXPLAIN for: SELECT "payments".* FROM "payments"
   INNER JOIN "payments" "self_refs_payments_join" ON "self_refs_payments_join"."id" = "payments"."id"
   AND "self_refs_payments_join"."subject_type" = ?
   INNER JOIN "users" ON "users"."id" = "self_refs_payments_join"."subject_id" [["subject_type", "User"]]

4|0|0|SEARCH TABLE payments AS self_refs_payments_join USING COVERING INDEX index_payments_on_subject_type_and_subject_id (subject_type=?)
10|0|0|SEARCH TABLE payments USING INTEGER PRIMARY KEY (rowid=?)
13|0|0|SEARCH TABLE users USING INTEGER PRIMARY KEY (rowid=?)

Wrap up

In this article we discussed 3 possible solutions to deal with the polymorphic associations in ActiveRecord:

  • using includes
  • defining a new association with a scope
  • defining a new association with a self ref

I think the developer should take the one which fits the most of his needs or maybe even overthink if polymorphic association is required for that particular case.

Leave a Comment