Skip to content

Relations

Load related resources alongside the primary one, and unlock their fields for selection, filtering and sorting.

URL keyinclude
AST nodesRelations / Relation { name }
Schema optionsallowed, mapping, validate / validateMany

On the wire

txt
include=realm,items           comma-separated
include=items.realm           nested path (dot notation)

Parser input shapes:

typescript
{ relations: 'realm,items' }        // comma-separated string
{ relations: ['realm', 'items'] }   // array
{ relations: ['items.realm'] }      // nested paths

Nested paths automatically include their parents: requesting items.realm also includes items.

Building in code

typescript
defineQuery<User>({ relations: ['items.realm'] });

// record form works too
defineQuery<User>({ relations: { realm: true, items: { user: true } } });

defineRelations<User>(['realm']);   // standalone fragment

Validation

Each requested relation is checked against the schema's allowed list. Nested paths resolve segment by segment through schemaMapping: for items.realm, the items segment must be allowed on the root schema and realm on the schema registered for items.

Relation names must match [a-zA-Z0-9_-] segments separated by dots; anything else is dropped (or throws with throwOnFailure).

Schema options

typescript
defineSchema<User>({
    relations: {
        allowed: ['realm', 'items'],
        mapping: { children: 'items' },
    },
    schemaMapping: {
        items: 'item',
        realm: 'realm',
    },
});
OptionDescription
allowedTraversable relation names. Omit to allow all; [] blocks the parameter.
mappingAlias → relation translation applied before validation.
validate / validateManyPer-request key hooks: may this actor traverse this relation? See below.

Validate hooks

Allow-lists are static; validate / validateMany decide per request. Every parse/decode call may carry a context (typically the authenticated actor), and the hook answers for each requested relation with that context in hand. The general contract (verdicts, the scope argument, batching, sync/async) lives at Validate hooks and parse context.

typescript
type Actor = { permissions: string[] };

defineSchema<User, Actor>({
    name: 'user',
    relations: {
        allowed: ['realm', 'items'],
        // may THIS actor include THIS relation? A missing context
        // must be rejected explicitly: it does not fail closed on
        // its own.
        validate: (relation, actor) => !!actor && actor.permissions.includes(`${relation}_read`),
    },
    schemaMapping: { items: 'item' },
});

const query = parser.parse(input, { schema: 'user', context: actor });

The hook runs on the canonical (alias-mapped) relation name against the schema that governs it: include=items.realm invokes the user schema's hook with items and the item schema's hook with realm. A relation is not a column, so an ICondition verdict has nothing to gate and counts as a rejection.

Two rules make the gate hard to bypass:

  • Traversal counts. The hook also gates relations reached through dotted filter, field and sort keys (filter[items.id], fields[items], sort=items.name), which the backends would otherwise auto-join. It runs once per distinct relation across the whole query, so a join has a single authorization point regardless of which parameter forced it.
  • Rejection prunes deep. Rejecting a relation drops every deeper relation reached through it and every dependent key in every parameter, at parse time; the pruned branch never enters the AST. Under throwOnFailure the rejection fails the parse instead, recorded as a KEY_VALIDATE_REJECTED issue for the relations parameter on the trace of the raised inputRejected error.

One thing pruning never drops: a preserve() filter condition. If a filters validate hook preserves a condition that names a relation this hook rejects, the two hooks contradict each other: pruning the condition returns a result set the policy did not describe (a wider one for the usual and(<leaf>, <scope>) residual), keeping it joins a relation the gate refused. Parsing therefore throws a SchemaError with ErrorCode.SCHEMA_PRESERVED_CONDITION_PRUNED rather than picking one. That is a server misconfiguration, not client input: see Authorization.

A schema default filter is exempt from pruning: it is the server-authored baseline and is re-applied after the gate has run, even when it names a rejected relation. Preserving a default opts it back into the contradiction check, since it asserts the same must-survive contract as a residual and raises the same error.

Interaction with other parameters

Parsed relations feed back into the other parameter parsers: fields, filters and sorts input that addresses a relation (items.id, realm.name) is only accepted when the relation was requested and allowed. Request the relation first, then reference its fields. A validate hook participates in the same wiring: it also authorizes the relations those keys traverse.

On violation

Disallowed or invalid relation input is dropped silently; with throwOnFailure it fails the parse instead (RelationsParseError from parseRelations, the general ParseError from a whole-query parse, each carrying the rejection on its issue trace).

Released under the MIT License.