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. On a singleton it returns the one record.
const post = await query('Posts').findFirst();The fields come from your collections. The same grammar is available over HTTP.
Filtering
where narrows the read. The short form takes a field and a value, and matches records where the field equals that value:
await query('Posts').where('status', 'published').findMany();The value is typed to the field, so passing a number to a text field is a compile error.
For anything other than equality, pass a callback. It receives a builder with exactly the operators that field allows:
await query('Posts')
.where('views', (w) => w.atLeast(100))
.where('title', (w) => w.contains('ohne'))
.findMany();| Operators | Fields |
|---|---|
equalsTo, in | Text and number columns |
greaterThan, atLeast, lessThan, atMost | Text and number columns |
contains, startsWith, endsWith, like | Text columns |
includes, includesAll, includesAny | multiSelect, a custom field type marked jsonList, and _translations |
includes, includesAny | records, by the linked record's UUID |
isNull | Nullable fields |
has, empty | Relations, object, range, repeater, and blocks |
An operator the field does not allow, like ordering on a boolean or contains on a number, does not compile.
contains, startsWith, and endsWith ignore case and accents in every script, so contains('cafe') finds Café and contains('strasse') finds Straße. A spelled-out umlaut is a different word: mueller does not find Müller. equalsTo and like stay exact about accents.
Chained where calls are combined with AND. Each returns the builder, so you keep filtering.
Negation
not negates the next operator. You write it as its own step before the operator, never as part of the operator's name:
await query('Posts').where('status', (w) => w.not.equalsTo('draft')).findMany();Null
null is never a value to compare against. A nullable field allows isNull, the only null test, and 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 matches:
await query('Posts').where('views', (w) => w.atLeast(100).or.equalsTo(0)).findMany();Either-or
where clauses combine with AND. For an OR, whereAny opens a group of branches, and a record matches when any branch matches. Chaining where on one branch combines with AND inside that branch.
await query('Posts')
.whereAny((q) => [
q.where('featured', true),
q.where('views', (w) => w.atLeast(1000)),
])
.findMany();A whereAny combines with the rest of the query using AND, so you can read "published, and either featured or popular" by placing it after a where.
Filtering relations
A record or records field relates to another collection. has filters by the related rows. Its callback filters on the target collection's fields:
await query('Posts')
.where('author', (w) => w.has((a) => a.where('name', 'Anduin')))
.findMany();has() with no argument tests that the relation is set at all, and 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 takes the same pair. has() with no argument tests that it holds anything, empty() tests the opposite, and a callback filters on the composite's subfields.
A records field also takes includes and includesAny. They match by the linked record's UUID, without reading the linked record itself:
const news = await query('Tags').where('label', 'News').findFirst();
if (news) await query('Posts').where('tags', (w) => w.includes(news.UUID)).findMany();Populating relations
By default a relation field is returned as UUIDs: the id of the related row, or an array of them. populate replaces those ids with 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 recordsPass a callback to shape the related records. select keeps the fields you name, and populate goes one level deeper, 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 row types match exactly what you wrote in the callback, at every depth. A populated level has exactly the fields its select names. It includes UUID and _updatedAt only when you name them.
- When a level has a
select, a populated relation must be named in it, or it is silently left out. Populate narrows a read and never widens one. - A field can be populated once per level. Repeating the name alone is fine, but repeating it with a callback or a spec object like
{ author: { select: ['name'] } }is an error.
Each level loads in one batched read, so a deep populate costs one query per relation, not one per row. Parents that link to the same row share one object, so do not mutate a populated record.
Blocks
A blocks field filters with the same has and empty, with one extra step: has names the block type first, and then a callback filters on its fields.
await query('Pages')
.where('content', (w) => w.has('Hero', (h) => h.where('title', 'Launch')))
.findMany();The blocks guide covers these two steps and how to combine conditions across block types.
Ordering
orderBy sorts by a field. An optional second argument gives the direction, defaulting to ascending. Call it again to add a second field, used when rows have the same value in the first:
await query('Posts').orderBy('publishedAt', 'desc').orderBy('title').findMany();Rows that are still equal are always sorted by UUID last, so a paginated read never reorders rows between pages.
A field whose type names a sort part sorts by that part. A record without the field sorts as a null value does: first ascending, last descending.
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; // error: not selected- Each
selectcall adds to the ones before it. - A write-only field is never returned unless your
selectnames it explicitly. _translationscan be selected and filtered, but not ordered by.
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 of rows without the totals.
Counting and checking
count returns how many records match, and 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, with the query's order and window applied:
const titles = await query('Posts').pluck('title'); // string[]Plucking a populated relation field returns the full related records, exactly as a full read would.
Locales
A collection with translatable fields reads one locale per query. .locale() picks it, and the configured default applies without it:
const german = await query('Posts').locale('de').findMany();