Eager Loading Polymorphic Associations in Ruby on Rails

For when .includes is not enough.

Image for post
Image for post
Photo by Paul Smith on Unsplash

Basic Rails Associations

class Post < ApplicationRecord
has_many :comments
end
class Comment < ApplicationRecord
belongs_to :post
end

A Post has many Comments. This allows us to call Post.first.comments to get all the comments associated with a post! ActiveRecord internally generates our required SQL queries:

SELECT "posts".* FROM "posts" WHERE "posts".id = 1
SELECT "comments".* FROM "comments" WHERE "comments".post_id

N+1 Queries and .includes

SELECT "posts".* FROM "posts"
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 1
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 2
...
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = N

As seen above, we end up getting one query to get all the posts and then N queries to get the comments for each of the N posts. Sounds familiar? This is what is often called N+1 queries and is a major slow down in large applications.

Luckily, Rails provides a built in solution for this. Just add the .includes method on the call to the parent object: Post.all.includes(:comments).each { |p| puts p.comments }. This informs Rails to eager load the comments resulting in only 2 total queries:

SELECT "posts".* FROM "posts"
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (1, 2, ..., N)

In case you want to preemptively detect N+1 queries in your code before it makes it into production, I recommend using the Bullet Gem that I go into more detail in this other article:

Polymorphic Associations

Consider that now our Post model will have attachments. An attachment, can either be a Video or an Image where a Video has a Thumbnail.

class Post < ApplicationRecord
belongs_to :attacheable, polymorphic: true
end
class Thumbnail < ApplicationRecord
belongs_to :video
end
class Video < ApplicationRecord
has_many :posts, as: :attacheable
has_one :thumbnail
end
class Image < ApplicationRecord
has_many :posts, as: :attacheable
end

Similarly to the case with a regular association, we can use .includes to create optimized queries:

SELECT "posts".* FROM "posts"
SELECT "videos".* FROM "videos" WHERE "videos"."id" IN (1, ..., X)
SELECT "images".* FROM "images" WHERE "images"."id" IN (X+1, ..., N)

The Problem

Post.all.includes(:attacheable).each do |p| 
if p.attacheable_type == ‘Video’
puts p.attacheable.thumbnail
else
puts p.attacheable
end
end

In this case, our N+1 issue is back!

SELECT "posts".* FROM "posts"
SELECT "videos".* FROM "videos" WHERE "videos"."id" IN (1, ..., X)
SELECT "images".* FROM "images" WHERE "images"."id" IN (X+1, ..., N)
SELECT "thumbnails".* FROM "thumbnails" WHERE "thumbnails"."video_id" = 1 LIMIT 1
SELECT "thumbnails".* FROM "thumbnails" WHERE "thumbnails"."video_id" = 2 LIMIT 1
...
SELECT "thumbnails".* FROM "thumbnails" WHERE "thumbnails"."video_id" = X LIMIT 1

The Solution

The updated code is shown below:

@posts = Post.all.includes(:attacheable)preloader = ActiveRecord::Associations::Preloader.new
preloader.preload(@posts.select { |p| p.attacheable_type == 'Video' }, attacheable: [:thumbnail])
@posts.each do |p|
if p.attacheable_type == ‘Video’
puts p.attacheable.thumbnail
else
puts p.attacheable
end
end

Using the preloader we are back down to the following queries:

SELECT "posts".* FROM "posts"
SELECT "videos".* FROM "videos" WHERE "videos"."id" IN (1, ..., X)
SELECT "images".* FROM "images" WHERE "images"."id" IN (X+1, ..., N)
SELECT "thumbnails".* FROM "thumbnails" WHERE "thumbnails"."video_id" IN (1, ..., X)
SELECT "thumbnails".* FROM "thumbnails" LIMIT 11

Conclusion

Make It Work Make It Right Make It Fast — Kent Beck

A method to solve the issue of N+1 queries was presented for regular Rails associations and then extended to polymorphic associations using the ActiveRecord::Associations::Preloader class.

Hope this helps in making your code faster!

Written by

A curious minded engineer.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store