@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.
381 lines (283 loc) • 22.9 kB
Markdown
# @adonis-agora/filter
## 0.9.0
### Minor Changes
- [#35](https://github.com/DavideCarvalho/adonis-agora-filter/pull/35) [`b6424ec`](https://github.com/DavideCarvalho/adonis-agora-filter/commit/b6424ec696dbf8b50928e03a619d0227f5436345) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - One class style that does everything: `BaseFilter<TQuery>` — the generic base behind `BaseModelFilter` (Lucid) and custom backends alike — plus `applyCustomFilter` (methods per key over a caller-created draft, same envelope, loud rejections) and `groupByCount` end to end (`FilterQueryBuilder.groupByCount()` + `groupByCount[field]` envelope + `groupByCountFromRequest` over Lucid `GROUP BY` or a `GroupByCountAdapter`, with scope, search and paging). Lucid gains the `select`/`count`/`groupBy`/`offset` aggregation seam (optional, non-breaking).
## 0.8.1
### Patch Changes
- [#32](https://github.com/DavideCarvalho/adonis-agora-filter/pull/32) [`1fe94ce`](https://github.com/DavideCarvalho/adonis-agora-filter/commit/1fe94cee8ef847c25b163b68f3eaba9eb4c1c7ac) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Declare `@adonisjs/core` as a required peer, not an optional one.
The package's main entry point reads the ambient `HttpContext` (the Lucid macros and the
`Filterable` mixin both resolve the request context from AsyncLocalStorage when no `ctx` is
passed), so importing `@adonis-agora/filter` without `@adonisjs/core` installed failed at load
with `ERR_MODULE_NOT_FOUND` rather than warning at install. The peer now says what the code does.
`@adonisjs/lucid` stays optional and structural — the library talks to any `QueryBuilderLike`. A
new test walks the barrel's import graph and fails on any value import of `@adonisjs/lucid`, which
is the promise that _is_ kept.
## 0.8.0
### Minor Changes
- [#29](https://github.com/DavideCarvalho/adonis-agora-filter/pull/29) [`2fd4700`](https://github.com/DavideCarvalho/adonis-agora-filter/commit/2fd47000b87b025edf4c117ef97b1d9b731731a0) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Filter classes — the `adonis-lucid-filter` authoring shape, with the whole pipeline behind it.
A filter can now be a class:
```ts
@inject()
export default class UserFilter extends BaseModelFilter {
declare $query: ModelQueryBuilderContract<typeof User>;
constructor(private tenants: TenantsService) {
super();
}
static model = User;
static filterable = ["name", "email", "status"];
static sortable = ["name", "createdAt"];
static searchable = ["name", "email"];
setup() {
this.$query.where("tenantId", this.tenants.current(this.$ctx));
}
fullName(value: string) {
this.$query.whereRaw("first_name || ' ' || last_name ilike ?", [
`%${value}%`,
]);
}
}
```
- **`BaseModelFilter`** — a method per request key, the builder on `this.$query`, a `setup()` that
runs before anything the request asked for. Writing the method is what exposes the key, so it
needs no allow-list entry; plain columns stay declarative in the statics.
- **Container-resolved** — the class is constructed through the request's IoC resolver, so
`@inject()` on the constructor works exactly as it does in a controller.
- **`Filterable` model mixin** — `static $filter = () => UserFilter`, then
`User.filterPaginate(ctx)` for the whole endpoint, or `const { query } = await User.filter(ctx)`
to keep composing and `query.filterPaginate()` to page it with what the request asked for.
- **The macros take a class** too, and `filterPaginate()` with no arguments pages a query a
previous call already filtered.
- **`node ace make:filter user`** scaffolds the class.
- Key matching follows the same conventions as `adonis-lucid-filter`: `static blacklist`,
`static dropId`, `static camelCase`, and a bare top-level key (`?minAge=21`) reaching a method of
that name. Keys the wire format owns (`sort`, `page`, `search`, …) are never dispatched.
Everything compiles to the same `FilterSpec` the declarative `defineFilter` produces, so classes
and specs share one runner — same allow-listing, operators, search, sort and page clamping.
Note for the class form: `applyFilterFromRequest(query, UserFilter, ctx)` and `User.filter(ctx)`
resolve to `{ page, size }` and `{ query, page, size }` respectively, never to the builder as the
promise's own value — a Lucid query builder is thenable, so a promise resolving to one would run
the query instead of handing it back.
- [#30](https://github.com/DavideCarvalho/adonis-agora-filter/pull/30) [`9796b85`](https://github.com/DavideCarvalho/adonis-agora-filter/commit/9796b851b078d6d1aa977bbe5a396058d8059d55) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Decorators for the class form, and a `FilterClass` type fix.
`@filterFor('team.name')` binds a filter method to the request key(s) it answers, for keys a method
name could never spell and for one method answering several keys; a bound method stops answering to
its own name, so the public key survives a rename. A key the wire format owns (`sort`, `page`, …)
is refused when the class is defined.
`@filterable()`, `@sortable()` and `@searchable()` declare a model's filterable surface on the
columns themselves, stacked under Lucid's `@column()`; a filter picks them up through its
`static model`, and a static on the filter still replaces the list outright so a stricter filter can
narrow a shared model. `@filterable('number')` also declares the field's kind.
Both work under legacy (`experimentalDecorators`, what AdonisJS compiles) and standard TC39
decorators — the flavour is detected at call time.
Fixes `FilterClass`, which intersected `Record<string, unknown>`: no concrete
`class UserFilter extends BaseModelFilter` satisfied that, so `static $filter = () => UserFilter`
on a model did not typecheck. A type test over a real Lucid model now covers it.
## 0.7.1
### Patch Changes
- [#26](https://github.com/DavideCarvalho/adonis-agora-filter/pull/26) [`9b07c15`](https://github.com/DavideCarvalho/adonis-agora-filter/commit/9b07c159716c5032812bbf5c2360ff82df7be4c5) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Ship TanStack Intent AI-agent skills: five core skills under `packages/core/skills/` (filter-basics, filter-safety, filter-definitions, filter-querying, filter-codegen) and one under `packages/client/skills/` (filter-query-builder), plus repo-level `_artifacts/` (domain map, skill spec, skill tree) and a `check-skills` GitHub workflow validating them on PRs. Skills are included in each package's `files` so they land in `node_modules` on install.
## 0.7.0
### Minor Changes
- [#23](https://github.com/DavideCarvalho/adonis-agora-filter/pull/23) [`2bd4e7b`](https://github.com/DavideCarvalho/adonis-agora-filter/commit/2bd4e7baa50d3d0b4a25c834fc9edf612f7d854a) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - `parseSpatieRequest` now parses `distinct`, matching `parseFilterRequest`.
`parseSpatieRequest` is documented as the additive counterpart to
`parseFilterRequest` — same filter, sort and search shapes, plus cursor
pagination, includes and sparse fieldsets — so a controller can swap one for the
other. It did not read `distinct`, even though the runner applies it. Swapping
the parser turned a working `?distinct=city` into a full, un-deduped result set
with nothing to signal that the parameter had been dropped.
Both string (`distinct=city,tier`) and repeated (`distinct[]=city&distinct[]=tier`)
forms now parse identically in the two parsers.
- [#23](https://github.com/DavideCarvalho/adonis-agora-filter/pull/23) [`2bd4e7b`](https://github.com/DavideCarvalho/adonis-agora-filter/commit/2bd4e7baa50d3d0b4a25c834fc9edf612f7d854a) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Add the `configure` hook, so `node ace add @adonis-agora/filter` actually wires the package.
The install instructions have always said to run `node ace add @adonis-agora/filter`,
but the package shipped no `configure` hook, so the command installed the
dependency and wired nothing. Two things silently did not happen: the provider
was never added to `adonisrc.ts` (no `applyFilterFromRequest` / `filterPaginate`
macros on `ModelQueryBuilder`), and the commands barrel was never registered, so
`make:filter-client` never appeared in `node ace list`.
`node ace add @adonis-agora/filter` — or `node ace configure @adonis-agora/filter`
on an already-installed package — now registers both:
```ts
// adonisrc.ts
providers: [() => import('@adonis-agora/filter/filter_provider')],
commands: [() => import('@adonis-agora/filter/commands')],
```
Nothing is published to `config/`: a filter policy is a per-model
`defineFilterSpec` call in your own code, not global configuration. Apps that
wired those two entries by hand need no change — the codemod is idempotent.
- [#23](https://github.com/DavideCarvalho/adonis-agora-filter/pull/23) [`2bd4e7b`](https://github.com/DavideCarvalho/adonis-agora-filter/commit/2bd4e7baa50d3d0b4a25c834fc9edf612f7d854a) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - `parseFilterRequest` now understands the client builder's structured shape, closing a silent unfiltered-response bug.
A POST search endpoint that hands `filterQuery()…build()` straight to
`parseFilterRequest` — the pairing the guides describe — did not work. The
builder returns
```ts
{ filter: { where: [{ field: 'status', operator: 'in', value: [...] }] },
sort: [{ field: 'createdAt', direction: 'desc' }],
paginate: { page: 2, size: 25 } }
```
and the parser reshaped it as if `where` were a column: the entire condition list
became one `in` filter on a field named `where`, which the allow-list then
pruned, while `sort` and `paginate` were discarded for not being strings. The
endpoint answered with **every row, unsorted and unpaginated**, and raised
nothing — the failure looked like a working search with a broad result set.
The same shape reaches a plain GET too, because OR/AND groups serialize to a
top-level `where[0][field]=…`, so grouped queries were silently unfiltered as
well.
`parseFilterRequest` now recognises an already-structured condition list — under
`filter.where` or at the top level — and takes it as the filters, with nested
`AND`/`OR` groups intact. It also reads `sort` in the `[{ field, direction }]`
form and maps `paginate: { page, size }` onto `page`/`size`.
Existing requests are unaffected: `filter[field]=…`, `sort=-createdAt`,
`page`/`size` and `page[number]`/`page[size]` parse exactly as before, and a real
column named `where` (`filter[where]=lobby`, `filter[where][contains]=lob`,
`filter[where][]=a&filter[where][]=b`) is still treated as a column — only an
array of `{ field, operator }` records is read as a structured list, and a query
string cannot produce one by accident.
Note that `include` is still not consumed: eager-loading stays the caller's
`preload` call.
### Patch Changes
- [#23](https://github.com/DavideCarvalho/adonis-agora-filter/pull/23) [`2bd4e7b`](https://github.com/DavideCarvalho/adonis-agora-filter/commit/2bd4e7baa50d3d0b4a25c834fc9edf612f7d854a) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Report the real version from the exported `VERSION` constant.
`VERSION` is a hand-written literal next to a "keep in sync with package.json"
comment, and it had not been touched since the first release: the package shipped
`0.2.0` through `0.6.0` while `VERSION` still answered `'0.1.0'`. Anything gating
on it — a feature check, a bug report, a diagnostics banner — got a wrong answer.
It now reads `0.6.0`, and a test compares it against `package.json` so the next
release cannot silently drift again.
- [#22](https://github.com/DavideCarvalho/adonis-agora-filter/pull/22) [`a33b2fe`](https://github.com/DavideCarvalho/adonis-agora-filter/commit/a33b2febc48baa3dafcf6a703e4250bdebbde275) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Fix a 500 when `distinct` is given a relation path.
`distinct` was gated on the `allowed` list and nothing else. A relation path like
`posts.title` is filterable by design — declaring `relations: { posts: { filterable: ['title'] } }`
whitelists it, and `make:filter-client` enumerates it into the generated field
union — so `?distinct=posts.title` cleared the check and was handed to Lucid
verbatim:
```sql
select distinct "posts"."title" from "users"
--> ERROR: missing FROM-clause entry for table "posts"
```
Lucid filters a relation with a correlated `EXISTS` subquery, so the relation is
never joined into the outer `FROM` and there is no alias to project a column
from. A `whereHas` on the same relation in the same request does not help.
**If your `distinct` works today, nothing changes.** Root-table columns —
including ones qualified with the root table's own name — behave exactly as
before, as do the alias resolution and the allow-list drop.
**If you pass a relation path** (`posts.title`) or a to-many aggregate path
(`posts.$count`), you now get a defined refusal instead of a database error: the
field is dropped from the `distinct` list (the remaining fields still apply), or
raises `InvalidColumnFilterError` — a 400, not a 500 — when your spec sets
`throwOnInvalid`. The message names the cause: it is the missing join, not the
allow-list, so adding the path to `filterable` will not (and should not) change
it. Filtering on that path keeps working; only projecting it is refused.
- [#23](https://github.com/DavideCarvalho/adonis-agora-filter/pull/23) [`2bd4e7b`](https://github.com/DavideCarvalho/adonis-agora-filter/commit/2bd4e7baa50d3d0b4a25c834fc9edf612f7d854a) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Declare a supported Node range in `engines` again, instead of one exact version.
Both packages shipped `"engines": { "node": "v26.7.0" }` — an exact version, and
plainly the output of `node -v`, leading `v` and all. Every consumer on any other
Node got an unsatisfied-engine warning on install, and anyone running with
`engine-strict` (or a package manager that treats it as fatal) could not install
at all. The pinned version was also higher than anything the project itself uses:
CI runs Node 22 and `.nvmrc` names Node 20.
`engines` states the floor the package actually supports, which is `>=20.6.0` —
the same range it declared before, and the one the rest of the Agora packages
use. Nothing about the code changed; this only stops a false incompatibility
signal.
## 0.6.0
### Minor Changes
- Parity sync from nestjs-filter: execute the server-side `distinct` projection (was a silent no-op), computed (virtual) fields for filter + sort (verbatim-string + `({alias}) => sql` forms), and native to-many aggregate fields (`$count`/`$sum`/`$avg`/`$min`/`$max`) auto-discovered from Lucid relation metadata — value stays parameterized (injection-safe), identifiers quoted.
## 0.5.0
### Minor Changes
- [#6](https://github.com/DavideCarvalho/adonis-filter/pull/6) [`e39da2c`](https://github.com/DavideCarvalho/adonis-filter/commit/e39da2c6f7e47553990f3295414b23d508894c9a) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Os macros `query.applyFilterFromRequest(spec, ctx?)` e `query.filterPaginate(spec, ctx?)` passam a aceitar `ctx` opcional: quando omitido, leem o `HttpContext` ativo do AsyncLocalStorage do Adonis (`HttpContext.getOrFail()`). Nos controllers (99% dos casos) você chama `query.applyFilterFromRequest(spec)` sem passar o ctx. Fora de uma request (job/command), passe o ctx explicitamente. O default vive só no macro (camada Adonis); a função livre `applyFilterFromRequest` continua framework-agnostic, exigindo o ctx.
## 0.4.0
### Minor Changes
- [#4](https://github.com/DavideCarvalho/adonis-filter/pull/4) [`76b2ca8`](https://github.com/DavideCarvalho/adonis-filter/commit/76b2ca82d27811b09dbd98629be6964e9aee6167) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Add chainable Lucid query-builder macros via an optional `FilterProvider`
Register `@adonis-agora/filter/filter_provider` to get the method-call form of
`applyFilterFromRequest` on any Lucid query builder:
```ts
// filter + sort + search, then keep chaining:
const rows = await User.query()
.where("tenantId", tenant.id)
.applyFilterFromRequest(userFilter, ctx)
.orderBy("createdAt", "desc");
// filter + paginate in one terminal call (returns Lucid's paginator):
const page = await User.query().filterPaginate(userFilter, ctx);
```
`applyFilterFromRequest` applies the spec's server scope + allow-listed
filter/sort/search and returns the query for chaining (pagination resolved but
not applied); `filterPaginate` additionally calls `paginate(page, size)`. The
free functions are unchanged and work without the provider — the macros only add
the chainable sugar, so `@adonisjs/lucid` is an optional peer. `registerFilterMacros`
is also exported for manual registration.
## 0.3.1
### Patch Changes
- [`e6fb05c`](https://github.com/DavideCarvalho/adonis-filter/commit/e6fb05c0e137cd4ab3bad1f9ce5626216e670a8f) - Fix `QueryBuilderLike` rejecting every real Lucid query builder
Passing a Lucid builder to `applyFilterFromRequest` (or any adapter entry
point) failed to typecheck in consuming apps:
```
Argument of type 'ModelQueryBuilderContract<typeof Post, Post>' is not
assignable to parameter of type 'QueryBuilderLike'.
Types of property 'where' are incompatible.
```
The message blames `where`, but `where` was fine — TS reports the first member
it tries. The real culprit was `whereHas`, declared here as
`whereHas(relation: string, ...)`. Lucid types its own as
`<Name extends ExtractModelRelations<Model>>(relation: Name, ...)`, a union of
the model's literal relation names, and `string` is not assignable to that
union under the contravariant parameter check — so no real builder ever
satisfied the interface. Runtime was always fine; this was types-only.
`relation` is now `any`, which is the only type that both accepts the `string`
the adapter passes and is assignable to each model's relation-name union.
Marking the member optional does not help: an optional member that is present
is still checked.
A compile-time guard against real `@adonisjs/lucid` types now covers this
(`test/types/lucid_compat.types.ts`, run by `pnpm typecheck`). Lucid is a
devDependency only — nothing under `src/` imports it, so the package stays
framework-free. A hand-transcribed stub of Lucid's types was tried first and
compiled clean while real Lucid did not, so the guard uses Lucid's own `.d.ts`.
## 0.3.0
### Minor Changes
- [`d20245c`](https://github.com/DavideCarvalho/adonis-filter/commit/d20245cc6818120098d0f9027b59284380fd9f7e) - `filterable` accepts a colocated map: field name and its kind in one place
The array form makes every non-string field appear twice — once in `filterable`, once in
`fieldTypes` — which is ceremony for what is usually a short list:
```ts
filterable: ['advisorId', 'dayOfWeek', 'isRecurring'],
fieldTypes: { dayOfWeek: { kind: 'number' }, isRecurring: { kind: 'boolean' } },
```
`filterable` now also accepts a map, declaring both at once:
```ts
filterable: { advisorId: 'string', dayOfWeek: 'number', isRecurring: 'boolean' },
```
It desugars at the `defineFilter` boundary — the keys become the allow-list, the values become
`fieldTypes` — so everything downstream (predicates, runner, codegen) sees exactly the spec the
array form produces. An explicit `fieldTypes` entry still wins per field, which is how a caller
adds codegen-only richness (`enumValues`/`typeRef`) on top of a bare kind.
Both existing forms (`string[]` and `'*'`) are untouched and remain the right choice when no field
needs a declared type — `'string'` is the no-op kind, so a spec of only string columns should keep
using the array.
## 0.2.0
### Minor Changes
- [`058f0c0`](https://github.com/DavideCarvalho/adonis-filter/commit/058f0c0ef3dd224277663e6a5d40c0ef58e6bbd7) - `fieldTypes` on `defineFilter`: server-side value validation, and one type declaration for both ends
A filter value arriving over a query string is always a string, and Postgres implicitly casts the
benign cases — `day_of_week = '3'` and `is_recurring = 'false'` both work — so the gap stayed
invisible. It surfaces when a client sends something uncastable: `?filter[isRecurring][equals]=xyz`
becomes `is_recurring = 'xyz'`, which Postgres rejects with `invalid input syntax for type boolean`.
That is a **500 on a public endpoint, driven entirely by user input**. The allow-list guarded which
FIELD could be filtered; nothing guarded the VALUE that reached the column.
`defineFilter` now accepts `fieldTypes`, and a declared field has its value coerced before it ever
reaches the driver. An uncoercible value is treated exactly like a disallowed field — dropped by
default, or a loud `InvalidColumnFilterError` (→ 400 instead of 500) under `throwOnInvalid`. The
existing semantics are reused rather than a second error path invented.
```ts
export const availabilityFilter = defineFilter({
filterable: ["advisorId", "dayOfWeek", "isRecurring"],
fieldTypes: {
dayOfWeek: { kind: "number" },
isRecurring: { kind: "boolean" },
},
});
```
The same declaration now also feeds `make:filter-client`, which previously required repeating the
types in the codegen manifest. Declaring a kind once drives both value coercion and the client's
operator narrowing; an explicit manifest `fieldTypes` still wins when the client wants richer
codegen-only info (`enumValues`/`typeRef`).
Details:
- Array-valued operators (`in`, `between`, ...) coerce element-wise and fail as a whole if any
element fails — a partially-coerced list would filter on something the client never asked for.
- Pattern operators (`contains`, `startsWith`, ...) are never coerced: their argument is a LIKE
pattern, so turning `contains: '3'` into the number `3` would destroy it.
- `date` values are validated but handed back verbatim, never rewritten — converting `'2026-07-15'`
to a `Date` would silently re-zone a date-only value and shift the day for negative-offset clients.
- Undeclared fields are untouched, so this is backwards compatible and opt-in.
- `FilterFieldKind` moved from `generate_client.ts` to `types.ts` (it is no longer codegen-only) and
is re-exported from its old path.