Speed Up Queries with Nested Scopes in Rails

In my recent project, I was working with scopes across various models, and I needed to apply a nested scope for more advanced querying.

To achieve this with performant query I used different concepts, this blog is about the same!!

Lets revise the basics!
Firstly, lets start with what is query optimisation ?

Optimising queries is key for better application performance

By using various ORM techniques like includeseager_loadpreload etc we can reduce the database calls and avoid N+1 queries!!

What are N+1 queries ?

N+1 queries occur when the application executes one query to retrieve the main data and then executes additional queries for each related record.

class Post < ApplicationRecord
has_many :comments
end
@posts = Post.all

@posts.each do |post|
post.comments.count
end

Here we can see,

  1. A single query retrieves all (N) posts
  2. For each post, (+1) a separate query retrieves its comments

This leads to multiple database calls, significantly slowing down the application..

Solving N+1 queries

Mainly preload and includes are used for solving this!

When to use preload:

preload is used when we only need to iterate over associated records without filtering or querying on them. preload does not join tables; instead, it fires a separate query for the associated records.

Example: If we want to display all comments of all posts

@posts = Post.preload(:comments) 

@posts.each do |post|
puts "Post: #{post.title}"
post.comments.each do |comment|
puts "Comment: #{comment.body}"
end
end
  1. Post.preload(:comments) fetches all posts and comments in two separate queries.
  2. No table joins are performed, making it efficient for simple iteration.

When to use includes:

includes is used when filtering or querying on associated records. However, it does not always behave the same way — it dynamically decides whether to use preload or eager_load, depending on whether the associated records are used in filtering.

If the associated records are not filteredincludes behaves like preload (executing separate queries).
If filtering is applied on the associated records, Rails automatically converts includes into eager_load, which performs a SQL LEFT OUTER JOIN instead.

Example: If we want first user whose post has title “Rails”

user = User.includes(:posts).where(posts: { title: "Rails" }).first

Here,

  1. includes joins the table posts with users
  2. The where clause filters on posts.title
  3. Because filtering is used, Rails treats includes as eager_load, ensuring everything is fetched in a single query.

Key difference in preload and includes:

  1. Key difference in preload and includes:

Lets go advance!

Now, lets see the nested scopes scenario!!

See the below models and query scenario:

class User < ApplicationRecord
has_many :posts
has_many :comments
has_many :recent_posts, -> { recent }, class_name: "Post"
end

class Post < ApplicationRecord
belongs_to :user
has_many :comments
has_many :recent_comments, -> { recent }, class_name: "Comment"

scope :recent, -> { where(date: (Date.yesterday..Date.today)) }
end

class Comment < ApplicationRecord
belongs_to :post
belongs_to :user

scope :recent, -> { where(date: (Date.yesterday..Date.today)) }
end

Here, we have usersposts and comments table associated, and we have “recent” scope in posts and comments to get recent (last 2 days) posts and comments. I have added new association recent_posts and recent_comments for calling recent scope on posts and comments.

Scenario: Fetch specific user’s (@user) recent posts which has recent comments added by admin user

I was trying to chain the recent_comments scope directly on recent_comments for the user,

filtered_posts = @user.recent_posts
.recent_comments
.where(comments: { user_id: admin.id })

but, recent_comments scope is defined on Postnot on a collection of posts, and @user.recent_posts returns a collection, so this will not work!

Then I found solution below, where scope can be applied directly on class!

filtered_posts = @user.recent_posts
.includes(comments: :user)
.where(comments: { user_id: admin.id })
.merge(Comment.recent)

Here:

  1. recent_posts is scope defined for getting last 2 days posts
  2. The includes method joins the tables and loads the comments association and, within comments, also loads the associated user
  3. Filters the comments to include only those created by the specified admin user.
  4. Applies merge the recent scope to the comments. You can see merge query here
  5. .merge(Comment.recent) recent scope applied on Comment class which is merged with recent_posts
  6. We can also use joins instead of includes to ensure that the filtering happens at the database level.

In this way, we can chain the scopes, optimise our queries and increase the application performance!!

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.