@adonis-agora/filter 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/dist/commands/main.d.ts +5 -3
  3. package/dist/commands/main.d.ts.map +1 -1
  4. package/dist/commands/main.js +4 -2
  5. package/dist/commands/main.js.map +1 -1
  6. package/dist/commands/make_filter.d.ts +18 -0
  7. package/dist/commands/make_filter.d.ts.map +1 -0
  8. package/dist/commands/make_filter.js +32 -0
  9. package/dist/commands/make_filter.js.map +1 -0
  10. package/dist/src/apply_from_request.d.ts +8 -0
  11. package/dist/src/apply_from_request.d.ts.map +1 -1
  12. package/dist/src/apply_from_request.js +70 -21
  13. package/dist/src/apply_from_request.js.map +1 -1
  14. package/dist/src/base_model_filter.d.ts +109 -0
  15. package/dist/src/base_model_filter.d.ts.map +1 -0
  16. package/dist/src/base_model_filter.js +89 -0
  17. package/dist/src/base_model_filter.js.map +1 -0
  18. package/dist/src/decorator_metadata.d.ts +41 -0
  19. package/dist/src/decorator_metadata.d.ts.map +1 -0
  20. package/dist/src/decorator_metadata.js +83 -0
  21. package/dist/src/decorator_metadata.js.map +1 -0
  22. package/dist/src/decorators.d.ts +73 -0
  23. package/dist/src/decorators.d.ts.map +1 -0
  24. package/dist/src/decorators.js +118 -0
  25. package/dist/src/decorators.js.map +1 -0
  26. package/dist/src/filter_class.d.ts +46 -0
  27. package/dist/src/filter_class.d.ts.map +1 -0
  28. package/dist/src/filter_class.js +238 -0
  29. package/dist/src/filter_class.js.map +1 -0
  30. package/dist/src/filterable_mixin.d.ts +49 -0
  31. package/dist/src/filterable_mixin.d.ts.map +1 -0
  32. package/dist/src/filterable_mixin.js +46 -0
  33. package/dist/src/filterable_mixin.js.map +1 -0
  34. package/dist/src/index.d.ts +8 -1
  35. package/dist/src/index.d.ts.map +1 -1
  36. package/dist/src/index.js +5 -1
  37. package/dist/src/index.js.map +1 -1
  38. package/dist/src/lucid_macros.d.ts +31 -0
  39. package/dist/src/lucid_macros.d.ts.map +1 -1
  40. package/dist/src/lucid_macros.js +28 -4
  41. package/dist/src/lucid_macros.js.map +1 -1
  42. package/dist/src/pagination_stash.d.ts +4 -0
  43. package/dist/src/pagination_stash.d.ts.map +1 -0
  44. package/dist/src/pagination_stash.js +19 -0
  45. package/dist/src/pagination_stash.js.map +1 -0
  46. package/dist/stubs/main.d.ts +3 -0
  47. package/dist/stubs/main.d.ts.map +1 -0
  48. package/dist/stubs/main.js +4 -0
  49. package/dist/stubs/main.js.map +1 -0
  50. package/dist/stubs/make/filter/main.stub +45 -0
  51. package/package.json +10 -5
  52. package/skills/filter-basics/SKILL.md +248 -0
  53. package/skills/filter-codegen/SKILL.md +246 -0
  54. package/skills/filter-definitions/SKILL.md +337 -0
  55. package/skills/filter-querying/SKILL.md +319 -0
  56. package/skills/filter-safety/SKILL.md +304 -0
  57. package/stubs/main.ts +4 -0
  58. package/stubs/make/filter/main.stub +45 -0
