Agora
Guides

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.

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

HalfAnswersWhere it lives
static filterable / sortable / searchablewhich columns a client may reach with the standard operatorsthe statics, read by the same runner a defineFilter spec drives
a methodwhat a key that is not a plain column means in SQLyour 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:

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

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

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

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

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

MemberWhat it holds
this.$querythe query builder being filtered — the one the caller created
this.$ctxthe request context (a real HttpContext in an app)
this.$inputthe raw decoded input the dispatch reads
this.$parsedthe 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.

app/filters/user_filter.ts
import { specFromFilterClass } from '@adonis-agora/filter'

specFromFilterClass(UserFilter) // the FilterSpec the class compiles to

Testing one

The class is a plain class: construct it, hand it a recording builder, assert the calls.

tests/unit/user_filter.spec.ts
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:

tests/unit/user_filter.spec.ts
const ctx = {
  request: { qs: () => ({}) },
  containerResolver: { make: async () => new UserFilter(fakeTenants) },
}

See Testing for the rest.

On this page