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.