Getting Started
Add declarative query filtering to an AdonisJS project in minutes — install, declare a filter next to your model, apply it in a controller, and build the matching query string on the front-end.
@adonis-agora/filter is a pair of small, focused packages:
| Package | Where it runs | What it does |
|---|---|---|
@adonis-agora/filter | server (AdonisJS) | read a request through a declared policy → apply it to a Lucid query → resolve pagination |
@adonis-agora/filter-client | browser / Node | a fluent, type-safe builder that emits exactly the query string the server parses |
There is no config file and no decorator. A filter is a class in app/filters/ — a method per
request key that needs SQL of its own, plain columns declared as statics — and applying it is one
call in a controller.
Prerequisites
- Node.js 20.6+
- AdonisJS 6 with Lucid (
@adonisjs/lucid) - TypeScript 5+
Install
node ace add @adonis-agora/filterFor the front-end, install the client builder in your web app or shared package:
npm install @adonis-agora/filter-clientNothing here needs the provider
The package has no runtime dependency on Lucid — it targets a structural interface that any
Lucid query builder satisfies — and its functions need no service provider and nothing in
adonisrc.ts, so the npm/pnpm tabs above are enough to follow this guide.
node ace add additionally runs the configure hook, which writes two entries into adonisrc.ts:
the optional ./filter_provider (chainable query-builder macros)
and the commands barrel that makes make:filter-client discoverable. If you
installed with npm/pnpm, you can add either by hand later.
Write the filter
node ace make:filter user creates app/filters/user_filter.ts. The statics are the security
boundary — read them as the query vocabulary this endpoint publishes — and a method is how a key
that has no column behind it gets its SQL:
import { BaseModelFilter } from '@adonis-agora/filter'
import type { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model'
import User from '#models/user'
export default class UserFilter extends BaseModelFilter {
declare $query: ModelQueryBuilderContract<typeof User>
static model = User
static filterable = ['name', 'email', 'age', 'status'] // columns clients may filter on
static sortable = ['name', 'createdAt'] // defaults to `filterable`
static searchable = ['name', 'email'] // columns the `search` term scans
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.whereNull('deletedAt')
}
/** `?filter[fullName]=silva` — writing the method is what exposes the key. */
fullName(value: string) {
this.$query.whereRaw("first_name || ' ' || last_name ilike ?", [`%${value}%`])
}
}A column that is not on a list and has no method cannot be filtered, sorted or searched, whatever
the query string says — it is dropped, or rejected with a 400 when the class sets
throwOnInvalid. The rest of
what a filter can declare — relations, tenant scoping, computed fields, aliases, value coercion —
is in Defining Filters, and the class form itself is covered in
Filter Classes.
A filter can use your services
The class is constructed by the IoC container, so @inject() on the constructor works exactly as
it does in a controller — a filter that needs a tenant resolver, a policy service or a cache just
asks for it.
The #filters subpath
#filters/user_filter is an AdonisJS subpath import, not something this library invents. Add it
once to your package.json, alongside the ones the starter kit ships:
"imports": {
"#filters/*": "./app/filters/*.js"
}Point the model at it
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…
}Apply it in a controller
One call reads the request through the filter, applies the surviving filters, search and sort to the Lucid query, and pages it the way the request asked:
import type { HttpContext } from '@adonisjs/core/http'
import User from '#models/user'
export default class UsersController {
async index(ctx: HttpContext) {
return User.filterPaginate(ctx)
}
}When the endpoint needs more than that, take the builder instead — it comes back filtered, sorted and searched, with nothing executed yet:
async index(ctx: HttpContext) {
const { query } = await User.filter(ctx)
query.whereNotNull('confirmedAt').preload('team').withCount('posts')
return query.filterPaginate() // the page the request asked for, already clamped
}import router from '@adonisjs/core/services/router'
const UsersController = () => import('#controllers/users_controller')
router.get('/users', [UsersController, 'index'])That is the whole endpoint. It is also the whole library, for most applications.
Nothing executes until you say so
size is resolved and clamped to [1, maxSize] and page floored at 1, but the query is only
ever mutated. That leaves the terminal operation to you — filterPaginate(), an explicit
paginate(page, size), a plain exec(), or a DISTINCT projection off the same filtered query.
See Lucid Integration.
What a request actually does
Take one:
GET /users?filter[status]=active&filter[age][gte]=18&search=fleet&sort=-createdAt&page=1&size=25Against the filter above, it:
- Reads
ctx.request.qs()into{ filters: [...], search: 'fleet', sort: [...], page: 1, size: 25 } - Resolves aliases, then validates each filter — operator known, value shape correct, field charset safe, depth ≤ 10
- Prunes anything outside the allow-list —
statusandagesurvive;filter[passwordHash]never becomes SQL - Applies the survivors as
WHERE/ILIKE/ORDER BYclauses, with every client value bound as a parameter - Dispatches any key the class owns a method for to that method — the declarative path never sees it
- Resolves
{ page: 1, size: 25 }, whichfilterPaginate()(or your ownpaginate) then runs
The query string it understands is the Spatie / JSON:API convention, which is also exactly what the client builder emits:
| Query string | Parsed into |
|---|---|
filter[status]=active | { field: 'status', operator: 'equals', value: 'active' } |
filter[id]=1,2,3 | { field: 'id', operator: 'in', value: ['1','2','3'] } |
filter[id][]=1&filter[id][]=2 | { field: 'id', operator: 'in', value: ['1','2'] } |
filter[age][gte]=18 | { field: 'age', operator: 'gte', value: '18' } |
sort=-createdAt,name | [{ field: 'createdAt', direction: 'desc' }, { field: 'name', direction: 'asc' }] |
search=fleet | search: 'fleet' |
distinct=city,tier (or distinct[]=…) | distinct: ['city', 'tier'] |
page=2&size=50 (or page[number]/page[size]) | page: 2, size: 50 |
Build the request from the front-end
The client builder produces that wire format from type-checked calls, so the query string is never hand-assembled on either side:
import { filterQuery } from '@adonis-agora/filter-client'
const qs = filterQuery()
.equals('status', 'active')
.gte('age', 18)
.search('fleet')
.sort('createdAt', 'desc')
.page(1, 25)
.toQueryString()
const res = await fetch(`/users?${qs}`)make:filter-client goes one step further and generates a builder from the spec you just wrote,
so a field the server does not publish is a compile error in the front-end — see
Client Codegen.
The two-function primitive
applyFilterFromRequest is a wrapper over two exported functions, and for a one-off endpoint whose
policy will never be reused you can call them directly:
import type { HttpContext } from '@adonisjs/core/http'
import { parseFilterRequest, applyFilter } from '@adonis-agora/filter'
import AuditLog from '#models/audit_log'
export default class AuditLogsController {
async index({ request }: HttpContext) {
const input = parseFilterRequest(request.qs()) // pure reshape — no validation, no allow-list
const query = AuditLog.query()
const { page, size } = applyFilter(query, input, {
allowed: ['action', 'actorId'], // the allow-list, inline
sortable: ['createdAt'],
})
return query.paginate(page, size)
}
}parseFilterRequest reshapes a decoded query object into a FilterInput; applyFilter is where
validation, pruning and the clamps live. They are also what you reach for when the input is not an
HTTP query string at all — a POST body, a job payload, a test:
const input = parseFilterRequest(request.body()) // a POST /users/search endpoint
const input = parseFilterRequest(request.input('query', {})) // input nested under one keyThe moment the same policy shows up in a second endpoint, move it into a filter class (or a
defineFilter spec) — that is also what the codegen and the tenant scope read.
Next steps
Filter Classes — methods, setup(), injected services, and every way to apply one.
Defining Filters — the full declaration: relations, tenant scope, computed fields, aliases, and value coercion.
Operators — all 22 operators, SQL-symbol aliases, and the wire format.
Controllers — per-request policies, forced scopes, error handling, and response shapes.
Client Builder — the typed front-end query builder and framework adapters.
Filter
A typed query-filter language for AdonisJS — turn Spatie/JSON:API query strings into safe, allow-listed Lucid queries, with a typed client builder for the front-end.
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.