Skip to content

Queries & executors

A query is an immutable chain: a context names the sources, operators describe the result, and nothing touches the provider until an executor runs. This page is the whole single-source surface — the operators, the executors, and explain(). Queries over more than one source are in Joins & includes; bucketing rows is in Grouping & aggregates.

Contexts

createContext binds a schema to a provider. Property access on the context (db.users) is a Queryable; the options carry relations for include/flatMap and computed members:

ts
import { createContext } from "@greffon/query";

const db = createContext<{ users: User; orders: Order }>(provider, { relations, computed });

The lambdas you pass to operators are expression lambdas — reified by the build plugin, checked against the expression subset.

Operators

Every operator returns a new query; the one it was called on is unchanged. Names follow the Array methods they mirror, so a query reads like the equivalent array transform:

OperatorMeaning
filter(p)Keep the rows the predicate accepts.
map(s)Project each row through a selector.
orderBy(k) / orderByDescending(k)Sort by a key selector; chain thenBy(k) / thenByDescending(k) for further levels.
take(n) / skip(n)Page: the first n rows, or everything after the first n.
distinct()Drop duplicate rows — value equality, so two structurally equal objects are one row.
groupBy(k)Bucket rows by key — Grouping & aggregates.
join / leftJoin / flatMap / includeCombine sources — Joins & includes.
inMemory()Cross into client-side evaluation — The boundary rule.

Sorting is stable across levels, and null keys order the same everywhere: last when ascending, first when descending — the memory reference and the SQL providers agree row for row.

Because chains are immutable values, a prefix is reusable:

ts
const active = db.users.filter((u) => u.active);

const names = await active.orderBy((u) => u.name).map((u) => ({ name: u.name })).toArray();
const total = await active.count();

Executors

A Queryable is not a promise and never runs on its own — I/O happens only at a named executor, so a line that queries the database looks like one. Row executors:

ExecutorReturns
toArray()All rows.
first(p?)The first matching row, or null — never throws on an empty result.
firstOrThrow(p?)The first matching row; throws when there is none.
single(p?)Exactly one matching row; throws on zero or more than one.

Value executors:

ExecutorReturns
count(p?)Number of matching rows.
some(p?)true when any row matches.
every(p)true when every row matches — true on an empty result, like Array.prototype.every.
sum(s)Sum of the selected values; 0 on an empty result.
min(s) / max(s) / avg(s)The extreme or mean of the selected values; null on an empty result.

The optional predicate is shorthand for a final filter: db.users.count(u => u.active) is db.users.filter(u => u.active).count().

ts
const page = await db.users.orderBy((u) => u.name).skip(20).take(10).toArray();
const oldest = await db.users.max((u) => u.age); // number | null
const ada = await db.users.single((u) => u.name === "Ada"); // throws unless exactly one

A query is also async-iterable — for await (const u of active) { … } fetches the rows once (one toArray() under the hood), then yields them.

explain()

Every provider answers explain() with the text it would run: the SQL providers return the compiled statement (plus one line per batched include fetch), the memory provider returns its op chain. No I/O happens.

ts
await db.users.filter((u) => u.age >= 18).explain();
// SELECT "users".* FROM "users" WHERE ("users"."age" >= $1)

When a provider can't run an op

Before any I/O, the query checks the provider's declared capabilities and rejects an unsupported op with a located R2001 — and untranslatable expressions fail the same way, never by silently pulling rows into JavaScript. The boundary rule covers that contract and the explicit inMemory() escape hatch.

Where to go next

MIT licensed. Expression trees for TypeScript.