@adonis-agora/filter 0.9.0 → 0.11.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.
- package/CHANGELOG.md +12 -0
- package/README.md +17 -0
- package/dist/src/in_memory.d.ts +268 -0
- package/dist/src/in_memory.d.ts.map +1 -0
- package/dist/src/in_memory.js +759 -0
- package/dist/src/in_memory.js.map +1 -0
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/index.js.map +1 -1
- package/package.json +29 -20
- package/skills/filter-basics/SKILL.md +34 -2
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
import { buildCursorPage, } from './cursor.js';
|
|
2
|
+
import { applyCursor, applyFilter } from './runner.js';
|
|
3
|
+
/**
|
|
4
|
+
* Thrown when the runner asks the in-memory builder for something only SQL can do: a raw
|
|
5
|
+
* predicate or ordering (policy `computed` fields, `fullText`, `vectorSimilarity`) — declare a
|
|
6
|
+
* virtual field instead — or a relation/field path the collection does not declare.
|
|
7
|
+
*/
|
|
8
|
+
export class InMemoryQueryError extends Error {
|
|
9
|
+
constructor(message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'InMemoryQueryError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const ARRAY_KINDS = {
|
|
15
|
+
'string[]': 'string',
|
|
16
|
+
'number[]': 'number',
|
|
17
|
+
'boolean[]': 'boolean',
|
|
18
|
+
'date[]': 'date',
|
|
19
|
+
};
|
|
20
|
+
function resolveType(type) {
|
|
21
|
+
const element = ARRAY_KINDS[type];
|
|
22
|
+
return element ? { kind: element, array: true } : { kind: type, array: false };
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Declare a collection: its fields (and their types), virtual fields, primary key and relations.
|
|
26
|
+
*
|
|
27
|
+
* ```ts
|
|
28
|
+
* const posts = defineCollection<Post>({ fields: { id: 'number', title: 'string' } })
|
|
29
|
+
* const users = defineCollection<User>({
|
|
30
|
+
* fields: {
|
|
31
|
+
* id: 'number',
|
|
32
|
+
* name: 'string',
|
|
33
|
+
* age: 'number',
|
|
34
|
+
* tags: 'string[]',
|
|
35
|
+
* fullName: { type: 'string', get: (u) => `${u.first} ${u.last}` },
|
|
36
|
+
* },
|
|
37
|
+
* relations: { posts: { kind: 'one-to-many', target: () => posts, get: (u) => u.posts } },
|
|
38
|
+
* })
|
|
39
|
+
*
|
|
40
|
+
* const qb = users.query(rows)
|
|
41
|
+
* const { page, size } = applyFilter(qb, input, { allowed: ['name', 'posts.title'] })
|
|
42
|
+
* return qb.paginate(page, size)
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export function defineCollection(options) {
|
|
46
|
+
const fields = Object.create(null);
|
|
47
|
+
const filterable = {};
|
|
48
|
+
const fieldTypes = {};
|
|
49
|
+
for (const [name, declaration] of Object.entries(options.fields)) {
|
|
50
|
+
const virtual = typeof declaration === 'object' ? declaration : undefined;
|
|
51
|
+
const { kind, array } = resolveType(virtual ? virtual.type : declaration);
|
|
52
|
+
fields[name] = { kind, array, get: virtual?.get };
|
|
53
|
+
filterable[name] = kind;
|
|
54
|
+
fieldTypes[name] = { kind };
|
|
55
|
+
}
|
|
56
|
+
const relations = Object.create(null);
|
|
57
|
+
for (const [name, relation] of Object.entries(options.relations ?? {})) {
|
|
58
|
+
relations[name] = relation;
|
|
59
|
+
}
|
|
60
|
+
const collection = {
|
|
61
|
+
name: options.name,
|
|
62
|
+
primaryKey: options.primaryKey ?? 'id',
|
|
63
|
+
filterable,
|
|
64
|
+
fieldTypes,
|
|
65
|
+
$fields: fields,
|
|
66
|
+
$relations: relations,
|
|
67
|
+
query: (rows) => new InMemoryQueryBuilder(collection, [...rows]),
|
|
68
|
+
};
|
|
69
|
+
return collection;
|
|
70
|
+
}
|
|
71
|
+
function isNil(value) {
|
|
72
|
+
return value === null || value === undefined;
|
|
73
|
+
}
|
|
74
|
+
function hasToMillis(value) {
|
|
75
|
+
return (typeof value === 'object' &&
|
|
76
|
+
value !== null &&
|
|
77
|
+
typeof value.toMillis === 'function');
|
|
78
|
+
}
|
|
79
|
+
/** The kind a runtime value has — used for undeclared (`unknown`/`json`) fields. */
|
|
80
|
+
function inferKind(value) {
|
|
81
|
+
if (typeof value === 'number' || typeof value === 'bigint')
|
|
82
|
+
return 'number';
|
|
83
|
+
if (typeof value === 'boolean')
|
|
84
|
+
return 'boolean';
|
|
85
|
+
if (typeof value === 'string')
|
|
86
|
+
return 'string';
|
|
87
|
+
if (value instanceof Date || hasToMillis(value))
|
|
88
|
+
return 'date';
|
|
89
|
+
return 'json';
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Normalize a value to a comparable primitive of `kind`, the way Postgres casts a bound literal to
|
|
93
|
+
* the column type. `undefined` means "cannot be cast" — the comparison is then false, where
|
|
94
|
+
* Postgres would have rejected the query outright.
|
|
95
|
+
*/
|
|
96
|
+
function normalize(value, kind) {
|
|
97
|
+
if (isNil(value))
|
|
98
|
+
return undefined;
|
|
99
|
+
switch (kind) {
|
|
100
|
+
case 'number': {
|
|
101
|
+
if (typeof value === 'number')
|
|
102
|
+
return Number.isFinite(value) ? value : undefined;
|
|
103
|
+
if (typeof value === 'bigint')
|
|
104
|
+
return Number(value);
|
|
105
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
106
|
+
const n = Number(value.trim());
|
|
107
|
+
return Number.isFinite(n) ? n : undefined;
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
case 'boolean': {
|
|
112
|
+
if (typeof value === 'boolean')
|
|
113
|
+
return value;
|
|
114
|
+
if (value === 1 || value === 0)
|
|
115
|
+
return value === 1;
|
|
116
|
+
if (typeof value === 'string') {
|
|
117
|
+
const v = value.trim().toLowerCase();
|
|
118
|
+
if (v === 'true' || v === '1')
|
|
119
|
+
return true;
|
|
120
|
+
if (v === 'false' || v === '0')
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
case 'date': {
|
|
126
|
+
if (value instanceof Date) {
|
|
127
|
+
const t = value.getTime();
|
|
128
|
+
return Number.isNaN(t) ? undefined : t;
|
|
129
|
+
}
|
|
130
|
+
if (hasToMillis(value))
|
|
131
|
+
return value.toMillis();
|
|
132
|
+
if (typeof value === 'number')
|
|
133
|
+
return Number.isFinite(value) ? value : undefined;
|
|
134
|
+
if (typeof value === 'string') {
|
|
135
|
+
const t = Date.parse(value);
|
|
136
|
+
return Number.isNaN(t) ? undefined : t;
|
|
137
|
+
}
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
case 'string':
|
|
141
|
+
return typeof value === 'string' ? value : stringify(value);
|
|
142
|
+
default:
|
|
143
|
+
// json / unknown: structural equality only.
|
|
144
|
+
return typeof value === 'object' ? JSON.stringify(value) : value;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Text form of a value — what a LIKE on a non-text column matches against. */
|
|
148
|
+
function stringify(value) {
|
|
149
|
+
if (typeof value === 'string')
|
|
150
|
+
return value;
|
|
151
|
+
if (value instanceof Date)
|
|
152
|
+
return value.toISOString();
|
|
153
|
+
if (hasToMillis(value))
|
|
154
|
+
return new Date(value.toMillis()).toISOString();
|
|
155
|
+
if (typeof value === 'object' && value !== null)
|
|
156
|
+
return JSON.stringify(value);
|
|
157
|
+
return String(value);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Compare a row value to a comparand under `kind` (`json`/`unknown` → the row value's own kind).
|
|
161
|
+
* `undefined` when either side cannot be cast or the kind has no ordering.
|
|
162
|
+
*/
|
|
163
|
+
function compare(rowValue, comparand, declared) {
|
|
164
|
+
const kind = declared === 'json' || declared === 'unknown' ? inferKind(rowValue) : declared;
|
|
165
|
+
const a = normalize(rowValue, kind);
|
|
166
|
+
const b = normalize(comparand, kind);
|
|
167
|
+
if (a === undefined || b === undefined)
|
|
168
|
+
return undefined;
|
|
169
|
+
if (typeof a === 'number' && typeof b === 'number')
|
|
170
|
+
return a - b;
|
|
171
|
+
if (typeof a === 'boolean' && typeof b === 'boolean')
|
|
172
|
+
return Number(a) - Number(b);
|
|
173
|
+
if (typeof a === 'string' && typeof b === 'string') {
|
|
174
|
+
// Code-unit order — Postgres' "C" collation. Deterministic and locale-free, which a keyset
|
|
175
|
+
// cursor needs: the seek predicate and the ORDER BY must agree on every pair of values.
|
|
176
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
177
|
+
}
|
|
178
|
+
return a === b ? 0 : undefined;
|
|
179
|
+
}
|
|
180
|
+
/** Compile a LIKE pattern (backslash escapes, `%`, `_`) into an anchored RegExp. */
|
|
181
|
+
function likeToRegExp(pattern, caseInsensitive) {
|
|
182
|
+
let source = '';
|
|
183
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
184
|
+
const ch = pattern[i];
|
|
185
|
+
if (ch === '\\' && i + 1 < pattern.length) {
|
|
186
|
+
source += escapeRegExp(pattern[++i]);
|
|
187
|
+
}
|
|
188
|
+
else if (ch === '%') {
|
|
189
|
+
source += '.*';
|
|
190
|
+
}
|
|
191
|
+
else if (ch === '_') {
|
|
192
|
+
source += '.';
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
source += escapeRegExp(ch);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return new RegExp(`^${source}$`, caseInsensitive ? 'is' : 's');
|
|
199
|
+
}
|
|
200
|
+
function escapeRegExp(ch) {
|
|
201
|
+
return ch.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
202
|
+
}
|
|
203
|
+
/** Relation targets normalized to a list (a to-one relation yields zero or one row). */
|
|
204
|
+
function relatedRows(value) {
|
|
205
|
+
if (isNil(value))
|
|
206
|
+
return [];
|
|
207
|
+
if (Array.isArray(value))
|
|
208
|
+
return value.filter((v) => !isNil(v));
|
|
209
|
+
return [value];
|
|
210
|
+
}
|
|
211
|
+
/** Parse a `expr AS alias` select/count argument. */
|
|
212
|
+
function parseAlias(expression) {
|
|
213
|
+
const match = /^\s*(.+?)\s+as\s+([A-Za-z_][A-Za-z0-9_]*)\s*$/i.exec(expression);
|
|
214
|
+
return match
|
|
215
|
+
? { expr: match[1].trim(), alias: match[2] }
|
|
216
|
+
: { expr: expression.trim(), alias: expression.trim() };
|
|
217
|
+
}
|
|
218
|
+
/** The raw predicate the group-by-count search emits — the only raw SQL this adapter understands. */
|
|
219
|
+
const GROUP_SEARCH_SQL = /^\s*LOWER\(\?\?\)\s+LIKE\s+\?\s*$/i;
|
|
220
|
+
/**
|
|
221
|
+
* A {@link QueryBuilderLike} over an array. Created by {@link InMemoryCollection.query}; hand it to
|
|
222
|
+
* any runner entry point exactly as you would a Lucid query, then read the result with
|
|
223
|
+
* {@link all}, {@link paginate}, or by awaiting it (it is thenable, like a Lucid query — which is
|
|
224
|
+
* what lets `groupByCountFromRequest` run over it unchanged).
|
|
225
|
+
*/
|
|
226
|
+
export class InMemoryQueryBuilder {
|
|
227
|
+
collection;
|
|
228
|
+
rows;
|
|
229
|
+
clauses = [];
|
|
230
|
+
sorts = [];
|
|
231
|
+
selects = [];
|
|
232
|
+
groups = [];
|
|
233
|
+
groupSearches = [];
|
|
234
|
+
distinctFields = [];
|
|
235
|
+
countAlias;
|
|
236
|
+
limitCount;
|
|
237
|
+
offsetCount;
|
|
238
|
+
constructor(collection, rows = []) {
|
|
239
|
+
this.collection = collection;
|
|
240
|
+
this.rows = rows;
|
|
241
|
+
}
|
|
242
|
+
// -- field access -----------------------------------------------------------------------------
|
|
243
|
+
field(name) {
|
|
244
|
+
return this.collection.$fields[name];
|
|
245
|
+
}
|
|
246
|
+
/** Strip a leading `<collection name>.` qualifier. */
|
|
247
|
+
unqualify(path) {
|
|
248
|
+
const name = this.collection.name;
|
|
249
|
+
return name !== undefined && path.startsWith(`${name}.`) ? path.slice(name.length + 1) : path;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Read a (possibly dotted) path off a row. A dotted path crosses a to-one relation; crossing a
|
|
253
|
+
* to-many relation is ambiguous outside a `whereHas` (which the runner uses for filters) and is
|
|
254
|
+
* rejected.
|
|
255
|
+
*/
|
|
256
|
+
read(row, rawPath) {
|
|
257
|
+
const path = this.unqualify(rawPath);
|
|
258
|
+
const dot = path.indexOf('.');
|
|
259
|
+
if (dot === -1) {
|
|
260
|
+
const declared = this.field(path);
|
|
261
|
+
if (declared?.get)
|
|
262
|
+
return declared.get(row);
|
|
263
|
+
return Object.hasOwn(row, path) ? row[path] : undefined;
|
|
264
|
+
}
|
|
265
|
+
const head = path.slice(0, dot);
|
|
266
|
+
const relation = this.collection.$relations[head];
|
|
267
|
+
if (!relation) {
|
|
268
|
+
throw new InMemoryQueryError(`"${rawPath}" crosses "${head}", which is not a relation declared on this collection.`);
|
|
269
|
+
}
|
|
270
|
+
if (relation.kind === 'one-to-many' || relation.kind === 'many-to-many') {
|
|
271
|
+
throw new InMemoryQueryError(`"${rawPath}" reads through the to-many relation "${head}" — only a filter (EXISTS) can cross it.`);
|
|
272
|
+
}
|
|
273
|
+
const related = relatedRows(relation.get(row))[0];
|
|
274
|
+
if (related === undefined)
|
|
275
|
+
return undefined;
|
|
276
|
+
return relation
|
|
277
|
+
.target()
|
|
278
|
+
.query([])
|
|
279
|
+
.read(related, path.slice(dot + 1));
|
|
280
|
+
}
|
|
281
|
+
/** The declared kind of a path (following to-one relations), or `unknown`. */
|
|
282
|
+
kindOf(rawPath) {
|
|
283
|
+
const path = this.unqualify(rawPath);
|
|
284
|
+
const dot = path.indexOf('.');
|
|
285
|
+
if (dot === -1)
|
|
286
|
+
return this.field(path) ?? { kind: 'unknown', array: false };
|
|
287
|
+
const relation = this.collection.$relations[path.slice(0, dot)];
|
|
288
|
+
if (!relation)
|
|
289
|
+
return { kind: 'unknown', array: false };
|
|
290
|
+
return relation
|
|
291
|
+
.target()
|
|
292
|
+
.query([])
|
|
293
|
+
.kindOf(path.slice(dot + 1));
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Build a leaf predicate. `match` tests one non-null element; `negated` flips the element-level
|
|
297
|
+
* result into "no element matches" — with SQL's rule that a NULL value (or an uncastable
|
|
298
|
+
* comparison) satisfies neither a condition nor its negation.
|
|
299
|
+
*/
|
|
300
|
+
leaf(path, match, negated = false) {
|
|
301
|
+
const { kind, array } = this.kindOf(path);
|
|
302
|
+
return (row) => {
|
|
303
|
+
const value = this.read(row, path);
|
|
304
|
+
if (isNil(value))
|
|
305
|
+
return false;
|
|
306
|
+
if (array || (Array.isArray(value) && (kind === 'unknown' || kind === 'json'))) {
|
|
307
|
+
const elements = (Array.isArray(value) ? value : [value]).filter((e) => !isNil(e));
|
|
308
|
+
const results = elements.map((e) => match(e, kind));
|
|
309
|
+
if (negated)
|
|
310
|
+
return results.every((r) => r === false);
|
|
311
|
+
return results.some((r) => r === true);
|
|
312
|
+
}
|
|
313
|
+
const result = match(value, kind);
|
|
314
|
+
if (result === undefined)
|
|
315
|
+
return false;
|
|
316
|
+
return negated ? !result : result;
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
equalsLeaf(path, value, negated) {
|
|
320
|
+
return this.leaf(path, (e, kind) => {
|
|
321
|
+
const c = compare(e, value, kind);
|
|
322
|
+
return c === undefined ? undefined : c === 0;
|
|
323
|
+
}, negated);
|
|
324
|
+
}
|
|
325
|
+
compareLeaf(path, operator, value) {
|
|
326
|
+
switch (operator) {
|
|
327
|
+
case '=':
|
|
328
|
+
return isNil(value) ? () => false : this.equalsLeaf(path, value, false);
|
|
329
|
+
case '!=':
|
|
330
|
+
case '<>':
|
|
331
|
+
return isNil(value) ? () => false : this.equalsLeaf(path, value, true);
|
|
332
|
+
case '>':
|
|
333
|
+
case '>=':
|
|
334
|
+
case '<':
|
|
335
|
+
case '<=':
|
|
336
|
+
if (isNil(value))
|
|
337
|
+
return () => false;
|
|
338
|
+
return this.leaf(path, (e, kind) => {
|
|
339
|
+
const c = compare(e, value, kind);
|
|
340
|
+
if (c === undefined)
|
|
341
|
+
return undefined;
|
|
342
|
+
if (operator === '>')
|
|
343
|
+
return c > 0;
|
|
344
|
+
if (operator === '>=')
|
|
345
|
+
return c >= 0;
|
|
346
|
+
if (operator === '<')
|
|
347
|
+
return c < 0;
|
|
348
|
+
return c <= 0;
|
|
349
|
+
});
|
|
350
|
+
default:
|
|
351
|
+
throw new InMemoryQueryError(`Unsupported comparison operator "${operator}".`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
inLeaf(path, values, negated) {
|
|
355
|
+
// SQL: `x IN ()` is false and `x NOT IN ()` is true for every row, NULLs included (knex emits
|
|
356
|
+
// 1 = 0 / 1 = 1); `x NOT IN (…, NULL)` is never true.
|
|
357
|
+
if (values.length === 0)
|
|
358
|
+
return () => negated;
|
|
359
|
+
if (negated && values.some(isNil))
|
|
360
|
+
return () => false;
|
|
361
|
+
const candidates = values.filter((v) => !isNil(v));
|
|
362
|
+
return this.leaf(path, (e, kind) => {
|
|
363
|
+
let unknown = false;
|
|
364
|
+
for (const candidate of candidates) {
|
|
365
|
+
const c = compare(e, candidate, kind);
|
|
366
|
+
if (c === 0)
|
|
367
|
+
return true;
|
|
368
|
+
if (c === undefined)
|
|
369
|
+
unknown = true;
|
|
370
|
+
}
|
|
371
|
+
return unknown ? undefined : false;
|
|
372
|
+
}, negated);
|
|
373
|
+
}
|
|
374
|
+
betweenLeaf(path, range, negated) {
|
|
375
|
+
const [low, high] = Array.isArray(range) ? range : [undefined, undefined];
|
|
376
|
+
if (isNil(low) || isNil(high))
|
|
377
|
+
return () => false;
|
|
378
|
+
return this.leaf(path, (e, kind) => {
|
|
379
|
+
const lo = compare(e, low, kind);
|
|
380
|
+
const hi = compare(e, high, kind);
|
|
381
|
+
if (lo === undefined || hi === undefined)
|
|
382
|
+
return undefined;
|
|
383
|
+
return lo >= 0 && hi <= 0;
|
|
384
|
+
}, negated);
|
|
385
|
+
}
|
|
386
|
+
likeLeaf(path, pattern) {
|
|
387
|
+
const regex = likeToRegExp(pattern, true);
|
|
388
|
+
return this.leaf(path, (e) => regex.test(stringify(e)));
|
|
389
|
+
}
|
|
390
|
+
add(test, or = false) {
|
|
391
|
+
this.clauses.push({ or, test });
|
|
392
|
+
return this;
|
|
393
|
+
}
|
|
394
|
+
group(callback, or) {
|
|
395
|
+
const child = new InMemoryQueryBuilder(this.collection);
|
|
396
|
+
callback(child);
|
|
397
|
+
// An empty group compiles to nothing in knex — not to an always-true/false clause.
|
|
398
|
+
if (child.clauses.length === 0)
|
|
399
|
+
return this;
|
|
400
|
+
return this.add((row) => child.matches(row), or);
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Does `row` satisfy every recorded condition? Clauses chain left to right the way knex joins
|
|
404
|
+
* them — `a AND b OR c` — so AND binds tighter than OR, exactly as the emitted SQL would.
|
|
405
|
+
*/
|
|
406
|
+
matches(row) {
|
|
407
|
+
let result = false;
|
|
408
|
+
let segment = true;
|
|
409
|
+
this.clauses.forEach((clause, index) => {
|
|
410
|
+
if (clause.or && index > 0) {
|
|
411
|
+
result ||= segment;
|
|
412
|
+
segment = true;
|
|
413
|
+
}
|
|
414
|
+
segment &&= clause.test(row);
|
|
415
|
+
});
|
|
416
|
+
return this.clauses.length === 0 ? true : result || segment;
|
|
417
|
+
}
|
|
418
|
+
where(...args) {
|
|
419
|
+
if (typeof args[0] === 'function') {
|
|
420
|
+
return this.group(args[0], false);
|
|
421
|
+
}
|
|
422
|
+
const column = args[0];
|
|
423
|
+
if (args.length >= 3)
|
|
424
|
+
return this.add(this.compareLeaf(column, String(args[1]), args[2]));
|
|
425
|
+
// knex compiles `where(col, null)` to `col IS NULL`.
|
|
426
|
+
if (isNil(args[1]))
|
|
427
|
+
return this.whereNull(column);
|
|
428
|
+
return this.add(this.equalsLeaf(column, args[1], false));
|
|
429
|
+
}
|
|
430
|
+
orWhere(callback) {
|
|
431
|
+
return this.group(callback, true);
|
|
432
|
+
}
|
|
433
|
+
// biome-ignore lint/suspicious/noExplicitAny: matches QueryBuilderLike.whereHas (see its docblock).
|
|
434
|
+
whereHas(relation, callback) {
|
|
435
|
+
const name = String(relation);
|
|
436
|
+
const declared = this.collection.$relations[name];
|
|
437
|
+
if (!declared) {
|
|
438
|
+
throw new InMemoryQueryError(`"${name}" is not a relation declared on this collection.`);
|
|
439
|
+
}
|
|
440
|
+
const child = new InMemoryQueryBuilder(declared.target());
|
|
441
|
+
callback(child);
|
|
442
|
+
return this.add((row) => relatedRows(declared.get(row)).some((r) => child.matches(r)));
|
|
443
|
+
}
|
|
444
|
+
whereNot(column, value) {
|
|
445
|
+
if (isNil(value))
|
|
446
|
+
return this.whereNotNull(column);
|
|
447
|
+
return this.add(this.equalsLeaf(column, value, true));
|
|
448
|
+
}
|
|
449
|
+
whereIn(column, values) {
|
|
450
|
+
return this.add(this.inLeaf(column, Array.isArray(values) ? values : [values], false));
|
|
451
|
+
}
|
|
452
|
+
whereNotIn(column, values) {
|
|
453
|
+
return this.add(this.inLeaf(column, Array.isArray(values) ? values : [values], true));
|
|
454
|
+
}
|
|
455
|
+
whereNull(column) {
|
|
456
|
+
return this.add((row) => isNil(this.read(row, column)));
|
|
457
|
+
}
|
|
458
|
+
whereNotNull(column) {
|
|
459
|
+
return this.add((row) => !isNil(this.read(row, column)));
|
|
460
|
+
}
|
|
461
|
+
whereBetween(column, range) {
|
|
462
|
+
return this.add(this.betweenLeaf(column, range, false));
|
|
463
|
+
}
|
|
464
|
+
whereNotBetween(column, range) {
|
|
465
|
+
return this.add(this.betweenLeaf(column, range, true));
|
|
466
|
+
}
|
|
467
|
+
whereILike(column, value) {
|
|
468
|
+
return this.add(this.likeLeaf(column, value));
|
|
469
|
+
}
|
|
470
|
+
orWhereILike(column, value) {
|
|
471
|
+
return this.add(this.likeLeaf(column, value), true);
|
|
472
|
+
}
|
|
473
|
+
orderBy(column, direction) {
|
|
474
|
+
this.sorts.push({ field: column, direction: direction === 'desc' ? 'desc' : 'asc' });
|
|
475
|
+
return this;
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Only the group-by-count search predicate (`LOWER(??) LIKE ?`) is understood; any other raw
|
|
479
|
+
* SQL — a policy's `computed` fields, `fullText`, `vectorSimilarity` — throws
|
|
480
|
+
* {@link InMemoryQueryError}. Declare a virtual field on the collection instead of `computed`.
|
|
481
|
+
*/
|
|
482
|
+
whereRaw(sql, bindings = []) {
|
|
483
|
+
if (GROUP_SEARCH_SQL.test(sql) && typeof bindings[0] === 'string') {
|
|
484
|
+
const field = bindings[0];
|
|
485
|
+
const pattern = likeToRegExp(String(bindings[1] ?? ''), false);
|
|
486
|
+
this.groupSearches.push({ field, pattern });
|
|
487
|
+
return this.add(this.leaf(field, (e) => pattern.test(stringify(e).toLowerCase())), false);
|
|
488
|
+
}
|
|
489
|
+
throw new InMemoryQueryError(`The in-memory adapter cannot evaluate raw SQL (${JSON.stringify(sql)}). Policy \`computed\` fields, \`fullText\` and \`vectorSimilarity\` are SQL-only — declare a virtual field on the collection instead.`);
|
|
490
|
+
}
|
|
491
|
+
orderByRaw(sql, _bindings = []) {
|
|
492
|
+
throw new InMemoryQueryError(`The in-memory adapter cannot order by raw SQL (${JSON.stringify(sql)}). Declare a virtual field on the collection and sort on it instead.`);
|
|
493
|
+
}
|
|
494
|
+
distinct(...columns) {
|
|
495
|
+
this.distinctFields = columns;
|
|
496
|
+
return this;
|
|
497
|
+
}
|
|
498
|
+
limit(count) {
|
|
499
|
+
this.limitCount = Math.max(0, count);
|
|
500
|
+
return this;
|
|
501
|
+
}
|
|
502
|
+
offset(n) {
|
|
503
|
+
this.offsetCount = Math.max(0, n);
|
|
504
|
+
return this;
|
|
505
|
+
}
|
|
506
|
+
select(...columns) {
|
|
507
|
+
for (const column of columns)
|
|
508
|
+
this.selects.push(parseAlias(column));
|
|
509
|
+
return this;
|
|
510
|
+
}
|
|
511
|
+
count(column) {
|
|
512
|
+
const { expr, alias } = parseAlias(column);
|
|
513
|
+
if (expr !== '*') {
|
|
514
|
+
throw new InMemoryQueryError(`Only count('*') is supported in memory, got "${column}".`);
|
|
515
|
+
}
|
|
516
|
+
this.countAlias = alias;
|
|
517
|
+
return this;
|
|
518
|
+
}
|
|
519
|
+
groupBy(...columns) {
|
|
520
|
+
this.groups.push(...columns);
|
|
521
|
+
return this;
|
|
522
|
+
}
|
|
523
|
+
// -- execution --------------------------------------------------------------------------------
|
|
524
|
+
compareRows(a, b) {
|
|
525
|
+
for (const { field, direction } of this.sorts) {
|
|
526
|
+
const c = this.compareValues(this.read(a, field), this.read(b, field), this.kindOf(field).kind);
|
|
527
|
+
if (c !== 0)
|
|
528
|
+
return direction === 'desc' ? -c : c;
|
|
529
|
+
}
|
|
530
|
+
return 0;
|
|
531
|
+
}
|
|
532
|
+
/** Ascending comparison with NULLs sorting last (so NULLS FIRST once a desc sort negates it). */
|
|
533
|
+
compareValues(a, b, kind) {
|
|
534
|
+
if (isNil(a))
|
|
535
|
+
return isNil(b) ? 0 : 1;
|
|
536
|
+
if (isNil(b))
|
|
537
|
+
return -1;
|
|
538
|
+
return compare(a, b, kind) ?? 0;
|
|
539
|
+
}
|
|
540
|
+
/** Filter + sort (+ distinct / projection / aggregation), before limit/offset. */
|
|
541
|
+
evaluate() {
|
|
542
|
+
const matched = this.rows.filter((row) => this.matches(row));
|
|
543
|
+
if (this.groups.length > 0)
|
|
544
|
+
return this.aggregate(matched);
|
|
545
|
+
const sorted = this.sorts.length > 0 ? [...matched].sort((a, b) => this.compareRows(a, b)) : matched;
|
|
546
|
+
if (this.distinctFields.length > 0) {
|
|
547
|
+
const seen = new Set();
|
|
548
|
+
const out = [];
|
|
549
|
+
for (const row of sorted) {
|
|
550
|
+
const projected = {};
|
|
551
|
+
const key = [];
|
|
552
|
+
for (const field of this.distinctFields) {
|
|
553
|
+
const value = this.read(row, field);
|
|
554
|
+
projected[this.unqualify(field)] = value;
|
|
555
|
+
key.push(normalize(value, this.kindOf(field).kind) ?? null);
|
|
556
|
+
}
|
|
557
|
+
const k = JSON.stringify(key);
|
|
558
|
+
if (!seen.has(k)) {
|
|
559
|
+
seen.add(k);
|
|
560
|
+
out.push(projected);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return out;
|
|
564
|
+
}
|
|
565
|
+
if (this.selects.length > 0) {
|
|
566
|
+
return sorted.map((row) => {
|
|
567
|
+
const projected = {};
|
|
568
|
+
for (const { expr, alias } of this.selects)
|
|
569
|
+
projected[alias] = this.read(row, expr);
|
|
570
|
+
return projected;
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
return sorted;
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* `GROUP BY` over the matched rows. An array-typed grouping field is unnested — each element is
|
|
577
|
+
* its own group — which is what a tag picker wants (Postgres would group whole arrays).
|
|
578
|
+
*/
|
|
579
|
+
aggregate(rows) {
|
|
580
|
+
const columns = this.groups;
|
|
581
|
+
const groups = new Map();
|
|
582
|
+
const single = columns.length === 1 ? columns[0] : undefined;
|
|
583
|
+
const unnest = single !== undefined && this.kindOf(single).array;
|
|
584
|
+
const searches = this.groupSearches.filter((s) => s.field === single);
|
|
585
|
+
for (const row of rows) {
|
|
586
|
+
const tuples = [];
|
|
587
|
+
if (unnest) {
|
|
588
|
+
const value = this.read(row, single);
|
|
589
|
+
const elements = Array.isArray(value) ? value : isNil(value) ? [null] : [value];
|
|
590
|
+
for (const element of elements) {
|
|
591
|
+
if (searches.every((s) => !isNil(element) && s.pattern.test(stringify(element).toLowerCase()))) {
|
|
592
|
+
tuples.push([element]);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
else {
|
|
597
|
+
tuples.push(columns.map((c) => this.read(row, c)));
|
|
598
|
+
}
|
|
599
|
+
for (const values of tuples) {
|
|
600
|
+
const key = JSON.stringify(values.map((v, i) => normalize(v, this.kindOf(columns[i]).kind) ?? null));
|
|
601
|
+
const group = groups.get(key);
|
|
602
|
+
if (group)
|
|
603
|
+
group.count++;
|
|
604
|
+
else
|
|
605
|
+
groups.set(key, { values, count: 1 });
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
const outputs = [...groups.values()].map(({ values, count }) => {
|
|
609
|
+
const row = {};
|
|
610
|
+
const selects = this.selects.length > 0 ? this.selects : columns.map((c) => ({ expr: c, alias: c }));
|
|
611
|
+
for (const { expr, alias } of selects) {
|
|
612
|
+
const index = columns.indexOf(expr);
|
|
613
|
+
if (index === -1) {
|
|
614
|
+
throw new InMemoryQueryError(`"${expr}" is selected but not grouped by.`);
|
|
615
|
+
}
|
|
616
|
+
row[alias] = values[index];
|
|
617
|
+
}
|
|
618
|
+
if (this.countAlias !== undefined)
|
|
619
|
+
row[this.countAlias] = count;
|
|
620
|
+
return { row, values, count };
|
|
621
|
+
});
|
|
622
|
+
outputs.sort((a, b) => {
|
|
623
|
+
for (const { field, direction } of this.sorts) {
|
|
624
|
+
let c;
|
|
625
|
+
if (field === this.countAlias) {
|
|
626
|
+
c = a.count - b.count;
|
|
627
|
+
}
|
|
628
|
+
else {
|
|
629
|
+
const index = columns.indexOf(field);
|
|
630
|
+
const aliasIndex = this.selects.findIndex((s) => s.alias === field);
|
|
631
|
+
const i = index !== -1
|
|
632
|
+
? index
|
|
633
|
+
: aliasIndex !== -1
|
|
634
|
+
? columns.indexOf(this.selects[aliasIndex].expr)
|
|
635
|
+
: -1;
|
|
636
|
+
if (i === -1)
|
|
637
|
+
throw new InMemoryQueryError(`Cannot order grouped rows by "${field}".`);
|
|
638
|
+
c = this.compareValues(a.values[i], b.values[i], this.kindOf(columns[i]).kind);
|
|
639
|
+
}
|
|
640
|
+
if (c !== 0)
|
|
641
|
+
return direction === 'desc' ? -c : c;
|
|
642
|
+
}
|
|
643
|
+
return 0;
|
|
644
|
+
});
|
|
645
|
+
return outputs.map((o) => o.row);
|
|
646
|
+
}
|
|
647
|
+
window(rows) {
|
|
648
|
+
const start = this.offsetCount ?? 0;
|
|
649
|
+
const end = this.limitCount === undefined ? undefined : start + this.limitCount;
|
|
650
|
+
return rows.slice(start, end);
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Execute: the matching rows in order, honouring `limit`/`offset` (which is what a cursor page
|
|
654
|
+
* sets). Under a `distinct`, a `select` or a `groupBy`, the rows are projection objects — just
|
|
655
|
+
* as a Lucid query returns them.
|
|
656
|
+
*/
|
|
657
|
+
all() {
|
|
658
|
+
return this.window(this.evaluate());
|
|
659
|
+
}
|
|
660
|
+
/** How many rows (or distinct tuples / groups) match, ignoring `limit`/`offset`. */
|
|
661
|
+
total() {
|
|
662
|
+
return this.evaluate().length;
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* One offset page — the in-memory `query.paginate(page, size)`. Feed it the `{ page, size }`
|
|
666
|
+
* the runner resolved (clamped and defaulted), never raw request values.
|
|
667
|
+
*/
|
|
668
|
+
paginate(page, perPage) {
|
|
669
|
+
const size = Math.max(1, Math.floor(perPage));
|
|
670
|
+
const current = Math.max(1, Math.floor(page));
|
|
671
|
+
const rows = this.evaluate();
|
|
672
|
+
const total = rows.length;
|
|
673
|
+
return {
|
|
674
|
+
data: rows.slice((current - 1) * size, current * size),
|
|
675
|
+
meta: {
|
|
676
|
+
total,
|
|
677
|
+
perPage: size,
|
|
678
|
+
currentPage: current,
|
|
679
|
+
lastPage: Math.max(1, Math.ceil(total / size)),
|
|
680
|
+
firstPage: 1,
|
|
681
|
+
},
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Thenable, like a Lucid query: `await qb` resolves to {@link all}. This is what lets helpers
|
|
686
|
+
* that execute a builder by awaiting it (`groupByCountFromRequest`) run over an array.
|
|
687
|
+
*/
|
|
688
|
+
// biome-ignore lint/suspicious/noThenProperty: deliberately thenable, mirroring Lucid's query builders.
|
|
689
|
+
then(onfulfilled, onrejected) {
|
|
690
|
+
return new Promise((resolve) => resolve(this.all())).then(onfulfilled, onrejected);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
// ---------------------------------------------------------------------------------------------
|
|
694
|
+
// One-call helpers
|
|
695
|
+
// ---------------------------------------------------------------------------------------------
|
|
696
|
+
/**
|
|
697
|
+
* Filter, search, sort and offset-paginate `rows` in one call: {@link applyFilter} over a fresh
|
|
698
|
+
* builder, then {@link InMemoryQueryBuilder.paginate} with the resolved page. A `distinct` request
|
|
699
|
+
* pages the distinct tuples instead of rows.
|
|
700
|
+
*
|
|
701
|
+
* ```ts
|
|
702
|
+
* const page = filterInMemory(users, rows, parseFilterRequest(ctx.request.qs()), {
|
|
703
|
+
* allowed: ['name', 'age', 'posts.title'],
|
|
704
|
+
* searchable: ['name'],
|
|
705
|
+
* fieldTypes: users.fieldTypes,
|
|
706
|
+
* })
|
|
707
|
+
* // → { data, meta: { total, perPage, currentPage, lastPage, firstPage } }
|
|
708
|
+
* ```
|
|
709
|
+
*/
|
|
710
|
+
export function filterInMemory(collection, rows, input, config) {
|
|
711
|
+
const qb = collection.query(rows);
|
|
712
|
+
const { page, size } = applyFilter(qb, input, config);
|
|
713
|
+
return qb.paginate(page, size);
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* The keyset (cursor) counterpart of {@link filterInMemory}: {@link applyCursor} over a fresh
|
|
717
|
+
* builder (the primary key defaults to the collection's), then `buildCursorPage`. Keyset values
|
|
718
|
+
* are read through the collection, so a virtual field or a to-one relation path works as a cursor
|
|
719
|
+
* sort field.
|
|
720
|
+
*/
|
|
721
|
+
export function cursorInMemory(collection, rows, input, config) {
|
|
722
|
+
const qb = collection.query(rows);
|
|
723
|
+
const cursorConfig = {
|
|
724
|
+
...config,
|
|
725
|
+
primaryKey: config.primaryKey ?? collection.primaryKey,
|
|
726
|
+
};
|
|
727
|
+
const resolved = applyCursor(qb, input, cursorConfig);
|
|
728
|
+
const fetched = qb.all();
|
|
729
|
+
// buildCursorPage reads keyset values as plain properties; stand each row in with an object
|
|
730
|
+
// carrying its keyset values (virtual fields and relation paths resolved), then map back.
|
|
731
|
+
const originals = new Map();
|
|
732
|
+
const keyed = fetched.map((row) => {
|
|
733
|
+
const values = resolved.keyset.map((k) => qb.read(row, k.field));
|
|
734
|
+
const stand = {};
|
|
735
|
+
resolved.keyset.forEach((k, i) => {
|
|
736
|
+
setPath(stand, k.field, values[i]);
|
|
737
|
+
});
|
|
738
|
+
originals.set(stand, row);
|
|
739
|
+
return stand;
|
|
740
|
+
});
|
|
741
|
+
const page = buildCursorPage(keyed, resolved);
|
|
742
|
+
return { ...page, items: page.items.map((stand) => originals.get(stand)) };
|
|
743
|
+
}
|
|
744
|
+
function setPath(target, path, value) {
|
|
745
|
+
const segments = path.split('.');
|
|
746
|
+
let node = target;
|
|
747
|
+
for (const segment of segments.slice(0, -1)) {
|
|
748
|
+
const next = node[segment];
|
|
749
|
+
if (typeof next === 'object' && next !== null)
|
|
750
|
+
node = next;
|
|
751
|
+
else {
|
|
752
|
+
const created = {};
|
|
753
|
+
node[segment] = created;
|
|
754
|
+
node = created;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
node[segments[segments.length - 1]] = value;
|
|
758
|
+
}
|
|
759
|
+
//# sourceMappingURL=in_memory.js.map
|