Agora
Definitions

Defining Filters

defineFilter + applyFilterFromRequest — a declarative, reusable filter definition (filterable/sortable allow-lists, relation whitelist with a depth cap, tenant scope, and server defaults) applied to a Lucid query in one call.

parseFilterRequest + applyFilter are the two-function primitive — great when a policy lives in one controller. When the same policy is reused across endpoints (and grows relations, a tenant scope, and default filters), reach for the declarative definition: build a FilterSpec once with defineFilter, then apply it on every request with applyFilterFromRequest.

A FilterSpec is a plain, framework-free options object — no decorators, no metadata reflection. That keeps the policy readable at a glance, testable without a request, and importable from anywhere: the same object drives the controller, the codegen that emits the typed client, and your unit tests.

Or write it as a class

Everything on this page is also available as a filter class — the same options as statics, plus a method per request key that needs SQL of its own and a setup() scope, with the class resolved through the IoC container so it can use your services. A class compiles to exactly the FilterSpec described here, so the two forms share one runner.

defineFilter — the declaration

Build the spec once (module scope) and freeze it. It captures the whole policy: what is filterable/sortable, which relations are reachable, tenant scoping, and server-side defaults.

app/filters/user_filter.ts
import type { HttpContext } from '@adonisjs/core/http'
import { defineFilter } from '@adonis-agora/filter'

export const userFilter = defineFilter({
  filterable: ['name', 'email', 'age', 'status'], // '*' to allow any base column
  sortable: ['name', 'createdAt'],                // defaults to `filterable`
  searchable: ['name', 'email'],                  // ILIKE scan for the `search` term

  // Whitelisted relations — bare (unprefixed) columns; the request field is the
  // dotted path `relation.column` (see Relation Filtering).
  relations: {
    posts: { filterable: ['title', 'status'], sortable: ['createdAt'] },
  },

  // Server policy, never exposed to the client allow-list:
  tenant: {
    column: 'tenantId',
    // `ctx` is typed `unknown` — the spec is framework-free and never imports
    // HttpContext — so narrow it to what you actually pass in.
    resolve: (ctx) => (ctx as HttpContext).auth.user!.tenantId,
  },
  defaultFilters: [{ field: 'deletedAt', operator: 'isNull' }],
  defaultSort: [{ field: 'createdAt', direction: 'desc' }],

  defaultSize: 25,
  maxSize: 100,
})

The returned FilterSpec is frozen and reusable. Its isFilterable(field) / isSortable(field) predicates are the allow-list boundary — relation-path and depth aware.

Options

OptionWhat it does
filterableBase columns clients may filter on. '*' allows any base column. Also accepts a colocated map — see The colocated filterable map below. Required.
sortableBase columns clients may sort on. Defaults to filterable.
searchableColumns the free-text search term scans with a portable ILIKE.
fieldTypesPer-field column value kinds — drives server-side value coercion and type-aware client codegen. See Field types & value coercion below.
fullTextOpt-in Postgres tsvector search — routes search through websearch_to_tsquery. See Full-Text Search.
relationsWhitelisted relations + their nested filterable/sortable columns. See Relation Filtering.
modelThe owning Lucid model — unlocks to-many aggregates ($count/$sum/…) by introspecting relation metadata, and supplies the default table. Optional; features degrade gracefully without it.
tableThe root table name — the correlated-subquery outer alias surfaced to computed/aggregate function sources. Defaults to model.table.
maxDepthMax relation-path hops (base column = 0). Defaults to the deepest declared relation nesting.
aliasesClient-alias → target-field remapping. See Field Aliases.
computedVirtual/computed fields — alias → dev-declared SQL expression. See Computed Fields.
vectorSimilarityOpt-in pgvector embedding-similarity ordering. See Vector Similarity.
tenantAuto-scope a column to the tenant id resolved from ctx.
defaultFiltersServer-declared filters always AND-combined with the request (bypass the allow-list — trusted policy).
defaultSortSort applied when the request supplies none.
defaultSize / maxSizePage-size default (25) and hard cap (100).
throwOnInvalidThrow InvalidColumnFilterError on a disallowed field instead of dropping it.

defineFilter validates the declaration itself — a missing filterable, or a negative/non-integer maxDepth, throws FilterDefinitionError at wiring time (a developer error), separate from the request-time InvalidColumnFilterError the runner raises for a bad client request.

Field types & value coercion

A filter value arriving over a query string is always a string?filter[dayOfWeek][equals]=3 yields '3'. Postgres papers over the benign cases with an implicit cast (day_of_week = '3' works, is_recurring = 'false' works), so the gap stays invisible until a client sends something uncastable: filter[isRecurring]=xyz raises invalid input syntax for type boolean at the database, surfacing as a 500 driven by pure user input.

