Agora

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:

PackageWhere it runsWhat it does
@adonis-agora/filterserver (AdonisJS)read a request through a declared policy → apply it to a Lucid query → resolve pagination
@adonis-agora/filter-clientbrowser / Nodea 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/filter

For the front-end, install the client builder in your web app or shared package:

npm install @adonis-agora/filter-client

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

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'] // 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:

package.json
"imports": {
  "#filters/*": "./app/filters/*.js"
}

Point the model at it

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

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

app/controllers/users_controller.ts
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
}
start/routes.ts
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=25

Against the filter above, it:

  1. Reads ctx.request.qs() into { filters: [...], search: 'fleet', sort: [...], page: 1, size: 25 }
  2. Resolves aliases, then validates each filter — operator known, value shape correct, field charset safe, depth ≤ 10
  3. Prunes anything outside the allow-list — status and age survive; filter[passwordHash] never becomes SQL
  4. Applies the survivors as WHERE / ILIKE / ORDER BY clauses, with every client value bound as a parameter
  5. Dispatches any key the class owns a method for to that method — the declarative path never sees it
  6. Resolves { page: 1, size: 25 }, which filterPaginate() (or your own paginate) then runs

The query string it understands is the Spatie / JSON:API convention, which is also exactly what the client builder emits:

Query stringParsed 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=fleetsearch: '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:

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

app/controllers/audit_logs_controller.ts
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 key

The 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.

On this page