class Tag < ApplicationRecord
has_many :taggings, dependent: :destroy
scope :top, ->(limit = 20) {
joins(:taggings)
.group(Arel.sql("tags.id"))
.order(Arel.sql("COUNT(taggings.id) DESC"))
.limit(limit)
.pluck(Arel.sql("tags.id"), Arel.sql("COUNT(taggings.id)"))
}
def self.each_tag_count(batch_size: 1_000)
return enum_for(:each_tag_count, batch_size: batch_size) unless block_given?
joins(:taggings)
.group(Arel.sql("tags.id"))
.in_batches(of: batch_size) do |relation|
relation.pluck(Arel.sql("tags.id"), Arel.sql("COUNT(taggings.id)")).each do |id, count|
yield(id, count)
end
end
end
end
class TagLeaderboard
Entry = Struct.new(:id, :name, :count)
def self.build(limit: 20)
new(limit: limit).build
end
def initialize(limit:)
@limit = limit
end
def build
pairs = Tag.top(@limit)
return [] if pairs.empty?
ids = pairs.map(&:first)
names = Tag.where(id: ids).pluck(:id, :name).to_h
pairs.map do |id, count|
Entry.new(id, names[id], count)
end
end
end
class TagsController < ApplicationController
def leaderboard
stamp = Tagging.maximum(:updated_at).to_i
limit = params.fetch(:limit, 20).to_i.clamp(1, 100)
@entries = Rails.cache.fetch(["tag_leaderboard", limit, stamp], expires_in: 1.hour) do
TagLeaderboard.build(limit: limit)
end
respond_to do |format|
format.html
format.json { render json: @entries }
end
end
end
Computing a "top tags" leaderboard over a large taggings table is a classic place where a naive Rails query quietly blows up memory. Calling Tagging.all.each or building an in-memory Hash from millions of loaded ActiveRecord objects allocates one heavy object per row plus the association graph, which is exactly what this snippet avoids. The pattern here pushes the counting work into the database and pulls back only the two scalar columns that matter.
In Tag model, the top scope is the core idea: joins(:taggings) moves the aggregation to SQL, group(:id) collapses rows by tag, and pluck(Arel.sql("tags.id"), Arel.sql("COUNT(taggings.id)")) returns a plain array of [id, count] pairs rather than materialized model instances. pluck is the key — it bypasses ActiveRecord object instantiation entirely, so the memory footprint is proportional to the number of distinct tags, not the number of taggings. order(Arel.sql("COUNT(...) DESC")) and limit keep the result bounded regardless of table size. Wrapping the raw fragments in Arel.sql marks them as trusted, silencing Rails' unsafe-SQL deprecation without disabling protection everywhere.
For even larger windows, each_tag_count uses in_batches so a huge grouped result set is streamed in chunks instead of held all at once — a useful safety valve when the distinct-tag count itself is large.
In TagLeaderboard service, the plucked pairs are the input to a second cheap query: where(id: ids) with index_by(&:id) builds a lookup so names can be attached in tag order without an N+1. Only the top slice of tags is ever hydrated into real objects, which is the whole point of separating counting from display. The returned Struct keeps the shape explicit and allocation-light.
TagsController shows the caller: it fetches a bounded leaderboard and caches it under a key derived from Tagging.maximum(:updated_at), so the expensive aggregation runs only when data actually changes. The trade-off is that pluck returns raw values with no callbacks or typecasting beyond the adapter's, and the SQL fragments are database-specific — acceptable when the goal is a fast, memory-safe read path. This approach is the right reach whenever an aggregate is needed over a table far larger than its distinct grouping keys.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.