fieldTypes closes that gap. Declare a field's kind and its value is coerced up front; a value that can't be coerced is treated exactly like a disallowed field — dropped by default, or a loud InvalidColumnFilterError (→ 400) under throwOnInvalid:

import { defineFilter } from '@adonis-agora/filter'

export const scheduleFilter = defineFilter({
  filterable: ['advisorId', 'dayOfWeek', 'isRecurring'],
  fieldTypes: {
    dayOfWeek: { kind: 'number' },    // '3' → 3 ; 'xyz' → rejected
    isRecurring: { kind: 'boolean' }, // 'true'/'1' → true, 'false'/'0' → false ; else rejected
  },
})

The kinds are 'string' | 'number' | 'boolean' | 'date' | 'json' | 'unknown' (backed by coerceFilterValue). Coercion rules worth knowing: an empty string is not a valid number (Number('') would silently become 0), null passes through every kind (a legitimate IS NULL), and a date string is validated but handed back verbatim (the driver parses ISO strings; re-zoning '2026-07-15' to midnight UTC would shift the day for negative-offset clients). Undeclared fields keep the previous behaviour — no coercion — so adding fieldTypes to an existing spec is backwards compatible.

The same declaration drives type-aware client codegen: make:filter-client reads it and narrows operators per field. One declaration, both ends — see Client Codegen.

The colocated filterable map

When several fields have a non-string kind, writing each one twice (once in filterable, once in fieldTypes) is noise. filterable therefore also accepts a map form (FilterableMap) that states each field and its kind in one place:

// These two are equivalent:
defineFilter({
  filterable: ['advisorId', 'dayOfWeek', 'isRecurring'],
  fieldTypes: { dayOfWeek: { kind: 'number' }, isRecurring: { kind: 'boolean' } },
})

defineFilter({
  filterable: { advisorId: 'string', dayOfWeek: 'number', isRecurring: 'boolean' },
})

The map desugars at the boundary: its keys become the allow-list, its values become fieldTypes. Everything downstream sees the array form it always saw, so this is purely an authoring convenience. A field whose kind carries no contract uses 'string' — the no-op kind. An explicit fieldTypes entry still wins per field, which is how you add codegen-only richness (enumValues, typeRef) on top of a bare kind.

The map form is only for the base filterable list. Relation columns (relations.posts.filterable) stay the array/'*' form.

applyFilterFromRequest — the one call

In the controller, apply the spec to a Lucid query straight from the HttpContext. It sources the input from ctx.request.qs(), injects the server scope (tenant + default filters) before the allow-listed request filters, applies the default sort when the request has none, and returns the resolved offset pagination.

app/controllers/users_controller.ts
import type { HttpContext } from '@adonisjs/core/http'
import { applyFilterFromRequest } from '@adonis-agora/filter'
import { userFilter } from '#filters/user_filter'
import User from '#models/user'

export default class UsersController {
  async index(ctx: HttpContext) {
    const query = User.query()
    const { page, size } = applyFilterFromRequest(query, userFilter, ctx)
    return query.paginate(page, size)
  }
}

That single call replaces the whole parse-validate-apply sequence: it reads the request through the spec's configured source, resolves aliases, prunes anything outside the allow-list, applies the tenant scope and default filters, and returns the pagination the controller hands to paginate.

Tenant scope is un-tamperable

The tenant constraint and defaultFilters are applied as trusted server policy — they bypass the allow-list on purpose. A client that sends ?filter[tenantId]=999 cannot override the resolved tenant: tenantId isn't in filterable, so the client filter is dropped, while the server's where('tenantId', 42) still lands.

The tenant scope is opt-in per request: when resolve(ctx) returns null/undefined (no tenant in context), scoping is simply skipped.

Under the hood

applyFilterFromRequest is a thin wrapper. If you need to reuse the spec against a hand-built input (a non-HTTP caller, a test), the pieces are exported:

specToFilterConfig(spec) projects a FilterSpec onto the per-call FilterConfig the runner consumes (the allow-lists become predicates so relation-path + depth rules survive; defaultSort fields are unioned into the sortable predicate so a default ordering is never dropped).

Pass a pre-parsed input via options.input to skip reading ctx.request.qs().

For keyset (cursor) pagination, use the sibling applyCursorFromRequest — same spec, same server scope, cursor params instead of page/size.

import { applyFilterFromRequest } from '@adonis-agora/filter'

// Non-HTTP / test caller — supply the input directly:
applyFilterFromRequest(query, userFilter, ctx, {
  input: { filters: [{ field: 'status', operator: 'equals', value: 'active' }] },
})

On this page