Agora
Guides

Validation

Built-in structural validation of column filters, the InvalidColumnFilterError, layering VineJS on parsed input, and turning failures into HTTP responses.

Validation in @adonis-agora/filter happens at two levels: the library's own structural validation (always on, runs inside applyFilter), and your application validation (optional, VineJS — for type coercion and business rules). This guide covers both.

Structural validation (built in)

Every time applyFilter is given filters, it runs validateColumnFilters before applying anything. This guards the shape of each ColumnFilter, independent of your allow-list:

  • field — must be a non-empty string matching ^[a-zA-Z_][a-zA-Z0-9_.]*$ (letters, digits, underscores, dots). Anything else is rejected — this blocks SQL injection through field names.
  • operator — must be one of the 22 known operators, or a SQL-symbol alias (normalized to canonical in place).
  • value shape — matched to the operator:
    • unary operators (isNull, isNotNull, isEmpty, isNotEmpty, exists, notExists) ignore any value;
    • between/notBetween require a 2-element array;
    • in/notIn/isAnyOf require an array;
    • everything else requires a defined value, and rejects null (use isNull).
  • depthAND/OR nesting beyond MAX_FILTER_DEPTH (10) is rejected, preventing stack-overflow DoS from deep payloads.
  • group nodes — a ColumnFilter with an empty field and an AND/OR array is treated as a pure boolean group; only its children are validated.

Each failure throws InvalidColumnFilterError with a descriptive message.

tests/unit/validation.spec.ts
import { validateColumnFilters, InvalidColumnFilterError } from '@adonis-agora/filter'

try {
  validateColumnFilters([{ field: 'name', operator: 'banana', value: 'x' }])
} catch (err) {
  if (err instanceof InvalidColumnFilterError) {
    // 'Unknown filter operator "banana". Valid operators: equals, notEquals, ...'
  }
}

validateColumnFilter and validateColumnFilters are exported so you can run them manually — e.g. before feeding client-derived filters into a whereHas sub-query via applyColumnFilters, which does not validate on its own.

throwOnInvalid — allow-list rejection

Structural validation guards the shape; the allow-list guards which columns. By default a disallowed field is dropped silently. Set throwOnInvalid: true to reject the request instead:

app/filters/user_filter.ts
export const userFilter = defineFilter({
  model: User,
  filterable: ['name', 'email', 'status'],
  throwOnInvalid: true, // a disallowed column is a 400, not a silent drop
})

Both structural failures and (when strict) allow-list failures surface as the same InvalidColumnFilterError, so one catch covers both. Which one you want is a product decision: dropping keeps a stale bookmark working, throwing tells an API consumer their query was wrong.

Turning failures into HTTP 400

In an AdonisJS controller, map InvalidColumnFilterError to a 400:

app/controllers/users_controller.ts
import type { HttpContext } from '@adonisjs/core/http'
import { applyFilterFromRequest, InvalidColumnFilterError } 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()

    try {
      const { page, size } = applyFilterFromRequest(query, userFilter, ctx)
      return query.paginate(page, size)
    } catch (error) {
      if (error instanceof InvalidColumnFilterError) {
        return ctx.response.badRequest({ message: error.message })
      }
      throw error
    }
  }
}

One endpoint at a time is rarely what you want. Put the branch in the exception handler and every filtered endpoint answers the same way:

app/exceptions/handler.ts
import { ExceptionHandler } from '@adonisjs/core/http'
import type { HttpContext } from '@adonisjs/core/http'
import { InvalidColumnFilterError } from '@adonis-agora/filter'

export default class HttpExceptionHandler extends ExceptionHandler {
  async handle(error: unknown, ctx: HttpContext) {
    if (error instanceof InvalidColumnFilterError) {
      return ctx.response.badRequest({ message: error.message })
    }

    return super.handle(error, ctx)
  }
}

Layering VineJS

The library validates filter structure, not your domain. Query-string values arrive as strings, and you may want real numbers/dates, enum constraints, or capped sizes. VineJS — AdonisJS's validation layer — is the idiomatic place for that. Validate the raw query, then parse:

app/validators/list_users.ts
import vine from '@vinejs/vine'

export const listUsersValidator = vine.compile(
  vine.object({
    filter: vine
      .object({
        status: vine.enum(['active', 'inactive']).optional(),
        age: vine
          .object({
            gte: vine.number().min(0).optional(),
            lte: vine.number().max(150).optional(),
          })
          .optional(),
      })
      .optional(),
    sort: vine.string().optional(),
    search: vine.string().trim().minLength(1).optional(),
    page: vine.number().min(1).optional(),
    size: vine.number().min(1).max(100).optional(),
  })
)
app/controllers/users_controller.ts
import type { HttpContext } from '@adonisjs/core/http'
import { applyFilterFromRequest, parseFilterRequest } from '@adonis-agora/filter'
import { listUsersValidator } from '#validators/list_users'
import { userFilter } from '#filters/user_filter'
import User from '#models/user'

export default class UsersController {
  async index(ctx: HttpContext) {
    // VineJS validates and coerces the raw query, then it is parsed into filter input
    const payload = await ctx.request.validateUsing(listUsersValidator)
    const input = parseFilterRequest(payload)

    const query = User.query()
    const { page, size } = applyFilterFromRequest(query, userFilter, ctx, { input })

    return query.paginate(page, size)
  }
}

VineJS coerces age.gte to a real number and rejects out-of-range values with its standard 422 response; @adonis-agora/filter then handles operator dispatch and allow-listing. The two layers are complementary — VineJS owns value correctness, the allow-list owns column exposure.

VineJS is optional. Without it, @adonis-agora/filter still parses, structurally validates, and allow-lists — values just stay as strings, which SQL comparison tolerates. Reach for VineJS when you need coercion, enums, or per-field bounds.

On this page