Relations
Filtering across Lucid relationships — dotted paths that become whereHas subqueries, filtering the rows you preload, and sorting by a relation aggregate.
This library does not invent join machinery. A relation filter becomes Lucid's own
whereHas, a filtered eager-load becomes Lucid's own preload callback, and a relation aggregate
is Lucid's own withCount. All of it works because a relation sub-query is itself a
QueryBuilderLike — the same contract the root builder satisfies.
Two things show up in a filtered endpoint, and they are not the same question: which parents come
back (a whereHas constraint), and which related rows you load for them (a preload
constraint).
A dotted field is a relation path
Declare the hop in the spec's relations map and the dotted path becomes filterable. Every segment
but the last is a relation; the last is the column the operator lands on inside the subquery:
import { defineFilter } from '@adonis-agora/filter'
import User from '#models/user'
export const userFilter = defineFilter({
model: User,
filterable: ['name', 'email', 'status'],
sortable: ['name', 'createdAt'],
relations: {
posts: { filterable: ['title', 'status'] }, // enables filter[posts.title] / filter[posts.status]
},
})GET /users?filter[posts.status]=published
→ User.query().whereHas('posts', (q) => q.where('status', 'published'))The relation does not have to be joined — Lucid builds the correlated subquery. Deeper paths nest:
posts.comments.body becomes a whereHas inside a whereHas, capped by the spec's maxDepth.
The declarative form and its depth rules are covered in
Relation Filtering.
A dotted filter is a subquery, not a joined column
If you have genuinely joined the relation and want a predicate on the joined table
(where('posts.title', …)) rather than an EXISTS subquery, select an alias for that column and
filter on the alias — or drop to applyColumnFilters on the joined builder.
Filtering the rows you preload
preload's callback receives a relation query builder, so a second filter can run inside it. This
narrows the loaded posts without changing which users come back:
import type { HttpContext } from '@adonisjs/core/http'
import { applyFilterFromRequest, applyFilter, parseFilterRequest } from '@adonis-agora/filter'
import { userFilter } from '#filters/user_filter'
import User from '#models/user'
export default class UsersController {
async index(ctx: HttpContext) {
// parents read `filter[...]`; the preloaded relation reads its own param
const postInput = parseFilterRequest({ filter: ctx.request.qs().postFilter })
const query = User.query().preload('posts', (postsQuery) => {
applyFilter(postsQuery, postInput, {
allowed: ['title', 'status'],
sortable: ['createdAt'],
})
})
const { page, size } = applyFilterFromRequest(query, userFilter, ctx)
return query.paginate(page, size)
}
}Each call carries its own allow-list, so what a client may filter on the relation is scoped independently of the parent.
Ignore the { page, size } returned inside a preload callback. Paginating a to-many relation
per parent row corrupts the counts; if the return value tempts you, call the lower-level
applyColumnFilters / applySort there instead.
Constraining the parent yourself
When the constraint is a server policy rather than a client filter, write the whereHas directly —
the low-level stage functions apply one piece of the pipeline to any sub-builder:
import { applyColumnFilters } from '@adonis-agora/filter'
const query = User.query().whereHas('posts', (postsQuery) => {
applyColumnFilters(postsQuery, [
{ field: 'status', operator: 'equals', value: 'published' },
{ field: 'title', operator: 'contains', value: 'release' },
])
})
// users with at least one post where status = 'published' and title ilike '%release%'The stage functions trust their input
applyColumnFilters, applySort and applySearch do not allow-list. Hand them filters you wrote,
or prune client input first — otherwise a query string reaches columns the spec never exposed.
Sorting by a relation aggregate
withCount selects a posts_count column, and a column that exists can be sorted on. List the
alias in sortable and ?sort=-posts_count works like any other sort:
export default class UsersController {
async index(ctx: HttpContext) {
const query = User.query().withCount('posts')
const { page, size } = applyFilterFromRequest(query, userFilter, ctx)
return query.paginate(page, size)
}
}export const userFilter = defineFilter({
model: User,
filterable: ['name', 'status'],
sortable: ['name', 'createdAt', 'posts_count'], // the alias withCount selects
})For a count that the spec derives on its own — including filtering on it, not just sorting — use a
computed aggregate instead of withCount.