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.
Install
npx skillscat add thibautbaissac/rails-ai-agents/rails-query-object Install via the SkillsCat registry.
SKILL.md
Rails Query Object Generator (TDD)
Creates query objects that encapsulate complex database queries with specs first.
Quick Start
- Write failing spec in
spec/queries/ - Run spec to confirm RED
- Implement query object in
app/queries/ - Run spec to confirm GREEN
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 Spec (RED)
# spec/queries/[name]_query_spec.rb
RSpec.describe [Name]Query do
subject(:query) { described_class.new(account: account) }
let(:user) { create(:user) }
let(:account) { user.account }
let(:other_account) { create(:user).account }
# Test data for current account
let!(:resource1) { create(:resource, account: account) }
let!(:resource2) { create(:resource, account: account) }
# Test data for other account (should not appear)
let!(:other_resource) { create(:resource, account: other_account) }
describe "#initialize" do
it "requires an account parameter" do
expect { described_class.new }.to raise_error(ArgumentError)
end
it "stores the account" do
expect(query.account).to eq(account)
end
end
describe "#call" do
it "returns expected result type" do
expect(query.call).to be_a(ActiveRecord::Relation)
# OR for hash results:
# expect(query.call).to be_a(Hash)
end
it "only returns resources for the account (multi-tenant)" do
result = query.call
expect(result).to include(resource1, resource2)
expect(result).not_to include(other_resource)
end
end
describe "multi-tenant isolation" do
it "ensures account A cannot see account B data" do
other_query = described_class.new(account: other_account)
expect(query.call).not_to include(other_resource)
expect(other_query.call).not_to include(resource1)
end
end
endStep 2: Run Spec (Confirm RED)
bundle exec rspec spec/queries/[name]_query_spec.rbStep 3: Implement Query Object (GREEN)
# app/queries/[name]_query.rb
class [Name]Query
attr_reader :account
def initialize(account:)
@account = account
end
# Returns [description of result]
# @return [ActiveRecord::Relation<Resource>] OR [Hash]
def call
account.resources
.where(condition: value)
.order(created_at: :desc)
end
endStep 4: Run Spec (Confirm GREEN)
bundle exec rspec spec/queries/[name]_query_spec.rbQuery 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
endPattern 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
endPattern 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
endUsage 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)
endChecklist
- Spec written first (RED)
- Constructor accepts context (
user:oraccount:) - Multi-tenant isolation tested
- Return type documented (
@return) - Methods have clear, descriptive names
- Complex queries use
.includes()to prevent N+1 - All specs GREEN