Operators
The full operator set — 22 operators, SQL-symbol aliases, the Spatie/JSON:API wire format, AND/OR composition, and how each maps to Lucid.
A single filter condition is a ColumnFilter: a field, an operator, an optional value, and optional nested AND/OR arrays. parseFilterRequest builds these from the query string; applyFilter validates them and the Lucid adapter translates each into the matching where* call.
interface ColumnFilter {
field: string;
operator: FilterOperatorInput; // one of 22 operators, or a SQL-symbol alias
value?: unknown; // omitted for unary operators
AND?: ColumnFilter[]; // nested AND group
OR?: ColumnFilter[]; // nested OR group
}The operator list is exported as FILTER_OPERATORS and the alias map as OPERATOR_ALIASES from @adonis-agora/filter.
All 22 operators
Comparison
| Operator | Lucid call | Value | Example |
|---|---|---|---|
equals | where(field, value) | scalar | { field: 'status', operator: 'equals', value: 'active' } |
notEquals | whereNot(field, value) | scalar | { field: 'status', operator: 'notEquals', value: 'deleted' } |
gt | where(field, '>', value) | scalar | { field: 'age', operator: 'gt', value: 18 } |
gte | where(field, '>=', value) | scalar | { field: 'age', operator: 'gte', value: 18 } |
lt | where(field, '<', value) | scalar | { field: 'age', operator: 'lt', value: 65 } |
lte | where(field, '<=', value) | scalar | { field: 'age', operator: 'lte', value: 65 } |
String (all case-insensitive via whereILike)
| Operator | Lucid call | Pattern | Example |
|---|---|---|---|
contains | whereILike(field, '%v%') | %value% | { field: 'name', operator: 'contains', value: 'fleet' } |
iContains | whereILike(field, '%v%') | %value% | { field: 'email', operator: 'iContains', value: 'ACME' } |
startsWith | whereILike(field, 'v%') | value% | { field: 'name', operator: 'startsWith', value: 'A' } |
endsWith | whereILike(field, '%v') | %value | { field: 'email', operator: 'endsWith', value: '.com' } |
notContains | whereNot(field, value) + whereNotNull(field) | scalar | { field: 'name', operator: 'notContains', value: 'test' } |
contains and iContains are equivalent in this adapter — both compile to whereILike, which is case-insensitive on every dialect Lucid supports. notContains is implemented as whereNot(field, value) AND field IS NOT NULL (so NULL rows are excluded), not as a negated LIKE.
Array
| Operator | Lucid call | Value | Example |
|---|---|---|---|
in | whereIn(field, values) | array | { field: 'status', operator: 'in', value: ['A', 'B'] } |
notIn | whereNotIn(field, values) | array | { field: 'role', operator: 'notIn', value: ['banned'] } |
isAnyOf | whereIn(field, values) | array | Alias for in |
isAnyOf produces the same WHERE field IN (...) clause as in. Use whichever reads better.
Range
| Operator | Lucid call | Value | Example |
|---|---|---|---|
between | whereBetween(field, [low, high]) | [low, high] | { field: 'age', operator: 'between', value: [18, 65] } |
notBetween | whereNotBetween(field, [low, high]) | [low, high] | { field: 'price', operator: 'notBetween', value: [0, 10] } |
Unary (no value)
| Operator | Lucid call | Example |
|---|---|---|
isNull | whereNull(field) | { field: 'deletedAt', operator: 'isNull' } |
isNotNull | whereNotNull(field) | { field: 'email', operator: 'isNotNull' } |
exists | whereNotNull(field) | { field: 'avatar', operator: 'exists' } |
notExists | whereNull(field) | { field: 'avatar', operator: 'notExists' } |
isEmpty | where(field, '') | { field: 'bio', operator: 'isEmpty' } |
isNotEmpty | whereNot(field, '') | { field: 'bio', operator: 'isNotEmpty' } |
exists/notExists are aliases for isNotNull/isNull at the column level. isEmpty/isNotEmpty compare against the empty string '' (they do not also check NULL).
SQL-symbol aliases
The six scalar comparison operators accept their familiar SQL symbols. Aliases are normalized to the canonical operator during validation (and persisted in-place on the ColumnFilter), so the query builder never sees a symbol form.
| Alias | Canonical |
|---|---|
=, == | equals |
!=, <> | notEquals |
> | gt |
>= | gte |
< | lt |
<= | lte |
import { parseFilterRequest, applyFilter } from '@adonis-agora/filter'
// A client sending `?filter[age][>=]=18` parses to operator '>=',
// which applyFilter normalizes to 'gte' before building the query.If you need the canonical form programmatically, normalizeOperator() is exported:
import { normalizeOperator } from '@adonis-agora/filter'
normalizeOperator('=') // 'equals'
normalizeOperator('<>') // 'notEquals'
normalizeOperator('iContains') // 'iContains' (canonical operators pass through)Only the scalar comparison operators have symbol aliases. Array, range, string, and unary operators (in, between, contains, isNull, …) have no symbol form — use the canonical name.
The wire format
parseFilterRequest reads the Spatie / JSON:API conventions. Given a decoded request.qs():
| Query string | ColumnFilter produced |
|---|---|
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' } |
filter[age][gte]=18&filter[age][lte]=65 | two filters — one per operator key |
GET /users?filter[name][contains]=fleet&filter[age][gte]=18&filter[status]=activeparses to:
{
filters: [
{ field: 'name', operator: 'contains', value: 'fleet' },
{ field: 'age', operator: 'gte', value: '18' },
{ field: 'status', operator: 'equals', value: 'active' },
],
}Query-string values are always strings — filter[age][gte]=18 parses value: '18', not 18. SQL comparison still works because the database coerces, but if you need real numbers/dates (e.g. for app-level logic), coerce them after parseFilterRequest or use a VineJS validator.
Sort, pagination, search and distinct
?sort=-createdAt,name → [{ field: 'createdAt', direction: 'desc' }, { field: 'name', direction: 'asc' }]
?page=2&size=50 → page: 2, size: 50
?page[number]=2&page[size]=50 → page: 2, size: 50 (JSON:API nested form)
?search=fleet → search: 'fleet'
?distinct=city,tier → distinct: ['city', 'tier']
?distinct[]=city&distinct[]=tier → distinct: ['city', 'tier'] (repeated form)A leading - on a sort entry means descending. distinct names the columns of a
DISTINCT projection that
applyFilter applies for you — allow-listed like any other field, and limited to
root-table columns. Both parseFilterRequest and parseSpatieRequest read it.
The structured (POST) shape
The same parser accepts the object the client builder's
.build() returns, so a POST /search endpoint can hand the body straight in:
parseFilterRequest({
filter: { where: [{ field: 'age', operator: 'gte', value: 18 }] },
sort: [{ field: 'createdAt', direction: 'desc' }],
paginate: { page: 2, size: 25 },
})
// → { filters: [{ field: 'age', operator: 'gte', value: 18 }],
// sort: [{ field: 'createdAt', direction: 'desc' }], page: 2, size: 25 }An array of { field, operator } records under filter.where (or at the top
level, which is where OR/AND groups serialize to) is read as the condition list
itself. A real column named where is unaffected — filter[where]=lobby is still
an ordinary equals on where.
AND / OR composition
Top-level filters in the filters array are implicitly ANDed. For richer boolean logic, nest AND/OR arrays on a ColumnFilter. The adapter maps these onto Lucid's grouped where/orWhere closures.
Nested OR
{
filters: [
{
field: 'status',
operator: 'equals',
value: 'active',
OR: [
{ field: 'name', operator: 'contains', value: 'sync' },
{ field: 'email', operator: 'contains', value: 'sync' },
],
},
],
}Produces roughly: WHERE (status = 'active' OR name ILIKE '%sync%' OR email ILIKE '%sync%').
Group nodes (no field of their own)
A ColumnFilter can be a pure group — an empty field plus an AND/OR array. This is what the client builder emits for .or() / .and(). The validator recognizes it and validates only the nested arrays:
{
filters: [
{ field: '', operator: 'equals', value: undefined, OR: [
{ field: 'name', operator: 'contains', value: 'sync' },
{ field: 'email', operator: 'contains', value: 'sync' },
] },
],
}Nesting is capped at MAX_FILTER_DEPTH (10) to prevent stack-overflow from maliciously deep payloads. Exceeding it throws InvalidColumnFilterError. The constant is exported from @adonis-agora/filter.
Security
- Allow-list first. Operators only matter for fields that survive the allow-list in
applyFilter. A condition on a disallowed field is dropped before it reaches the adapter. See Filter Classes. - Field charset.
validateColumnFilterrejects any field name not matching^[a-zA-Z_][a-zA-Z0-9_.]*$, which blocks SQL injection through field names (dots are allowed for relation paths). - Parameterized values. Every value goes through Lucid's parameter binding — no raw interpolation.
- LIKE escaping. All string operators escape
%,_, and\viaescapeLike()before building the pattern, so user input can't inject wildcards.
import { escapeLike } from '@adonis-agora/filter'
escapeLike('50%_off') // '50\\%\\_off'escapeLike(value, dialect?) takes an optional second argument, because the two
major SQL families spell LIKE escaping differently:
dialect | Escapes | How |
|---|---|---|
'standard' (default) | %, _, \ | backslash — 50\%\_off |
'mssql' | %, _, [ | bracket — 50[%][_]off |
The adapter always uses 'standard', which is what Postgres, MySQL and SQLite
want. Pass 'mssql' only when you are building a pattern for SQL Server by hand.
Lucid Integration
How a filter reaches SQL — the builder stays yours, pagination is resolved rather than run, distinct projections for facet endpoints, and the structural contract that makes all of it work on any Lucid builder.
Filter Config
The FilterConfig policy object — allow-listing filterable, sortable and searchable columns, bounding page size, and choosing between dropping and rejecting a disallowed field.