QueryBuilder
The QueryBuilder provides a fluent, type-safe interface for constructing and executing SQL queries. It supports complex WHERE conditions, joins, aggregations, pagination, and seamless integration with the ORM.
Quick Start
import { query, transaction, withSchema } from '@strav/database'
import User from '../app/models/user'
// Basic query
const users = await query(User)
.where('active', true)
.orderBy('createdAt', 'desc')
.all()
// With transaction
await transaction(async (trx) => {
const users = await query(User, trx)
.where('role', 'admin')
.all()
})
Complete Method Reference
WHERE Conditions
Basic WHERE
// Simple equality (operator defaults to '=')
query(User).where('status', 'active')
// With operator
query(User).where('age', '>=', 18)
query(User).where('name', 'LIKE', 'John%')
// Grouped conditions with callback
query(User).where(q => {
q.where('active', true)
.orWhere('premium', true)
})
IN/NOT IN Queries
query(User).whereIn('role', ['admin', 'moderator'])
query(User).whereNotIn('status', ['banned', 'suspended'])
NULL Checks
query(User).whereNull('deletedAt')
query(User).whereNotNull('emailVerifiedAt')
BETWEEN Ranges
query(User).whereBetween('age', 18, 65)
query(Product).whereBetween('price', 10.00, 99.99)
Raw SQL Conditions
// With parameter binding (safe)
query(User).whereRaw('"email" ILIKE $1', ['%@gmail.com'])
// Complex expressions
query(Order).whereRaw('DATE("createdAt") = CURRENT_DATE')
OR Conditions
All WHERE methods have OR variants that add conditions with OR logic:
query(User)
.where('role', 'admin')
.orWhere('role', 'super_admin')
query(Product)
.where('category', 'electronics')
.orWhereIn('featured', [true])
.orWhereBetween('discount', 20, 50)
// Grouped OR conditions
query(User).orWhere(q => {
q.where('premium', true)
.where('credits', '>', 0)
})
Complete OR Methods
orWhere(column, operatorOrValue?, value?)orWhereIn(column, values)orWhereNotIn(column, values)orWhereNull(column)orWhereNotNull(column)orWhereBetween(column, low, high)orWhereRaw(sql, params?)
JOIN Operations
// INNER JOIN
const results = await query(User)
.innerJoin(Profile).on('User.id', '=', 'Profile.userId')
.select('User.email', 'Profile.bio')
.all()
// LEFT JOIN
query(Post)
.leftJoin(Comment).on('Post.id', '=', 'Comment.postId')
.select('Post.*', 'COUNT(Comment.id) as commentCount')
.groupBy('Post.id')
.all()
// RIGHT JOIN
query(Order)
.rightJoin(Product).on('Order.productId', '=', 'Product.id')
.all()
// Multiple joins
query(User)
.innerJoin(Profile).on('User.id', '=', 'Profile.userId')
.leftJoin(Team).on('User.teamId', '=', 'Team.id')
.all()
SELECT & Projection
// Specific columns
query(User).select('id', 'email', 'name').all()
// With table prefix
query(User)
.innerJoin(Profile).on('User.id', '=', 'Profile.userId')
.select('User.email', 'Profile.bio', 'Profile.avatar')
.all()
// Distinct values
query(Order).select('status').distinct().all()
// Single column values (returns array of values, not objects)
const emails: string[] = await query(User).pluck('email')
const ids: number[] = await query(Product).pluck('id')
Ordering, Limiting & Pagination
// ORDER BY
query(User).orderBy('createdAt', 'desc').all()
query(Product).orderBy('category', 'asc').orderBy('price', 'desc').all()
// LIMIT & OFFSET
query(Post).limit(10).all()
query(Post).offset(20).limit(10).all()
// Pagination with metadata
const result = await query(User)
.where('active', true)
.orderBy('name', 'asc')
.paginate(2, 20) // page 2, 20 per page
/*
result = {
data: User[],
meta: {
page: 2,
perPage: 20,
total: 145,
lastPage: 8,
from: 21, // 1-based index
to: 40 // 1-based index
}
}
*/
Aggregation Functions
// COUNT
const totalUsers = await query(User).count()
const activeUsers = await query(User).where('active', true).count()
// SUM
const totalRevenue = await query(Order).sum('amount')
const monthlyRevenue = await query(Order)
.whereRaw('DATE("createdAt") >= DATE_TRUNC(\'month\', CURRENT_DATE)')
.sum('amount')
// AVERAGE
const avgAge = await query(User).avg('age')
const avgOrderValue = await query(Order)
.where('status', 'completed')
.avg('total')
// MIN/MAX
const minPrice = await query(Product).min('price')
const maxScore = await query(Review).max('rating')
const oldestUser = await query(User).min('createdAt')
// EXISTS check
const hasAdmins = await query(User).where('role', 'admin').exists()
if (!hasAdmins) {
console.log('No admins found!')
}
GROUP BY & HAVING
// Simple grouping
const usersByRole = await query(User)
.select('role', 'COUNT(*) as count')
.groupBy('role')
.all()
// Multiple group columns
query(Order)
.select('status', 'DATE(createdAt) as date', 'SUM(total) as revenue')
.groupBy('status', 'DATE(createdAt)')
.orderBy('date', 'desc')
.all()
// HAVING clause for aggregate conditions
query(Product)
.select('category', 'AVG(price) as avgPrice')
.groupBy('category')
.having('AVG(price)', '>', 100)
.all()
// Raw HAVING for complex conditions
query(User)
.select('teamId', 'COUNT(*) as members')
.groupBy('teamId')
.havingRaw('COUNT(*) >= $1', [5])
.all()
Data Modification
UPDATE Operations
// Update matching records (returns affected count)
const affected = await query(User)
.where('lastLoginAt', '<', oneYearAgo)
.update({ status: 'inactive' })
// Update with transaction
await transaction(async (trx) => {
await query(Product, trx)
.where('category', 'electronics')
.update({ discount: 15 })
})
INCREMENT/DECREMENT
// Increment by 1 (default)
await query(Post).where('id', postId).increment('views')
// Increment by specific amount
await query(User).where('id', userId).increment('credits', 100)
// Decrement
await query(Product).where('id', productId).decrement('stock', 5)
// Multiple operations
await query(Cart)
.where('userId', userId)
.where('productId', productId)
.increment('quantity', 2)
DELETE Operations
// Soft delete (if model has softDeletes = true)
const deleted = await query(User)
.where('status', 'banned')
.delete()
// Force hard delete
const forceDeleted = await query(User)
.where('createdAt', '<', twoYearsAgo)
.forceDelete()
// Delete with join condition
await query(Comment)
.innerJoin(Post).on('Comment.postId', '=', 'Post.id')
.where('Post.status', 'archived')
.delete()
Soft Delete Control
// Include soft-deleted records
const allUsers = await query(User).withTrashed().all()
// Only soft-deleted records
const deletedUsers = await query(User).onlyTrashed().all()
// Combining with other conditions
query(User)
.withTrashed()
.where('role', 'admin')
.orderBy('deletedAt', 'desc')
.all()
Eager Loading Relationships
// Load single relationship
const users = await query(User)
.with('profile')
.all()
// Load multiple relationships
const posts = await query(Post)
.with('author', 'comments', 'tags')
.all()
// With conditions
const orders = await query(Order)
.with('items', 'customer')
.where('status', 'pending')
.all()
Model Scopes
Define reusable query logic as static scope methods on your models:
// Define scopes in your model
class User extends BaseModel {
// Simple scope
static scopeActive(query: QueryBuilder<User>) {
return query.where('active', true).whereNotNull('emailVerifiedAt')
}
// Scoped query for specific role
static scopeAdmin(query: QueryBuilder<User>) {
return query.where('role', 'admin')
}
// Complex scope with multiple conditions
static scopePremium(query: QueryBuilder<User>) {
return query.where('plan', 'premium')
.where('subscriptionStatus', 'active')
.whereNull('deletedAt')
}
// Scope with joins
static scopeWithOrders(query: QueryBuilder<User>) {
return query.innerJoin(Order).on('User.id', '=', 'Order.userId')
.where('Order.status', 'completed')
}
// Register scopes (required for scope() method to work)
static scopes = {
active: this.scopeActive,
admin: this.scopeAdmin,
premium: this.scopePremium,
withOrders: this.scopeWithOrders,
}
}
// Using scopes
const activeUsers = await query(User).scope('active').all()
const adminUsers = await query(User).scope('admin').all()
const premiumUsers = await query(User).scope('premium').scope('active').all()
// Combine scopes with regular query methods
const recentPremiumUsers = await query(User)
.scope('premium')
.scope('active')
.where('createdAt', '>', DateTime.now().minus({ days: 30 }))
.orderBy('createdAt', 'desc')
.all()
Chunked Processing
Process large datasets in memory-efficient chunks:
// Process users in chunks of 100
await query(User)
.where('needsProcessing', true)
.chunk(100, async (users) => {
for (const user of users) {
await processUser(user)
}
})
// With early termination (return false to stop)
await query(Order)
.where('status', 'pending')
.chunk(50, async (orders) => {
for (const order of orders) {
await processOrder(order)
if (shouldStop()) {
return false // Stop chunking early
}
}
return true // Continue to next chunk
})
SQL Inspection
Debug queries by inspecting generated SQL without execution:
const query = query(User)
.where('active', true)
.whereIn('role', ['admin', 'moderator'])
.orderBy('createdAt', 'desc')
const { sql, params } = query.toSQL()
console.log('SQL:', sql)
console.log('Params:', params)
// SQL: SELECT * FROM "user" WHERE "active" = $1 AND "role" IN ($2, $3) ORDER BY "created_at" DESC
// Params: [true, 'admin', 'moderator']
Advanced Patterns
Complex Filtering
// Dynamic filter building
function buildUserFilter(query: QueryBuilder, filters: UserFilters) {
if (filters.search) {
query.where(q => {
q.where('name', 'ILIKE', `%${filters.search}%`)
.orWhere('email', 'ILIKE', `%${filters.search}%`)
})
}
if (filters.roles?.length) {
query.whereIn('role', filters.roles)
}
if (filters.minAge) {
query.whereRaw('EXTRACT(YEAR FROM AGE(birthDate)) >= $1', [filters.minAge])
}
return query
}
const filtered = await buildUserFilter(query(User), filters).paginate()
Subqueries with Callbacks
// Complex nested conditions
const results = await query(Product)
.where('active', true)
.where(q => {
q.where(sub => {
sub.where('stock', '>', 0)
.orWhere('preorder', true)
})
.where('price', '<', 100)
})
.all()
// Equivalent to:
// WHERE active = true
// AND ((stock > 0 OR preorder = true) AND price < 100)
Report Queries
// Sales report by month
const monthlySales = await query(Order)
.select(
'DATE_TRUNC(\'month\', createdAt) as month',
'COUNT(*) as orderCount',
'SUM(total) as revenue',
'AVG(total) as avgOrderValue'
)
.where('status', 'completed')
.whereBetween('createdAt', startDate, endDate)
.groupBy('DATE_TRUNC(\'month\', createdAt)')
.orderBy('month', 'desc')
.all()
// Top customers
const topCustomers = await query(Order)
.innerJoin(User).on('Order.userId', '=', 'User.id')
.select(
'User.id',
'User.name',
'User.email',
'COUNT(Order.id) as orderCount',
'SUM(Order.total) as totalSpent'
)
.where('Order.status', 'completed')
.groupBy('User.id', 'User.name', 'User.email')
.having('COUNT(Order.id)', '>', 5)
.orderBy('totalSpent', 'desc')
.limit(10)
.all()
Batch Operations
// Archive old records in batches
async function archiveOldPosts() {
const oneYearAgo = DateTime.now().minus({ years: 1 }).toJSDate()
await query(Post)
.where('createdAt', '<', oneYearAgo)
.where('archived', false)
.chunk(100, async (posts) => {
const ids = posts.map(p => p.id)
// Archive posts
await query(Post)
.whereIn('id', ids)
.update({ archived: true, archivedAt: new Date() })
// Log the operation
console.log(`Archived ${ids.length} posts`)
})
}
Transaction Patterns
All queries within a transaction are automatically committed on success or rolled back on error. In multi-tenant applications, tenant context is preserved throughout the transaction.
// Complex transaction with multiple queries
const order = await transaction(async (trx) => {
// Check stock
const product = await query(Product, trx)
.where('id', productId)
.first()
if (!product || product.stock < quantity) {
throw new Error('Insufficient stock')
}
// Decrement stock
await query(Product, trx)
.where('id', productId)
.decrement('stock', quantity)
// Create order
const order = await Order.create({
userId,
productId,
quantity,
total: product.price * quantity
}, trx)
// Update user stats
await query(User, trx)
.where('id', userId)
.increment('totalOrders')
.increment('totalSpent', order.total)
return order
})
// Multi-tenant transaction (preserves schema context)
await withSchema('tenant_123', async () => {
await transaction(async (trx) => {
// All queries automatically use tenant_123 schema
const users = await query(User, trx)
.where('role', 'admin')
.all()
// Create audit log record
await AuditLog.create({
action: 'bulk_update',
affectedRecords: users.length
}, trx)
})
})
// Nested model operations in transaction
await transaction(async (trx) => {
const user = await User.create({
name: 'John Doe',
email: 'john@example.com'
}, trx)
const profile = await Profile.create({
userId: user.id,
bio: 'Software Developer'
}, trx)
// QueryBuilder operations on existing records
await query(User, trx)
.where('id', user.id)
.update({ profileComplete: true })
})
Performance Tips
1. Use Indexes
Ensure columns used in WHERE, JOIN, and ORDER BY have appropriate indexes:
// Check query performance with EXPLAIN
const { sql } = query(User)
.where('email', 'user@example.com')
.toSQL()
// Run in database: EXPLAIN ANALYZE
2. Limit Selected Columns
Only select columns you need:
// Bad - selects all columns
const users = await query(User).all()
// Good - selects only needed columns
const users = await query(User)
.select('id', 'name', 'email')
.all()
3. Use Pagination for Large Results
// Bad - loads all records into memory
const allUsers = await query(User).all()
// Good - paginated results
const page1 = await query(User).paginate(1, 100)
// Good - chunked processing
await query(User).chunk(100, async (users) => {
// Process batch
})
4. Optimize Aggregations
// Use database aggregations instead of loading all records
// Bad
const orders = await query(Order).where('userId', userId).all()
const total = orders.reduce((sum, o) => sum + o.total, 0)
// Good
const total = await query(Order).where('userId', userId).sum('total')
5. Avoid N+1 Queries
Use eager loading for relationships:
// Bad - N+1 queries
const posts = await query(Post).all()
for (const post of posts) {
post.author = await User.find(post.authorId) // N queries!
}
// Good - 2 queries total with eager loading
const posts = await query(Post).with('author').all()
Column Name Resolution
The QueryBuilder automatically handles column name resolution:
// 'email' → "user"."email" (primary table + snake_case)
query(User).where('email', 'test@example.com')
// 'User.email' → "user"."email" (explicit model reference)
query(User).where('User.email', 'test@example.com')
// 'Profile.userId' → "profile"."user_id" (cross-table + case conversion)
query(User)
.innerJoin(Profile).on('User.id', '=', 'Profile.userId')
Error Handling
import { ModelNotFoundError } from '@strav/kernel/exceptions/errors'
import { query, transaction } from '@strav/database'
// firstOrFail throws ModelNotFoundError when no record is found
try {
const user = await query(User)
.where('email', 'nonexistent@example.com')
.firstOrFail()
} catch (error) {
if (error instanceof ModelNotFoundError) {
console.log('User not found')
// Handle the missing record appropriately
}
}
// Database connection errors
try {
const users = await query(User).all()
} catch (error) {
console.error('Database query failed:', error.message)
}
// Transaction rollback on errors
try {
await transaction(async (trx) => {
await query(User, trx).where('id', 1).update({ status: 'active' })
throw new Error('Something went wrong')
// Transaction automatically rolls back
})
} catch (error) {
console.log('Transaction rolled back due to error')
}
TypeScript Support
The QueryBuilder is fully typed and provides IntelliSense support:
// Type-safe model querying
const users: User[] = await query(User).all()
const user: User | null = await query(User).first()
const user: User = await query(User).firstOrFail()
// Typed aggregations
const count: number = await query(User).count()
const sum: number = await query(Order).sum('total')
// Typed pluck results
const emails: string[] = await query(User).pluck('email')
const ids: number[] = await query(Product).pluck('id')
// Typed pagination
const result: PaginationResult = await query(User).paginate()