Kevin Sylvestre

Eager Loading Polymorphic Associations with Ruby on Rails

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 (...)