# @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.
