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.
@adonis-agora/filter turns messy ?filter[name]=Al&filter[age][gte]=18 query strings into safe, validated Lucid queries. You parse the request into a structured input, then apply it to any Lucid query builder under a field allow-list — the allow-list is the security boundary, so client input can never probe arbitrary columns. The library does the rest: operator dispatch, AND/OR composition, ILIKE search, sorting, and offset pagination resolution.
The wire format is the Spatie / JSON:API convention (filter[field][op]=value, sort=-createdAt, page/size), so the companion @adonis-agora/filter-client builder produces exactly what the server parses — fully type-safe, framework-agnostic, with optional TanStack Table state sync.
One request, one call, one query — here is what applyFilterFromRequest does to a Lucid builder,
clause by clause:
1export default class UsersController {2 async index(ctx: HttpContext) {3 const { query } = await User.filter(ctx)4 5 query.preload('team')6 7 return query.filterPaginate()8 }9}The problem it solves
List endpoints accumulate filtering logic fast. Without a structure, every controller grows a tangle of if (qs.name) { query.where(...) } branches, ad-hoc operator handling, and string concatenation that invites injection bugs. @adonis-agora/filter replaces that with one declaration and one call:
- A filter is a class. One method per request key that needs SQL of its own, the builder on
this.$query, asetup()scope the client cannot relax — and constructor injection through the container, so a filter can use a service. Plain columns stay declarative in the statics. (The object form,defineFilter, is the same pipeline and still exported.) - The allow-list is the contract.
filterable,sortable, andsearchabledecide what is queryable. Anything else is dropped (or rejected withthrowOnInvalid). No accidental exposure of internal columns. - No hard Lucid import. The adapter targets a structural
QueryBuilderLikeinterface — any LucidModelQueryBuildersatisfies it, and the core stays unit-testable against a recording mock. - Parameterized & escaped. Values go through Lucid's parameter binding; LIKE patterns are escaped with
escapeLike(). Field names are charset-validated before they ever reach SQL.
Quickstart
The whole loop — install, write the filter, wire the model, query — in four steps. For the full walkthrough see Getting Started.
Install the server package:
node ace add @adonis-agora/filterWrite the filter — node ace make:filter user. The statics are the security boundary (a column that is not on a list cannot be filtered, sorted or searched, whatever the query string says); a method is for a key that needs SQL of its own:
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']
static searchable = ['name', 'email']
static sortable = ['name', 'createdAt']
static defaultSort = [{ field: 'createdAt', direction: 'desc' as const }]
/** The scope no query string can relax. */
setup() {
this.$query.whereNull('deletedAt')
}
/** `?filter[fullName]=silva` — a key with no column behind it. */
fullName(value: string) {
this.$query.whereRaw("first_name || ' ' || last_name ilike ?", [`%${value}%`])
}
}Point the model at it and the endpoint is one line — filters, search, sort and the page the request asked for:
import { compose } from '@adonisjs/core/helpers'
import { Filterable } from '@adonis-agora/filter'
import UserFilter from '#filters/user_filter'
export default class User extends compose(BaseModel, Filterable) {
static $filter = () => UserFilter
}export default class UsersController {
async index(ctx: HttpContext) {
return User.filterPaginate(ctx)
}
}Build the query string from the front-end with the typed client — done:
import { filterQuery } from '@adonis-agora/filter-client'
const qs = filterQuery()
.contains('name', 'Al')
.gte('age', 18)
.equals('status', 'active')
.sort('createdAt', 'desc')
.page(1, 25)
.toQueryString()
// → filter%5Bname%5D[contains]=Al&filter%5Bage%5D[gte]=18&filter%5Bstatus%5D=active&sort=-createdAt&page=1&size=25
await fetch(`/users?${qs}`)The builder is still yours
filterPaginate is the one-liner, not the only option: const { query } = await User.filter(ctx) hands the builder back — filtered, searched, sorted, nothing executed — so you can preload, withCount or project off it and then page it with query.filterPaginate(). See Filter Classes and Lucid Integration.
More than a WHERE clause
A parsed FilterInput combines five concerns in one request — column filters (with AND/OR), free-text search, sort, and offset pagination:
{
filters: [
{ field: 'status', operator: 'equals', value: 'active' },
{ field: 'age', operator: 'gte', value: 18 },
],
sort: [{ field: 'createdAt', direction: 'desc' }, { field: 'name', direction: 'asc' }],
search: 'fleet', // ILIKE across the configured searchable columns
page: 1,
size: 25,
}parseFilterRequest() understands every shape the client builder emits — bracket-notation operators (filter[age][gte]=18), array/comma values that become IN, sort=-createdAt, and both page/size and JSON:API page[number]/page[size].
Where to go next
Getting Started
Install, parse a request, apply it to a Lucid query under an allow-list, and paginate.
Operators
All 22 operators, SQL-symbol aliases, the wire format, and how each maps to Lucid.
Filter Classes
A method per request key, this.$query, setup(), and constructor injection through the container.
Decorators
@filterFor for keys a method name cannot spell, and @filterable / @sortable / @searchable on the model's columns.
Filter Config
The FilterConfig policy — allowed / sortable / searchable, defaults, and throwOnInvalid.
Relations
Filtering across Lucid relationships with the structural adapter.
Validation
Structural validation, VineJS integration, and the security boundary.
Controllers
Wiring parse → apply into AdonisJS controllers and routes.
Lucid Integration
Applying filters to Lucid models and query builders, pagination, and the QueryBuilderLike contract.
Client Builder
@adonis-agora/filter-client — the typed query builder, reactivity, and TanStack Table sync.
Testing
Unit-testing filters with a recording mock query builder, end-to-end tests with Japa.
Defining Filters
defineFilter + applyFilterFromRequest — a reusable definition with relations, tenant scope, and defaults.
Cursor Pagination
Keyset pagination — stable, seek-based pages with opaque forward/backward cursors.
Search
ILIKE, Postgres tsvector full-text search, and pgvector embedding-similarity ordering.
Client Codegen
Generate a typed client from a spec with generateFilterClient + make:filter-client.