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.
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
| Option | What it does |
|---|---|
filterable | Base columns clients may filter on. '*' allows any base column. Also accepts a colocated map — see The colocated filterable map below. Required. |
sortable | Base columns clients may sort on. Defaults to filterable. |
searchable | Columns the free-text search term scans with a portable ILIKE. |
fieldTypes | Per-field column value kinds — drives server-side value coercion and type-aware client codegen. See Field types & value coercion below. |
fullText | Opt-in Postgres tsvector search — routes search through websearch_to_tsquery. See Full-Text Search. |
relations | Whitelisted relations + their nested filterable/sortable columns. See Relation Filtering. |
model | The owning Lucid model — unlocks to-many aggregates ($count/$sum/…) by introspecting relation metadata, and supplies the default table. Optional; features degrade gracefully without it. |
table | The root table name — the correlated-subquery outer alias surfaced to computed/aggregate function sources. Defaults to model.table. |
maxDepth | Max relation-path hops (base column = 0). Defaults to the deepest declared relation nesting. |
aliases | Client-alias → target-field remapping. See Field Aliases. |
computed | Virtual/computed fields — alias → dev-declared SQL expression. See Computed Fields. |
vectorSimilarity | Opt-in pgvector embedding-similarity ordering. See Vector Similarity. |
tenant | Auto-scope a column to the tenant id resolved from ctx. |
defaultFilters | Server-declared filters always AND-combined with the request (bypass the allow-list — trusted policy). |
defaultSort | Sort applied when the request supplies none. |
defaultSize / maxSize | Page-size default (25) and hard cap (100). |
throwOnInvalid | Throw 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.
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' }] },
})Testing
Unit-testing a filter spec against the shipped recording mock, asserting the allow-list and the server scope, and end-to-end Japa tests against a real database.
Relation Filtering
Declaratively whitelist relations in a FilterSpec — a dotted request field (relation.column) is translated into a nested Lucid whereHas subquery, bounded by a depth cap.