Skip to content

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 operatorWire tokenExample
EQeqfilter[status][eq]=active
NEnefilter[status][ne]=banned
GT / GTEgt / gtefilter[age][gte]=18
LT / LTElt / ltefilter[age][lt]=65
INinfilter[status][in]=active,pending
NOT_INnotInfilter[role][notIn]=bot,test
LIKElikefilter[name][like]=%25john%25
ILIKEilikefilter[name][ilike]=%25john%25
BETWEENbetweenfilter[createdAt][between]=2026-01-01,2026-06-01
IS_NULLisNullfilter[deletedAt][isNull]=true
IS_NOT_NULLisNotNullfilter[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,email

resolves 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. Multiple filter[...] 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 form filter[status][in][]=a&filter[status][in][]=b is also accepted. A bare empty operandfilter[status][in]=, or its repeated-key spelling filter[status][in][]= — is a KAVO_QUERY_INVALID_VALUE 400 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 "", so filter[name][in]= built a live IN ('') (a search for the empty string, usually zero rows and no error), while filter[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, so between=65,18 is an empty range rather than a silently corrected one.

  • isNull / isNotNull: boolean-valued. false flips to the complementary operator (isNull=falseisNotNull=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 matching ESCAPE clause, 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 default sql_mode treats it as its own in-string escape character, Postgres does not — so a parameter is the portable spelling). ilike is 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/prisma has no raw pattern operator, so an interior % and any _ are rejected with a 400 rather than mistranslated (doc 14 §6), and @kavo/mikroorm cannot attach an ESCAPE clause, 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 a LEFT JOIN plus a WHERE, and the declarative adapters spell it as Prisma's some / 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 QueryNormalizer checks allowlists and limits, not arity — so every adapter has to answer for it, and all four agree:

    ASTMatchesWhy
    AND []every rowtrue is the identity of conjunction
    OR []no rowfalse is the identity of disjunction
    NOT []no rowNOT(AND []) — the negation of the tautology

    Each adapter emits a real predicate for these, never an omitted one: an omitted predicate is exactly how an empty OR silently widened to every row in @kavo/typeorm. The spellings differ because the targets do — 1 = 0 in SQL, $nor: [{}] in MongoDB, an empty $in on the primary key in MikroORM, { OR: [] } in Prisma (whose NOT over 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: filter also 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 in filter-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 no sort falls back to the resolved query.defaultSort setting (doc 08) if one is configured; a client- or caller-supplied sort always wins outright over the default rather than merging with it. With neither, there is no ORDER BY at all — row order is DB-dependent.

  • Pagination: pluggable PaginationStrategy. Default offset: flat limit/offset (0-based) — the same field names the response envelope reports, so request and response mirror each other. Built-in alternative page: page[number]/page[size] (1-indexed), normalized internally to the same limit/offset. Missing limitdefaultLimit; limit above maxLimit → clamped; malformed or negative → 400.

    The third built-in, cursor, is the one that does not normalize to limit/offset: Pagination is a union, and its keyset variant carries { limit, cursor, keyset } with no offset at all (ADR-0021). Consumers narrow with isCursorPagination before reading offset. Wire form is flat limit plus an opaque cursor token; the next page's token comes back as meta.nextCursor on the list envelope, null on the last page.

    Two pieces of the cursor pipeline are deliberately not in the strategy, because normalize(rawParams, limits) sees neither sort nor metadata: QueryNormalizer enforces what the effective sort has to be, then decodes the token into pagination.keyset — a plain filter AST node (OR of AND chains, LT for each desc key, 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 calling readFilter(query) in findMany; count keeps using query.filter, so total still spans the whole match set.

    The sort rules are: it ends in idField, every key is a root scalar column, no key is json, and every key is on filterable and selectable as well as sortable. That last one is the load-bearing security rule rather than a tidiness one — the keyset predicate is AND-ed in after DefaultFilterParser and validateExpression have run, and cursorValuesOf reads the raw entity into meta, which never passes through the serializer. Gated on sortable alone, 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_VALUE issue on cursor; a sort that cannot support keyset paging is KAVO_QUERY_CONFLICTING_PARAMS on sort, or KAVO_QUERY_INVALID_FIELD on the offending field for the allowlist gates. Supplying ?cursor= to an entity that does not page by keyset is KAVO_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 its Pagination variant is a third hasKeyset member alongside offset and cursor: { limit, since, keyset } (ADR-0022). Wire form is flat limit plus a plain, compound since value — "<since.field value>|<id>", e.g. 2024-03-01T10:00:00.000Z|42 — against pagination.since.field (default "updatedAt", a documented convention core cannot detect the way it detects a soft-delete marker). Never opaque, unlike cursor: an adopter can read or construct one by hand. The effective sort is forced to [since.field, idField] ascending regardless of any client-supplied sort, which is rejected outright (KAVO_QUERY_CONFLICTING_PARAMS on sort) rather than silently overridden. QueryNormalizer.resolveSince splits the token on its last |, decodes each half, and composes them with the samekeysetExpression cursor pagination builds — the id half is what makes since pagination exactly-once even when rows tie on since.field (ADR-0022 explains why an earlier, id-less sinceField >= value design was rejected: a tied group larger than one page never advances without it). The next poll's value comes back as meta.nextSince, computed from the last returned row regardless of whether the page filled up (unlike nextCursor, which is null on a non-full page) — polling has no "last page" to signal the end of, so an exhausted poll echoes the request's own since back rather than reporting null. since.field's existence, date/string kind, and filterable/selectable membership (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,title narrows an included node, validated against the target entity's allowlist (doc 12). Programmatic callers pass FieldSelectionInput, whose three spellings mirror these wire forms and collapse to the same normalized selection (doc 03).

  • Soft delete: withDeleted=true includes soft-deleted rows, which are otherwise excluded from every read (doc 11); onlyDeleted=true narrows 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 with KAVO_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 is KAVO_QUERY_CONFLICTING_PARAMS. Neither flag changes include resolution: a trash-view read resolves include= 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 bootstrap ConfigurationException rather 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, for selectable, 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) on in/notIn arrays, 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), null for nullable columns. Failures are field-level 400 issues, never a silent NaN or Invalid 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]=v therefore assigns an ordinary own key and is rejected as a non-allowlisted field (KAVO_QUERY_INVALID_FIELD) rather than writing through to Object.prototype. The same applies to fields[__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.