Eager-loading Active Record polymorphic or STI associations
At a certain point in the growth of your application's object model, you'll likely employ single table inheritence or polymorphism. Asscociations involving either of these concepts require more configuration and unfortunately, often more hair-pulling.
One particular snag is that when you query a collection of objects that don't all have the same associations, you can't simply eager load them, resulting in a barrage of N+1 queries.
Preloading STI models
Given an STI Animal model where a Kangeroo
is the only type that has a :pouch
association:
class Animal < ApplicationRecord
end
class Kangeroo < Animal
has_one :pouch
end
Trying to reference the pouch association for any Kangeroo
in the set of Animal
s would result in an N+1:
SELECT "animals".* FROM "animals" ORDER BY "animals"."id" ASC LIMIT 10
SELECT "pouches".* FROM "pouches" WHERE "pouches"."id" = 1 LIMIT 1
SELECT "pouches".* FROM "pouches" WHERE "pouches"."id" = 2 LIMIT 1
SELECT "pouches".* FROM "pouches" WHERE "pouches"."id" = 3 LIMIT 1
SELECT "pouches".* FROM "pouches" WHERE "pouches"."id" = 4 LIMIT 1
Preloading via Animal.preload(:pouch)
presumes a pouch
association exists on all the other Animal subclasses, which does not. We need to take one step closer to the groundwork of what happens when you call ordinary methods includes
or preload
on an ActiveRecord::Relation
.
Enter ActiveRecord::Associations::Preloader
. An instance of it exposes a method, #preload
, which first takes a collection
, and then associations_to_preload
as a hash or single symbol, just like what you'd pass to includes
or preload
.
Pass instances of Animal
that implement :pouch
, and then literal symbol :pouch
to roll up the N+1 issue:
animals = Animal.limit(10)
pl = ActiveRecord::Associations::Preloader.new
pl.preload(animals.select { |a| a.respond_to?(:pouch) }, :pouch)
animals.select { |a| a.is_a?(Kangeroo) }.map(&:pouch) # No N+1 query
The SQL output of that would be:
SELECT "animals".* FROM "animals" ORDER BY "animals"."id" ASC LIMIT 10
SELECT "pouches".* FROM "pouches" WHERE "pouches"."id" IN (1, 2, 3, 4)
Preloading associations of polymorphic associations
Address the polymorphic version of this problem in pretty much the same way. Consider an Animal associated to a species, which, for the sake of a silly example, is a holding place for general data.
class Animal < ApplicationRecord
belongs_to :species, polymorphic: true
end
class ReptileSpecies
has_many :animals
has_many :scales
end
class BirdSpecies
has_many :animals
has_many :wings
end
To preload all of this data, handle each identity individually, similarly to above:
animals = Animal.preload(:species).limit(10)
pl = ActiveRecord::Associations::Preloader.new
pl.preload(animals.select { |a| a.species_type == 'ReptileSpecies' }, species: :scales)
pl.preload(animals.select { |a| a.species_type == 'BirdsSpecies' }, species: :wings)
animals.map { |a| a.species.try(:scales) } # No N+1 query
animals.map { |a| a.species.try(:wings) } # No N+1 query
Note that the data path given as the second argument still starts at the Animal level, so we need to step through :species
first.
In summary, we query records with STI or polymorphic qualities and load them into memory, and group them by whatever defines their unique set of associations. Then, we feed each group into the Preloader and give it a path to the data to query.