Field Aliases
Remap a client-facing field name to a different target column before allow-listing — decouple the public query vocabulary from your schema, without cascading or cycles.
Field aliases remap a client-facing field name to a different resolved target — a base column or a relation path. The alias is a public name; the target is what your schema actually exposes. This lets the query vocabulary stay stable while the underlying columns move.
Declaring aliases
Add an aliases map (client alias → target field) to a defineFilter spec or a raw FilterConfig:
import { defineFilter } from '@adonis-agora/filter'
export const userFilter = defineFilter({
filterable: ['status', 'name'],
relations: { posts: { filterable: ['title'] } },
aliases: {
legacyStatus: 'status', // rename a column publicly
postTitle: 'posts.title', // flatten a relation path into a single alias
},
})A dot in the target means a relation, not a JSON path
There is one reading of a dotted target: everything before the last dot is a
relation hop. 'posts.title' compiles to whereHas('posts', …), which is
why the posts relation has to be declared in relations.
So an alias cannot reach inside a JSON or jsonb column. Writing
tier: 'metadata.tier' does not read the tier key out of a metadata column —
it looks for a relation named metadata, and on a model that has none, the
query fails at the database. Filtering on JSON contents needs a real column (a
generated one, or an extracted one), or a computed field
whose SQL you write yourself.
Now ?filter[legacyStatus]=active resolves to the status column, and ?filter[postTitle]=hi resolves to the whitelisted posts.title relation path (translated to a whereHas subquery).
Resolution runs first — and is the security-safe order
Alias resolution runs before everything else. The allow-list, structural validation, and query builder all see the resolved target, never the client-facing alias key:
?filter[legacyStatus]=active
→ resolve alias: legacyStatus → status
→ allow-list: is `status` filterable? ✓
→ apply: where('status', 'active')This means the allow-list is always expressed in terms of your real columns — an alias can only reach a target that is itself whitelisted.
resolveFieldAlias, remapFilterAliases, and remapSortAliases are exported if you need to remap fields outside the runner. resolveFieldAlias(aliases, field) is the single choke point.
No cascading, no cycles
Aliases resolve exactly one hop. A target is never re-run through the alias map, even when it happens to also be a declared alias key — which makes alias cycles structurally impossible:
resolveFieldAlias({ a: 'b', b: 'c' }, 'a') // → 'b' (not 'c')Prototype-pollution-style keys (__proto__, constructor, prototype, toString, valueOf) are never treated as alias lookup keys.
To-many Aggregates
Filter and sort by a to-many relation's aggregate — posts.$count, posts.$sum.views, $avg / $min / $max — synthesised as correlated-subquery computed fields from a Lucid model's relation metadata.
Request Input
The input layer — parseSpatieRequest for the full Spatie/JSON:API shape (includes, sparse fieldsets, cursor pagination), resolveInputFromRequest for where input is read from, and normalizeInput for key casing.