Agora
Definitions

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]=Release

is 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 containswhereHas('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.

On this page