Creates query objects for complex database queries following TDD. Use when encapsulating complex queries, aggregating statistics, building reports, or when user mentions queries, stats, dashboards, or data aggregation.
Creates query objects that encapsulate complex database queries with tests first.
test/queries/app/queries/| Scenario | Use |
|---|---|
| Simple WHERE clause | Scope on the model |
| Single-condition filter | Scope on the model |
| Multi-table joins with conditions | Query object |
| Dashboard aggregations | Query object |
| Report generation |
| Query object |
| Queries needing constructor params | Query object |
| Reusable across controllers | Query object |
Rule of thumb: If the query fits in one line and needs no context, use a scope. If it needs parameters, joins multiple tables, or returns computed data, use a query object.
Query objects in this project:
user: or account:)ActiveRecord::Relation for chainability OR Hash for aggregationscall method for primary operation# test/queries/stale_leads_query_test.rb
require "test_helper"
class StaleLeadsQueryTest < ActiveSupport::TestCase
setup do
@account = accounts(:one)
@other_account = accounts(:two)
end
test "requires an account parameter" do
assert_raises(ArgumentError) { StaleLeadsQuery.new }
end
test "#call returns ActiveRecord::Relation" do
query = StaleLeadsQuery.new(account: @account)
assert_kind_of ActiveRecord::Relation, query.call
end
test "#call returns only leads for the account (multi-tenant)" do
own_lead = leads(:stale_one)
other_lead = leads(:other_account_stale)
results = StaleLeadsQuery.new(account: @account).call
assert_includes results, own_lead
assert_not_includes results, other_lead
end
test "#call returns only stale leads" do
stale = leads(:stale_one)
fresh = leads(:fresh_one)
results = StaleLeadsQuery.new(account: @account).call
assert_includes results, stale
assert_not_includes results, fresh
end
test "multi-tenant isolation" do
other_query = StaleLeadsQuery.new(account: @other_account)
own_query = StaleLeadsQuery.new(account: @account)
assert_empty(other_query.call.where(id: leads(:stale_one).id))
assert_not_empty(own_query.call.where(id: leads(:stale_one).id))
end
end
bin/rails test test/queries/stale_leads_query_test.rb
# app/queries/stale_leads_query.rb
class StaleLeadsQuery
attr_reader :account
def initialize(account:)
@account = account
end
def call
account.leads.stale
end
end
bin/rails test test/queries/stale_leads_query_test.rb
# app/queries/stale_leads_query.rb
class StaleLeadsQuery
attr_reader :account
def initialize(account:)
@account = account
end
def call
account.leads.stale
end
end
# app/queries/dashboard_stats_query.rb
class DashboardStatsQuery
attr_reader :user, :account
def initialize(user:)
@user = user
@account = user.account
end
def upcoming_events(limit: 3)
account.events
.where("event_date >= ?", Date.today)
.order(event_date: :asc)
.limit(limit)
end
def pending_commissions_total
EventVendor
.joins(:event)
.where(events: { account_id: account.id })
.where(commission_status: :to_invoice)
.sum(:commission_value)
end
def top_vendors(limit: 5)
account.vendors
.left_joins(:event_vendors)
.select("vendors.*, COUNT(event_vendors.id) as events_count")
.group("vendors.id")
.order("events_count DESC")
.limit(limit)
end
def leads_by_status
account.leads.group(:status).count
end
end
# app/queries/leads_by_status_query.rb
class LeadsByStatusQuery
attr_reader :account
def initialize(account:)
@account = account
end
def call
leads = account.leads.order(created_at: :desc)
result = Lead.statuses.keys.map(&:to_sym).index_with { [] }
leads.group_by(&:status).each do |status, status_leads|
result[status.to_sym] = status_leads
end
result
end
end
# test/queries/dashboard_stats_query_test.rb
require "test_helper"
class DashboardStatsQueryTest < ActiveSupport::TestCase
setup do
@user = users(:one)
@query = DashboardStatsQuery.new(user: @user)
end
test "#upcoming_events returns future events only" do
results = @query.upcoming_events
results.each do |event|
assert event.event_date >= Date.today
end
end
test "#upcoming_events respects limit" do
results = @query.upcoming_events(limit: 2)
assert results.size <= 2
end
test "#leads_by_status returns hash of status to count" do
result = @query.leads_by_status
assert_kind_of Hash, result
end
test "scoped to user account only" do
other_user = users(:other_account)
other_query = DashboardStatsQuery.new(user: other_user)
own_events = @query.upcoming_events
other_events = other_query.upcoming_events
own_events.each do |event|
assert_equal @user.account_id, event.account_id
end
end
end
# Simple query
def index
@leads_by_status = LeadsByStatusQuery.new(account: current_account).call
end
# Aggregation query with presenter
def index
stats_query = DashboardStatsQuery.new(user: current_user)
@stats = DashboardStatsPresenter.new(stats_query)
end
app/queries/
stale_leads_query.rb
leads_by_status_query.rb
dashboard_stats_query.rb
events/
upcoming_query.rb
by_vendor_query.rb
test/queries/
stale_leads_query_test.rb
dashboard_stats_query_test.rb
user: or account:).includes() to prevent N+1