#rails #postgresql
Preloading data with custom scopes and counting association records in Rails
May 07, 2025
//2 min read
Recently, I needed to load records with a counter on one of the associations and preload some others.
Think about Teams, where you need to count all members and preload just the recent ones to show them on the page with their avatars.
In this short note, I will show you how to approach it step by step.
Before we dive into the solution, let’s highlight the models and their associations:
class Team < ApplicationRecord
has_many :memberships
has_many :users, through: :memberships
end
class Membership < ApplicationRecord
belongs_to :team
belongs_to :user
end
class User < ApplicationRecord
has_many :memberships
has_many :teams, through: :memberships
has_one_attached :avatar
end
Step 1: Count team members
To count the number of associated records in Ruby on Rails, we can use joins and then group the records by the main model, followed by applying count on the association.
teams = Team
.joins(:memberships)
.select('teams.*, COUNT(memberships.id) AS memberships_count')
.group('teams.id')
To make it easier to use, we define a scope in the Team
model:
def self.with_memberships_count
joins(:memberships)
.group('teams.id')
.select('teams.*, COUNT(memberships.id) AS memberships_count')
end
Step 2: Define the most recent members
Next, we need to define the association for the most recent members. We can do this by using a virtual has_many association (this is not a real association but more like a read-only association that retrieves a subset of records from the parent association):
has_many :memberships
has_many :recent_memberships, -> { recent(5) }, class_name: 'Membership', inverse_of: :team, dependent: nil
By having this as an association instead of regular methods, we can easily preload these recent memberships when we load the teams:
teams = Team.preload(recent_memberships: :user)
Step 3: Load the most recent memberships
This is a bit tricky as we need to define a scope that will load the last n memberships for each team. Assuming we are using PostgreSQL, we can use two approaches:
- Using a lateral join (personally less readable to me)
- Using window ranking functions with partition over.
Using Lateral Join
def self.recent(limit: 5)
recent_memberships = Team
.joins(Team.sanitize_sql_array([<<~SQL.squish, { limit: }]))
JOIN LATERAL (
SELECT * FROM memberships
WHERE team_id = teams.id
ORDER BY created_at ASC LIMIT :limit
) AS selected_memberships ON TRUE
SQL
.select('selected_memberships.*')
from(recent_memberships, 'memberships')
end
Here we’re joining memberships with teams (by using a lateral join, each team is joined with subquery results to load :limit recent memberships).
Using Window Ranking Functions
ranked_memberships = select('memberships.*, dense_rank() OVER (PARTITION BY team_id ORDER BY created_at DESC) AS rank')
.from(ranked_memberships, 'memberships').where(rank: ..limit)
Here we are “splitting” memberships by team_id
, and within each group, we rank them based on the condition (ORDER BY created_at DESC
). Then we specify a condition for ranking (basically our limit
argument).
Step 4: Preload avatars for the users
Assuming that the User model has an avatar association via has_one_attached, we can define a scope to preload the avatars:
scope :with_avatars, -> { includes(avatar_attachment: { blob: { variant_records: { image_attachment: :blob } } }) }
Step 5: Combine everything together
Now we can combine everything together and take advantage of the ActiveRecord::Associations::Preloader to specify additional scopes for the preloaded associations:
scope = Team.with_memberships_count
ActiveRecord::Associations::Preloader
.new(records: scope, associations: :recent_memberships, scope: Membership.merge(User.with_avatars))
.call