rails-query-object
SKILL.md
Rails Query Object Generator (TDD)
Creates query objects that encapsulate complex database queries with tests first.
Quick Start
- Write failing test in
test/queries/ - Run test to confirm RED
- Implement query object in
app/queries/ - Run test to confirm GREEN
When to Use Query Objects vs Scopes
| 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.
Project Conventions
Query objects in this project:
- Accept context via constructor (
user:oraccount:) - Return
ActiveRecord::Relationfor chainability ORHashfor aggregations - Have a
callmethod for primary operation - Support multi-tenancy (scoped to account)
TDD Workflow
Step 1: Create Query Test (RED)
# 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
Step 2: Run Test (Confirm RED)
bin/rails test test/queries/stale_leads_query_test.rb
Step 3: Implement Query Object (GREEN)
# app/queries/stale_leads_query.rb
class StaleLeadsQuery
attr_reader :account
def initialize(account:)
@account = account
end
def call
account.leads.stale
end
end
Step 4: Run Test (Confirm GREEN)
bin/rails test test/queries/stale_leads_query_test.rb
Query Object Patterns
Pattern 1: Simple Filtered Query
# app/queries/stale_leads_query.rb
class StaleLeadsQuery
attr_reader :account
def initialize(account:)
@account = account
end
def call
account.leads.stale
end
end
Pattern 2: Aggregation Query (Multiple Methods)
# 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
Pattern 3: Grouping Query
# 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
Testing Aggregation Queries
# 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
Usage in Controllers
# 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
Directory Structure
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
Checklist
- Test written first (RED)
- Constructor accepts context (
user:oraccount:) - Multi-tenant isolation tested
- Return type documented
- Methods have clear, descriptive names
- Complex queries use
.includes()to prevent N+1 - Database-agnostic (no PostgreSQL-specific SQL)
- All tests GREEN
Weekly Installs
2
Repository
dchuk/rails_ai_agentsFirst Seen
7 days ago
Security Audits
Installed on
opencode2
gemini-cli2
antigravity2
claude-code2
windsurf2
codex2