Agora

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}
GET /users?filter[status]=active&filter[isAdmin]=true&search=ana&sort=-createdAt&page=2select * from "users"where "tenant_id" = ?and "status" = ?and ("name" ilike ? or "email" ilike ?)order by "created_at" desclimit ? offset ?bindings[ 42, 'active', '%ana%', '%ana%', 25, 25 ]
you execute itThe page the request asked for was resolved and clamped, never run — so the terminal call stays yours. filterPaginate() uses it; paginate(page, size), exec() or a DISTINCT projection work just as well.
8 / 8

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, a setup() 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, and searchable decide what is queryable. Anything else is dropped (or rejected with throwOnInvalid). No accidental exposure of internal columns.
  • No hard Lucid import. The adapter targets a structural QueryBuilderLike interface — any Lucid ModelQueryBuilder satisfies 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/filter

Write 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:

app/filters/user_filter.ts
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:

app/models/user.ts
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
}
app/controllers/users_controller.ts
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:

web/users-table.ts
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

On this page