QueryBuilder API Reference
QueryBuilder is returned by Model.query(). All filter and shape methods are chainable. Terminal methods (get, first, count, etc.) execute the query against D1 and return a Promise. The get() method returns a Collection — a lightweight array wrapper with utility methods.
Terminal methods return proxy-wrapped model instances with typed property access. No manual casting needed.
The db parameter is optional on all terminal methods. When omitted, the database is resolved automatically from the registry configured via configure(env).
The orderBy direction accepts both lowercase and uppercase: 'asc', 'desc', 'ASC', 'DESC'.
Where-Clause Values Are Cast
Values passed to filter methods (whereEq, where, whereIn, whereBetween, …) are run through the model's casts before binding — the same dehydration applied on write. You pass the application value and the builder binds the stored primitive:
class Todo extends BaseModel<TodoAttrs> {
static table = 'todos'
static casts = {
done: 'boolean', // JS boolean ↔ D1 INTEGER 0/1
created_at: 'date', // JS Date ↔ D1 ISO 8601 TEXT
tags: 'json', // JS array ↔ D1 TEXT
}
}
// Booleans — pass `false`/`true`, not raw 0/1. The cast binds 0/1 for you.
Todo.query().whereEq('done', false).get()
// WHERE done = ? ← binds 0
// Dates — pass `Date` objects to a date range. They're serialized to match storage.
const start = new Date('2026-01-01')
const end = new Date('2026-02-01')
Todo.query().whereBetween('created_at', [start, end]).get()
// WHERE created_at BETWEEN ? AND ? ← binds two ISO strings
// JSON columns — pass the OBJECT; it's serialized the same way it's stored.
Todo.query().whereEq('tags', ['urgent', 'home']).get()
// WHERE tags = ? ← binds '["urgent","home"]'JSON path / containment
Equality on a whole json column matches the exact serialized string. To match inside a JSON document — a path value, array containment, or length — use the dedicated whereJsonPath / whereJsonContains / whereJsonLength filters instead of whereEq (see the JSON Aggregates & Updates guide).
A column with no cast binds the value as-is, so non-cast columns are unaffected. whereColumn (identifier-vs-identifier) and whereRaw bindings are never cast — you control those bindings directly.
// Set db once at query creation — no need to repeat at terminal calls
const users = await User.query(env.DB)
.whereEq('status', 'active')
.orderBy('created_at', 'DESC')
.limit(25)
.get()
users[0].name // typed as string — no .toObject() needed
// Explicit db at terminal call (existing pattern, still works)
const users = await User.query()
.whereEq('status', 'active')
.get(env.DB)
// Auto-resolved db (after configure(env) is called)
const users = await User.query()
.whereEq('status', 'active')
.get()Filter Methods
where(col, op, val)
.where(col: string, op: string, val: unknown): thisAdds a WHERE col op ? clause. op can be any SQL comparison operator (=, !=, <, >, <=, >=, LIKE, NOT LIKE).
User.query().where('age', '>', 18)orWhere(col, op, val)
.orWhere(col: string, op: string, val: unknown): thisAdds an OR col op ? clause.
User.query().whereEq('role', 'admin').orWhere('role', '=', 'moderator')whereEq(col, val)
.whereEq(col: string, val: unknown): thisShorthand for .where(col, '=', val).
User.query().whereEq('email', 'alice@example.com')whereLike(col, pattern)
.whereLike(col: string, pattern: string): thisAdds a WHERE col LIKE ? clause. Use % as the wildcard character.
User.query().whereLike('name', 'Ali%')whereIn(col, vals | subquery)
.whereIn(col: string, vals: unknown[]): this
.whereIn(col: string, subquery: QueryBuilder): thisAdds a WHERE col IN (...) clause. Accepts either an array of values or a QueryBuilder subquery.
// With array
User.query().whereIn('status', ['active', 'pending'])
// With subquery
const activeUserIds = User.query().select(['id']).where('status', '=', 'active')
Post.query().whereIn('user_id', activeUserIds)
// WHERE user_id IN (SELECT id FROM users WHERE status = ?)Empty arrays are safe
whereIn(col, []) does not emit the invalid col IN () (a SQLite syntax error). An empty set matches nothing, so a constant-false predicate 0 = 1 is compiled instead — the query returns no rows, exactly as set semantics imply.
User.query().whereIn('id', []).get(db)
// WHERE 0 = 1 → no rows, no errorwhereNotIn(col, vals | subquery)
.whereNotIn(col: string, vals: unknown[]): this
.whereNotIn(col: string, subquery: QueryBuilder): thisAdds a WHERE col NOT IN (...) clause. Accepts arrays or subqueries.
// Exclude posts by banned users
const bannedIds = User.query().select(['id']).where('status', '=', 'banned')
Post.query().whereNotIn('user_id', bannedIds)Empty arrays are safe
whereNotIn(col, []) does not emit the invalid col NOT IN (). An empty exclusion set excludes nothing, so a constant-true predicate 1 = 1 is compiled instead — the query matches every row, exactly as set semantics imply.
Post.query().whereNotIn('user_id', []).get(db)
// WHERE 1 = 1 → all rows, no errororWhereNotIn(col, vals)
.orWhereNotIn(col: string, vals: unknown[]): thisSame as whereNotIn but connects with OR.
User.query().whereEq('active', true).orWhereNotIn('role', ['guest'])whereNull(col)
.whereNull(col: string): thisAdds a WHERE col IS NULL clause.
User.query().whereNull('deleted_at')whereNotNull(col)
.whereNotNull(col: string): thisAdds a WHERE col IS NOT NULL clause.
User.query().whereNotNull('email_verified_at')orWhereNull(col) / orWhereNotNull(col)
.orWhereNull(col: string): this
.orWhereNotNull(col: string): thisOR variants of whereNull and whereNotNull.
User.query().whereEq('status', 'active').orWhereNull('deleted_at')whereBetween(col, range)
.whereBetween(col: string, range: [unknown, unknown]): thisAdds a WHERE col BETWEEN ? AND ? clause.
User.query().whereBetween('age', [18, 65])whereNotBetween(col, range)
.whereNotBetween(col: string, range: [unknown, unknown]): thisAdds a WHERE col NOT BETWEEN ? AND ? clause.
User.query().whereNotBetween('score', [0, 10])orWhereBetween(col, range) / orWhereNotBetween(col, range)
.orWhereBetween(col: string, range: [unknown, unknown]): this
.orWhereNotBetween(col: string, range: [unknown, unknown]): thisOR variants of whereBetween and whereNotBetween.
User.query().whereEq('status', 'vip').orWhereBetween('points', [500, 1000])whereRaw(sql, bindings?)
.whereRaw(sql: string, bindings?: unknown[]): thisAppends a raw SQL fragment to the WHERE clause. Use ? placeholders for values; pass the corresponding values as bindings.
User.query().whereRaw('json_extract(metadata, "$.tier") = ?', ['pro'])whereGroup(fn)
.whereGroup(fn: (qb: QueryBuilder<TModel>) => void): thisGroups a set of conditions in parentheses. Pass a callback that calls filter methods on the inner qb.
User.query()
.whereGroup(qb => {
qb.whereEq('status', 'active').orWhere('role', '=', 'admin')
})
.get(env.DB)
// WHERE (status = ? OR role = ?)orWhereGroup(fn)
.orWhereGroup(fn: (qb: QueryBuilder<TModel>) => void): thisSame as whereGroup but connects the group with OR instead of AND.
User.query()
.whereEq('role', 'admin')
.orWhereGroup(qb => {
qb.whereEq('role', 'editor').whereEq('status', 'active')
})
.get(env.DB)
// WHERE role = ? OR (role = ? AND status = ?)whereColumn(col1, op?, col2) / orWhereColumn(col1, op?, col2)
.whereColumn(col1: string, col2: string): this // defaults to '='
.whereColumn(col1: string, op: string, col2: string): this
.orWhereColumn(col1: string, col2: string): this
.orWhereColumn(col1: string, op: string, col2: string): thisCompares two column identifiers directly — no parameter bindings are emitted. The 2-argument form defaults the operator to =.
// 3-arg: explicit operator
Order.query().whereColumn('price', '>', 'cost')
// WHERE price > cost
// 2-arg: defaults to =
Post.query().whereColumn('updated_at', 'created_at')
// WHERE updated_at = created_at
// OR variant
Post.query()
.where('status', '=', 'draft')
.orWhereColumn('price', '<', 'cost')Conditional Clauses
when(condition, ifTrue, ifFalse?)
.when<T>(condition: T | false | null | undefined | 0 | "", ifTrue: (q: QueryBuilder, value: T) => void, ifFalse?: (q: QueryBuilder) => void): thisConditionally applies query clauses based on a runtime value. When the condition is truthy, ifTrue is called with the query builder and the truthy value. When falsy, ifFalse is called if provided. Returns this for chaining regardless of which branch executes.
// Apply filters only when present
const search = request.query.search // string | undefined
const users = await User.query()
.when(search, (q, val) => q.whereLike('name', `%${val}%`))
.when(filters.role, (q, role) => q.whereEq('role', role))
.orderBy('created_at', 'DESC')
.get()With an ifFalse branch:
const users = await User.query()
.when(
showActive,
(q) => q.whereEq('status', 'active'),
(q) => q.whereEq('status', 'inactive'),
)
.get()Shape Methods
distinct()
.distinct(): thisAdds the DISTINCT modifier to the SELECT clause, eliminating duplicate rows from results.
User.query().distinct().select(['email']).get(db)
// SELECT DISTINCT email FROM users
// Useful with joins to avoid duplicate rows
Post.query()
.distinct()
.join('tags', 'posts.id = tags.post_id')
.select(['posts.*'])
.get(db)select(cols)
.select(cols: string[]): thisLimits the columns included in the SELECT. By default all columns are selected (*).
User.query().select(['id', 'name', 'email'])join(table, on)
.join(table: string, on: string): thisAdds an INNER JOIN clause. on is the raw join condition.
Post.query()
.join('users', 'posts.user_id = users.id')
.select(['posts.*', 'users.name as author_name'])leftJoin(table, on)
.leftJoin(table: string, on: string): thisAdds a LEFT JOIN clause. on is the raw join condition.
Post.query()
.leftJoin('comments', 'posts.id = comments.post_id')selectSub(query, alias)
.selectSub(query: QueryBuilder, alias: string): thisEmbeds a subquery in the SELECT list as a named column. The subquery is parenthesized and its bindings are prepended before WHERE bindings in the final SQL. Calling select() after selectSub() resets subquery bindings to prevent stale state.
const commentCount = Comment.query()
.select(['COUNT(*)'])
.whereColumn('comments.post_id', 'posts.id')
const posts = await Post.query()
.select(['id', 'title'])
.selectSub(commentCount, 'comment_count')
.get(db)
// SELECT id, title, (SELECT COUNT(*) FROM comments
// WHERE comments.post_id = posts.id) AS comment_count FROM postsgroupBy(col)
.groupBy(col: string): thisAdds a GROUP BY col clause. Typically combined with an aggregate terminal method or having().
Post.query()
.select(['user_id'])
.groupBy('user_id')
.count(env.DB)having(col, op, val) / orHaving(col, op, val)
.having(col: string, op: string, val: unknown): this
.orHaving(col: string, op: string, val: unknown): thisAdds a HAVING clause to filter grouped results. Use after groupBy(). HAVING bindings are placed after WHERE bindings in the compiled SQL.
Post.query()
.selectRaw('user_id, COUNT(*) as post_count')
.groupBy('user_id')
.having('post_count', '>', 5)
.get(db)
// SELECT user_id, COUNT(*) as post_count FROM posts
// GROUP BY user_id HAVING post_count > ?
// OR variant
Post.query()
.selectRaw('status, COUNT(*) as cnt')
.groupBy('status')
.having('cnt', '>', 10)
.orHaving('status', '=', 'featured')
.get(db)havingRaw(sql, bindings?) / orHavingRaw(sql, bindings?)
.havingRaw(sql: string, bindings?: unknown[]): this
.orHavingRaw(sql: string, bindings?: unknown[]): thisAdds a raw SQL expression to the HAVING clause. Use for complex aggregate conditions.
Post.query()
.selectRaw('user_id, COUNT(*) as cnt')
.groupBy('user_id')
.havingRaw('COUNT(*) > ?', [5])
.get(db)
// ... GROUP BY user_id HAVING COUNT(*) > ?union(query) / unionAll(query)
.union(query: QueryBuilder): this
.unionAll(query: QueryBuilder): thisCombines results from multiple queries using SQL UNION (removes duplicates) or UNION ALL (keeps duplicates). Union clauses compile after LIMIT/OFFSET. Each unioned query compiles independently, including its own soft-delete scope. Returns this for chaining.
const admins = User.query().where('role', '=', 'admin')
const active = User.query().whereEq('active', true) // cast binds 1
// UNION (deduplicated)
const users = await admins.union(active).get(db)
// SELECT * FROM users WHERE role = ?
// UNION SELECT * FROM users WHERE active = ?
// UNION ALL (keeps duplicates)
const all = await admins.unionAll(active).get(db)
// Chain multiple unions
const result = await q1.union(q2).unionAll(q3).get(db)orderBy(col, dir?)
.orderBy(col: string, dir?: 'ASC' | 'DESC'): thisAdds an ORDER BY col dir clause. Defaults to ASC when dir is omitted.
Post.query().orderBy('created_at', 'DESC')limit(n)
.limit(n: number): thisAdds a LIMIT n clause.
Post.query().limit(20)offset(n)
.offset(n: number): thisAdds an OFFSET n clause. Use with limit for pagination.
Post.query().orderBy('created_at', 'DESC').limit(20).offset(40)from(table)
.from(table: string): thisOverrides the table name for this query. Useful for querying archive or shadow tables that share the same schema without creating a new model.
// Query an archive table using the same model
const archived = await Post.query(env.DB).from('posts_archive').get()
// Main table is unaffected on subsequent queries
const current = await Post.query(env.DB).get()Soft Delete Scopes
These methods apply only when the model has softDeletes = true. By default, queries on a soft-deleting model automatically append WHERE deleted_at IS NULL, excluding soft-deleted rows.
Scope is grouped against top-level OR
The deleted_at IS NULL scope is AND-appended to your predicates. Because SQL AND binds tighter than OR, a naive append would bind the scope to only the final OR branch — leaking soft-deleted rows. The builder guards against this: when your WHERE contains a top-level orWhere, your predicates are wrapped in a single group before the scope is appended, so the scope applies to all of them.
Post.query() // softDeletes model
.whereEq('status', 'draft')
.orWhere('status', '=', 'review')
.get(db)
// WHERE (status = ? OR status = ?) AND deleted_at IS NULL
// └─ user predicates grouped ─┘ └─ scope applies to both branches ─┘The same grouping applies to the onlyTrashed() scope (deleted_at IS NOT NULL).
withTrashed()
.withTrashed(): thisRemoves the deleted_at IS NULL filter so soft-deleted rows are included alongside normal rows.
Post.query().withTrashed().whereEq('user_id', userId).get(env.DB)onlyTrashed()
.onlyTrashed(): thisRestricts results to soft-deleted rows only (WHERE deleted_at IS NOT NULL).
Post.query().onlyTrashed().get(env.DB)Eager Loading
with(relations)
.with(relations: string[]): thisEager-loads the named relations after the primary query executes. Relations are resolved from static relations (auto-derived) or static eagerLoaders (manual). Explicit eagerLoaders take precedence.
class Post extends BaseModel<PostAttrs> {
static table = 'posts'
static relations = {
comments: { type: 'hasMany', model: () => Comment, foreignKey: 'post_id' },
author: { type: 'belongsTo', model: () => User, foreignKey: 'user_id' },
}
}
const posts = await Post.query()
.with(['comments', 'author'])
.limit(10)
.get(env.DB)
// posts[0].relations.comments → Comment[]
// posts[0].relations.author → User | nullSee Relationships API for full relation types and has()/whereHas() support.
Terminal Methods
Terminal methods execute the query and return a Promise. The db parameter is optional on all terminal methods — when omitted, the database is resolved automatically via configure(env). See the Configuration guide for setup details.
get(db?)
.get(db?: D1Database): Promise<Collection<TModel>>Executes the query and returns all matching rows as model instances wrapped in a Collection.
// Explicit db
const posts = await Post.query().whereEq('user_id', userId).get(env.DB)
// Auto-resolved db
const posts = await Post.query().whereEq('user_id', userId).get()first(db?)
.first(db?: D1Database): Promise<TModel | null>Executes the query with an implicit LIMIT 1 and returns the first row, or null if no rows match.
const post = await Post.query().whereEq('slug', slug).first(env.DB)
const post = await Post.query().whereEq('slug', slug).first()firstOrFail(db?)
.firstOrFail(db?: D1Database): Promise<TModel>Like first(), but throws ModelNotFoundException if no rows match.
const post = await Post.query().whereEq('slug', slug).firstOrFail(env.DB)sole(db?)
.sole(db?: D1Database): Promise<TModel>Returns exactly one result. Throws ModelNotFoundException if no rows match, or MultipleRecordsFoundException if more than one row matches.
import { ModelNotFoundException, MultipleRecordsFoundException } from '@orphnet/d1-eloquent'
const admin = await User.query().whereEq('role', 'admin').sole(env.DB)count(db?)
.count(db?: D1Database): Promise<number>Executes SELECT COUNT(*) FROM ... and returns the count as a number.
const total = await Post.query().whereEq('user_id', userId).count(env.DB)
const total = await Post.query().whereEq('user_id', userId).count()sum(col) | sum(db, col)
.sum(col: string): Promise<number>
.sum(db: D1Database, col: string): Promise<number>Executes SELECT SUM(col) FROM ... and returns the sum.
const totalViews = await Post.query().sum(env.DB, 'view_count')
const totalViews = await Post.query().sum('view_count')avg(col) | avg(db, col)
.avg(col: string): Promise<number | null>
.avg(db: D1Database, col: string): Promise<number | null>Executes SELECT AVG(col) FROM ... and returns the average, or null if no rows match.
const avgScore = await Post.query().whereEq('user_id', userId).avg(env.DB, 'score')
const avgScore = await Post.query().whereEq('user_id', userId).avg('score')min(col) | min(db, col)
.min(col: string): Promise<unknown>
.min(db: D1Database, col: string): Promise<unknown>Executes SELECT MIN(col) FROM ... and returns the minimum value.
const earliest = await Post.query().min(env.DB, 'created_at')
const earliest = await Post.query().min('created_at')max(col) | max(db, col)
.max(col: string): Promise<unknown>
.max(db: D1Database, col: string): Promise<unknown>Executes SELECT MAX(col) FROM ... and returns the maximum value.
const latest = await Post.query().max(env.DB, 'created_at')
const latest = await Post.query().max('created_at')Mutation Methods
insert(values) | insert(db, values)
.insert(values: Record<string, unknown>): Promise<void>
.insert(db: D1Database, values: Record<string, unknown>): Promise<void>Insert a single row.
await User.query().insert(env.DB, { id: crypto.randomUUID(), name: 'Alice', email: 'alice@example.com' })
await User.query().insert({ id: crypto.randomUUID(), name: 'Alice', email: 'alice@example.com' })insertOrIgnore(values) | insertOrIgnore(db, values)
.insertOrIgnore(values: Record<string, unknown>): Promise<void>
.insertOrIgnore(db: D1Database, values: Record<string, unknown>): Promise<void>Insert a single row, silently skipping if a row with the same primary key already exists.
await User.query().insertOrIgnore(env.DB, { id: 'known-id', name: 'Alice', email: 'alice@example.com' })upsert(values, conflictCols, updateCols?)
.upsert(values: Record<string, unknown>, conflictCols: string[], updateCols?: string[]): Promise<void>
.upsert(db: D1Database, values: Record<string, unknown>, conflictCols: string[], updateCols?: string[]): Promise<void>Insert a row, or update it if a row with the same conflictCols already exists. Uses INSERT ... ON CONFLICT(...) DO UPDATE SET ....
When updateCols is omitted, all non-conflict columns are updated. Pass updateCols to update only specific columns.
// Update all non-PK columns on conflict
await User.query().upsert(env.DB, {
id: userId,
name: 'Alice',
email: 'alice@example.com',
updated_at: new Date().toISOString(),
}, ['id'])
// Only update email and updated_at on conflict
await User.query().upsert(env.DB, {
id: userId,
name: 'Alice',
email: 'newemail@example.com',
updated_at: new Date().toISOString(),
}, ['id'], ['email', 'updated_at'])insertMany(rows) | insertMany(db, rows)
.insertMany(rows: Record<string, unknown>[]): Promise<void>
.insertMany(db: D1Database, rows: Record<string, unknown>[]): Promise<void>Batch-insert multiple rows using D1's db.batch() for a single round-trip. All rows are inserted in one transaction.
await User.query().insertMany(env.DB, [
{ id: crypto.randomUUID(), name: 'Alice', email: 'alice@example.com' },
{ id: crypto.randomUUID(), name: 'Bob', email: 'bob@example.com' },
{ id: crypto.randomUUID(), name: 'Carol', email: 'carol@example.com' },
])insertOrIgnoreMany(rows) | insertOrIgnoreMany(db, rows)
.insertOrIgnoreMany(rows: Record<string, unknown>[]): Promise<void>
.insertOrIgnoreMany(db: D1Database, rows: Record<string, unknown>[]): Promise<void>Batch-insert multiple rows, silently skipping any that conflict on primary key. Uses D1's db.batch().
await User.query().insertOrIgnoreMany(env.DB, [
{ id: 'existing-id', name: 'Skip', email: 'skip@example.com' },
{ id: crypto.randomUUID(), name: 'New', email: 'new@example.com' },
])upsertMany(rows, conflictCols, updateCols?)
.upsertMany(rows: Record<string, unknown>[], conflictCols: string[], updateCols?: string[]): Promise<void>
.upsertMany(db: D1Database, rows: Record<string, unknown>[], conflictCols: string[], updateCols?: string[]): Promise<void>Batch-upsert multiple rows. Each row is inserted or updated using ON CONFLICT ... DO UPDATE SET. All statements are executed in a single db.batch() round-trip.
await User.query().upsertMany(env.DB, [
{ id: 'u1', name: 'Updated Alice', email: 'alice@example.com' },
{ id: 'u2', name: 'New Bob', email: 'bob@example.com' },
], ['id'])
// Only update specific columns on conflict
await User.query().upsertMany(env.DB, users, ['id'], ['name', 'updated_at'])update(values) | update(db, values)
.update(values: Record<string, unknown>): Promise<number>
.update(db: D1Database, values: Record<string, unknown>): Promise<number>Update rows matching the current WHERE clauses. Returns the number of changed rows.
const changed = await User.query()
.whereEq('role', 'guest')
.update(env.DB, { role: 'user' })delete(db?)
.delete(db?: D1Database): Promise<number>Delete rows matching the current WHERE clauses. Returns the number of deleted rows.
const deleted = await Post.query()
.whereEq('status', 'draft')
.where('created_at', '<', cutoff)
.delete(env.DB)Prepared Statements
For atomic multi-table operations using db.batch(), use prepared statement methods to get D1PreparedStatement objects without executing them.
toInsertPrepared(values, db?)
.toInsertPrepared(values: Record<string, unknown>, db?: D1Database): D1PreparedStatementtoInsertOrIgnorePrepared(values, db?)
.toInsertOrIgnorePrepared(values: Record<string, unknown>, db?: D1Database): D1PreparedStatementtoUpsertPrepared(values, conflictCols, updateCols?, db?)
.toUpsertPrepared(values: Record<string, unknown>, conflictCols: string[], updateCols?: string[], db?: D1Database): D1PreparedStatementtoUpdatePrepared(values, db?)
.toUpdatePrepared(values: Record<string, unknown>, db?: D1Database): D1PreparedStatementRequires a WHERE clause. Throws if no conditions are set.
toDeletePrepared(db?)
.toDeletePrepared(db?: D1Database): D1PreparedStatementRequires a WHERE clause. Throws if no conditions are set.
Batch Example
// Atomic workspace creation: insert workspace + owner membership
await db.batch([
Workspace.query().toInsertPrepared({ id: wsId, name, owner_id: userId }, db),
WorkspaceMember.query().toInsertPrepared({
workspace_id: wsId, user_id: userId, role: 'owner'
}, db),
])
// Atomic ownership transfer: 3 updates in one batch
await db.batch([
WorkspaceMember.query().whereEq('user_id', oldOwner).whereEq('workspace_id', wsId)
.toUpdatePrepared({ role: 'admin' }, db),
WorkspaceMember.query().whereEq('user_id', newOwner).whereEq('workspace_id', wsId)
.toUpdatePrepared({ role: 'owner' }, db),
Workspace.query().whereEq('id', wsId)
.toUpdatePrepared({ owner_id: newOwner }, db),
])Scopes
scoped(...names)
.scoped(...names: string[]): thisApply one or more named scopes from static scopes on the model. Scopes are functions that modify the query builder.
class Post extends BaseModel<PostAttrs> {
static scopes = {
active: (q) => q.where('status', '=', 'active'),
recent: (q) => q.orderBy('created_at', 'desc').limit(10),
}
}
Post.query().scoped('active', 'recent').get(db)Throws if the scope name is not defined.
Relation Existence Queries
These methods require static relations to be defined on the model. See Relationships API for full details.
has(relation) / doesntHave(relation)
.has(relation: string): this
.doesntHave(relation: string): thisFilter by existence (or absence) of related models using EXISTS subqueries.
// Users who have at least one post
User.query().has('posts').get(db)
// Posts with no comments
Post.query().doesntHave('comments').get(db)whereHas(relation, cb?) / whereDoesntHave(relation, cb?)
.whereHas(relation: string, cb?: (q: QueryBuilder) => void): this
.whereDoesntHave(relation: string, cb?: (q: QueryBuilder) => void): thisFilter by related models matching additional constraints.
// Users with admin role (belongsToMany)
User.query().whereHas('roles', q => q.where('name', '=', 'admin')).get(db)
// Posts without approved comments
Post.query().whereDoesntHave('comments', q => q.where('approved', '=', 1)).get(db)Inner OR stays correlated
The callback runs inside a correlated EXISTS subquery whose leading AND ties the related rows back to the parent row. If your callback introduces a top-level orWhere, its predicates are wrapped in a single group so they can't decorrelate that parent-correlation AND — the subquery still matches only related rows, never the whole table.
Post.query()
.whereHas('comments', q =>
q.where('approved', '=', 1).orWhere('flagged', '=', 1),
)
.get(db)
// ... EXISTS (SELECT 1 FROM comments
// WHERE comments.post_id = posts.id ← correlation stays intact
// AND (approved = ? OR flagged = ?)) ← callback OR groupedA callback with no top-level OR is appended flat (no extra parentheses), preserving the simple-case SQL.
All four methods have or variants: orHas, orDoesntHave, orWhereHas, orWhereDoesntHave.
withCount(relation, as?) / withSum(relation, col, as?) / withAvg(relation, col, as?)
Attach correlated aggregate subqueries as virtual columns on each row. One round-trip, no N+1. Default aliases: <relation>_count, <relation>_<col>_sum, <relation>_<col>_avg — override via the trailing as? arg.
// COUNT
const users = await User.query().withCount('posts').get(db)
users[0].get('posts_count') // number
// SUM / AVG
const u = await User.query()
.withSum('orders', 'total')
.withAvg('orders', 'total')
.get(db)
u[0].get('orders_total_sum') // number | null (null when no rows)
u[0].get('orders_total_avg') // number | null
// Custom alias
await User.query().withCount('posts', 'post_count').get(db)Supported on hasMany / hasOne / belongsTo / belongsToMany / morphMany / morphOne / morphToMany / morphedByMany. Throws on morphTo — the inverse side spans multiple tables and can't be aggregated; aggregate on the owning morphMany side instead.
For SUM/AVG on a relation with zero rows, SQLite returns NULL (not 0).
whereExists(sql, bindings?) / whereNotExists(sql, bindings?)
Low-level EXISTS clause for custom subqueries.
Post.query().whereExists('SELECT 1 FROM comments WHERE comments.post_id = posts.id', [])Additional Shape Methods
selectRaw(expression)
.selectRaw(expression: string): thisAdd raw SQL expressions to the SELECT clause. Replaces default * on first call, appends on subsequent.
Post.query().selectRaw('user_id, COUNT(*) as cnt').groupBy('user_id')
// SELECT user_id, COUNT(*) as cnt FROM posts GROUP BY user_id
Post.query().select(['id']).selectRaw('LENGTH(title) as title_len')
// SELECT id, LENGTH(title) as title_len FROM postsPagination & Scalar Results
paginate(page, perPage?)
.paginate(page: number, perPage?: number): Promise<TPaginationResult<TModel>>
.paginate(db: D1Database, page: number, perPage?: number): Promise<TPaginationResult<TModel>>Runs a count query and a data query, returning paginated results. Default perPage is 15.
const result = await Post.query().scoped('active').paginate(db, 1, 20)
result.data // Collection<Post> (current page)
result.total // number (total matching rows)
result.page // number (current page)
result.perPage // number
result.lastPage // number (calculated)paginateCursor(opts, db?)
Keyset (cursor) pagination — stable under concurrent writes and faster than offset pagination on large tables. Skips the COUNT query and uses hasMore derived by overfetching one row.
.paginateCursor(opts: TCursorPaginateOpts, db?: D1Database): Promise<TCursorPaginationResult<TModel>>// First page
const page1 = await Post.query()
.whereEq('user_id', uid)
.paginateCursor({ orderBy: 'created_at', direction: 'desc', perPage: 20 })
page1.data // Collection<Post>
page1.nextCursor // string | null — pass as `after` on next call
page1.prevCursor // string | null
page1.hasMore // boolean
// Next page
const page2 = await Post.query()
.whereEq('user_id', uid)
.paginateCursor({
orderBy: 'created_at',
direction: 'desc',
perPage: 20,
after: page1.nextCursor!,
})
// Go back from page2
const back = await Post.query()
.whereEq('user_id', uid)
.paginateCursor({
orderBy: 'created_at',
direction: 'desc',
perPage: 20,
before: page2.prevCursor!,
})Options
| Option | Type | Default | Description |
|---|---|---|---|
orderBy | string | required | Column to sort by. Combined with the model PK as a tiebreaker for deterministic ordering. |
direction | 'asc' | 'desc' | 'desc' | Sort direction. |
perPage | number | 20 | Page size. |
after | string | — | Cursor from a previous nextCursor — advance forward. |
before | string | — | Cursor from a previous prevCursor — go back. |
Notes
- Pass either
afterorbefore, not both — throws otherwise. - Only single-column
orderByis supported (with PK tiebreaker). Usepaginate()(offset) when you need multi-column ordering. - Cursors are opaque base64url payloads — don't parse them on the client. The format is implementation detail and may change.
- WHERE filters, joins, scopes, etc. are preserved across pages.
pluck(col)
.pluck(col: string): Promise<unknown[]>
.pluck(db: D1Database, col: string): Promise<unknown[]>Returns a flat array of a single column's values.
const emails = await User.query().pluck(db, 'email')
// ["alice@example.com", "bob@example.com"]value(col)
.value(col: string): Promise<unknown>
.value(db: D1Database, col: string): Promise<unknown>Returns a single scalar value from the first row.
const email = await User.query().whereEq('id', 'u1').value(db, 'email')
// "alice@example.com"chunk(size, cb)
.chunk(size: number, cb: (models: TModel[]) => Promise<void | false>): Promise<void>
.chunk(db: D1Database, size: number, cb: (models: TModel[]) => Promise<void | false>): Promise<void>Process query results in batches. The callback receives each batch; return false to stop early.
await Post.query().chunk(db, 100, async (posts) => {
for (const post of posts) await processPost(post)
})Collection
get() returns a Collection<T> — a lightweight wrapper that extends Array with utility methods. It's fully backward-compatible with array code.
import { Collection } from '@orphnet/d1-eloquent'
const users = await User.query().get(db) // Collection<User>
// Extraction
users.pluck('email') // unknown[]
users.keyBy('id') // Map<unknown, User>
users.groupBy('role') // Map<unknown, Collection<User>>
users.mapToGroups(u => [...]) // Map<K, Collection<V>>
// Filtering
users.where('status', 'active') // Collection<User>
users.whereIn('role', ['admin']) // Collection<User>
users.unique('email') // Collection<User>
users.reject(u => ...) // Collection<User>
users.contains('name', 'Alice') // boolean
users.contains(u => u.get('age') > 18) // boolean
// Sorting & slicing
users.sortBy('name') // Collection<User>
users.sortBy('name', 'desc') // Collection<User>
users.sortByDesc('name') // Collection<User>
users.partition(u => ...) // [Collection<User>, Collection<User>]
users.take(5) // Collection<User> (first 5)
users.take(-3) // Collection<User> (last 3)
users.skip(10) // Collection<User>
users.chunk(10) // Collection<Collection<User>>
// Aggregates
users.sum('score') // number
users.min('score') // number | null
users.max('score') // number | null
users.avg('score') // number | null
// Element access
users.first() // User | undefined
users.first(u => u.get('age') > 18) // User | undefined (predicate)
users.last() // User | undefined
users.last(u => u.get('age') > 18) // User | undefined (predicate)
users.isEmpty() // boolean
users.isNotEmpty() // boolean
// Transformation (all return Collection, not Array)
users.map(u => u.toObject()) // Collection<Record<...>>
users.filter(u => ...) // Collection<User>
users.flatMap(u => ...) // Collection<U>
users.toArray() // Record<string, unknown>[]
// Side effects & piping
users.each(u => console.log(u)) // Collection<User> (returns self)
users.tap(c => console.log(c.length)) // Collection<User> (returns self)
users.pipe(c => c.length) // number (returns callback result)