@adonis-agora/filter 0.6.0 → 0.7.1
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 +136 -0
- package/README.md +1 -1
- 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/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/parse_request.d.ts +13 -1
- package/dist/src/parse_request.d.ts.map +1 -1
- package/dist/src/parse_request.js +66 -4
- package/dist/src/parse_request.js.map +1 -1
- package/dist/src/runner.d.ts.map +1 -1
- package/dist/src/runner.js +41 -5
- package/dist/src/runner.js.map +1 -1
- package/dist/src/spatie_parser.d.ts +1 -0
- package/dist/src/spatie_parser.d.ts.map +1 -1
- package/dist/src/spatie_parser.js +5 -1
- package/dist/src/spatie_parser.js.map +1 -1
- package/dist/src/types.d.ts.map +1 -1
- package/package.json +21 -12
- package/skills/filter-basics/SKILL.md +248 -0
- package/skills/filter-codegen/SKILL.md +246 -0
- package/skills/filter-definitions/SKILL.md +337 -0
- package/skills/filter-querying/SKILL.md +319 -0
- package/skills/filter-safety/SKILL.md +304 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: filter-definitions
|
|
3
|
+
description: >
|
|
4
|
+
Declare reusable FilterSpec policies with defineFilter from @adonis-agora/filter.
|
|
5
|
+
Covers the full options map — filterable (array, '*', or colocated kind map),
|
|
6
|
+
sortable/searchable defaults, relations whitelists translating dotted paths
|
|
7
|
+
into Lucid whereHas subqueries with maxDepth caps, aliases remapping client
|
|
8
|
+
names to real targets (one hop, cycle-free), computed virtual columns
|
|
9
|
+
(string vs correlated-subquery function forms, table/model requirement),
|
|
10
|
+
to-many aggregates ($count/$sum/$avg/$min/$max unlocked via model +
|
|
11
|
+
RelationSpec.aggregates), fieldTypes value coercion and the colocated
|
|
12
|
+
filterable map form, tenant scope, defaultFilters/defaultSort/defaultSize/
|
|
13
|
+
maxSize, throwOnInvalid, FilterDefinitionError at wiring time, specToFilterConfig,
|
|
14
|
+
and resolveFieldAlias/remap* helpers. Use when authoring a filter policy,
|
|
15
|
+
exposing relation/computed/aggregate fields, or renaming public field names.
|
|
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/definitions/index.mdx"
|
|
23
|
+
- "DavideCarvalho/adonis-filter:docs/definitions/relations.mdx"
|
|
24
|
+
- "DavideCarvalho/adonis-filter:docs/definitions/computed.mdx"
|
|
25
|
+
- "DavideCarvalho/adonis-filter:docs/definitions/aggregates.mdx"
|
|
26
|
+
- "DavideCarvalho/adonis-filter:docs/definitions/aliases.mdx"
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
# Filter definitions: declare the policy once with defineFilter
|
|
30
|
+
|
|
31
|
+
A `FilterSpec` is a plain, framework-free object built once at module scope by
|
|
32
|
+
`defineFilter`. It captures what is filterable/sortable, which relations are
|
|
33
|
+
reachable, tenant scoping, and server-side defaults — then
|
|
34
|
+
`applyFilterFromRequest(query, spec, ctx)` applies it on every request. The same
|
|
35
|
+
object also drives client codegen (`generateFilterClient`).
|
|
36
|
+
|
|
37
|
+
## Setup
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
// app/filters/user_filter.ts
|
|
41
|
+
import type { HttpContext } from '@adonisjs/core/http'
|
|
42
|
+
import { defineFilter } from '@adonis-agora/filter'
|
|
43
|
+
import User from '#models/user'
|
|
44
|
+
|
|
45
|
+
export const userFilter = defineFilter({
|
|
46
|
+
filterable: ['name', 'email', 'age', 'status'],
|
|
47
|
+
sortable: ['name', 'createdAt'],
|
|
48
|
+
searchable: ['name', 'email'],
|
|
49
|
+
|
|
50
|
+
relations: {
|
|
51
|
+
posts: {
|
|
52
|
+
filterable: ['title', 'status'],
|
|
53
|
+
sortable: ['createdAt'],
|
|
54
|
+
relations: { comments: { filterable: ['body'] } }, // posts.comments.body
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
tenant: {
|
|
59
|
+
column: 'tenantId',
|
|
60
|
+
resolve: (ctx) => (ctx as HttpContext).auth.user!.tenantId,
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
defaultFilters: [{ field: 'deletedAt', operator: 'isNull' }],
|
|
64
|
+
defaultSort: [{ field: 'createdAt', direction: 'desc' }],
|
|
65
|
+
defaultSize: 25,
|
|
66
|
+
maxSize: 100,
|
|
67
|
+
})
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Apply it in the controller:
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
import { applyFilterFromRequest } from '@adonis-agora/filter'
|
|
74
|
+
import { userFilter } from '#filters/user_filter'
|
|
75
|
+
import User from '#models/user'
|
|
76
|
+
|
|
77
|
+
export default class UsersController {
|
|
78
|
+
async index(ctx: HttpContext) {
|
|
79
|
+
const query = User.query()
|
|
80
|
+
const { page, size } = applyFilterFromRequest(query, userFilter, ctx)
|
|
81
|
+
return query.paginate(page, size)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`defineFilter` validates the declaration itself: a missing `filterable` or a
|
|
87
|
+
negative/non-integer `maxDepth` throws `FilterDefinitionError` at wiring time —
|
|
88
|
+
distinct from request-time `InvalidColumnFilterError`.
|
|
89
|
+
|
|
90
|
+
## Core patterns
|
|
91
|
+
|
|
92
|
+
### 1. Relations — dotted request paths become whereHas subqueries
|
|
93
|
+
|
|
94
|
+
Declare each relation by its model name; columns stay bare. Every dotted path
|
|
95
|
+
segment but the last is a relation hop, translated into nested
|
|
96
|
+
`whereHas` subqueries — no manual join:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
?filter[posts.title]=Release
|
|
100
|
+
→ whereHas('posts', (sub) => sub.where('title', 'Release'))
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`maxDepth` bounds hops (base column = 0) and defaults to your deepest declared
|
|
104
|
+
nesting; an explicit smaller value caps paths even when deeper ones exist.
|
|
105
|
+
Relation sorting forwards through the sort stage, but ordering across a to-many
|
|
106
|
+
relation may need joins/aggregation — prefer relation filters, and sort by
|
|
107
|
+
aggregate aliases (`withCount`'s `posts_count`) where possible.
|
|
108
|
+
|
|
109
|
+
Source: `docs/definitions/relations.mdx`, `docs/guides/relations.mdx`
|
|
110
|
+
|
|
111
|
+
### 2. Computed fields — virtual columns backed by your SQL
|
|
112
|
+
|
|
113
|
+
String sources inline verbatim; function sources receive `{ alias }` (the root
|
|
114
|
+
table name) for correlated subqueries. A declared alias is filterable and
|
|
115
|
+
sortable like any column, and its *declaration* is its allow-list — never add
|
|
116
|
+
it to `filterable`:
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
export const userFilter = defineFilter({
|
|
120
|
+
filterable: ['status'],
|
|
121
|
+
model: User, // supplies table: 'users' — required for the function form
|
|
122
|
+
computed: {
|
|
123
|
+
fullName: "first_name || ' ' || last_name",
|
|
124
|
+
postCount: ({ alias }) =>
|
|
125
|
+
`(SELECT COUNT(*) FROM posts WHERE posts.author_id = ${alias}.id)`,
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Client values always ride as positional bindings; only dev-authored SQL is
|
|
131
|
+
inlined. Keep computed conditions at top level — see mistakes below.
|
|
132
|
+
|
|
133
|
+
Source: `docs/definitions/computed.mdx`
|
|
134
|
+
|
|
135
|
+
### 3. To-many aggregates — `$count` / `$sum` / `$avg` / `$min` / `$max`
|
|
136
|
+
|
|
137
|
+
Aggregates are auto-generated computed fields, off until the spec can
|
|
138
|
+
introspect relations: pass `model` plus a `relations` entry naming the to-many
|
|
139
|
+
relations; column functions additionally require those child columns listed
|
|
140
|
+
under `aggregates` (you assert numericness — Lucid does not reflect SQL types):
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
export const userFilter = defineFilter({
|
|
144
|
+
filterable: ['status'],
|
|
145
|
+
model: User,
|
|
146
|
+
relations: {
|
|
147
|
+
posts: { filterable: ['status'], aggregates: ['views'] },
|
|
148
|
+
},
|
|
149
|
+
})
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
This makes `posts.$count` plus `posts.$sum.views` / `$avg` / `$min` / `$max`
|
|
153
|
+
filterable and sortable. Discovery degrades gracefully — no introspection means
|
|
154
|
+
no aggregates, never a wiring-time throw. Each aggregate is a correlated
|
|
155
|
+
subquery evaluated per row; index the child FK (or materialise a counter) on
|
|
156
|
+
hot paths.
|
|
157
|
+
|
|
158
|
+
Source: `docs/definitions/aggregates.mdx`
|
|
159
|
+
|
|
160
|
+
### 4. Aliases and field types
|
|
161
|
+
|
|
162
|
+
Aliases remap a public name onto a whitelisted target (base column or relation
|
|
163
|
+
path); resolution runs before allow-listing, exactly one hop:
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
export const userFilter = defineFilter({
|
|
167
|
+
filterable: ['status', 'name'],
|
|
168
|
+
relations: { posts: { filterable: ['title'] } },
|
|
169
|
+
aliases: {
|
|
170
|
+
legacyStatus: 'status',
|
|
171
|
+
postTitle: 'posts.title',
|
|
172
|
+
},
|
|
173
|
+
fieldTypes: { age: { kind: 'number' } },
|
|
174
|
+
})
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Prefer the colocated map form when several fields have non-string kinds —
|
|
178
|
+
`filterable: { advisorId: 'string', dayOfWeek: 'number', isRecurring: 'boolean' }`
|
|
179
|
+
desugars into the array plus `fieldTypes`. An explicit `fieldTypes` entry still
|
|
180
|
+
wins per field, which is how you add codegen-only richness (`enumValues`,
|
|
181
|
+
`typeRef`).
|
|
182
|
+
|
|
183
|
+
Source: `docs/definitions/aliases.mdx`, `docs/definitions/index.mdx`
|
|
184
|
+
|
|
185
|
+
### 5. Reuse the spec outside HTTP
|
|
186
|
+
|
|
187
|
+
`specToFilterConfig(spec)` projects the spec onto the per-call `FilterConfig`;
|
|
188
|
+
pass pre-parsed input via `options.input` for jobs/tests;
|
|
189
|
+
`resolveFieldAlias` / `remapFilterAliases` / `remapSortAliases` are exported
|
|
190
|
+
for custom pipelines:
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
import { applyFilterFromRequest } from '@adonis-agora/filter'
|
|
194
|
+
|
|
195
|
+
applyFilterFromRequest(query, userFilter, ctx, {
|
|
196
|
+
input: { filters: [{ field: 'status', operator: 'equals', value: 'active' }] },
|
|
197
|
+
})
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Source: `docs/definitions/index.mdx` (Under the hood)
|
|
201
|
+
|
|
202
|
+
## Common mistakes
|
|
203
|
+
|
|
204
|
+
### [HIGH] Declaring aggregate paths without the owning `model`
|
|
205
|
+
|
|
206
|
+
Wrong:
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
defineFilter({
|
|
210
|
+
filterable: ['status'],
|
|
211
|
+
relations: { posts: { filterable: ['title'], aggregates: ['views'] } },
|
|
212
|
+
}) // posts.$count etc. simply don't exist — referencing requests get pruned
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Correct:
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
defineFilter({
|
|
219
|
+
filterable: ['status'],
|
|
220
|
+
model: User,
|
|
221
|
+
relations: { posts: { filterable: ['title'], aggregates: ['views'] } },
|
|
222
|
+
})
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Aggregates activate only when the spec can read relation metadata off the model
|
|
226
|
+
— without `model`, nothing is synthesised and no error points at the cause.
|
|
227
|
+
|
|
228
|
+
Source: docs/definitions/aggregates.mdx ("Unlocking aggregates — the model option")
|
|
229
|
+
|
|
230
|
+
### [HIGH] Function-form computed fields without a root table
|
|
231
|
+
|
|
232
|
+
Wrong:
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
defineFilter({
|
|
236
|
+
filterable: ['status'],
|
|
237
|
+
computed: { postCount: ({ alias }) => `(SELECT COUNT(*) FROM posts WHERE posts.author_id = ${alias}.id)` },
|
|
238
|
+
}) // alias resolves to '' → malformed SQL
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Correct:
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
defineFilter({
|
|
245
|
+
filterable: ['status'],
|
|
246
|
+
model: User, // or table: 'users'
|
|
247
|
+
computed: { postCount: ({ alias }) => `(SELECT COUNT(*) FROM posts WHERE posts.author_id = ${alias}.id)` },
|
|
248
|
+
})
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Correlated subqueries splice `spec.table` as the outer alias; with neither
|
|
252
|
+
`table` nor `model` set, the alias is an empty string and the SQL breaks at the
|
|
253
|
+
database.
|
|
254
|
+
|
|
255
|
+
Source: docs/definitions/computed.mdx ("table is required for the function form")
|
|
256
|
+
|
|
257
|
+
### [HIGH] Reading a dotted alias target as a JSON path
|
|
258
|
+
|
|
259
|
+
Wrong:
|
|
260
|
+
|
|
261
|
+
```typescript
|
|
262
|
+
aliases: { tier: 'metadata.tier' }
|
|
263
|
+
// → whereHas('metadata') — looks for a RELATION named metadata, fails at the DB
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Correct:
|
|
267
|
+
|
|
268
|
+
```typescript
|
|
269
|
+
computed: { tier: "metadata->>'tier'" } // JSON access needs computed SQL you write
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
In alias targets everything before the last dot is a relation hop; there is no
|
|
273
|
+
JSON-path reading, and adding the path to `relations` will not make it one.
|
|
274
|
+
|
|
275
|
+
Source: docs/definitions/aliases.mdx ("A dot in the target means a relation, not a JSON path")
|
|
276
|
+
|
|
277
|
+
### [MEDIUM] Nesting computed aliases inside AND/OR groups
|
|
278
|
+
|
|
279
|
+
Wrong:
|
|
280
|
+
|
|
281
|
+
```
|
|
282
|
+
filter[OR][0][postCount][gte]=5 // treated as a normal column → dropped
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Correct:
|
|
286
|
+
|
|
287
|
+
```
|
|
288
|
+
filter[postCount][gte]=5 // keep computed conditions top-level
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Computed filtering is recognized only on top-level leaf filters; inside a
|
|
292
|
+
group the alias falls back to the column pipeline and vanishes unless it is
|
|
293
|
+
also a real column.
|
|
294
|
+
|
|
295
|
+
Source: docs/definitions/computed.mdx ("How a computed filter is routed")
|
|
296
|
+
|
|
297
|
+
### [MEDIUM] Chaining aliases expecting multi-hop resolution
|
|
298
|
+
|
|
299
|
+
Wrong:
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
resolveFieldAlias({ a: 'b', b: 'c' }, 'a') // expecting 'c' — actually returns 'b'
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Correct:
|
|
306
|
+
|
|
307
|
+
```typescript
|
|
308
|
+
resolveFieldAlias({ a: 'c', b: 'c' }, 'a') // point aliases directly at real targets
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Resolution runs exactly one hop and never re-runs the target through the map,
|
|
312
|
+
which makes cycles structurally impossible — and multi-hop chains silently
|
|
313
|
+
resolve short.
|
|
314
|
+
|
|
315
|
+
Source: docs/definitions/aliases.mdx ("No cascading, no cycles")
|
|
316
|
+
|
|
317
|
+
### [MEDIUM] Expecting date-coerced values back as Date objects
|
|
318
|
+
|
|
319
|
+
Wrong:
|
|
320
|
+
|
|
321
|
+
```typescript
|
|
322
|
+
// fieldTypes: { bornOn: { kind: 'date' } } — then expecting value instanceof Date downstream
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Correct:
|
|
326
|
+
|
|
327
|
+
```typescript
|
|
328
|
+
// the bound value stays the validated ISO string ('2026-07-15'); the driver parses it
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
A `date` kind validates syntax but hands the string back verbatim — re-zoning
|
|
332
|
+
it to midnight UTC would shift days for negative-offset clients.
|
|
333
|
+
|
|
334
|
+
Source: docs/definitions/index.mdx (coercion rules)
|
|
335
|
+
|
|
336
|
+
See also: `../filter-codegen/SKILL.md` (the same spec generates the typed
|
|
337
|
+
client), `../filter-safety/SKILL.md` (the boundary these declarations enforce).
|
|
@@ -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).
|