@@ -0,0 +1,319 @@
1
+ ---
2
+ name: filter-querying
3
+ description: >
4
+ Advanced querying with @adonis-agora/filter beyond basic offset filtering —
5
+ keyset/cursor pagination (applyCursor, applyCursorFromRequest, buildCursorPage,
6
+ buildKeyset, opaque base64url encodeCursor/decodeCursor, after/before/first/last
7
+ params), the three search modes (searchable ILIKE scan vs fullText tsvector
8
+ websearch_to_tsquery/ts_rank vs vectorSimilarity pgvector distance metrics
9
+ cosine/l2/innerProduct with threshold/topK), DISTINCT projections and their
10
+ root-table-column restriction, and the richer request surface (parseSpatieRequest
11
+ with include/sparse fieldsets/cursor params, resolveInputFromRequest sources,
12
+ normalizeInput key casing). Use when serving stable deep pages, ranking by
13
+ relevance or embeddings, projecting distinct column values, or accepting
14
+ filters from a POST body.
15
+ metadata:
16
+ type: core
17
+ library: "@adonis-agora/filter"
18
+ library_version: "0.7.0"
19
+ framework: adonisjs
20
+ sources:
21
+ - "DavideCarvalho/adonis-filter:docs/pagination/index.mdx"
22
+ - "DavideCarvalho/adonis-filter:docs/search/index.mdx"
23
+ - "DavideCarvalho/adonis-filter:docs/search/full-text.mdx"
24
+ - "DavideCarvalho/adonis-filter:docs/search/vector-similarity.mdx"
25
+ - "DavideCarvalho/adonis-filter:docs/guides/lucid.mdx"
26
+ - "DavideCarvalho/adonis-filter:docs/definitions/request-input.mdx"
27
+ ---
28
+
29
+ # Structured querying: cursors, search modes, distinct
30
+
31
+ Offset pagination (`applyFilter` → `page`/`size`) drifts as rows are inserted
32
+ and gets slow on deep pages. Keyset pagination seeks from a boundary row; text
33
+ `search` routes through one of two engines you pick per policy; `distinct`
34
+ projects the values that exist under the current filters.
35
+
36
+ ## Setup
37
+
38
+ ```typescript
39
+ // app/controllers/users_controller.ts
40
+ import type { HttpContext } from '@adonisjs/core/http'
41
+ import { applyCursorFromRequest, buildCursorPage } from '@adonis-agora/filter'
42
+ import { userFilter } from '#filters/user_filter'
43
+ import User from '#models/user'
44
+
45
+ export default class UsersController {
46
+ async index(ctx: HttpContext) {
47
+ const query = User.query()
48
+
49
+ // same spec as offset flow; cursor params instead of page/size
50
+ const resolved = applyCursorFromRequest(query, userFilter, ctx, { primaryKey: 'id' })
51
+
52
+ const rows = await query.exec()
53
+ const page = buildCursorPage(rows.map((r) => r.serialize()), resolved)
54
+
55
+ return {
56
+ data: page.items,
57
+ nextCursor: page.nextCursor,
58
+ prevCursor: page.prevCursor,
59
+ hasNext: page.hasNext,
60
+ hasPrev: page.hasPrev,
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ Cursor params arrive as `after`/`before`/`first`/`last` (or JSON:API
67
+ `page[after]`…). `after` wins if both bounds are sent. Filters, search, and sort
68
+ pass through the same allow-list boundary as `applyFilter`.
69
+
70
+ ## Core patterns
71
+
72
+ ### 1. Keyset building blocks
73
+
74
+ The effective sort plus a primary-key tiebreaker forms the keyset; the query is
75
+ limited to `size + 1` so `buildCursorPage` can detect a next page:
76
+
77
+ ```typescript
78
+ import { applyCursor, buildCursorPage } from '@adonis-agora/filter'
79
+
80
+ const resolved = applyCursor(
81
+ User.query(),
82
+ { sort: [{ field: 'name', direction: 'asc' }], after: cursor, first: 20 },
83
+ { allowed: ['name'], primaryKey: 'id' },
84
+ )
85
+
86
+ const rows = await query.exec()
87
+ const page = buildCursorPage(rows.map((r) => r.serialize()), resolved)
88
+ ```
89
+
90
+ A malformed cursor is ignored, not fatal. Backward paging (`before`) reverses
91
+ the walk internally and `buildCursorPage` restores your requested order;
92
+ `prevCursor` from a forward page is a valid `before`.
93
+
94
+ Source: `docs/pagination/index.mdx`
95
+
96
+ ### 2. Full-text search (keywords) via fullText
97
+
98
+ When the policy declares `fullText`, the request `search` term routes through
99
+ Postgres tsvector matching instead of ILIKE:
100
+
101
+ ```typescript
102
+ import { defineFilter } from '@adonis-agora/filter'
103
+
104
+ export const articleFilter = defineFilter({
105
+ filterable: ['status'],
106
+ fullText: {
107
+ column: 'search_vector', // stored, GIN-indexed tsvector column
108
+ columnKind: 'tsvector',
109
+ language: 'english',
110
+ rank: true, // ORDER BY ts_rank(...) DESC
111
+ },
112
+ })
113
+ ```
114
+
115
+ `?search=quick brown fox` becomes
116
+ `WHERE "search_vector" @@ websearch_to_tsquery('english', ?)` — raw user input
117
+ is safe because `websearch_to_tsquery` tolerates stray punctuation and the term
118
+ always travels bound. Plain text columns work too (`columnKind: 'text'`),
119
+ wrapped in `to_tsvector` at query time. `applyFullTextSearch` is exported for
120
+ direct use outside the runner.
121
+
122
+ Source: `docs/search/full-text.mdx`
123
+
124
+ ### 3. Vector similarity (embeddings) via vectorSimilarity
125
+
126
+ Rank rows nearest-first by pgvector distance to a query embedding — supplied in
127
+ code, never through the URL:
128
+
129
+ ```typescript
130
+ export const docFilter = defineFilter({
131
+ filterable: ['status'],
132
+ vectorSimilarity: { column: 'embedding', metric: 'cosine', threshold: 0.25, topK: 20 },
133
+ })
134
+
135
+ // controller — compute the embedding, then pass it in options:
136
+ const embedding = await embeddingService.embed(q)
137
+ const { page, size } = applyFilterFromRequest(query, docFilter, ctx, {
138
+ vectorSimilarity: embedding,
139
+ })
140
+ ```
141
+
142
+ Similarity ordering lands before user sort (sort becomes the tiebreaker) and
143
+ runs only when both policy and request carry an embedding. The embedding binds
144
+ as `?::vector`; NaN/Infinity components throw rather than emitting bad SQL.
145
+ `applyVectorSimilarity` is exported for direct use.
146
+
147
+ Source: `docs/search/vector-similarity.mdx`
148
+
149
+ ### 4. DISTINCT projections come free on the wire format
150
+
151
+ `?distinct=status` parses into `input.distinct`, is allow-listed like any
152
+ field, and applies itself:
153
+
154
+ ```typescript
155
+ const input = parseFilterRequest(request.qs())
156
+ applyFilter(query, input, { allowed: ['status', 'baseId'] })
157
+ const rows = await query.select('status') // DISTINCT already applied
158
+ ```
159
+
160
+ ### 5. Richer request shapes
161
+
162
+ ```typescript
163
+ import {
164
+ parseSpatieRequest,
165
+ resolveInputFromRequest,
166
+ normalizeInput,
167
+ } from '@adonis-agora/filter'
168
+
169
+ // Spatie/JSON:API superset: includes, sparse fieldsets, cursor page params
170
+ const input = parseSpatieRequest(request.qs())
171
+
172
+ // source filters from body (POST /search), auto-merging query+body on writes
173
+ const raw = resolveInputFromRequest(request, 'auto')
174
+
175
+ // snake_case API → camelCase columns (top-level keys only)
176
+ normalizeInput({ company_id: 5 }, { normalizer: 'camelCase' })
177
+ ```
178
+
179
+ Source: `docs/definitions/request-input.mdx`
180
+
181
+ ## Common mistakes
182
+
183
+ ### [HIGH] Passing model instances to buildCursorPage
184
+
185
+ Wrong:
186
+
187
+ ```typescript
188
+ const rows = await query.exec()
189
+ const page = buildCursorPage(rows, resolved) // Lucid model instances
190
+ ```
191
+
192
+ Correct:
193
+
194
+ ```typescript
195
+ const rows = await query.exec()
196
+ const page = buildCursorPage(rows.map((r) => r.serialize()), resolved)
197
+ ```
198
+
199
+ `buildCursorPage` reads keyset values off plain objects by field name; model
200
+ instances do not expose those plain keys, so boundary cursors encode garbage
201
+ and the next page silently starts from the wrong row.
202
+
203
+ Source: docs/pagination/index.mdx ("Feed buildCursorPage plain rows")
204
+
205
+ ### [HIGH] Projecting distinct on a relation path
206
+
207
+ Wrong:
208
+
209
+ ```typescript
210
+ applyFilter(User.query(), input, { allowed: ['status', 'posts.title'] })
211
+ // ?distinct=posts.title → dropped / InvalidColumnFilterError under throwOnInvalid
212
+ ```
213
+
214
+ Correct:
215
+
216
+ ```typescript
217
+ // distinct only names root-table columns: ?distinct=status
218
+ applyFilter(User.query(), input, { allowed: ['status'] })
219
+ ```
220
+
221
+ Relation filtering compiles to correlated EXISTS subqueries, so the relation is
222
+ never joined and there is no alias to project from — the emitted SQL would be a
223
+ missing-FROM error at the database. Adding the path to allow-lists cannot
224
+ change this; it is structural, not a policy decision.
225
+
226
+ Source: docs/guides/lucid.mdx ("distinct takes root-table columns only")
227
+
228
+ ### [HIGH] Expecting include/select to be applied
229
+
230
+ Wrong:
231
+
232
+ ```typescript
233
+ const input = parseSpatieRequest(request.qs())
234
+ applyFilter(query, input, { allowed: ['name'] })
235
+ return query.paginate(1, 25) // ?include=posts ignored — posts never loaded
236
+ ```
237
+
238
+ Correct:
239
+
240
+ ```typescript
241
+ const input = parseSpatieRequest(request.qs())
242
+ applyFilter(query, input, { allowed: ['name'] })
243
+ for (const relation of input.include ?? []) {
244
+ if (PRELOADABLE.includes(relation)) query.preload(relation as any)
245
+ }
246
+ return query.paginate(1, 25)
247
+ ```
248
+
249
+ `include` and sparse fieldset `select` are parsed but deliberately applied by no
250
+ runner — eager-loading which relations are safe stays your validated decision.
251
+ The client builder's `.include(...)` is likewise only a request.
252
+
253
+ Source: docs/guides/client.mdx ("include is a request, not an action"); docs/definitions/request-input.mdx
254
+
255
+ ### [HIGH] Confusing full-text search with vector similarity
256
+
257
+ Wrong:
258
+
259
+ ```typescript
260
+ defineFilter({ fullText: { column: 'embedding' } }) // tsvector @@ against a vector column
261
+ ```
262
+
263
+ Correct:
264
+
265
+ ```typescript
266
+ defineFilter({
267
+ vectorSimilarity: { column: 'embedding', metric: 'cosine' }, // embeddings rank nearest-first
268
+ // fullText: { column: 'search_vector' }, // keywords match lexemes
269
+ })
270
+ ```
271
+
272
+ Full-text matches words against a tsvector document; vector similarity ranks by
273
+ embedding distance. Configuring one while sending the other's input yields
274
+ silently wrong search semantics. They are additive — filter by keywords, rank
275
+ by embedding.
276
+
277
+ Source: docs/search/index.mdx ("Full-text search ≠ vector similarity")
278
+
279
+ ### [MEDIUM] Trusting paginate() totals alongside distinct
280
+
281
+ Wrong:
282
+
283
+ ```typescript
284
+ const result = await query.distinct('status').paginate(page, size)
285
+ result.totalCount // counts UN-deduped rows
286
+ ```
287
+
288
+ Correct:
289
+
290
+ ```typescript
291
+ // run your own countDistinct when the total must match the deduped page
292
+ ```
293
+
294
+ Lucid's count leg rebuilds the SELECT, so the reported total counts un-deduped
295
+ rows while the page itself is deduped.
296
+
297
+ Source: docs/guides/lucid.mdx (distinct/paginate total callout)
298
+
299
+ ### [MEDIUM] Wrapping large text columns in query-time to_tsvector
300
+
301
+ Wrong:
302
+
303
+ ```typescript
304
+ fullText: { column: ['title', 'body'], columnKind: 'text' } // re-tokenizes every row
305
+ ```
306
+
307
+ Correct:
308
+
309
+ ```typescript
310
+ fullText: { column: 'search_vector', columnKind: 'tsvector' } // GIN-indexable
311
+ ```
312
+
313
+ Query-time `to_tsvector` cannot use an index — fine for small tables, a
314
+ sequential scan on anything big.
315
+
316
+ Source: docs/search/full-text.mdx (text-columns warning)
317
+
318
+ See also: `../filter-definitions/SKILL.md` (where fullText/vectorSimilarity are
319
+ declared), `../filter-safety/SKILL.md` (the allow-list these paths respect).
@@ -0,0 +1,304 @@
1
+ ---
2
+ name: filter-safety
3
+ description: >
4
+ Lock down a public @adonis-agora/filter endpoint in AdonisJS. Covers the
5
+ allowed/sortable/searchable allow-lists as the security boundary, AllowList
6
+ forms ('*' | string[] | predicate), throwOnInvalid + InvalidColumnFilterError
7
+ mapped to HTTP 400, built-in structural validation (validateColumnFilters,
8
+ MAX_FILTER_DEPTH, field charset, operator aliases), query-string value
9
+ coercion with fieldTypes/coerceFilterValue, escapeLike for hand-built ILIKE
10
+ patterns, the trust boundary of low-level applyColumnFilters/applySort/
11
+ applySearch inside whereHas callbacks, layering VineJS for domain validation,
12
+ and unit-testing allow-listing with @adonis-agora/filter/testing
13
+ MockQueryBuilder. Use when exposing filters to untrusted clients, preventing
14
+ arbitrary-column probing, turning silent drops into 400s, or writing filter
15
+ unit tests.
16
+ metadata:
17
+ type: core
18
+ library: "@adonis-agora/filter"
19
+ library_version: "0.7.0"
20
+ framework: adonisjs
21
+ sources:
22
+ - "DavideCarvalho/adonis-filter:docs/guides/filter-classes.mdx"
23
+ - "DavideCarvalho/adonis-filter:docs/guides/validation.mdx"
24
+ - "DavideCarvalho/adonis-filter:docs/guides/operators.mdx"
25
+ - "DavideCarvalho/adonis-filter:docs/testing/index.mdx"
26
+ - "DavideCarvalho/adonis-filter:docs/guides/lucid.mdx"
27
+ ---
28
+
29
+ # Filter safety: the allow-list is the security boundary
30
+
31
+ `allowed`, `sortable`, and `searchable` are not conveniences — they are the only
32
+ thing standing between a client query string and your columns. Any field a
33
+ client references outside them is pruned before it reaches Lucid. This skill
34
+ covers enforcing that boundary loudly, coercing hostile string values, and
35
+ testing it without a database.
36
+
37
+ ## Setup
38
+
39
+ ```typescript
40
+ // app/controllers/users_controller.ts
41
+ import type { HttpContext } from '@adonisjs/core/http'
42
+ import { parseFilterRequest, applyFilter } from '@adonis-agora/filter'
43
+ import User from '#models/user'
44
+
45
+ export default class UsersController {
46
+ async index({ request, response }: HttpContext) {
47
+ const input = parseFilterRequest(request.qs())
48
+ const query = User.query()
49
+
50
+ try {
51
+ const { page, size } = applyFilter(query, input, {
52
+ allowed: ['name', 'email', 'status'], // explicit array — never '*' on public endpoints
53
+ sortable: ['name', 'createdAt'],
54
+ searchable: ['name', 'email'],
55
+ maxSize: 100,
56
+ throwOnInvalid: true,
57
+ })
58
+ return query.paginate(page, size)
59
+ } catch (err) {
60
+ if (err instanceof InvalidColumnFilterError) {
61
+ return response.badRequest({ message: err.message })
62
+ }
63
+ throw err
64
+ }
65
+ }
66
+ }
67
+ ```
68
+
69
+ (Import `InvalidColumnFilterError` from `@adonis-agora/filter` alongside
70
+ `parseFilterRequest` / `applyFilter`.)
71
+
72
+ ## Core patterns
73
+
74
+ ### 1. The AllowList has three forms
75
+
76
+ `type AllowList = '*' | string[] | ((field) => boolean)`. Prefer an explicit
77
+ array everywhere a client can reach; use the predicate form for rules a flat
78
+ list cannot express — e.g. whitelisting one hop into `posts` under a cap:
79
+
80
+ ```typescript
81
+ applyFilter(query, input, {
82
+ allowed: (field) => !field.includes('.') || /^posts\.[a-z]+$/i.test(field),
83
+ })
84
+ ```
85
+
86
+ The predicate receives the already alias-resolved target, never the client-facing alias key.
87
+
88
+ Source: `docs/guides/filter-classes.mdx`
89
+
90
+ ### 2. throwOnInvalid — reject instead of silently dropping
91
+
92
+ By default disallowed fields are dropped silently. With `throwOnInvalid: true`,
93
+ both structural failures and allow-list rejections surface as
94
+ `InvalidColumnFilterError`; handle it once globally instead of per controller:
95
+
96
+ ```typescript
97
+ // app/exceptions/handler.ts
98
+ import { InvalidColumnFilterError } from '@adonis-agora/filter'
99
+
100
+ async handle(error: unknown, ctx: HttpContext) {
101
+ if (error instanceof InvalidColumnFilterError) {
102
+ return ctx.response.badRequest({ message: error.message })
103
+ }
104
+ return super.handle(error, ctx)
105
+ }
106
+ ```
107
+
108
+ Structural validation runs on every `applyFilter`: field charset
109
+ (`^[a-zA-Z_][a-zA-Z0-9_.]*$`), known operators or SQL-symbol aliases
110
+ (normalized in place), per-operator value shapes, and `AND`/`OR` nesting capped
111
+ at `MAX_FILTER_DEPTH` (10). `validateColumnFilters` is exported to run the same
112
+ checks manually.
113
+
114
+ Source: `docs/guides/validation.mdx`
115
+
116
+ ### 3. Coerce query-string values with fieldTypes
117
+
118
+ Query-string values are always strings. Declare kinds so bad values are
119
+ rejected up front (dropped, or `InvalidColumnFilterError` → 400 under
120
+ `throwOnInvalid`) instead of surfacing as Postgres cast errors:
121
+
122
+ ```typescript
123
+ applyFilter(query, input, {
124
+ allowed: ['advisorId', 'dayOfWeek', 'isRecurring'],
125
+ fieldTypes: {
126
+ dayOfWeek: { kind: 'number' },
127
+ isRecurring: { kind: 'boolean' },
128
+ },
129
+ })
130
+ ```
131
+
132
+ Kinds: `'string' | 'number' | 'boolean' | 'date' | 'json' | 'unknown'`.
133
+ Coercion rules worth knowing: an empty string is not a valid number;
134
+ `null` passes every kind (legitimate `IS NULL`); a `date` string is validated
135
+ but returned verbatim.
136
+
137
+ Source: `docs/definitions/index.mdx` (Field types & value coercion)
138
+
139
+ ### 4. Escape LIKE patterns you build by hand
140
+
141
+ All library string operators escape `%`, `_`, and `\` via `escapeLike()`
142
+ before building patterns. If you build an ILIKE pattern yourself (outside the
143
+ adapter), do the same:
144
+
145
+ ```typescript
146
+ import { escapeLike } from '@adonis-agora/filter'
147
+
148
+ qb.whereILike('code', `${escapeLike(prefix)}%`)
149
+ ```
150
+
151
+ Source: `docs/guides/operators.mdx` (Security)
152
+
153
+ ### 5. Unit-test the boundary with the shipped mock
154
+
155
+ `@adonis-agora/filter/testing` exports a recording `QueryBuilderLike` stand-in,
156
+ so allow-listing is assertable with no database:
157
+
158
+ ```typescript
159
+ import { test } from '@japa/runner'
160
+ import { applyFilter } from '@adonis-agora/filter'
161
+ import { makeMockQueryBuilder } from '@adonis-agora/filter/testing'
162
+
163
+ test('drops disallowed fields', ({ assert }) => {
164
+ const qb = makeMockQueryBuilder()
165
+ applyFilter(
166
+ qb,
167
+ { filters: [
168
+ { field: 'name', operator: 'equals', value: 'Al' },
169
+ { field: 'secret', operator: 'equals', value: 'x' },
170
+ ] },
171
+ { allowed: ['name'] },
172
+ )
173
+ const flat = qb.flatten()
174
+ assert.deepInclude(flat, { method: 'where', args: ['name', 'Al'] })
175
+ assert.isUndefined(flat.find((c) => c.args.includes('secret')))
176
+ })
177
+ ```
178
+
179
+ Source: `docs/testing/index.mdx`, `docs/guides/testing.mdx`
180
+
181
+ ## Common mistakes
182
+
183
+ ### [CRITICAL] Allowing every column with `'*'`
184
+
185
+ Wrong:
186
+
187
+ ```typescript
188
+ applyFilter(query, input, { allowed: '*' })
189
+ ```
190
+
191
+ Correct:
192
+
193
+ ```typescript
194
+ applyFilter(query, input, { allowed: ['name', 'email', 'status'] })
195
+ ```
196
+
197
+ A star allow-list disables column filtering entirely — any base column
198
+ (`passwordHash`, internal flags) becomes probeable through filter values and
199
+ result counts. Reserve it for trusted internal endpoints.
200
+
201
+ Source: docs/guides/filter-classes.mdx ("The allow-list is the security boundary" warning)
202
+
203
+ ### [CRITICAL] Feeding client filters to low-level apply functions unpruned
204
+
205
+ Wrong:
206
+
207
+ ```typescript
208
+ const raw = parseFilterRequest(request.qs())
209
+ query.whereHas('posts', (p) => applyColumnFilters(p, raw.filters ?? []))
210
+ ```
211
+
212
+ Correct:
213
+
214
+ ```typescript
215
+ const raw = parseFilterRequest(request.qs())
216
+ // prune against a policy first (or validate fields yourself)…
217
+ const scratch = User.query()
218
+ applyFilter(scratch, raw, { allowed: ['title', 'status'] })
219
+ query.whereHas('posts', (p) =>
220
+ applyColumnFilters(p, [{ field: 'title', operator: 'equals', value: 'Hi' }]),
221
+ )
222
+ ```
223
+
224
+ `applyColumnFilters` / `applySort` / `applySearch` trust their input — they
225
+ enforce no allow-list — so client-derived filters handed straight to them can
226
+ reference arbitrary columns inside the subquery.
227
+
228
+ Source: docs/guides/lucid.mdx (low-level functions warning); docs/guides/relations.mdx
229
+
230
+ ### [HIGH] Trusting silent-drop while debugging
231
+
232
+ Wrong:
233
+
234
+ ```typescript
235
+ applyFilter(query, input, { allowed: ['name', 'createdAt'] })
236
+ // ?sort=-craetedAt does nothing, no error anywhere
237
+ ```
238
+
239
+ Correct:
240
+
241
+ ```typescript
242
+ try {
243
+ const { page, size } = applyFilter(query, input, {
244
+ allowed: ['name', 'createdAt'],
245
+ throwOnInvalid: true,
246
+ })
247
+ } catch (err) {
248
+ if (err instanceof InvalidColumnFilterError) {
249
+ return response.badRequest({ message: err.message })
250
+ }
251
+ throw err
252
+ }
253
+ ```
254
+
255
+ With defaults, a typo'd sort or filter just vanishes and the query "works" but
256
+ ignores the client's intent — contract drift ships unnoticed.
257
+
258
+ Source: docs/guides/validation.mdx (throwOnInvalid)
259
+
260
+ ### [HIGH] Assuming query-string numbers arrive as numbers
261
+
262
+ Wrong:
263
+
264
+ ```typescript
265
+ // ?filter[isRecurring]=xyz → Postgres: invalid input syntax for type boolean → 500
266
+ applyFilter(query, input, { allowed: ['isRecurring'] })
267
+ ```
268
+
269
+ Correct:
270
+
271
+ ```typescript
272
+ applyFilter(query, input, {
273
+ allowed: ['isRecurring'],
274
+ fieldTypes: { isRecurring: { kind: 'boolean' } }, // rejected up front → drop or 400
275
+ })
276
+ ```
277
+
278
+ Every query-string value parses as a string; SQL implicit casts hide the gap
279
+ until an uncastable value raises at the database as a user-input-driven 500.
280
+
281
+ Source: docs/definitions/index.mdx (Field types & value coercion); docs/guides/operators.mdx
282
+
283
+ ### [MEDIUM] Building ILIKE patterns from unescaped user input
284
+
285
+ Wrong:
286
+
287
+ ```typescript
288
+ qb.whereILike('name', `%${userInput}%`) // userInput '%%' widens the match arbitrarily
289
+ ```
290
+
291
+ Correct:
292
+
293
+ ```typescript
294
+ import { escapeLike } from '@adonis-agora/filter'
295
+ qb.whereILike('name', `%${escapeLike(userInput)}%`)
296
+ ```
297
+
298
+ The adapter escapes `%`, `_` and `\` on every string operator it emits, but a
299
+ pattern you interpolate yourself skips that protection entirely.
300
+
301
+ Source: docs/guides/operators.mdx (Security — LIKE escaping)
302
+
303
+ See also: `../filter-querying/SKILL.md` (cursor pagination and search route
304
+ through this same boundary), `../filter-basics/SKILL.md` (endpoint wiring).
package/stubs/main.ts ADDED
@@ -0,0 +1,4 @@
1
+ import { fileURLToPath } from 'node:url';
2
+
3
+ /** Absolute path to this package's stubs, handed to `codemods.makeUsingStub`. */
4
+ export const stubsRoot = fileURLToPath(new URL('./', import.meta.url));
@@ -0,0 +1,45 @@
1
+ {{#var filterName = string(entity.name).pascalCase().removeSuffix('Filter').toString() + 'Filter'}}
2
+ {{#var modelName = string(entity.name).pascalCase().removeSuffix('Filter').toString()}}
3
+ {{#var filterFileName = string(entity.name).snakeCase().removeSuffix('filter').toString() + '_filter'}}
4
+ {{#var modelFileName = string(entity.name).snakeCase().removeSuffix('filter').toString()}}
5
+ {{{
6
+ exports({
7
+ to: app.makePath('app/filters', entity.path, filterFileName + '.ts')
8
+ })
9
+ }}}
10
+ import { BaseModelFilter } from '@adonis-agora/filter'
11
+ import type { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model'
12
+ import {{ modelName }} from '#models/{{ modelFileName }}'
13
+
14
+ export default class {{ filterName }} extends BaseModelFilter {
15
+ declare $query: ModelQueryBuilderContract<typeof {{ modelName }}>
16
+
17
+ static model = {{ modelName }}
18
+
19
+ /** Columns clients may filter on with the standard operators — these need no method. */
20
+ static filterable = ['name']
21
+ /** Columns clients may sort on. Defaults to `filterable`. */
22
+ static sortable = ['createdAt']
23
+ /** Columns the free-text `search` term scans. */
24
+ static searchable = ['name']
25
+
26
+ static defaultSort = [{ field: 'createdAt', direction: 'desc' as const }]
27
+ static defaultSize = 25
28
+ static maxSize = 100
29
+
30
+ /**
31
+ * Runs on every call, before anything the request asked for — the place for a scope the client
32
+ * cannot relax (a tenant, a soft-delete guard, an ownership check).
33
+ */
34
+ setup() {
35
+ // this.$query.whereNull('deletedAt')
36
+ }
37
+
38
+ /**
39
+ * One method per request key that needs SQL of its own. `?filter[fullName]=silva` lands here;
40
+ * writing the method IS what exposes the key, so it needs no allow-list entry.
41
+ */
42
+ // fullName(value: string) {
43
+ // this.$query.whereILike('full_name', `%${value}%`)
44
+ // }
45
+ }