@adonis-agora/filter
Version:
Server-side query filtering/sorting/pagination for AdonisJS — Spatie-style input, a Lucid adapter, and field allow-listing. Part of the Agora ecosystem.
249 lines (190 loc) • 8.32 kB
Markdown
---
name: filter-basics
description: >
Add Spatie/JSON:API-style query filtering to an AdonisJS endpoint with
@adonis-agora/filter. Covers parseFilterRequest(qs), applyFilter(query, input,
{ allowed, sortable, searchable }) with the returned/clamped { page, size },
the declarative defineFilter + applyFilterFromRequest flow (tenant scope,
defaultFilters, defaultSort, defaultSize/maxSize), the optional provider
macros .applyFilterFromRequest() / .filterPaginate(), wire format parsing
(filter[field][op]=value, sort=-createdAt, page/size), and input sourcing from
query string, POST body, or a nested key. Use when building a filtered list
endpoint, wiring a reusable filter policy, or deciding free functions vs macros.
metadata:
type: core
library: "@adonis-agora/filter"
library_version: "0.7.0"
framework: adonisjs
sources:
- "DavideCarvalho/adonis-filter:docs/getting-started.mdx"
- "DavideCarvalho/adonis-filter:docs/guides/controllers.mdx"
- "DavideCarvalho/adonis-filter:docs/guides/filter-classes.mdx"
- "DavideCarvalho/adonis-filter:docs/guides/provider.mdx"
- "DavideCarvalho/adonis-filter:docs/guides/operators.mdx"
---
# Filter basics: parse a request, apply it to a Lucid query
The whole server surface is two functions: `parseFilterRequest(request.qs())`
reshapes the request into a structured `FilterInput`, then
`applyFilter(query, input, config)` validates it, prunes it against an
allow-list, mutates your Lucid query in place, and returns the resolved
`{ page, size }`. No config file, no decorators; the optional service provider
only adds chainable macros.
## Setup
```bash
npm install @adonis-agora/filter
```
```typescript
// app/controllers/users_controller.ts
import type { HttpContext } from '@adonisjs/core/http'
import { parseFilterRequest, applyFilter } from '@adonis-agora/filter'
import User from '#models/user'
export default class UsersController {
async index({ request }: HttpContext) {
const input = parseFilterRequest(request.qs())
const query = User.query()
const { page, size } = applyFilter(query, input, {
allowed: ['name', 'email', 'age', 'status'],
sortable: ['name', 'createdAt'],
searchable: ['name', 'email'],
defaultSize: 25,
maxSize: 100,
})
return query.paginate(page, size)
}
}
```
```typescript
// start/routes.ts
import router from '@adonisjs/core/services/router'
const UsersController = () => import('#controllers/users_controller')
router.get('/users', [UsersController, 'index'])
```
A request like
`GET /users?filter[status]=active&filter[age][gte]=18&search=fleet&sort=-createdAt&page=1&size=25`
parses, validates, prunes against the allow-list, applies WHERE/ORDER BY/ILIKE,
and returns `{ page: 1, size: 25 }` for `query.paginate()`.
## Core patterns
### 1. Two-function primitive — parse once, apply once
`parseFilterRequest` is a pure reshape (no validation, no allow-listing);
`applyFilter` owns validation and pruning. It reads any decoded object, so the
same controller serves GET query strings and structured POST bodies:
```typescript
import { parseFilterRequest, applyFilter } from '@adonis-agora/filter'
// GET — decoded query string
const input = parseFilterRequest(request.qs())
// POST /search — the body may be the flat Spatie shape or the structured
// object the client builder's .build() returns ({ filter: { where }, sort, paginate })
const bodyInput = parseFilterRequest(request.body())
```
Source: `docs/guides/controllers.mdx`, `docs/guides/operators.mdx`
### 2. Reusable policies with defineFilter + applyFilterFromRequest
When a policy spans endpoints, declare a `FilterSpec` once at module scope and
apply it in one call. Server scope (tenant + `defaultFilters`) is injected
before allow-listed request filters; `defaultSort` applies when the request has
none:
```typescript
// app/filters/user_filter.ts
import { defineFilter } from '@adonis-agora/filter'
import User from '#models/user'
export const userFilter = defineFilter({
filterable: ['name', 'email', 'age', 'status'],
sortable: ['name', 'createdAt'],
searchable: ['name', 'email'],
relations: { posts: { filterable: ['title', 'status'] } },
tenant: { column: 'tenantId', resolve: (ctx) => (ctx as any).auth.user!.tenantId },
defaultFilters: [{ field: 'deletedAt', operator: 'isNull' }],
defaultSort: [{ field: 'createdAt', direction: 'desc' }],
defaultSize: 25,
maxSize: 100,
})
```
```typescript
// app/controllers/users_controller.ts
import { applyFilterFromRequest } from '@adonis-agora/filter'
import { userFilter } from '#filters/user_filter'
import User from '#models/user'
async index(ctx: HttpContext) {
const query = User.query()
const { page, size } = applyFilterFromRequest(query, userFilter, ctx)
return query.paginate(page, size)
}
```
Source: `docs/definitions/index.mdx`
### 3. Optional macros — inline chaining
Register the provider (`node ace add @adonis-agora/filter`, or add
`() => import('@adonis-agora/filter/filter_provider')` under `providers` in
`adonisrc.ts`) and every model query gains `.applyFilterFromRequest(spec)` /
`.filterPaginate(spec)`:
```typescript
import { userFilter } from '#filters/user_filter'
import User from '#models/user'
// ctx read from AsyncLocalStorage inside a request
return User.query().where('active', true).applyFilterFromRequest(userFilter)
// filter + paginate in one terminal call
return User.query().filterPaginate(userFilter)
```
The free `applyFilterFromRequest(query, spec, ctx, options?)` always needs an
explicit `ctx` — the AsyncLocalStorage fallback exists only in the macro layer.
Source: `docs/guides/provider.mdx`
### 4. Pagination is returned, not applied
`applyFilter` resolves `size = clamp(input.size ?? defaultSize ?? 25, 1, maxSize ?? 100)`
and `page = max(1, input.page ?? 1)` but leaves execution to you — so you can
paginate, run plain `.exec()`, or layer a DISTINCT projection on the same
filtered query.
Source: `docs/getting-started.mdx`, `docs/guides/filter-classes.mdx`
## Common mistakes
### [CRITICAL] Feeding raw request page/size to paginate()
Wrong:
```typescript
const input = parseFilterRequest(request.qs())
await User.query().paginate(input.page ?? 1, input.size ?? 25)
```
Correct:
```typescript
const query = User.query()
const { page, size } = applyFilter(query, input, { allowed: ['name'], maxSize: 100 })
return query.paginate(page, size)
```
`applyFilter` clamps size into `[1, maxSize]` and floors page at 1; values taken
straight off the query string skip the clamp entirely, so `?size=500000`
executes against the database.
Source: docs/getting-started.mdx ("Why pagination is returned, not applied"); docs/guides/filter-classes.mdx (Pagination bounds)
### [HIGH] Putting the tenant column in the allow-list
Wrong:
```typescript
export const userFilter = defineFilter({
filterable: ['name', 'tenantId'], // client can now constrain tenantId too
tenant: { column: 'tenantId', resolve: (ctx) => ctx.tenantId },
})
```
Correct:
```typescript
export const userFilter = defineFilter({
filterable: ['name'],
tenant: { column: 'tenantId', resolve: (ctx) => ctx.tenantId },
})
```
The tenant scope always lands as trusted server policy; if the same column is
also client-filterable, a sent value AND-combines with it and silently returns
an empty result set. Same rule for pre-scoped queries: constrain before
`applyFilter` and leave the column out of `allowed`.
Source: docs/definitions/index.mdx ("Tenant scope is un-tamperable"); docs/guides/controllers.mdx
### [MEDIUM] Expecting `search` to work without `searchable`
Wrong:
```typescript
applyFilter(query, input, { allowed: ['name'] })
// ?search=fleet is silently skipped — no searchable columns declared
```
Correct:
```typescript
applyFilter(query, input, {
allowed: ['name'],
searchable: ['name', 'email'],
})
```
The free-text term only routes anywhere when `searchable` is non-empty;
otherwise it is dropped with no error and rows come back unfiltered by search.
Source: docs/guides/filter-classes.mdx ("searchable enables free-text search")
See also: `../filter-safety/SKILL.md` (tightening the boundary before exposing
the endpoint publicly), `../filter-definitions/SKILL.md` (growing the policy
into a reusable spec).