Agora
Guides

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

OperatorLucid callValueExample
equalswhere(field, value)scalar{ field: 'status', operator: 'equals', value: 'active' }
notEqualswhereNot(field, value)scalar{ field: 'status', operator: 'notEquals', value: 'deleted' }
gtwhere(field, '>', value)scalar{ field: 'age', operator: 'gt', value: 18 }
gtewhere(field, '>=', value)scalar{ field: 'age', operator: 'gte', value: 18 }
ltwhere(field, '<', value)scalar{ field: 'age', operator: 'lt', value: 65 }
ltewhere(field, '<=', value)scalar{ field: 'age', operator: 'lte', value: 65 }

String (all case-insensitive via whereILike)

OperatorLucid callPatternExample
containswhereILike(field, '%v%')%value%{ field: 'name', operator: 'contains', value: 'fleet' }
iContainswhereILike(field, '%v%')%value%{ field: 'email', operator: 'iContains', value: 'ACME' }
startsWithwhereILike(field, 'v%')value%{ field: 'name', operator: 'startsWith', value: 'A' }
endsWithwhereILike(field, '%v')%value{ field: 'email', operator: 'endsWith', value: '.com' }
notContainswhereNot(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

OperatorLucid callValueExample
inwhereIn(field, values)array{ field: 'status', operator: 'in', value: ['A', 'B'] }
notInwhereNotIn(field, values)array{ field: 'role', operator: 'notIn', value: ['banned'] }
isAnyOfwhereIn(field, values)arrayAlias for in

isAnyOf produces the same WHERE field IN (...) clause as in. Use whichever reads better.

Range

OperatorLucid callValueExample
betweenwhereBetween(field, [low, high])[low, high]{ field: 'age', operator: 'between', value: [18, 65] }
notBetweenwhereNotBetween(field, [low, high])[low, high]{ field: 'price', operator: 'notBetween', value: [0, 10] }

Unary (no value)

OperatorLucid callExample
isNullwhereNull(field){ field: 'deletedAt', operator: 'isNull' }
isNotNullwhereNotNull(field){ field: 'email', operator: 'isNotNull' }
existswhereNotNull(field){ field: 'avatar', operator: 'exists' }
notExistswhereNull(field){ field: 'avatar', operator: 'notExists' }
isEmptywhere(field, ''){ field: 'bio', operator: 'isEmpty' }
isNotEmptywhereNot(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.

AliasCanonical
=, ==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 stringColumnFilter 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]=65two filters — one per operator key
GET /users?filter[name][contains]=fleet&filter[age][gte]=18&filter[status]=active

parses 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 stringsfilter[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. validateColumnFilter rejects 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 \ via escapeLike() 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:

dialectEscapesHow
'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.

On this page