rails-query-object
from thibautbaissac/rails_ai_agents
A collection of specialized AI agents for Rails 8.1 development, for AI driven-development and follow TDD best practices.
268 stars31 forksUpdated Jan 20, 2026
npx skills add https://github.com/thibautbaissac/rails_ai_agents --skill rails-query-objectSKILL.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
end
Step 2: Run Spec (Confirm RED)
bundle exec rspec spec/queries/[name]_query_spec.rb
Step 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
end
Step 4: Run Spec (Confirm GREEN)
bundle exec rspec spec/queries/[name]_query_spec.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
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
Checklist
- 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
Repository Stats
Stars268
Forks31
LicenseMIT License