Keelv0.86.0
Docs / Query Builder

Keel's driver-agnostic query builder — build and run SQL by chaining methods off db(table). Nothing hits the database until a terminal method runs, every value is a parameterized binding (injection-safe), and the same chain compiles for sqlite, MySQL, and Postgres. Models add an active-record layer on top — see the ORM.

Start a query with db(table), chain constraints (they return the builder, so order doesn't matter), and finish with a terminal method. Nothing hits the database until a terminal runs. Every value becomes a binding, never string-interpolated SQL — the builder is injection-safe by construction. It's driver-agnostic and edge-safe: the same chain compiles for sqlite, MySQL, and Postgres.

import { db } from "@shaferllc/keel/core";

const active = await db("users")
  .where("active", true)
  .where("age", ">", 18)
  .orderBy("name")
  .limit(20)
  .get();

Retrieving results

await db("users").get();                     // Row[]
await db("users").where("id", 1).first();    // Row | null
await db("users").where("id", 1).firstOrFail(); // Row, or throws NotFoundException
await db("users").find(1);                   // by primary key (default "id")
await db("users").where("email", e).sole();  // exactly one, else throws
await db("users").where("id", 1).value("email"); // one column of the first row
await db("posts").pluck("title");            // string[] of one column
await db("tags").orderBy("name").implode("name", ", "); // "a, b, c"

For large sets, chunk pages through without loading everything (return false to stop early):

await db("users").orderBy("id").chunk(500, async (rows) => {
  for (const row of rows) await process(row);
});

Aggregates

await db("orders").count();
await db("orders").where("paid", true).sum("total");
await db("orders").avg("total");   // also min(col), max(col)
await db("users").where("email", e).exists();       // boolean
await db("users").where("banned", true).doesntExist();

Selects

db("users").select("id", "email");
db("users").select("id").addSelect("email");        // append, don't replace
db("orders").selectRaw("SUM(total) AS revenue");
db("users").distinct().select("country");

Where clauses

db("users").where("votes", 100);                    // = is the default operator
db("users").where("votes", ">=", 100);
db("users").where("name", "like", "T%");

db("users").where("votes", 100).orWhere("name", "John");
db("users").whereNot("status", "cancelled");

db("users").whereIn("id", [1, 2, 3]).whereNotIn("id", [4]);
db("users").whereNull("deleted_at").whereNotNull("email_verified_at");
db("products").whereBetween("price", [10, 100]).whereNotBetween("stock", [0, 5]);
db("posts").whereLike("title", "%keel%");
db("events").whereColumn("updated_at", ">", "created_at");  // column vs column
db("users").whereRaw("score >= ? AND score <= ?", [10, 90]);

Every clause has an orWhere… twin — orWhereIn, orWhereNull, orWhereNotNull, orWhereBetween, orWhereColumn, orWhereLike, orWhereRaw, orWhereNotIn.

Grouped clauses. Pass a callback to where/orWhere to parenthesize a set of conditions — the way to express A AND (B OR C):

await db("users")
  .where("active", true)
  .where((q) => q.where("role", "admin").orWhere("role", "owner"))
  .get();
// … WHERE active = ? AND (role = ? OR role = ?)

Ordering, grouping, limit & offset

db("users").orderBy("name").orderByDesc("created_at");
db("posts").latest();                 // ORDER BY created_at DESC (oldest() for ASC)
db("posts").orderByRaw("LENGTH(title) DESC");
db("users").inRandomOrder();          // dialect-aware RANDOM()/RAND()
db("users").reorder("name");          // clear existing ordering, then set

db("orders")
  .select("user_id")
  .selectRaw("SUM(total) AS spent")
  .groupBy("user_id")
  .having("spent", ">", 1000)         // also havingRaw(...), havingBetween(...)
  .get();

db("users").limit(10).offset(20);     // take(10)/skip(20) are aliases
db("users").forPage(3, 15);           // page 3, 15 per page

Joins

await db("posts")
  .join("users", "posts.user_id", "users.id")   // INNER JOIN on equality
  .leftJoin("images", "images.post_id", "posts.id")
  .select("posts.title", "users.name")
  .get();

rightJoin and crossJoin round out the set. Joins with several ON conditions aren't modelled — use whereRaw or a view.

Conditional clauses

when / unless apply a callback based on a runtime value, so you build a query without breaking the chain into ifs. The callback receives the value:

await db("users")
  .when(search, (q, term) => q.whereLike("name", `%${term}%`))
  .unless(includeArchived, (q) => q.whereNull("archived_at"))
  .get();

Inserts

await db("users").insert({ email, name });
const id = await db("users").insertGetId({ email, name });   // new primary key
await db("logs").insertOrIgnore({ key, value });             // skip unique conflicts
await db("users").upsert([{ id: 1, name: "Ada" }], ["id"], ["name"]); // insert/update

upsert(rows, uniqueBy, update?) inserts, updating the update columns (default: everything not in uniqueBy) on a conflict — dialect-aware (ON CONFLICT / ON DUPLICATE KEY UPDATE).

Updates

await db("users").where("id", id).update({ name: "Grace" });
await db("users").updateOrInsert({ email }, { name });        // update match, else insert
await db("posts").where("id", id).increment("views");         // += 1
await db("posts").where("id", id).decrement("stock", 3, { updated_at: now });
await db("counters").incrementEach({ hits: 1, misses: 2 });   // several columns at once

Deletes

await db("sessions").where("expires_at", "<", now).delete();
await db("cache").truncate();          // empty the table (DELETE on sqlite)

Guard your writes. update(), delete(), and the increments apply to every row matching the current where clause — with none, that's the whole table. Scope every write unless you truly mean to touch every row.

Pagination

const page = await db("posts").latest().paginate(2, 15);
// { data, total, perPage, currentPage, lastPage } — a COUNT plus a page query

const feed = await db("posts").latest().simplePaginate(2, 15);
// { data, perPage, currentPage, hasMore } — no COUNT; one extra row tells hasMore

Pessimistic locking

Inside a transaction, lock the selected rows against concurrent writes. No-ops on sqlite (which locks the whole database anyway):

await transaction(async () => {
  const row = await db("accounts").where("id", id).lockForUpdate().first(); // FOR UPDATE
  await db("accounts").where("id", id).update({ balance: row.balance - 10 });
});
// sharedLock() takes a read lock (FOR SHARE) instead.

Debugging

db("users").where("active", true).toSql();       // "SELECT * FROM users WHERE active = ?"
db("users").where("active", true).getBindings(); // [true]
db("users").where("active", true).dump();        // logs SQL + bindings, returns the builder
db("users").where("active", true).dd();          // logs and throws (dump-and-die)

Not (yet) modelled

Kept out on purpose, to stay driver-agnostic and honest about what compiles everywhere: unions, subquery where/join builders (whereExists, joinSub), the whereDate/whereMonth/… date-function family (no portable form across dialects), and cursor/lazy streaming. Reach for whereRaw, a raw connection().select(sql), or a database view when you need them.

QueryBuilder — method reference

Returned by db(). Constraint methods return this (chainable); terminal methods return a promise. You never construct it directly.

select(...columns)

select(...columns: string[]): this

Restricts the selected columns. With no arguments, selects *.

db("users").select("id", "email").get();

Notes: column names are interpolated as-is (they are not parameterized), so never pass user input as a column name. Calling it again replaces the prior selection.

where(column, value) / where(column, operator, value)

where(column: string, value: unknown): this where(column: string, operator: Operator, value: unknown): this

Adds an AND condition. The two-argument form uses =; the three-argument form takes an explicit operator.

db("users").where("active", true);
db("users").where("age", ">", 18);
db("users").where("email", "like", "%@example.com");

Notes: Operator is "=" | "!=" | "<" | "<=" | ">" | ">=" | "like". Values are always parameterized. Chaining multiple wheres combines them with AND.

orWhere(column, value) / orWhere(column, operator, value)

orWhere(column: string, value: unknown): this orWhere(column: string, operator: Operator, value: unknown): this

Same as where, but joins the condition with OR.

db("orders").where("status", "paid").orWhere("status", "shipped").get();

Notes: conditions are combined left-to-right without grouping parentheses, so mixing where and orWhere follows SQL's AND/OR precedence — group complex logic in separate queries if you need explicit parenthesization.

whereIn(column, values)

whereIn(column: string, values: unknown[]): this

Matches rows where column is any of values (AND-joined).

db("posts").whereIn("id", [1, 2, 3]).get();

Notes: each value becomes its own placeholder. An empty array produces IN (), which most engines reject — guard against empty lists yourself.

whereNull(column) / whereNotNull(column)

whereNull(column: string): this whereNotNull(column: string): this

Adds an AND IS NULL / IS NOT NULL condition — no binding.

db("posts").whereNull("deleted_at").get();
db("users").whereNotNull("verified_at").get();

orderBy(column, direction?)

orderBy(column: string, direction?: "asc" | "desc"): this

Adds an ORDER BY clause (default "asc"). Call it repeatedly for multiple sort keys, applied in call order.

db("users").orderBy("last_name").orderBy("created_at", "desc").get();

Notes: the column is interpolated, not parameterized — don't pass user input.

limit(n) / offset(n)

limit(n: number): this offset(n: number): this

Caps the number of rows / skips the first n. Together they paginate.

db("posts").limit(20).offset(40).get(); // page 3, 20 per page

Notes: first() sets limit(1) internally, overriding any prior limit.

get()

get(): Promise<T[]>

Runs the SELECT and returns all matching rows.

const rows = await db("users").where("active", true).get();

first()

first(): Promise<T | null>

Runs the SELECT with LIMIT 1 and returns the first row, or null.

const user = await db("users").where("email", email).first();

Notes: overrides any limit you set. Returns null (not undefined) when nothing matches.

count()

count(): Promise<number>

Returns COUNT(*) for the current where clause.

const active = await db("users").where("active", true).count();

Notes: ignores select, orderBy, limit, and offset — it counts matching rows, not the paginated slice.

exists()

exists(): Promise<boolean>

true when at least one row matches — a count() > 0 shorthand.

if (await db("users").where("email", email).exists()) { /* taken */ }

insert(data)

insert(data: Row): Promise<WriteResult>

Inserts one row and returns write metadata.

const result = await db("users").insert({ email, name });
result.rowsAffected; // 1
result.insertId;     // driver-dependent

Notes: column order follows Object.keys(data). insertId is only populated if the driver reports it in WriteResult.

insertGetId(data)

insertGetId(data: Row): Promise<number | string | undefined>

Inserts one row and returns just its new id (insert unwrapped).

const id = await db("users").insertGetId({ email, name });

Notes: returns undefined when the driver doesn't report an insertId.

update(data)

update(data: Row): Promise<WriteResult>

Updates every row matching the where clause, setting the given columns.

const r = await db("users").where("id", 1).update({ name: "Grace" });
r.rowsAffected; // rows changed

Notes: with no where, updates the entire table. Bindings are the new values followed by the where-clause values.

delete()

delete(): Promise<WriteResult>

Deletes every row matching the where clause.

await db("sessions").where("expires_at", "<", now).delete();

Notes: with no where, empties the table. There's no soft-delete here — pair with a deleted_at column and whereNull if you want one.

whereColumn(first, operator?, second) · whereRaw(sql, bindings?)

Compare two columns (no binding) or add a raw WHERE fragment with its own bindings. whereColumn("updated_at", ">", "created_at"); whereRaw("score >= ?", [10]).

join(table, first, operator?, second) · leftJoin(...)

Add an INNER JOIN / LEFT JOIN on an equality (or the given operator). Included in get, count, and aggregates. Qualify ambiguous columns ("posts.user_id").

groupBy(...columns) · having(column, operator?, value) · distinct()

GROUP BY, a bound HAVING predicate, and SELECT DISTINCT.

orderByRaw(sql) · when(condition, then, otherwise?)

A raw ORDER BY fragment; and conditional building — then(query, value) runs only when condition is truthy, else otherwise.

increment(column, amount?, extra?) · decrement(column, amount?, extra?)

increment(column: string, amount = 1, extra: Row = {}): Promise<WriteResult>

Atomically column = column ± amount on matching rows, optionally setting other columns in the same statement. Scope with where.

upsert(rows, uniqueBy, update?)

upsert(rows: Row | Row[], uniqueBy: string[], update?: string[]): Promise<WriteResult>

Insert rows, updating update columns (default: all non-unique) on a conflict against uniqueBy. Dialect-aware: ON CONFLICT … DO UPDATE (sqlite/postgres) or ON DUPLICATE KEY UPDATE (mysql).

insertOrIgnore(rows)

Insert one or more rows, skipping any that violate a unique constraint (INSERT OR IGNORE / INSERT IGNORE / ON CONFLICT DO NOTHING).

chunk(size, callback)

chunk(size: number, callback: (rows: T[]) => void | boolean | Promise<void | boolean>): Promise<void>

Process results a page at a time so a large table never loads at once. Return false from the callback to stop early. Pair with orderBy for a stable order.

addSelect(...columns) · selectRaw(sql)

Append columns to the SELECT list without replacing it; selectRaw appends a raw expression (selectRaw("SUM(total) AS revenue")).

orWhere family · whereNot(...) · whereNotBetween(column, [min, max])

Every where… clause has an orWhere… twin joined with ORorWhereIn, orWhereNotIn, orWhereNull, orWhereNotNull, orWhereBetween, orWhereColumn, orWhereLike, orWhereRaw. whereNot negates a comparison; whereNotBetween is the inverse of whereBetween. Passing a callback to where/orWhere groups its conditions in parentheses.

orderByDesc(column) · reorder(column?, direction?) · inRandomOrder()

Descending order; clear existing ordering (optionally setting a new one); random order (dialect-aware RANDOM()/RAND()).

groupByRaw(sql) · havingRaw(sql, bindings?) · havingBetween(column, [min, max])

Raw GROUP BY, a raw/bound HAVING, and a HAVING … BETWEEN.

take(n) · skip(n) · forPage(page, perPage?)

Aliases for limit/offset, and limit+offset for a 1-based page.

rightJoin(...) · crossJoin(table)

RIGHT JOIN on an equality; CROSS JOIN.

unless(condition, then, otherwise?)

The inverse of when — runs then only when condition is falsy.

find(id, key?) · firstOrFail() · sole() · doesntExist() · implode(column, glue?)

Find by key (default "id"); first-or-throw; exactly-one-or-throw; the negation of exists; and join one column's values into a string.

simplePaginate(page?, perPage?)

simplePaginate(page = 1, perPage = 15): Promise<SimplePaginated<T>>

A page without a COUNT — fetches one extra row to set hasMore. Cheaper than paginate for "load more" UIs.

lockForUpdate() · sharedLock()

Add FOR UPDATE / FOR SHARE to the SELECT (inside a transaction). Ignored on sqlite.

updateOrInsert(match, values?) · truncate() · incrementEach(cols, extra?) · decrementEach(cols, extra?)

Update the first match or insert { ...match, ...values }; empty the table (DELETE on sqlite); and step several numeric columns in one statement (cols is an array — each by 1 — or a { column: amount } map).

toSql() · getBindings() · dump() · dd()

The compiled ?-placeholder SQL and its bindings, without executing; dump logs them and returns the builder; dd logs and throws.