Filter Classes
The class-authoring form — a method per request key with the query builder on this.$query, a setup() scope the client cannot relax, constructor injection through the IoC container, and the pagination resolved for you.
A filter can be a class. It is the shape AdonisJS developers already know from
adonis-lucid-filter: one method per request key, the query builder on this.$query, a
setup() that runs on every call. What this library adds is everything around it — the operator
wire format, search, sort, allow-listing, and the page the request asked for, already clamped.
import { inject } from '@adonisjs/core'
import { BaseModelFilter } from '@adonis-agora/filter'
import type { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model'
import TenantsService from '#services/tenants_service'
import User from '#models/user'
@inject()
export default class UserFilter extends BaseModelFilter {
declare $query: ModelQueryBuilderContract<typeof User>
constructor(private tenants: TenantsService) {
super()
}
static model = User
// plain columns need no method — the runner applies the operators for them
static filterable = ['name', 'email', 'status']
static sortable = ['name', 'createdAt']
static searchable = ['name', 'email']
static defaultSort = [{ field: 'createdAt', direction: 'desc' as const }]
static defaultSize = 25
static maxSize = 100
// runs on every call, before anything the request asked for
setup() {
this.$query.where('tenantId', this.tenants.current(this.$ctx))
}
// `?filter[fullName]=silva` lands here instead of on a column of that name
fullName(value: string) {
this.$query.whereRaw("first_name || ' ' || last_name ilike ?", [`%${value}%`])
}
}node ace make:filter user scaffolds that file.
Writing the method is the decision to expose the key
There is no allow-list entry for fullName. There does not need to be one: the key exists because
a method exists, and the method is the only thing that can touch it. A key with no method and no
place in filterable is dropped before it reaches SQL, exactly as it always was.
That gives a class two halves that answer different questions:
| Half | Answers | Where it lives |
|---|---|---|
static filterable / sortable / searchable | which columns a client may reach with the standard operators | the statics, read by the same runner a defineFilter spec drives |
| a method | what a key that is not a plain column means in SQL | your code, with the builder in hand |
A class may be all methods and declare no filterable at all — then nothing reaches the database
except through the methods you wrote, which is the tightest allow-list there is.
Every key is matched the way you would expect
filter[full_name] finds fullName() (set static camelCase = false to turn that off), and with
static dropId = true a companyId key finds company(). A key the wire format owns — sort,
search, page, size, distinct, include, the cursor params — is never dispatched to a
method, so a sort() method of your own can never be triggered by ?sort=.
When a name is not enough
A method's name is its key, which covers most keys and no odd ones. Two decorators cover the rest:
@filterFor('team.name') binds a method to keys a method name could never spell (and pins the
public key against a rename), and @filterable() / @sortable() / @searchable() declare the
allow-list on the model's columns instead of in a static. Both are optional sugar over everything
on this page — see Decorators.
The class is resolved through the container
@inject() works because the filter is constructed by the IoC container, using the request's own
resolver — the same one that constructs a controller. A filter can depend on a service, and in a
test it can be constructed by hand.
That resolution is asynchronous, which is the one thing to know about the call sites below: they
await.
Applying it
Compose the model with Filterable and point it at its filter:
import { compose } from '@adonisjs/core/helpers'
import { BaseModel } from '@adonisjs/lucid/orm'
import { Filterable } from '@adonis-agora/filter'
import UserFilter from '#filters/user_filter'
export default class User extends compose(BaseModel, Filterable) {
static $filter = () => UserFilter
// columns…
}export default class UsersController {
// the whole list endpoint
async index(ctx: HttpContext) {
return User.filterPaginate(ctx)
}
// or keep the builder and go on composing
async index(ctx: HttpContext) {
const { query } = await User.filter(ctx)
query.whereNotNull('confirmedAt').preload('team')
return query.filterPaginate()
}
}A filtered builder comes back in an object, never as the promise's value
User.filter(ctx) resolves to { query, page, size }, and the class form of
applyFilterFromRequest resolves to { page, size } — neither hands you the builder as the
promise's own value. A Lucid query builder is thenable: a promise that resolved to one would
run the query and give you rows instead. Destructuring keeps a builder a builder.
filterPaginate() with no arguments
A query that has already been through a filter remembers the page and size that call resolved, so the endpoint can compose freely in between and still page it the way the request asked:
const { query } = await User.filter(ctx)
query.whereNotNull('confirmedAt').preload('team').withCount('posts')
return query.filterPaginate() // ?page=2&size=500 → page 2, size clamped to maxSizeCalling it on a query nothing has filtered throws rather than guessing.
What setup() is for
setup() runs before a single request filter is read, and it is the natural home for a constraint
the client must not be able to relax:
setup() {
this.$query.whereNull('deletedAt')
this.$query.where('tenantId', this.tenants.current(this.$ctx))
if (!this.$ctx.auth.user!.isAdmin) {
this.$query.where('visibility', 'public')
}
}Because it runs on the builder, and the request's filters are AND-combined with whatever is
already there, nothing a query string can say will widen it. The declarative equivalent for the
tenant case is static tenant = { column, resolve }; pick whichever reads better — a scope with a
condition in it usually wants the method.
What a method receives
The value the client sent, and the operator it sent it under:
minAge(value: string) {
this.$query.where('age', '>=', Number(value))
}
// `?filter[age][gte]=18` → age('18', 'gte')
age(value: string, operator: string) {
if (operator === 'gte') this.$query.where('age', '>=', Number(value))
else this.$query.where('age', Number(value))
}A bare top-level key works too — ?minAge=21 reaches minAge, the shape adonis-lucid-filter
dispatches on — so an existing front-end does not have to move to filter[...] to keep working.
A method may be async; the call awaits it before the response is built.
Inside the class
| Member | What it holds |
|---|---|
this.$query | the query builder being filtered — the one the caller created |
this.$ctx | the request context (a real HttpContext in an app) |
this.$input | the raw decoded input the dispatch reads |
this.$parsed | the parsed input — filters, sort, search, pagination |
this.input(key, fallback?) | one raw value, or the whole object with no arguments |
Classes and specs are the same pipeline
defineFilter has not gone anywhere: a class's statics are compiled into exactly the FilterSpec
it would have produced, and both go through the same runner — same allow-list, same operators,
same search and sort, same page clamping. Reach for a spec when the policy is data (generated,
shared, or handed to the client codegen); reach for a class when a key
needs SQL of its own, or when the filter needs a service.
import { specFromFilterClass } from '@adonis-agora/filter'
specFromFilterClass(UserFilter) // the FilterSpec the class compiles toTesting one
The class is a plain class: construct it, hand it a recording builder, assert the calls.
import { test } from '@japa/runner'
import { applyFilterFromRequest } from '@adonis-agora/filter'
import { makeMockQueryBuilder } from '@adonis-agora/filter/testing'
import UserFilter from '#filters/user_filter'
test('fullName searches both name columns', async ({ assert }) => {
const qb = makeMockQueryBuilder()
const ctx = { request: { qs: () => ({ filter: { fullName: 'silva' } }) } }
await applyFilterFromRequest(qb, UserFilter, ctx)
assert.deepEqual(qb.find('whereRaw')?.args[1], ['%silva%'])
})Pass a containerResolver on the fake ctx to control how the class is constructed:
const ctx = {
request: { qs: () => ({}) },
containerResolver: { make: async () => new UserFilter(fakeTenants) },
}See Testing for the rest.
Controllers
Wiring a filter into AdonisJS controllers — the shape of a list endpoint, per-request policies, forced scopes a client cannot relax, strict mode and error handling, and response shapes.
Decorators
Bind a filter method to the request keys it answers with @filterFor, and declare a model's filterable, sortable and searchable columns where the columns live.