Pankaj Gupta
Posted on February 4, 2024
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
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 }
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)
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
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]]
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"
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]]
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'
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.
Posted on February 4, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.