Eager-loading Polymorphic Relationships in Rails
Update: The internal Preloader class has changed between Rails 3 and Rails 4. The code examples have been updated to reflect that.
ActiveRecord has some great utilities for eager loading associations to solve the N+1 query problem. Take, for example, the following code to print a list of sellers’ email addresses in an online marketplace:
listings = Listing.limit(10) listings.each do |listing|
puts listing.seller.email
end
This code will run the following sql queries.
SELECT * FROM listings LIMIT 10
SELECT * FROM sellers WHERE sellers.id = 1
SELECT * FROM sellers WHERE sellers.id = 2
SELECT * FROM sellers WHERE sellers.id = 3
SELECT * FROM sellers WHERE sellers.id = 4
SELECT * FROM sellers WHERE sellers.id = 5
SELECT * FROM sellers WHERE sellers.id = 6
SELECT * FROM sellers WHERE sellers.id = 7
SELECT * FROM sellers WHERE sellers.id = 8
SELECT * FROM sellers WHERE sellers.id = 9
SELECT * FROM sellers WHERE sellers.id = 10
However, using ActiveRecord’s #includes
method
listings = Listing.includes(:seller).limit(10)
listings.each do |listing|
puts listing.seller.email
end
allows you to shorten it to just two queries.
SELECT "listings".* FROM "listings" LIMIT 10
SELECT "sellers".* FROM "sellers" WHERE "sellers"."id" IN (3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
Preloading polymorphic associations
Now, let’s say that we have two types of sellers, Users and Organizations. To get the email for a
listing sold by a User we still call listing.seller.email
, but if the listing is sold by an
Organization we need to call listing.seller.primary_contact.email
. This means we need to preload
seller on listings sold by users and seller.primary_contact
on listings sold by Organizations. Here’s
how to do it.
def listings_with_associations_preloaded
listings = Listing.includes(:seller)
listings_by_seller_type = listings.group_by(&:seller_type)
organization_listings
listings_by_seller_type.fetch("Organization",[])
preload_record_array(
organization_listings,
seller: [:primary_contact]
)
listings
end
def preload_record_array(record_array, preload_hash)
ActiveRecord::Associations::Preloader.new
.preload(record_array, preload_hash)
end
Let’s take this example line by line. First, we preload all of the related seller objects since we will need them for every record.
listings = Listing.includes(:seller)
Then, we group the listings by #seller_type
.
listings_by_seller_type = listings.group_by(&:seller_type)
#=> {"User" => [...], "Organization" => [...]}
Next, we need to get the array of listings with an organization as the seller. We use Ruby’s Hash#fetch
to return an
empty array if no organizations are found.
organization_listings =
listings_by_seller_type.fetch("Organization",[])
Now it’s time to do the actual preloading. Since organization_listings
is an Array
instead of an ActiveRecord::Relation
we can’t use the #includes
method that we used previously. Instead we need build a lower-level
ActiveRecord::Associations::Preloader
object and
call #preload
on it.
ActiveRecord::Associations::Preloader.new
.preload(record_array, preload_hash)
The syntax of this constructor includes the records you’d like loaded as the first argument and the associations to load as
the second argument. The associations to load argument uses the same syntax as the #includes
method that is called on an
ActiveRecord::Relation
.
In Rails 3
This API changed between Rails 3 and Rails 4. If you are using Rails 3, the preloader should be called like this.
ActiveRecord::Associations::Preloader.new(
record_array, preload_hash
).run()
Wrapping Up
The models in this example may be a bit contrived, but it illustrates how to preload different types of objects in a polymorphic
relationships, as well as preload objects that are in an Array
instead of an ActiveRecord::Relation
. There is an open
Rails issue because this class is marked as :nodoc:
and is for internal use only.
However, this class has proven to so useful and I think it’s completely worth blogging about.
Finally, to give credit where credit is due, I wouldn’t have stumbled upon this solution without this Stack Overflow question and answer.
If you have any feedback, please contact me on Twitter at @bayfieldcoder.
Originally published at blog.animascodelabs.com on October 30, 2014.