Endpoint whitelist rules
Complete reference for defineQuerySchema and defineQueryRules — what you declare, what is validated at startup, and the columns the portable profile requires.
In v3 authorisation comes from two declarations, and RulesConfig no longer
exists.
| Declaration | Scope | Answers |
|---|---|---|
defineQuerySchema | per model | what the model is |
defineQueryRules | per endpoint | what that endpoint authorises |
The split is the point: the model knowing a column does not authorise a client
to ask for it, and the same schema can serve endpoints with different
authorisations. defineQueryRules returns CompiledQueryRules — validated and
compiled at construction time, so an impossible configuration fails when the
application boots instead of on the first request that touches it.
defineQuerySchema
const userSchema: QuerySchema = defineQuerySchema({
model: 'user',
primaryKey: ['id'],
fields: [
{ path: 'id', kind: 'integer', nullable: false, primaryKey: true },
{
path: 'email',
kind: 'string',
nullable: false,
primaryKey: false,
foldedField: 'email_folded',
},
{
path: 'email_folded',
kind: 'string',
nullable: false,
primaryKey: false,
internal: true,
},
{
path: 'status',
kind: 'enum',
nullable: false,
primaryKey: false,
enumValues: ['active', 'suspended'],
portableOrderField: 'status_order',
},
{
path: 'status_order',
kind: 'integer',
nullable: false,
primaryKey: false,
internal: true,
},
],
relations: [
{ path: 'company', target: 'company', cardinality: 'one', nullable: true },
{ path: 'posts', target: 'post', cardinality: 'many', nullable: true },
],
});FieldDescriptor
| Property | Required | Meaning |
|---|---|---|
path | yes | The field name as the API exposes it. |
kind | yes | The logical type. It decides coercion, ordering and which operators apply. |
nullable | yes | Must match the source. isNull is only valid on a nullable field. |
primaryKey | yes | Part of the primary key. |
enumValues | for enum | The accepted members. A value outside them is FILTER_VALUE_INVALID. |
foldedField | no | The internal column holding the folded value. Required for ilike, notIlike and search. |
portableOrderField | no | The internal column giving a portable total order. Required for order comparisons on uuid and enum. |
internal | no | Never filterable, projectable or sortable, and never present in the JSON. |
The eleven kinds are string, uuid, enum, integer, bigint, decimal,
boolean, date, datetime, json, binary. Seven of them —string,
integer, bigint, decimal, boolean, date, datetime — already have a
total order that is identical across the three database families and need no
companion. uuid and enum do not: a UUID's physical representation differs
(SQL Server orders UNIQUEIDENTIFIER by byte groups) and an enum's order
depends on the provider. json and binary are opaque and get no inferred
operator at all.
RelationDescriptor
path, target (the model name in the registry), cardinality
('one' | 'many') and nullable, all required.
What defineQuerySchema refuses
All of these are SOURCE_CONFIGURATION_INVALID, at startup:
- a duplicate
pathinfields; - an
enumfield with noenumValues; - a relation whose
pathcollides with a field of the same name; - an empty
primaryKey; - a
primaryKeypart that is not a declared field, or that isnullable; - a
foldedFieldorportableOrderFieldthat does not exist, or that exists but is not markedinternal.
The registry
export const USER_SCHEMAS: SchemaRegistry = new Map([
['user', userSchema],
['company', companySchema],
['post', postSchema],
]);Every model reachable from the root has to be in it, keyed by model name. A
missing entry is SOURCE_CONFIGURATION_INVALID:
Schema registry has no entry for model <model>.
One schema per model, not one per endpoint — reuse the same object across endpoints and change only the rules.
defineQueryRules
export const userRules = defineQueryRules(USER_SCHEMAS, 'user', {
filters: [
{ path: 'id', operators: ['eq', 'in'] },
{ path: 'email', operators: ['eq', 'ilike'] },
{ path: 'createdAt', operators: ['gt', 'gte', 'lt', 'lte', 'between'] },
// Relation as the target accepts only isNull.
{ path: 'company', operators: ['isNull'] },
{ path: 'company.name', operators: ['eq', 'ilike'] },
{ path: 'posts', operators: ['isNull'] },
{ path: 'posts.title', operators: ['eq', 'ilike'] },
],
sorts: ['name', 'email', 'createdAt', 'company.name'],
fields: {
root: {
allowed: ['id', 'name', 'email', 'companyId', 'createdAt'],
default: ['id', 'name', 'email', 'createdAt'],
},
relations: {
company: { allowed: ['id', 'name'], default: ['id', 'name'] },
posts: {
allowed: ['id', 'title', 'createdAt'],
default: ['id', 'title'],
},
},
},
includes: ['company', 'posts'],
search: ['name', 'email', 'company.name'],
});filters
A list of { path, operators }, not a list of strings — there is no global
operator list in v3. forRoot({ operators }) is refused at startup, and the
Swagger documentation shows the union of what the fields actually authorise.
Refused at construction:
- a duplicate rule for the same
path; operators: [];- an operator the field's kind cannot serve (
likeon an integer,gton auuidwith noportableOrderField,ilikewith nofoldedField,isNullon a non-nullable field, any operator onjson/binary); - an operator other than
isNullon a path that ends at a relation.
sorts
A list of paths. - in the request means descending; the rules do not encode
direction.
Refused at construction:
- a path that crosses a
manyrelation:Sort <path> crosses a many relation, which has no deterministic order. In2.xTypeORM accepted this and returned an arbitrary join row. - a path with no portable total order:
Field <path> has no portable total order.
The sorts check is the most common upgrade break
sorts inherits the portable-order check from the operator matrix, so an
ordinary 2.x line like sorts: ['name', 'slug', 'status', 'createdAt'] —
with status an enum — stops the application from booting. Either drop the
path or give the field a portableOrderField.
fields no longer restricts sorts. In 2.x a path in sorts but missing from
fields was rejected; the two lists are independent now.
fields
fields.root is mandatory, with allowed and a non-empty default. In
2.x fields was optional.
defaultis what the response contains when the request carries nofields.- Every entry of
defaulthas to be inallowed. - Every relation in
includesneeds a declared projection with a non-emptydefault— and the reverse: a projection for a relation that is not inincludesis refused too. - Relation projections are keyed by the relation path, including deep ones:
'items.company'.
A wildcard exists, but only at construction time and only in the explicit form
'<relation>.*'. It must be the only entry in that relation's allowed, it
expands to the target model's non-internal fields, and it is never accepted from
a client — the request parser's path alphabet rejects * outright.
relations: {
company: { allowed: ['company.*'], default: ['id', 'name'] },
}includes
Relation paths a client may load with ?includes=. There is no implicit eager
loading: a relation is only joined and hydrated when the request asks for it (or
when a filter, sort or search needs it internally).
A deep include requires its parent: includes: ['items.company'] without
'items' is refused with
Include items.company requires its parent include items, and the same rule
applies to the request — ?includes=items.company alone is
400 FIELD_NOT_ALLOWED.
search
Paths a single ?search= term is compared against, combined with OR.
Refused at construction:
- a non-textual field:
Search field <path> must be textual(onlystring,uuidandenumqualify); - a field with no folded column:
Search field <path> declares no folded field.
A target that crosses one many relation compiles to a correlated EXISTS
in all three adapters, so the page keeps the size you asked for and total
keeps counting root rows. Under TypeORM a target that crosses a many relation
and then continues through another relation is refused at request time — see
Adapters.
The two column families
Folded columns
Under the portable-strict text profile, search, ilike and notIlike never
emit ILIKE or Prisma's mode: 'insensitive'. They compare a pre-normalised
column against a term normalised by the same function, which is what makes the
same request return the same rows on PostgreSQL, MySQL and SQL Server whatever
the server collation is.
Your application fills the column on write, with foldText(value) exported from
the root — the exact function the core applies to the incoming term.
import { foldText } from 'nestjs-rest-query';
@BeforeInsert()
@BeforeUpdate()
foldSearchableColumns(): void {
this.email_folded = foldText(this.email ?? '');
}foldText is NFC + toLowerCase. It does not strip diacritics.
?search=eletrica does not find "Elétrica". What folding buys you is that the
case of the term never changes the result set — nothing more. If a 2.x
endpoint relied on an accent-insensitive collation, that is an observable
regression, and the fix is a second, explicitly accent-stripped column of your
own.
Naming: under TypeORM the name is a convention, not a choice — the resolver
recognises the companion as <field path>_folded over the entity property
name. Under Drizzle and Prisma the schema is declared rather than
derived, so any name works as long as the declaration and the physical column
agree; the examples use nameFolded.
About indexes: like and search compile to "contains", i.e.
LIKE '%term%', which a plain b-tree index does not accelerate. What the folded
column changes is that the comparison becomes a literal LIKE instead of
ILIKE, so a plain index now serves anchored patterns; substring search
still wants a trigram or full-text index. Not measured here.
Portable order columns
uuid and enum do not order identically across the three database families,
so v3 refuses to order by them directly and orders by the portableOrderField
you declare. Under TypeORM the companion follows the _order suffix over the
property path and is likewise auto-marked internal.
The trap is pagination. The tie-break is always appended over the full primary
key, even when the URL carries no sort at all, so a uuid primary key with
no portable order column kills a bare GET /posts:
CAPABILITY_UNAVAILABLE: Primary key part id of post has no portable total orderThis hits almost every Prisma consumer, because @id @default(uuid()) is the
idiomatic Prisma default, and every Drizzle consumer using uuid primary keys.
The fix is another column plus a backfill — see posts.id_order in example 04
and idOrder in example 03.
Pagination
| Parameter | Type | Default | Notes |
|---|---|---|---|
page | positive integer | 1 | |
perPage | positive integer | 10 | Above maxPerPage (default 500) it is a 400. |
paginate | boolean literal | true | false returns { data } with no page metadata. |
Both defaults are set in forRoot — see
Installation.
paginate=false is capped
An unpaginated response is never unbounded:
| Where | Key | Default | Effect |
|---|---|---|---|
forRoot({ pagination }) | maxUnpaginatedRows | the effective maxPerPage | row cap of an unpaginated response, for every endpoint |
forRoot({ pagination }) | allowUnpaginated | true | false refuses paginate=false on every endpoint |
defineQueryRules(..., { pagination }) | the same two keys | inherits the global value | each key the endpoint declares replaces the global one for it |
- The adapter fetches at most
maxUnpaginatedRows + 1roots. One row over the cap makes the request a400 PAGINATION_INVALIDwithdetails: { param: 'paginate', maxRows }— the list is never silently cut. allowUnpaginated: falseis a400 PAGINATION_INVALIDbefore any query runs, and the endpoint's Swagger stops documentingpaginate.- With a
manyinclude the cap counts roots, not join rows.
// Every brand, for a dropdown: raise the cap for this endpoint only.
defineQueryRules(registry, 'brand', { /* ... */ pagination: { maxUnpaginatedRows: 500 } });
// A large table: unpaginated listings are refused here.
defineQueryRules(registry, 'product', { /* ... */ pagination: { allowUnpaginated: false } });
``` Ordering is always
deterministic: without a total, unique, non-null key appended at the end, two
pages could repeat or lose rows, which is why the primary-key tie-break is
mandatory rather than optional.
## Migrating a `2.x` whitelist
Re-read every one of them; it may have been exposing more than you meant.
| `2.x` | `3.x` |
| ------------------------------------------------------- | --------------------------------------------------------- |
| `filters: ['company']` also accepted `company.*` | declare `company.name` explicitly — matching is exact |
| `operators: { allowed: [...] }`, global or per endpoint | per-field `operators` in each filter rule |
| `fields` optional, and it restricted `sorts` | `fields.root` mandatory; `fields` and `sorts` independent |
| `alias` | gone |
| `RulesConfig` | `CompiledQueryRules`, returned by `defineQueryRules` |