@adonis-agora/filter 0.0.0-beta-20261110131309
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.
- package/CHANGELOG.md +417 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/dist/commands/main.d.ts +19 -0
- package/dist/commands/main.d.ts.map +1 -0
- package/dist/commands/main.js +19 -0
- package/dist/commands/main.js.map +1 -0
- package/dist/commands/make_filter.d.ts +18 -0
- package/dist/commands/make_filter.d.ts.map +1 -0
- package/dist/commands/make_filter.js +32 -0
- package/dist/commands/make_filter.js.map +1 -0
- package/dist/commands/make_filter_client.d.ts +35 -0
- package/dist/commands/make_filter_client.d.ts.map +1 -0
- package/dist/commands/make_filter_client.js +85 -0
- package/dist/commands/make_filter_client.js.map +1 -0
- package/dist/configure.d.ts +15 -0
- package/dist/configure.d.ts.map +1 -0
- package/dist/configure.js +20 -0
- package/dist/configure.js.map +1 -0
- package/dist/providers/filter_provider.d.ts +28 -0
- package/dist/providers/filter_provider.d.ts.map +1 -0
- package/dist/providers/filter_provider.js +33 -0
- package/dist/providers/filter_provider.js.map +1 -0
- package/dist/src/aggregate.d.ts +70 -0
- package/dist/src/aggregate.d.ts.map +1 -0
- package/dist/src/aggregate.js +195 -0
- package/dist/src/aggregate.js.map +1 -0
- package/dist/src/aggregate_path.d.ts +25 -0
- package/dist/src/aggregate_path.d.ts.map +1 -0
- package/dist/src/aggregate_path.js +35 -0
- package/dist/src/aggregate_path.js.map +1 -0
- package/dist/src/apply_from_request.d.ts +85 -0
- package/dist/src/apply_from_request.d.ts.map +1 -0
- package/dist/src/apply_from_request.js +164 -0
- package/dist/src/apply_from_request.js.map +1 -0
- package/dist/src/base_filter.d.ts +49 -0
- package/dist/src/base_filter.d.ts.map +1 -0
- package/dist/src/base_filter.js +33 -0
- package/dist/src/base_filter.js.map +1 -0
- package/dist/src/base_model_filter.d.ts +90 -0
- package/dist/src/base_model_filter.d.ts.map +1 -0
- package/dist/src/base_model_filter.js +85 -0
- package/dist/src/base_model_filter.js.map +1 -0
- package/dist/src/coerce_value.d.ts +26 -0
- package/dist/src/coerce_value.d.ts.map +1 -0
- package/dist/src/coerce_value.js +82 -0
- package/dist/src/coerce_value.js.map +1 -0
- package/dist/src/cursor.d.ts +92 -0
- package/dist/src/cursor.d.ts.map +1 -0
- package/dist/src/cursor.js +107 -0
- package/dist/src/cursor.js.map +1 -0
- package/dist/src/custom_filter.d.ts +36 -0
- package/dist/src/custom_filter.d.ts.map +1 -0
- package/dist/src/custom_filter.js +127 -0
- package/dist/src/custom_filter.js.map +1 -0
- package/dist/src/decorator_metadata.d.ts +41 -0
- package/dist/src/decorator_metadata.d.ts.map +1 -0
- package/dist/src/decorator_metadata.js +83 -0
- package/dist/src/decorator_metadata.js.map +1 -0
- package/dist/src/decorators.d.ts +73 -0
- package/dist/src/decorators.d.ts.map +1 -0
- package/dist/src/decorators.js +118 -0
- package/dist/src/decorators.js.map +1 -0
- package/dist/src/escape-like.d.ts +10 -0
- package/dist/src/escape-like.d.ts.map +1 -0
- package/dist/src/escape-like.js +15 -0
- package/dist/src/escape-like.js.map +1 -0
- package/dist/src/field_aliases.d.ts +30 -0
- package/dist/src/field_aliases.d.ts.map +1 -0
- package/dist/src/field_aliases.js +65 -0
- package/dist/src/field_aliases.js.map +1 -0
- package/dist/src/filter_class.d.ts +46 -0
- package/dist/src/filter_class.d.ts.map +1 -0
- package/dist/src/filter_class.js +244 -0
- package/dist/src/filter_class.js.map +1 -0
- package/dist/src/filter_spec.d.ts +231 -0
- package/dist/src/filter_spec.d.ts.map +1 -0
- package/dist/src/filter_spec.js +182 -0
- package/dist/src/filter_spec.js.map +1 -0
- package/dist/src/filterable_mixin.d.ts +49 -0
- package/dist/src/filterable_mixin.d.ts.map +1 -0
- package/dist/src/filterable_mixin.js +47 -0
- package/dist/src/filterable_mixin.js.map +1 -0
- package/dist/src/generate_client.d.ts +107 -0
- package/dist/src/generate_client.d.ts.map +1 -0
- package/dist/src/generate_client.js +164 -0
- package/dist/src/generate_client.js.map +1 -0
- package/dist/src/group_by_count.d.ts +67 -0
- package/dist/src/group_by_count.d.ts.map +1 -0
- package/dist/src/group_by_count.js +71 -0
- package/dist/src/group_by_count.js.map +1 -0
- package/dist/src/in_memory.d.ts +279 -0
- package/dist/src/in_memory.d.ts.map +1 -0
- package/dist/src/in_memory.js +810 -0
- package/dist/src/in_memory.js.map +1 -0
- package/dist/src/index.d.ts +51 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +33 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/lucid_adapter.d.ts +311 -0
- package/dist/src/lucid_adapter.d.ts.map +1 -0
- package/dist/src/lucid_adapter.js +469 -0
- package/dist/src/lucid_adapter.js.map +1 -0
- package/dist/src/lucid_macros.d.ts +87 -0
- package/dist/src/lucid_macros.d.ts.map +1 -0
- package/dist/src/lucid_macros.js +67 -0
- package/dist/src/lucid_macros.js.map +1 -0
- package/dist/src/normalizer.d.ts +21 -0
- package/dist/src/normalizer.d.ts.map +1 -0
- package/dist/src/normalizer.js +61 -0
- package/dist/src/normalizer.js.map +1 -0
- package/dist/src/operators.d.ts +37 -0
- package/dist/src/operators.d.ts.map +1 -0
- package/dist/src/operators.js +43 -0
- package/dist/src/operators.js.map +1 -0
- package/dist/src/paginate.d.ts +12 -0
- package/dist/src/paginate.d.ts.map +1 -0
- package/dist/src/paginate.js +88 -0
- package/dist/src/paginate.js.map +1 -0
- package/dist/src/pagination_stash.d.ts +4 -0
- package/dist/src/pagination_stash.d.ts.map +1 -0
- package/dist/src/pagination_stash.js +19 -0
- package/dist/src/pagination_stash.js.map +1 -0
- package/dist/src/parse_request.d.ts +50 -0
- package/dist/src/parse_request.d.ts.map +1 -0
- package/dist/src/parse_request.js +220 -0
- package/dist/src/parse_request.js.map +1 -0
- package/dist/src/runner.d.ts +65 -0
- package/dist/src/runner.d.ts.map +1 -0
- package/dist/src/runner.js +371 -0
- package/dist/src/runner.js.map +1 -0
- package/dist/src/source_resolver.d.ts +18 -0
- package/dist/src/source_resolver.d.ts.map +1 -0
- package/dist/src/source_resolver.js +64 -0
- package/dist/src/source_resolver.js.map +1 -0
- package/dist/src/spatie_parser.d.ts +39 -0
- package/dist/src/spatie_parser.d.ts.map +1 -0
- package/dist/src/spatie_parser.js +134 -0
- package/dist/src/spatie_parser.js.map +1 -0
- package/dist/src/testing.d.ts +73 -0
- package/dist/src/testing.d.ts.map +1 -0
- package/dist/src/testing.js +136 -0
- package/dist/src/testing.js.map +1 -0
- package/dist/src/types.d.ts +226 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/validate-column-filter.d.ts +30 -0
- package/dist/src/validate-column-filter.d.ts.map +1 -0
- package/dist/src/validate-column-filter.js +148 -0
- package/dist/src/validate-column-filter.js.map +1 -0
- package/dist/src/value-shape.d.ts +18 -0
- package/dist/src/value-shape.d.ts.map +1 -0
- package/dist/src/value-shape.js +36 -0
- package/dist/src/value-shape.js.map +1 -0
- package/dist/stubs/main.d.ts +3 -0
- package/dist/stubs/main.d.ts.map +1 -0
- package/dist/stubs/main.js +4 -0
- package/dist/stubs/main.js.map +1 -0
- package/dist/stubs/make/filter/main.stub +45 -0
- package/package.json +102 -0
- package/skills/filter-basics/SKILL.md +285 -0
- package/skills/filter-codegen/SKILL.md +246 -0
- package/skills/filter-definitions/SKILL.md +340 -0
- package/skills/filter-querying/SKILL.md +319 -0
- package/skills/filter-safety/SKILL.md +304 -0
- package/stubs/main.ts +4 -0
- 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,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 the filterable list. */
|
|
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
|
+
}
|