Including Polymorphic Association In Rails To Avoid N+1 Queries

pankajgupta221b

Pankaj Gupta

Posted on February 4, 2024

Including Polymorphic Association In Rails To Avoid N+1 Queries

Introduction

Polymorphic association is a more advanced type of association where a model can belong to one or more models. For example:

class Document < ApplicationRecord
  belongs_to :object, polymorphic: true
end

class Note < ApplicationRecord
  has_many :documents, as: :object
end

class Story < ApplicationRecord
  has_many :documents, as: :object
end
Enter fullscreen mode Exit fullscreen mode

Problem Statement

When querying the notes model, it is easier to include the associated documents because your model knows that there is only one model related to the documents. For example:

#note_ids is a list of note ids
notes = Note.includes(:documents).where(id: note_ids.pluck(:object_id))

documents = notes.flat_map { |note| note.documents }
Enter fullscreen mode Exit fullscreen mode

This will execute two queries, one on the notes and one on the documents model. It seems straightforward, but if you want to achieve the same thing from the other way around, let's see what happens:

docs = Document.includes(:notes).where(id: documents.map(&:id))

docs.map(&:id)
Enter fullscreen mode Exit fullscreen mode

It will throw an ActiveRecord::AssociationNotFoundError: Association named 'notes' was not found on Document; perhaps you misspelled it? error, which seems fair because we haven't declared anything on the Document model corresponding to the Note model.

Solution

We need to do that first of all, and let's see how it goes:

belongs_to :note
Enter fullscreen mode Exit fullscreen mode

After including this and running this again, we get a surprising result:

docs = Document.includes(:note).where(id: documents.map(&:id))

# request on notes
SELECT "notes".* FROM "notes" WHERE "notes"."id" = $1  [["id", nil]]

Enter fullscreen mode Exit fullscreen mode

This throws 2 DB requests, but it is not able to pick up the id for the notes somehow. If we provide the foreign key column, things might work in our favor, let's see:

belongs_to :note, foreign_key: "object_id"
Enter fullscreen mode Exit fullscreen mode

Now, if we check the queries, we get something like this:

docs = Document.includes(:note).where(id: documents.map(&:id).first(2))

# query on the Document model
Document Load (216.8ms)  SELECT "documents".* FROM "documents" WHERE "documents"."id" IN ($1, $2)  [["id", 26954], ["id", 26955]]

# query on the Note
Note Load (215.3ms)  SELECT "notes".* FROM "notes" WHERE "notes"."id" IN ($1, $2)  [["id", 68057], ["id", 68058]]
Enter fullscreen mode Exit fullscreen mode

We were able to reduce the N + 1 queries, but there is still an issue. It is querying for all the types, not just Note.

This would be a significant problem because this is not reliable at all. Imagine the consequences. To solve this issue, we have to use the following:

has_one :self_ref, class_name: 'Document', foreign_key: :id

has_one :note, through: :self_ref, source: :object, source_type: 'Note'
Enter fullscreen mode Exit fullscreen mode

Now, every time we include note, it will only query on the notes model and not any other model.

Of course, the downside is you'll have to include all the models you want to query this way.

Conclusion

There can be other ways to solve this problem. This is just one of the solution. Please feel free to explore and connect for other solutions.

💖 💪 🙅 🚩
pankajgupta221b
Pankaj Gupta

Posted on February 4, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related