Reading records
The fluent query builder: filter, sort, paginate, populate.
query opens a typed builder over a collection and reads its records. Every field name, operator, and returned row is typed from your schema, so a typo or a wrong-typed value is a compile error, not a runtime surprise.
import { query } from 'ohnejs';
const posts = await query('Posts').findMany();findMany returns every record as a full object: your fields, plus the UUID primary key, the _updatedAt timestamp, and on a translatable collection the _translations locale list. findFirst returns the first match or undefined.
const post = await query('Posts').findFirst();The collection is defined in a file under collections/; see collections for the field types and schema sync for how a collection becomes a table. The same query grammar is available over HTTP too - see querying over HTTP.
Filtering
where narrows the read. The short form takes a field and a value, and matches on equality:
await query('Posts').where('status', 'published').findMany();The value is typed to the field. Passing a number to a text field, or null to any field, is a compile error - null is never a value, and isNull is the only null test (below).
For anything past equality, pass a callback. It receives a builder carrying exactly the operators that field admits:
await query('Posts')
.where('views', (w) => w.atLeast(100))
.where('title', (w) => w.contains('ohne'))
.findMany();Which operators appear depends on the field's type. A text column offers contains, startsWith, endsWith, and the raw like; text and number columns alike admit the ordering comparisons greaterThan, atLeast, lessThan, and atMost; both offer equalsTo and in. A multiSelect field, or a custom field type marked jsonList, adds includes, includesAll, and includesAny - membership over the stored list's elements. Asking for an operator the field does not admit - ordering on a boolean, contains on a number - does not compile.
Chained where calls AND together. Each returns the builder, so you keep filtering.
Negation
not negates the next operator. It reads as a namespace, never a suffixed operator name:
await query('Posts').where('status', (w) => w.not.equalsTo('draft')).findMany();Null
A nullable field admits isNull. It is the only way to test null - equalsTo(null) does not compile:
await query('Posts').where('summary', (w) => w.isNull()).findMany();Or on one field
After an operator, or starts an alternative on the same field - the record matches when either comparison does:
await query('Posts').where('views', (w) => w.atLeast(100).or.equalsTo(0)).findMany();Either-or
where clauses AND. For an OR, whereAny opens a group of branches; a record matches when any branch matches. Chaining where on one branch ANDs within it.
await query('Posts')
.whereAny((q) => [
q.where('featured', true),
q.where('views', (w) => w.atLeast(1000)),
])
.findMany();A whereAny ANDs onto the rest of the query, so you can read "published, and either featured or popular" by placing it after a where.
Relations
A record or records field relates to another collection. has filters by the related rows, re-scoped to the target's fields:
await query('Posts')
.where('author', (w) => w.has((a) => a.where('name', 'Ada')))
.findMany();Bare has() tests that the relation is set at all; empty() is its opposite:
await query('Posts').where('author', (w) => w.has()).findMany();
await query('Posts').where('tags', (w) => w.empty()).findMany();An object or repeater field admits the same pair: bare has() tests it holds anything, empty() the opposite, and a callback probes the composite's subfields.
By default a relation field reads back as UUIDs - the id of the related row, or an array of them. populate swaps those ids for the full related records:
const posts = await query('Posts').populate('author', 'tags').findMany();
posts[0].author; // the full author record, or null
posts[0].tags; // an array of full tag recordsA callback narrows what the related records carry and populates their own relations. select names the fields to keep - the records then carry exactly those, UUID and _updatedAt only when named. populate descends one level further, with the same grammar at every depth:
const posts = await query('Posts')
.select('title', 'comments')
.populate('comments', (c) =>
c.select('text', 'author').populate('author', (a) => a.select('name')),
)
.findMany();
posts[0].comments[0].author; // { name: '...' }, or nullThe rows type exactly what the callback wrote, at every depth.
A populated relation must be named in its level's select when one is set, or it silently drops - populate narrows a read, it never widens one. A field populates once per level: repeating a bare name is fine, repeating it with a callback or spec is an error.
Each level loads in one batched read, so a deep populate costs one query per relation, not one per row. The same rows are shared across the parents that link them, so do not mutate a populated record.
Blocks
A blocks field filters with the same has/empty pair, with one extra step: has names the block type before a callback probes its fields.
await query('Pages')
.where('content', (w) => w.has('Hero', (h) => h.where('title', 'Launch')))
.findMany();The blocks guide covers the two-step and composing across types.
Ordering
orderBy sorts by a field. An optional second argument gives the direction, defaulting to ascending; call it again to add a tiebreaker:
await query('Posts').orderBy('publishedAt', 'desc').orderBy('title').findMany();Ties always resolve by UUID last, so a paginated read never reorders rows between pages.
Selecting fields
By default a read returns every field. select narrows it to the ones you name, and the returned type carries only those:
const rows = await query('Posts').select('title', 'views').findMany();
rows[0].title; // string
rows[0].views; // number
rows[0].author; // compile error: not selectedA write-only field inverts the default: no read returns it unless your select names it explicitly.
select accumulates across calls. _translations selects like any field, and it is the one field you can neither filter nor order by: a locale list has no order, and a missing translation is a null you test at its locale.
Pagination
paginate reads one page and its totals in a single call:
const page = await query('Posts').orderBy('publishedAt', 'desc').paginate(1, 20);
page.records; // the rows on this page
page.total; // matching rows across every page
page.lastPage; // the number of the last pagelimit and offset are the lower-level pair when you want a window without the totals.
Counting and checking
count returns how many records match; exists returns whether any do. Both ignore ordering and the row window:
const total = await query('Posts').where('status', 'published').count();
const any = await query('Posts').where('featured', true).exists();pluck reads one field's value from each record a findMany would return - the query's order and window apply:
const titles = await query('Posts').pluck('title'); // string[]Plucking a populated relation field returns the hydrated records, exactly as a full read would.
Locales
A collection with translatable fields reads one locale per query - .locale() picks it, the configured default applies without it:
const german = await query('Posts').locale('de').findMany();