@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.
247 lines (182 loc) • 7.65 kB
Markdown
---
name: filter-codegen
description: >
Generate a typed @adonis-agora/filter-client builder from a defineFilter spec
in @adonis-agora/filter — generateFilterClient(spec, options) pure string
transform, generateFilterClients(manifest), the make:filter-client ace command
with a FilterClientManifest in config/filter.ts, emitted artifacts
(<Name>FilterFields union, <Name>FilterFieldTypes interface,
<Name>FilterFieldMeta, <Name>FilterQuery factory), fieldTypes operator
narrowing (kind | enumValues | typeRef | nullable), maxDepth/banner/clientModule
options, filterableFieldPaths/sortableFieldPaths enumerators, and adonisrc.ts
commands-barrel registration. Use when sharing one field allow-list between
browser and server, regenerating clients after spec changes, or emitting
enum-aware typed builders.
metadata:
type: core
library: "@adonis-agora/filter"
library_version: "0.7.0"
framework: adonisjs
sources:
- "DavideCarvalho/adonis-filter:docs/codegen/index.mdx"
- "DavideCarvalho/adonis-filter:docs/definitions/index.mdx"
---
# Client codegen: one spec, both ends of the wire
A `defineFilter` spec already knows which fields are filterable. Codegen turns
that spec into a typed front-end module — a `filterQueryTyped<Fields, FieldTypes>()`
builder scoped to the spec's fields — so browser and server share one
field-name allow-list, checked at compile time. Generation is a pure function
of the spec: string in, string out, no AST walk, no reflection.
## Setup
Register the command barrel, declare a manifest, run the command:
```typescript
// adonisrc.ts
{
commands: [
// ...
() => import('@adonis-agora/filter/commands'),
],
}
```
```typescript
// config/filter.ts
import { defineFilter } from '@adonis-agora/filter'
import type { FilterClientManifest } from '@adonis-agora/filter'
export const filters: FilterClientManifest = {
people: {
spec: defineFilter({
filterable: ['age', 'name', 'status'],
sortable: ['age', 'name'],
searchable: ['name'],
relations: { posts: { filterable: ['title', 'published'] } },
defaultSort: [{ field: 'name', direction: 'asc' }],
defaultSize: 25,
maxSize: 100,
}),
fieldTypes: { age: { kind: 'number' }, status: { enumValues: ['active', 'inactive'] } },
},
}
```
```bash
node ace make:filter-client
# create app/generated/filters/people_filter_client.ts
```
Defaults: entrypoint `config/filter.js`, output `app/generated/filters`. Pass
an explicit entrypoint/dir with
`node ace make:filter-client config/filter.js --output app/generated/filters`.
## Core patterns
### 1. The pure core — generateFilterClient
Skip the command entirely when you want to own the IO:
```typescript
import { defineFilter, generateFilterClient } from '@adonis-agora/filter'
const spec = defineFilter({
filterable: ['age', 'name', 'status'],
sortable: ['age', 'name'],
})
const code = generateFilterClient(spec, {
name: 'people',
fieldTypes: { age: { kind: 'number' }, status: { enumValues: ['active', 'inactive'] } },
})
// code is a TypeScript module string — write it wherever you like
```
For `name: 'people'` the module exports: `type PeopleFilterFields` (union of
filterable paths — base plus relation-dotted), `interface PeopleFilterFieldTypes`
(only when `fieldTypes` supplied), `const peopleFilterMeta` (runtime metadata),
and `function peopleFilterQuery()` (a `filterQueryTyped` factory).
Options: `clientModule` (import specifier, default `@adonis-agora/filter-client`),
`maxDepth` (relation-path cap, defaults to `spec.maxDepth`), `banner`
(the DO NOT EDIT header, default `true`).
Source: `docs/codegen/index.mdx`
### 2. Consume the generated client
Import the generated factory and get field-name-checked, operator-narrowed
queries:
```typescript
// web/people-table.ts
import { peopleFilterQuery } from '#generated/filters/people_filter_client'
const qs = peopleFilterQuery()
.contains('name', 'Al') // 'name' checked against PeopleFilterFields
.gte('age', 18) // number ops via fieldTypes narrowing
.toQueryString()
await fetch(`/people?${qs}`)
```
Source: `docs/codegen/index.mdx`
### 3. fieldTypes drives operator narrowing
The spec carries the allow-list but not column value types (Lucid models are
not reflected). Supply kinds per field path to unlock type-aware narrowing:
| `FilterFieldTypeInfo` | Emits |
|---|---|
| `{ kind: 'string' \| 'number' \| 'boolean' \| 'date' \| 'json' }` | matching TS type |
| `{ enumValues: ['A', 'B'] }` | union `"A" \| "B"` (wins over `kind`) |
| `{ typeRef: 'Role' }` | named type verbatim (wins over everything) |
| `{ nullable: true }` | appends `\| null` |
Without `fieldTypes` the client stays field-name-safe but operator-permissive.
On the server side the same declaration powers value coercion — one
declaration, both ends.
Source: `docs/codegen/index.mdx`, `docs/definitions/index.mdx`
### 4. Just enumerate the paths
`filterableFieldPaths(spec)` / `sortableFieldPaths(spec)` export the enumerated
field-path lists if you're building your own artifact:
```typescript
import { filterableFieldPaths } from '@adonis-agora/filter'
const paths = filterableFieldPaths(peopleSpec)
// ['age', 'name', 'status', 'posts.title', 'posts.published']
```
Source: `docs/codegen/index.mdx`
## Common mistakes
### [HIGH] Running make:filter-client with the commands barrel unregistered
Wrong:
```typescript
// adonisrc.ts — no filter commands entry
{ commands: [] }
```
Correct:
```typescript
{ commands: [() => import('@adonis-agora/filter/commands')] }
```
Ace only sees commands an app opts into; without the barrel the command fails
with "command not found", which reads as though it does not exist.
`node ace add @adonis-agora/filter` adds the entry for you.
Source: docs/codegen/index.mdx (Step 1 callout)
### [HIGH] Hand-editing generated clients instead of regenerating
Wrong:
```typescript
// renamed a spec field, then edited people_filter_client.ts by hand
```
Correct:
```bash
node ace make:filter-client # regenerate after every spec change
```
Generated modules carry a DO NOT EDIT banner and are a pure function of the
spec; a stale client keeps sending fields the server now drops, so filters
silently stop applying with no error on either end.
Source: docs/codegen/index.mdx (banner option, Step 4)
### [MEDIUM] Expecting codegen unions from a '*' allow-list
Wrong:
```typescript
defineFilter({ filterable: ['*'] }) // PeopleFilterFields falls back to string
```
Correct:
```typescript
defineFilter({ filterable: ['age', 'name', 'status'] }) // enumerable literal union
```
A star list cannot be enumerated, so the emitter degrades to a permissive
string union — the generated client looks typed but checks nothing.
Source: docs/codegen/index.mdx (filterableFieldPaths callout)
### [MEDIUM] Skipping fieldTypes then expecting operator narrowing
Wrong:
```typescript
generateFilterClient(spec, { name: 'people' })
peopleFilterQuery().gte('name', 5) // compiles — gte gated to orderable fields only
```
Correct:
```typescript
generateFilterClient(spec, {
name: 'people',
fieldTypes: { age: { kind: 'number' }, status: { enumValues: ['active', 'inactive'] } },
})
```
Without a field-type map every field is kind-unknown, so all operators compile
and mismatches surface only server-side as drops or coercion errors.
Source: docs/codegen/index.mdx ("fieldTypes — unlock operator narrowing")
See also: `packages/client/skills/filter-query-builder/SKILL.md` (the runtime
the generated code targets), `../filter-definitions/SKILL.md` (spec authoring).