Efficiently eager loading polymorphic associations to avoid the 'n-plus-one' queries can be a challenge. Consider the following example:
class Author < ApplicationRecord
has_many :books
end
class Director < ApplicationRecord
has_many :films
end
class Book < ApplicationRecord
belongs_to :author
has_many :likes, as: :resource
end
class Film < ApplicationRecord
belongs_to :director
has_many :likes, as: :resource
end
class Like < ApplicationRecord
belongs_to :resource, polymorphic: true
belongs_to :profile
end
class Profile < ApplicationRecord
has_many :likes
end
nolan = Director.create!(name: "Nolan")
shakespeare = Author.create!(name: "Shakespeare")
memento = Film.create!(name: "Memento", director: nolan)
dunkirk = Film.create!(name: "Dunkirk", director: nolan)
macbeth = Book.create!(name: "Macbeth", author: shakespeare)
othello = Book.create!(name: "Othello", author: shakespeare)
john = Profile.create!(name: "John")
paul = Profile.create!(name: "Paul")
Like.create!(profile: john, resource: dunkirk)
Like.create!(profile: john, resource: macbeth)
Like.create!(profile: paul, resource: memento)
Like.create!(profile: paul, resource: othello)
Running these these produces an 'n-plus-one' query issue:
Book.all.each do |book|
puts book.name
puts book.author.name
end
SELECT "books".* FROM "books"
SELECT "authors".* FROM "authors" WHERE "id" = 1
SELECT "authors".* FROM "authors" WHERE "id" = 2
...
Film.all.each do |film|
puts film.name
puts film.director.name
end
SELECT "films".* FROM "films"
SELECT "films".* FROM "films" WHERE "id" = 1
SELECT "films".* FROM "films" WHERE "id" = 2
...
These are easily fixed by using preload
:
Book.preload(:author).each do |book|
puts book.name
puts book.author.name
end
SELECT "books".* FROM "books"
SELECT "authors".* FROM "authors" WHERE "authors"."id" IN (...)
Film.preload(:director).each do |film|
puts film.name
puts film.director.name
end
SELECT "films".* FROM "films"
SELECT "directors".* FROM "directors" WHERE "directors"."id" IN (...)
Now consider accessing through a polymorphic association:
Like.all.each do |like|
puts like.resource.name
puts case like.resource
when Book like.resource.author.name
when Film like.resource.director.name
end
end
Attempting to eager load the authors or directors for the books or films is slightly more challenging. For this case ActiveRecord::Association::Preloader
can be invoked manually:
class Like::Preloader
def self.preload(likes)
ActiveRecord::Associations::Preloader.new(
records: likes.select { |like| like.resource_type.eql?(Book.name) },
associations: [{ resource: :author }],
).call
ActiveRecord::Associations::Preloader.new(
records: likes.select { |like| like.resource_type.eql?(Film.name) },
associations: [{ resource: :director }],
).call
end
end
likes = Like.all
Like::Preloader.preload(likes)
likes.each do |like|
puts like.resource.name
puts case like.resource
when Book like.resource.author.name
when Film like.resource.director.name
end
end
SELECT "likes".* FROM "likes"
SELECT "books".* FROM "books" WHERE "books"."id" IN (...)
SELECT "authors".* FROM "authors" WHERE "authors"."id" IN (...)
SELECT "films".* FROM "films" WHERE "films"."id" IN (...)
SELECT "directors".* FROM "directors" WHERE "directors"."id" IN (...)
Finally consider the following example:
Profile.all.each do |like|
puts profile.name
profile.likes.each do |like|
puts like.resource.name
puts case like.resource
when Book like.resource.author.name
when Film like.resource.director.name
end
end
end
Attempting to eager load in this case can be solved using a combination of preload
and the ActiveRecord::Association::Preloader
manual invocation defined earlier:
class Profile::Preloader
def self.preload(profiles)
LikePreloader.preload(profiles.map(&:likes).flatten)
end
end
profiles = Profile.preload(:likes)
Profile::Preloader.preload(profiles)
profiles.each do |like|
puts profile.name
profile.likes.each do |like|
puts like.resource.name
puts case like.resource
when Book like.resource.author.name
when Film like.resource.director.name
end
end
end
SELECT "profiles".* FROM "profiles"
SELECT "likes".* FROM "likes" WHERE "likes"."id" IN (...)
SELECT "books".* FROM "books" WHERE "books"."id" IN (...)
SELECT "authors".* FROM "authors" WHERE "authors"."id" IN (...)
SELECT "films".* FROM "films" WHERE "films"."id" IN (...)
SELECT "directors".* FROM "directors" WHERE "directors"."id" IN (...)