05 — Query Model, Filter Engine & Query String Grammar
This is the standalone grammar reference — it is written to serve as end-user documentation verbatim. The implementation lives in core/src/query/ (DefaultFilterParser, QueryNormalizer, the pagination strategies); adapters only ever see the validated, normalized result.
1. Operators — AST names and wire tokens (single source of truth)
| AST operator | Wire token | Example |
|---|---|---|
EQ | eq | filter[status][eq]=active |
NE | ne | filter[status][ne]=banned |
GT / GTE | gt / gte | filter[age][gte]=18 |
LT / LTE | lt / lte | filter[age][lt]=65 |
IN | in | filter[status][in]=active,pending |
NOT_IN | notIn | filter[role][notIn]=bot,test |
LIKE | like | filter[name][like]=%25john%25 |
ILIKE | ilike | filter[name][ilike]=%25john%25 |
BETWEEN | between | filter[createdAt][between]=2026-01-01,2026-06-01 |
IS_NULL | isNull | filter[deletedAt][isNull]=true |
IS_NOT_NULL | isNotNull | filter[deletedAt][isNotNull]=true |
Wire tokens are camelCase and exact-case matched — one spelling, no aliases (GTE/Gte are 400s). Logical operators: AND, OR, NOT (wire: and, or, not). Core ships exactly this set; an operator registry mapping tokens to AST factories is a natural extension point but is deliberately not built in v6.
NOT is variadic and means NOT(AND(children)). All three logical operators take a child list — FilterGroup.children is readonly FilterExpression[] for every operator — so NOT needed an arity answer rather than an assumption. The wire parser only ever builds the unary shape (convertLogical wraps exactly one converted node), but a programmatic caller hand-builds the AST and QueryNormalizer validates allowlists and limits, not arity. Conjoining is what makes the unary case a special case of the general one instead of the only legal one, and it gives the degenerate group below its meaning for free. Reading children[0] and dropping the rest — which @kavo/typeorm and @kavo/prisma used to do — returned rows the caller asked to exclude.
2. Reference example
GET /users
?filter[age][gte]=18
&filter[status][in]=active,pending
&filter[name][like]=%25john%25
&filter[or][0][role][eq]=admin
&filter[or][1][status][eq]=banned
&sort=-createdAt,name
&limit=20&offset=20
&fields=id,name,emailresolves to
AND[ age GTE 18, status IN [active, pending], name LIKE "%john%",
OR[ role EQ "admin", status EQ "banned" ] ]
sort: [{ createdAt desc }, { name asc }]
pagination: { limit: 20, offset: 20 }
fields: root: [id, name, email]3. Grammar rules
Filters:
filter[field][operator]=value. Multiplefilter[...]params AND together implicitly. Multiple operators on one field also AND (filter[age][gte]=18&filter[age][lt]=65).Multi-value operators (
in,notIn): comma-separated by default; the repeated-key formfilter[status][in][]=a&filter[status][in][]=bis also accepted. A bare empty operand —filter[status][in]=, or its repeated-key spellingfilter[status][in][]=— is aKAVO_QUERY_INVALID_VALUE400 on every column kind. It is neither "no filter" nor "the empty set": a client that wants no filter omits the parameter. Left to coercion the two column kinds disagreed — string coercion accepts"", sofilter[name][in]=built a liveIN ('')(a search for the empty string, usually zero rows and no error), whilefilter[age][in]=was already a 400. A UI that submits a cleared multi-select now gets an error it can see rather than a silently empty page. An interior empty element (in=a,,b) is a different question and keeps its per-element coercion behavior. The genuinely empty array a programmatic caller can pass (value: []) is unaffected — that is the empty set, and it round-trips as one.between: exactly two comma-separated bounds, in the order given — the pair is never sorted, sobetween=65,18is an empty range rather than a silently corrected one.isNull/isNotNull: boolean-valued.falseflips to the complementary operator (isNull=false≡isNotNull=true), so both spellings mean what they read as.like/ilike: never auto-wrap wildcards — callers pass%explicitly. Literal%and_are escaped with a backslash (\%,\_); the adapter emits the matchingESCAPEclause, with the backslash bound as a query parameter rather than inlined as a'\'string literal (drivers disagree on how backslash is escaped inside a literal — MySQL's defaultsql_modetreats it as its own in-string escape character, Postgres does not — so a parameter is the portable spelling).ilikeis translated portably (LOWER(col) LIKE LOWER(:v)), identical on every driver. Both operators apply to string columns only. Two adapters cannot honor the whole pattern language:@kavo/prismahas no raw pattern operator, so an interior%and any_are rejected with a 400 rather than mistranslated (doc 14 §6), and@kavo/mikroormcannot attach anESCAPEclause, so the backslash escape there is driver-dependent (doc 17 §7).Relation-path filtering: dot notation (
filter[profile.city][eq]=Helsinki), permitted only for paths on the filterable allowlist. Relation-path filters restrict root rows (a non-selecting join); they never load or filter the included collection. On a to-many segment that reads as "at least one" — SQL gets it from aLEFT JOINplus aWHERE, and the declarative adapters spell it as Prisma'ssome/ MikroORM's nested list match.Degenerate empty groups. A group with zero children is unreachable from the wire (the parser only emits a group once a child converted), but a programmatic caller hand-builds the AST and
QueryNormalizerchecks allowlists and limits, not arity — so every adapter has to answer for it, and all four agree:AST Matches Why AND []every row trueis the identity of conjunctionOR []no row falseis the identity of disjunctionNOT []no row NOT(AND [])— the negation of the tautologyEach adapter emits a real predicate for these, never an omitted one: an omitted predicate is exactly how an empty
ORsilently widened to every row in@kavo/typeorm. The spellings differ because the targets do —1 = 0in SQL,$nor: [{}]in MongoDB, an empty$inon the primary key in MikroORM,{ OR: [] }in Prisma (whoseNOTover an empty operand is dropped rather than honored, doc 14 §2) — but the result sets do not, and each adapter's translator spec pins its own row of this table.Nested boolean trees:
filteralso accepts one JSON-encoded value —?filter={"or":[{"name":{"eq":"admin"}},{"not":{"status":{"eq":"x"}}}]}— parsed into the same AST. Bracket notation is sugar for the common flat cases; JSON is the full-power escape hatch. Both produce the identical AST (asserted infilter-parser.spec.ts); when both appear, they AND together.Sort:
sort=-createdAt,name— comma-separated,-prefix = descending, list order is priority order. Sortable-allowlist enforced. A request that supplies nosortfalls back to the resolvedquery.defaultSortsetting (doc 08) if one is configured; a client- or caller-suppliedsortalways wins outright over the default rather than merging with it. With neither, there is noORDER BYat all — row order is DB-dependent.Pagination: pluggable
PaginationStrategy. Defaultoffset: flatlimit/offset(0-based) — the same field names the response envelope reports, so request and response mirror each other. Built-in alternativepage:page[number]/page[size](1-indexed), normalized internally to the samelimit/offset. Missinglimit→defaultLimit;limitabovemaxLimit→ clamped; malformed or negative → 400.The third built-in,
cursor, is the one that does not normalize tolimit/offset:Paginationis a union, and its keyset variant carries{ limit, cursor, keyset }with nooffsetat all (ADR-0021). Consumers narrow withisCursorPaginationbefore readingoffset. Wire form is flatlimitplus an opaquecursortoken; the next page's token comes back asmeta.nextCursoron the list envelope,nullon the last page.Two pieces of the cursor pipeline are deliberately not in the strategy, because
normalize(rawParams, limits)sees neither sort nor metadata:QueryNormalizerenforces what the effective sort has to be, then decodes the token intopagination.keyset— a plain filter AST node (ORofANDchains,LTfor eachdesckey, AND-ed with a redundant non-strict bound on the leading key so a btree can start the scan there rather than filtering the whole disjunction). Adapters compose it by callingreadFilter(query)infindMany;countkeeps usingquery.filter, sototalstill spans the whole match set.The sort rules are: it ends in
idField, every key is a root scalar column, no key isjson, and every key is onfilterableandselectableas well assortable. That last one is the load-bearing security rule rather than a tidiness one — the keyset predicate is AND-ed in afterDefaultFilterParserandvalidateExpressionhave run, andcursorValuesOfreads the raw entity intometa, which never passes through the serializer. Gated onsortablealone, the cursor path would be a way around the other two allowlists in both directions (ADR-0021 §2). A key that fails is rejected, never dropped: dropping one would break the total order.A malformed, stale, or forged token is a
KAVO_QUERY_INVALID_VALUEissue oncursor; a sort that cannot support keyset paging isKAVO_QUERY_CONFLICTING_PARAMSonsort, orKAVO_QUERY_INVALID_FIELDon the offending field for the allowlist gates. Supplying?cursor=to an entity that does not page by keyset isKAVO_QUERY_UNSUPPORTED_PARAM, identically on the wire and programmatic paths — ignoring it would hand back page one forever. Cursors are opaque, never signed — ADR-0021 §2 explains why that is sufficient.The fourth built-in,
since, is a polling shape — "give me everything that changed since T" — not a bounded traversal, and itsPaginationvariant is a thirdhasKeysetmember alongside offset and cursor:{ limit, since, keyset }(ADR-0022). Wire form is flatlimitplus a plain, compoundsincevalue —"<since.field value>|<id>", e.g.2024-03-01T10:00:00.000Z|42— againstpagination.since.field(default"updatedAt", a documented convention core cannot detect the way it detects a soft-delete marker). Never opaque, unlikecursor: an adopter can read or construct one by hand. The effective sort is forced to[since.field, idField]ascending regardless of any client-suppliedsort, which is rejected outright (KAVO_QUERY_CONFLICTING_PARAMSonsort) rather than silently overridden.QueryNormalizer.resolveSincesplits the token on its last|, decodes each half, and composes them with the samekeysetExpressioncursor pagination builds — the id half is what makessincepagination exactly-once even when rows tie onsince.field(ADR-0022 explains why an earlier, id-lesssinceField >= valuedesign was rejected: a tied group larger than one page never advances without it). The next poll's value comes back asmeta.nextSince, computed from the last returned row regardless of whether the page filled up (unlikenextCursor, which isnullon a non-full page) — polling has no "last page" to signal the end of, so an exhausted poll echoes the request's ownsinceback rather than reportingnull.since.field's existence,date/stringkind, andfilterable/selectablemembership (idField's too) are bootstrap-checked (resolveEntityConfig), not per-request, because the forced sort is entirely config-known before any request arrives.Field selection:
fields=id,name,email— sparse fieldset for the root resource, validated against the selectable allowlist.fields[<relation path>]=id,titlenarrows an included node, validated against the target entity's allowlist (doc 12). Programmatic callers passFieldSelectionInput, whose three spellings mirror these wire forms and collapse to the same normalized selection (doc 03).Soft delete:
withDeleted=trueincludes soft-deleted rows, which are otherwise excluded from every read (doc 11);onlyDeleted=truenarrows a read to only those rows — the trash view — and applies to single-row reads as well as lists. On an entity that is not soft-deletable either is rejected withKAVO_QUERY_UNSUPPORTED_PARAM, not ignored; a non-boolean value is a field-level 400. The two are contradictory ("everything" vs. "only the deleted"), so sending both isKAVO_QUERY_CONFLICTING_PARAMS. Neither flag changes include resolution: a trash-view read resolvesinclude=exactly as a live one does.Includes:
include=posts.comments,profile— comma-separated dot-paths, merged into one validated tree (doc 12). A relation that is not on the entity's inclusion allowlist is a 400, never a silent omission.
4. Security & robustness
- Allowlists: every entity resolves filterable/sortable/selectable lists at bootstrap — explicitly configured, or defaulting to the entity's own scalar columns (relation paths are never allowlisted implicitly). Anything outside a list → 400 (
KAVO_QUERY_INVALID_FIELD), never a silent drop. Programmatic callers (findMany({ filter })) pass through the same allowlist and limit checks — typed input skips coercion, not security. - Computed fields are selectable only: a declared computed field (doc 04 §7) joins the selectable default and never the filterable or sortable one — it has no column to translate to
WHERE/ORDER BY, so naming it in either is a bootstrapConfigurationExceptionrather than an in-memory fallback (ADR-0019). - Excluding instead of enumerating: each allowlist key also accepts
{ exclude: [...] }instead of an explicit array — resolved at bootstrap to every own column (plus, forselectable, every selectable computed field) except the ones named, so hiding one column (e.g. a soft-delete marker) doesn't require re-listing every other one. Resolution starts from exactly the base set that key's plain default uses, so the result stays fail-closed like the plain array form. - Limits (configurable per scope, doc 8):
query.maxFilterDepth(default 3) on the built AST,query.maxInValues(default 100) onin/notInarrays,pagination.maxLimit(default 100) on page size. - Type coercion: raw wire strings coerce against column metadata before becoming AST values — number, boolean (
true/false/1/0), date (ISO 8601), enum (member match),nullfor nullable columns. Failures are field-level 400 issues, never a silentNaNorInvalid Date. Coercion consults the root entity's column metadata only: a relation-path value (filter[profile.city][eq]=…) has no entry in that map and passes through as a string. Include resolution and fieldset validation wire in the target entity's config (doc 12), but filter-value coercion does not. - Reserved keys: the bracket tree is built from attacker-controlled segments before any allowlist check, so it is built on prototype-less objects.
filter[__proto__][x]=vtherefore assigns an ordinary own key and is rejected as a non-allowlisted field (KAVO_QUERY_INVALID_FIELD) rather than writing through toObject.prototype. The same applies tofields[__proto__], and the deserializer reads request bodies with an own-property check, so a prototype polluted by anything else in the host application still cannot add a writable field to a request that omitted it. - One exception, all issues: every violation across filter, sort, fields, and pagination is collected into a single
QueryValidationException, so a client fixes its request in one round trip (errors[]in the problem-details body).
5. Normalization pipeline
raw query string (flat bracket keys)
→ DefaultFilterParser (allowlist + coercion + limits → Filter AST)
→ sort / fields parsing (allowlists)
→ PaginationStrategy (defaultLimit / maxLimit / 400s)
→ NormalizedQueryContext { filter, sort, pagination, fields,
include: {}, withDeleted: false,
onlyDeleted: false, count }QueryNormalizer.normalizeWire runs the whole pipeline for HTTP input (the WireQuery marker from the framework layer); QueryNormalizer.normalizeInput runs the same validation minus coercion for programmatic QueryContext input. Adapters consume the normalized form and never re-validate.