Relation Filtering
Declaratively whitelist relations in a FilterSpec — a dotted request field (relation.column) is translated into a nested Lucid whereHas subquery, bounded by a depth cap.
A defineFilter spec can whitelist relations, letting clients filter and sort by columns on related models. A dotted request field like posts.title is translated into a real Lucid whereHas subquery — not a dotted column reference — so it works without a manual join.
This is the declarative counterpart to the manual preload / whereHas composition in the Relations guide. Here the relation whitelist lives in the spec, and the adapter builds the subquery for you.
Whitelisting relations
Declare each relation by its name on the owning model. Column names are bare (unprefixed); the request field is the dotted path relation.column.
import { defineFilter } from '@adonis-agora/filter'
export const userFilter = defineFilter({
filterable: ['name', 'email'],
relations: {
posts: {
filterable: ['title', 'status'], // clients may filter posts.title / posts.status
sortable: ['createdAt'], // defaults to `filterable` when omitted
relations: {
comments: { filterable: ['body'] }, // deeper: posts.comments.body
},
},
},
})A RelationSpec's filterable defaults to '*' (any column of the related model) and its sortable defaults to its filterable.
Dotted paths become whereHas
Every segment of a dotted field but the last is a relation hop; the final segment is the bare column the operator lands on inside the innermost relation subquery:
?filter[posts.title]=Releaseis translated to:
query.whereHas('posts', (sub) => sub.where('title', 'Release'))
// → users having at least one post WHERE title = 'Release'The full operator set works inside the subquery (posts.title with contains → whereHas('posts', q => q.whereILike('title', '%…%'))), and deeper paths nest whereHas within whereHas:
?filter[posts.comments.body]=spam → whereHas('posts', q => q.whereHas('comments', c => c.where('body', 'spam')))Only whitelisted paths are ever translated — a disallowed relation or column is pruned by the allow-list before it reaches the adapter, so no dotted reference for an un-whitelisted relation can leak into the query.
The depth cap
maxDepth bounds how many relation hops a path may cross (a base column is depth 0, posts.title is depth 1, posts.comments.body is depth 2). It defaults to the deepest relation nesting you declared, so you rarely set it — but an explicit smaller value caps paths even when a deeper relation is declared:
const spec = defineFilter({
filterable: ['name'],
maxDepth: 1,
relations: { posts: { filterable: ['title'], relations: { comments: { filterable: ['body'] } } } },
})
spec.isFilterable('posts.title') // true (depth 1)
spec.isFilterable('posts.comments.body') // false (depth 2 > maxDepth 1)Sorting by a relation column
Sortable relation columns work the same way. A whitelisted posts.createdAt in a sort param is allow-listed against the relation's sortable list and forwarded to the sort stage.
Relation-path sorting produces an orderBy on the dotted field; ordering across a to-many relation may require the relation to be joined/aggregated for the column to resolve. Relation filtering (whereHas) needs no join. Prefer relation filters for correctness, and sort by aggregate aliases (e.g. withCount) where you need relation-derived ordering — see the Relations guide.
Defining Filters
defineFilter + applyFilterFromRequest — a declarative, reusable filter definition (filterable/sortable allow-lists, relation whitelist with a depth cap, tenant scope, and server defaults) applied to a Lucid query in one call.
Computed Fields
Declare virtual/computed columns on a filter spec — a dev-authored SQL expression (string or correlated-subquery function) that becomes filterable and sortable exactly like a real column, with the client value always parameterized.