@dudousxd/nestjs-filter 1.7.1 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/dist/adapter/adapter.d.ts +119 -3
- package/dist/adapter/adapter.d.ts.map +1 -1
- package/dist/decorator/allowed.d.ts +30 -0
- package/dist/decorator/allowed.d.ts.map +1 -0
- package/dist/decorator/allowed.js +32 -0
- package/dist/decorator/allowed.js.map +1 -0
- package/dist/decorator/filterable.decorator.d.ts.map +1 -1
- package/dist/decorator/filterable.decorator.js +3 -0
- package/dist/decorator/filterable.decorator.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/input/spatie-parser.d.ts +40 -0
- package/dist/input/spatie-parser.d.ts.map +1 -0
- package/dist/input/spatie-parser.js +159 -0
- package/dist/input/spatie-parser.js.map +1 -0
- package/dist/pagination/cursor.d.ts +37 -0
- package/dist/pagination/cursor.d.ts.map +1 -0
- package/dist/pagination/cursor.js +69 -0
- package/dist/pagination/cursor.js.map +1 -0
- package/dist/runner.d.ts +78 -3
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +402 -49
- package/dist/runner.js.map +1 -1
- package/dist/types.d.ts +113 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/runner.js
CHANGED
|
@@ -11,9 +11,10 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
|
11
11
|
return function (target, key) { decorator(target, key, paramIndex); }
|
|
12
12
|
};
|
|
13
13
|
var FilterRunner_1;
|
|
14
|
-
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
|
14
|
+
import { BadRequestException, Inject, Injectable, Logger, Optional, } from '@nestjs/common';
|
|
15
15
|
import { ModuleRef } from '@nestjs/core';
|
|
16
16
|
import { runWithFilterState } from './als-store.js';
|
|
17
|
+
import { allowedFieldNames, normalizeAllowed, } from './decorator/allowed.js';
|
|
17
18
|
import { getFilterForMap } from './decorator/filter-for.decorator.js';
|
|
18
19
|
import { getFilterableMetadata } from './decorator/filterable.decorator.js';
|
|
19
20
|
import { resolveRelation } from './decorator/relations.decorator.js';
|
|
@@ -21,8 +22,10 @@ import { getTenantScopedField } from './decorator/tenant-scoped.decorator.js';
|
|
|
21
22
|
import { FilterMethodException, FilterNotRegisteredException, UnknownFilterKeyException, } from './errors/exceptions.js';
|
|
22
23
|
import { resolveDispatchTarget } from './input/dispatcher.js';
|
|
23
24
|
import { normalizeInput } from './input/normalizer.js';
|
|
25
|
+
import { parseSpatieInput } from './input/spatie-parser.js';
|
|
24
26
|
import { validateInput } from './input/validator.js';
|
|
25
|
-
import { validateColumnFilters } from './operators/validate-column-filter.js';
|
|
27
|
+
import { normalizeOperator, validateColumnFilters } from './operators/validate-column-filter.js';
|
|
28
|
+
import { buildKeyset, decodeCursor, encodeCursor, extractCursorValues, } from './pagination/cursor.js';
|
|
26
29
|
import { CONTEXT_ACCESSOR, FILTER_ADAPTER, FILTER_MODULE_OPTIONS } from './tokens.js';
|
|
27
30
|
const MATCH_ALL_SET = { has: () => true };
|
|
28
31
|
let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
@@ -131,16 +134,19 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
131
134
|
this.descriptionCache.set(entity, description);
|
|
132
135
|
return description;
|
|
133
136
|
}
|
|
134
|
-
async apply(FilterClass, input, qb, context = {}) {
|
|
137
|
+
async apply(FilterClass, input, qb, context = {}, internal = {}) {
|
|
135
138
|
const filter = await this.resolveFilter(FilterClass);
|
|
136
139
|
const adapter = this.resolveAdapter();
|
|
137
140
|
// Extract structured input: { filter, include, search, sort, paginate }
|
|
138
|
-
const rawInput = this.extractStructuredInput(input
|
|
141
|
+
const rawInput = this.extractStructuredInput(input, {
|
|
142
|
+
...(internal.native !== undefined && { native: internal.native }),
|
|
143
|
+
});
|
|
139
144
|
const filterInput = rawInput.filter;
|
|
140
145
|
const rawInclude = rawInput.include;
|
|
141
146
|
const rawSearch = rawInput.search;
|
|
142
147
|
const rawSort = rawInput.sort;
|
|
143
148
|
const rawDistinct = rawInput.distinct;
|
|
149
|
+
const rawSelect = rawInput.select;
|
|
144
150
|
const rawPaginate = rawInput.paginate;
|
|
145
151
|
// Extract column filters from the filter portion before normalization
|
|
146
152
|
const { columnFilters, remainingInput } = this.extractColumnFilters(filterInput);
|
|
@@ -168,10 +174,16 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
168
174
|
// Opt-in tenant auto-scope (@TenantScoped). Only applies when an accessor
|
|
169
175
|
// is bound AND resolves a tenant id; otherwise a no-op.
|
|
170
176
|
this.applyTenantScope(FilterClass, qb, adapter, contextAccessor);
|
|
177
|
+
// Resolve the (possibly operator-restricting) allowlist once for this run.
|
|
178
|
+
const normalizedAllowed = normalizeAllowed(getFilterableMetadata(FilterClass)?.allowed);
|
|
179
|
+
const throwOnInvalidPolicy = this.resolveThrowOnInvalid(FilterClass);
|
|
171
180
|
// Apply column filters via adapter before @FilterFor dispatch
|
|
172
181
|
if (columnFilters.length > 0 && adapter?.applyColumnFilters) {
|
|
173
|
-
|
|
174
|
-
|
|
182
|
+
const opAllowed = this.enforceOperatorAllowlist(columnFilters, normalizedAllowed, throwOnInvalidPolicy);
|
|
183
|
+
if (opAllowed.length > 0) {
|
|
184
|
+
validateColumnFilters(opAllowed);
|
|
185
|
+
adapter.applyColumnFilters(qb, opAllowed, getFilterableMetadata(FilterClass)?.entity);
|
|
186
|
+
}
|
|
175
187
|
}
|
|
176
188
|
else if (columnFilters.length > 0 && !adapter?.applyColumnFilters) {
|
|
177
189
|
this.logger.warn('Column filters (where) provided but adapter does not support applyColumnFilters. Skipping.');
|
|
@@ -179,6 +191,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
179
191
|
// Resolve auto-fields configuration
|
|
180
192
|
const autoFieldSet = this.resolveAutoFields(FilterClass);
|
|
181
193
|
const filterableMeta = getFilterableMetadata(FilterClass);
|
|
194
|
+
const computed = filterableMeta?.computed;
|
|
182
195
|
// Collect relation-bound keys for batched processing
|
|
183
196
|
const relationBatches = new Map();
|
|
184
197
|
for (const [key, value] of Object.entries(finalInput)) {
|
|
@@ -209,10 +222,26 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
209
222
|
relationBatches.get(relationName).entries.push([key, value]);
|
|
210
223
|
continue;
|
|
211
224
|
}
|
|
225
|
+
// Check if this key is a computed/virtual field (dev-declared SQL).
|
|
226
|
+
if (computed && Object.hasOwn(computed, key)) {
|
|
227
|
+
if (adapter?.applyComputedField) {
|
|
228
|
+
const filtered = this.enforceAutoFieldOperators(key, value, normalizedAllowed, throwOnInvalidPolicy);
|
|
229
|
+
if (filtered !== undefined) {
|
|
230
|
+
adapter.applyComputedField(qb, computed[key], filtered);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
this.logger.warn(`Computed field "${key}" provided but adapter does not support applyComputedField. Skipping.`);
|
|
235
|
+
}
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
212
238
|
// Check if this key is an auto-field
|
|
213
239
|
if (autoFieldSet?.has(key)) {
|
|
214
240
|
if (adapter?.applyAutoField) {
|
|
215
|
-
|
|
241
|
+
const filtered = this.enforceAutoFieldOperators(key, value, normalizedAllowed, throwOnInvalidPolicy);
|
|
242
|
+
if (filtered !== undefined) {
|
|
243
|
+
adapter.applyAutoField(qb, key, filtered);
|
|
244
|
+
}
|
|
216
245
|
}
|
|
217
246
|
else {
|
|
218
247
|
this.logger.warn(`Auto-field "${key}" provided but adapter does not support applyAutoField. Skipping.`);
|
|
@@ -291,7 +320,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
291
320
|
if (distinctFields.length > 0 && adapter?.applyDistinct && filterableMeta) {
|
|
292
321
|
const allowedDistinct = FilterClass
|
|
293
322
|
.distinct;
|
|
294
|
-
const validDistinct = this.validateDistinct(distinctFields, allowedDistinct, adapter, filterableMeta.entity);
|
|
323
|
+
const validDistinct = this.validateDistinct(distinctFields, allowedDistinct, adapter, filterableMeta.entity, this.resolveThrowOnInvalid(FilterClass));
|
|
295
324
|
if (validDistinct.length > 0) {
|
|
296
325
|
adapter.applyDistinct(qb, validDistinct, filterableMeta.entity);
|
|
297
326
|
}
|
|
@@ -299,14 +328,27 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
299
328
|
else if (distinctFields.length > 0 && !adapter?.applyDistinct) {
|
|
300
329
|
this.logger.warn('Distinct requested but adapter does not support applyDistinct. Skipping.');
|
|
301
330
|
}
|
|
302
|
-
// Apply
|
|
303
|
-
|
|
331
|
+
// Apply sparse fieldsets (SELECT narrowing) — validated against the
|
|
332
|
+
// allowlist / entity metadata, mirroring distinct/sort safety.
|
|
333
|
+
const selectFields = this.parseDistinct(rawSelect);
|
|
334
|
+
if (selectFields.length > 0 && adapter?.applySelect && filterableMeta) {
|
|
335
|
+
const allowedSelect = FilterClass.select;
|
|
336
|
+
const validSelect = this.validateDistinct(selectFields, allowedSelect, adapter, filterableMeta.entity, throwOnInvalidPolicy);
|
|
337
|
+
if (validSelect.length > 0) {
|
|
338
|
+
adapter.applySelect(qb, validSelect, filterableMeta.entity);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
else if (selectFields.length > 0 && !adapter?.applySelect) {
|
|
342
|
+
this.logger.warn('Sparse fieldsets (select) requested but adapter does not support applySelect. Skipping.');
|
|
343
|
+
}
|
|
344
|
+
// Apply sort — falling back to defaultSort when the client gave none.
|
|
345
|
+
const parsedSorts = this.parseSorts(rawSort);
|
|
346
|
+
const sorts = parsedSorts.length > 0
|
|
347
|
+
? parsedSorts
|
|
348
|
+
: this.parseSorts(this.resolveDefaultSort(FilterClass));
|
|
304
349
|
if (sorts.length > 0 && adapter?.applySort) {
|
|
305
350
|
const allowedSorts = FilterClass.sort;
|
|
306
|
-
|
|
307
|
-
if (validSorts.length > 0) {
|
|
308
|
-
adapter.applySort(qb, validSorts);
|
|
309
|
-
}
|
|
351
|
+
this.applySortsWithComputed(qb, sorts, allowedSorts, adapter, filterableMeta?.entity, this.resolveThrowOnInvalid(FilterClass), computed);
|
|
310
352
|
}
|
|
311
353
|
// Apply pagination
|
|
312
354
|
this.applyPagination(qb, rawPaginate, adapter);
|
|
@@ -364,7 +406,9 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
364
406
|
inputObj[key] = value;
|
|
365
407
|
}
|
|
366
408
|
await this.adapter.applyRelationConstraint(qb, relationName, async (relationQb) => {
|
|
367
|
-
await this.apply(RelatedFilterClass, { filter: inputObj }, relationQb, context
|
|
409
|
+
await this.apply(RelatedFilterClass, { filter: inputObj }, relationQb, context, {
|
|
410
|
+
native: true,
|
|
411
|
+
});
|
|
368
412
|
});
|
|
369
413
|
}
|
|
370
414
|
/**
|
|
@@ -374,36 +418,56 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
374
418
|
* - `{ filter: {...}, include: [...], search: '...', sort: '...', paginate: {...} }` (structured format)
|
|
375
419
|
* - Any other shape is treated as the filter portion directly (backward compat for internal calls)
|
|
376
420
|
*/
|
|
377
|
-
extractStructuredInput(input) {
|
|
378
|
-
|
|
421
|
+
extractStructuredInput(input, internal = {}) {
|
|
422
|
+
// Opt-in spatie / JSON:API input format. Internal re-dispatch calls
|
|
423
|
+
// (relation constraints, findPage/findAndCount forwards) pass already-native
|
|
424
|
+
// structured input and set `internal.native` to bypass re-parsing.
|
|
425
|
+
const source = !internal.native && this.options.inputFormat === 'spatie' ? parseSpatieInput(input) : input;
|
|
426
|
+
if (source == null || typeof source !== 'object') {
|
|
379
427
|
return {
|
|
380
|
-
filter:
|
|
428
|
+
filter: source,
|
|
381
429
|
include: undefined,
|
|
382
430
|
search: undefined,
|
|
383
431
|
sort: undefined,
|
|
384
432
|
distinct: undefined,
|
|
433
|
+
select: undefined,
|
|
385
434
|
paginate: undefined,
|
|
386
435
|
};
|
|
387
436
|
}
|
|
388
|
-
const inputObj =
|
|
389
|
-
// Detect structured format:
|
|
390
|
-
|
|
437
|
+
const inputObj = source;
|
|
438
|
+
// Detect structured format: presence of any reserved structured key
|
|
439
|
+
// (`filter`, `include`, `search`, `sort`, `distinct`, `select`, `paginate`).
|
|
440
|
+
// The original detection keyed only on `filter`; broadening it lets callers
|
|
441
|
+
// pass e.g. `{ sort, paginate }` (no filter) — as `findPage` does — while
|
|
442
|
+
// remaining backward-compatible (these keys were always reserved).
|
|
443
|
+
const STRUCTURED_KEYS = [
|
|
444
|
+
'filter',
|
|
445
|
+
'include',
|
|
446
|
+
'search',
|
|
447
|
+
'sort',
|
|
448
|
+
'distinct',
|
|
449
|
+
'select',
|
|
450
|
+
'paginate',
|
|
451
|
+
];
|
|
452
|
+
if (STRUCTURED_KEYS.some((k) => k in inputObj)) {
|
|
391
453
|
return {
|
|
392
454
|
filter: inputObj.filter ?? undefined,
|
|
393
455
|
include: inputObj.include ?? undefined,
|
|
394
456
|
search: inputObj.search ?? undefined,
|
|
395
457
|
sort: inputObj.sort ?? undefined,
|
|
396
458
|
distinct: inputObj.distinct ?? undefined,
|
|
459
|
+
select: inputObj.select ?? undefined,
|
|
397
460
|
paginate: inputObj.paginate ?? undefined,
|
|
398
461
|
};
|
|
399
462
|
}
|
|
400
463
|
// Not structured — treat entire input as the filter portion
|
|
401
464
|
return {
|
|
402
|
-
filter:
|
|
465
|
+
filter: source,
|
|
403
466
|
include: undefined,
|
|
404
467
|
search: undefined,
|
|
405
468
|
sort: undefined,
|
|
406
469
|
distinct: undefined,
|
|
470
|
+
select: undefined,
|
|
407
471
|
paginate: undefined,
|
|
408
472
|
};
|
|
409
473
|
}
|
|
@@ -459,11 +523,12 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
459
523
|
return null;
|
|
460
524
|
if (autoFieldsConfig === true) {
|
|
461
525
|
// When autoFields is true with an allowed list, only allowed keys are auto-applicable
|
|
462
|
-
|
|
526
|
+
const allowedFields = allowedFieldNames(meta.allowed);
|
|
527
|
+
if (allowedFields) {
|
|
463
528
|
// Remove keys that already have @FilterFor mappings
|
|
464
529
|
const filterForMap = getFilterForMap(FilterClass);
|
|
465
530
|
const set = new Set();
|
|
466
|
-
for (const key of
|
|
531
|
+
for (const key of allowedFields) {
|
|
467
532
|
if (!filterForMap.has(key))
|
|
468
533
|
set.add(key);
|
|
469
534
|
}
|
|
@@ -545,7 +610,9 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
545
610
|
if (searchConfig && typeof searchConfig === 'object' && 'vector' in searchConfig) {
|
|
546
611
|
// tsvector search
|
|
547
612
|
if (adapter.applyVectorSearch) {
|
|
548
|
-
adapter.applyVectorSearch(qb, searchTerm, searchConfig.vector
|
|
613
|
+
adapter.applyVectorSearch(qb, searchTerm, searchConfig.vector, {
|
|
614
|
+
...(searchConfig.rank !== undefined && { rank: searchConfig.rank }),
|
|
615
|
+
});
|
|
549
616
|
}
|
|
550
617
|
return;
|
|
551
618
|
}
|
|
@@ -579,15 +646,18 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
579
646
|
* @param context - Optional filter context.
|
|
580
647
|
* @returns The query builder with filters applied.
|
|
581
648
|
*/
|
|
582
|
-
async applyDynamic(entity, input, qb, context = {}) {
|
|
649
|
+
async applyDynamic(entity, input, qb, context = {}, internal = {}) {
|
|
583
650
|
const adapter = this.resolveAdapter();
|
|
584
|
-
// Extract structured input: { filter, include, search, sort, distinct, paginate }
|
|
585
|
-
const rawInput = this.extractStructuredInput(input
|
|
651
|
+
// Extract structured input: { filter, include, search, sort, distinct, select, paginate }
|
|
652
|
+
const rawInput = this.extractStructuredInput(input, {
|
|
653
|
+
...(internal.native !== undefined && { native: internal.native }),
|
|
654
|
+
});
|
|
586
655
|
const filterInput = rawInput.filter;
|
|
587
656
|
const rawInclude = rawInput.include;
|
|
588
657
|
const rawSearch = rawInput.search;
|
|
589
658
|
const rawSort = rawInput.sort;
|
|
590
659
|
const rawDistinct = rawInput.distinct;
|
|
660
|
+
const rawSelect = rawInput.select;
|
|
591
661
|
const rawPaginate = rawInput.paginate;
|
|
592
662
|
// Extract column filters before normalization
|
|
593
663
|
const { columnFilters, remainingInput } = this.extractColumnFilters(filterInput);
|
|
@@ -599,11 +669,12 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
599
669
|
// Apply column filters via adapter — dynamic mode validates the filter
|
|
600
670
|
// fields against entity metadata (like sort/auto-fields), dropping
|
|
601
671
|
// unknown columns so a bad client `where` can't crash the ORM.
|
|
672
|
+
const throwOnInvalid = this.resolveThrowOnInvalid();
|
|
602
673
|
if (columnFilters.length > 0 && adapter?.applyColumnFilters) {
|
|
603
|
-
const knownFilters = this.pruneUnknownColumnFilters(columnFilters, entity, adapter);
|
|
674
|
+
const knownFilters = this.pruneUnknownColumnFilters(columnFilters, entity, adapter, throwOnInvalid);
|
|
604
675
|
if (knownFilters.length > 0) {
|
|
605
676
|
validateColumnFilters(knownFilters);
|
|
606
|
-
adapter.applyColumnFilters(qb, knownFilters);
|
|
677
|
+
adapter.applyColumnFilters(qb, knownFilters, entity);
|
|
607
678
|
}
|
|
608
679
|
}
|
|
609
680
|
else if (columnFilters.length > 0 && !adapter?.applyColumnFilters) {
|
|
@@ -654,21 +725,35 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
654
725
|
// Distinct projection (SELECT DISTINCT) — validate against entity metadata
|
|
655
726
|
const distinctFields = this.parseDistinct(rawDistinct);
|
|
656
727
|
if (distinctFields.length > 0 && adapter?.applyDistinct) {
|
|
657
|
-
const validDistinct = this.validateDistinct(distinctFields, undefined, adapter, entity);
|
|
728
|
+
const validDistinct = this.validateDistinct(distinctFields, undefined, adapter, entity, throwOnInvalid);
|
|
658
729
|
if (validDistinct.length > 0) {
|
|
659
730
|
adapter.applyDistinct(qb, validDistinct, entity);
|
|
660
731
|
}
|
|
661
732
|
}
|
|
662
|
-
//
|
|
663
|
-
const
|
|
664
|
-
if (
|
|
665
|
-
const
|
|
666
|
-
if (
|
|
667
|
-
adapter.
|
|
733
|
+
// Sparse fieldsets (SELECT narrowing) — validate against entity metadata.
|
|
734
|
+
const selectFields = this.parseDistinct(rawSelect);
|
|
735
|
+
if (selectFields.length > 0 && adapter?.applySelect) {
|
|
736
|
+
const validSelect = this.validateDistinct(selectFields, undefined, adapter, entity, throwOnInvalid);
|
|
737
|
+
if (validSelect.length > 0) {
|
|
738
|
+
adapter.applySelect(qb, validSelect, entity);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
// Sort and pagination are skipped when the caller (e.g. findPage with
|
|
742
|
+
// cursor pagination) owns ordering and slicing itself.
|
|
743
|
+
if (!internal.skipSortAndPagination) {
|
|
744
|
+
// Sort — validate against entity metadata only (no filter class).
|
|
745
|
+
// Fall back to defaultSort when the client gave none.
|
|
746
|
+
const parsedSorts = this.parseSorts(rawSort);
|
|
747
|
+
const sorts = parsedSorts.length > 0 ? parsedSorts : this.parseSorts(this.resolveDefaultSort());
|
|
748
|
+
if (sorts.length > 0 && adapter?.applySort) {
|
|
749
|
+
const validSorts = this.validateSorts(sorts, undefined, adapter, entity, throwOnInvalid);
|
|
750
|
+
if (validSorts.length > 0) {
|
|
751
|
+
adapter.applySort(qb, validSorts);
|
|
752
|
+
}
|
|
668
753
|
}
|
|
754
|
+
// Pagination
|
|
755
|
+
this.applyPagination(qb, rawPaginate, adapter);
|
|
669
756
|
}
|
|
670
|
-
// Pagination
|
|
671
|
-
this.applyPagination(qb, rawPaginate, adapter);
|
|
672
757
|
return qb;
|
|
673
758
|
}
|
|
674
759
|
/**
|
|
@@ -696,9 +781,10 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
696
781
|
search: structured.search,
|
|
697
782
|
sort: structured.sort,
|
|
698
783
|
distinct: structured.distinct,
|
|
784
|
+
select: structured.select,
|
|
699
785
|
paginate: structured.paginate,
|
|
700
786
|
include: joinIncludes,
|
|
701
|
-
}, qb, opts.context);
|
|
787
|
+
}, qb, opts.context, { native: true });
|
|
702
788
|
if (!adapter?.getResultAndCount) {
|
|
703
789
|
throw new Error('findAndCount requires an adapter that implements getResultAndCount().');
|
|
704
790
|
}
|
|
@@ -714,6 +800,103 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
714
800
|
}
|
|
715
801
|
return { rows: rows, total };
|
|
716
802
|
}
|
|
803
|
+
/**
|
|
804
|
+
* Runs a dynamic query with **keyset (cursor) pagination** and executes it,
|
|
805
|
+
* returning a stable, non-overlapping page plus opaque forward/backward
|
|
806
|
+
* cursors. Unlike offset pagination (which drifts and re-scans as rows are
|
|
807
|
+
* inserted), keyset pagination seeks past a boundary row using a
|
|
808
|
+
* `WHERE (sortcols, pk) > (...)` predicate, so it is O(1) per page and stable
|
|
809
|
+
* under concurrent writes.
|
|
810
|
+
*
|
|
811
|
+
* The keyset is the request's effective sort (or `defaultSort`) plus the
|
|
812
|
+
* entity's primary key as a stable tiebreaker. Multi-column sort composes
|
|
813
|
+
* naturally. Direction is honored per column (asc → seek forward with `>`,
|
|
814
|
+
* desc → with `<`); for backward paging (`before`) the comparison and order
|
|
815
|
+
* are reversed internally and the result re-reversed so `items` is always in
|
|
816
|
+
* the requested order.
|
|
817
|
+
*
|
|
818
|
+
* Requires an adapter implementing `getResult`, `getPrimaryKey`,
|
|
819
|
+
* `applyKeysetPagination` and `applyKeysetOrderAndLimit`. Additive — does not
|
|
820
|
+
* change `apply`/`applyDynamic`/`findAndCount`.
|
|
821
|
+
*/
|
|
822
|
+
async findPage(entity, input, opts = {}) {
|
|
823
|
+
const adapter = this.resolveAdapter();
|
|
824
|
+
if (!adapter?.getResult ||
|
|
825
|
+
!adapter.getPrimaryKey ||
|
|
826
|
+
!adapter.applyKeysetPagination ||
|
|
827
|
+
!adapter.applyKeysetOrderAndLimit) {
|
|
828
|
+
throw new Error('findPage requires an adapter implementing getResult, getPrimaryKey, applyKeysetPagination and applyKeysetOrderAndLimit.');
|
|
829
|
+
}
|
|
830
|
+
const qb = opts.qb ?? adapter.createQueryBuilder(entity);
|
|
831
|
+
const structured = this.extractStructuredInput(input);
|
|
832
|
+
const paginate = (structured.paginate ?? {});
|
|
833
|
+
const after = typeof paginate.after === 'string' ? paginate.after : undefined;
|
|
834
|
+
const before = after === undefined && typeof paginate.before === 'string' ? paginate.before : undefined;
|
|
835
|
+
const backward = before !== undefined;
|
|
836
|
+
const cursorStr = after ?? before;
|
|
837
|
+
const maxSize = this.options.maxPageSize ?? 100;
|
|
838
|
+
const requested = backward ? Number(paginate.last) : Number(paginate.first);
|
|
839
|
+
const limit = Math.min(Math.max(1, requested || 25), maxSize);
|
|
840
|
+
// Resolve the effective sort (falling back to defaultSort) and validate it
|
|
841
|
+
// against entity metadata, then append the primary key as a tiebreaker.
|
|
842
|
+
const parsedSorts = this.parseSorts(structured.sort);
|
|
843
|
+
const rawSorts = parsedSorts.length > 0 ? parsedSorts : this.parseSorts(this.resolveDefaultSort());
|
|
844
|
+
const validSorts = adapter.applySort
|
|
845
|
+
? this.validateSorts(rawSorts, undefined, adapter, entity, this.resolveThrowOnInvalid())
|
|
846
|
+
: rawSorts;
|
|
847
|
+
const pk = adapter.getPrimaryKey(entity);
|
|
848
|
+
if (!pk) {
|
|
849
|
+
throw new Error(`findPage: could not resolve a primary key for ${entity.name}.`);
|
|
850
|
+
}
|
|
851
|
+
const baseKeyset = buildKeyset(validSorts, pk);
|
|
852
|
+
// Build filters/search/includes only — findPage owns ordering and slicing.
|
|
853
|
+
await this.applyDynamic(entity, {
|
|
854
|
+
filter: structured.filter,
|
|
855
|
+
search: structured.search,
|
|
856
|
+
include: structured.include,
|
|
857
|
+
select: structured.select,
|
|
858
|
+
}, qb, opts.context, { skipSortAndPagination: true, native: true });
|
|
859
|
+
// For backward paging, reverse keyset directions so the boundary seek and
|
|
860
|
+
// ordering walk the other way; we re-reverse rows below.
|
|
861
|
+
const queryKeyset = backward ? this.reverseKeyset(baseKeyset) : baseKeyset;
|
|
862
|
+
// Decode and apply the cursor boundary predicate (ignored if malformed).
|
|
863
|
+
if (cursorStr) {
|
|
864
|
+
const values = decodeCursor(cursorStr);
|
|
865
|
+
if (values && values.length === queryKeyset.length) {
|
|
866
|
+
adapter.applyKeysetPagination(qb, queryKeyset, values);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
// Fetch one extra row to detect whether a further page exists.
|
|
870
|
+
adapter.applyKeysetOrderAndLimit(qb, queryKeyset, limit + 1);
|
|
871
|
+
const fetched = (await adapter.getResult(qb));
|
|
872
|
+
const hasExtra = fetched.length > limit;
|
|
873
|
+
let pageRows = hasExtra ? fetched.slice(0, limit) : fetched;
|
|
874
|
+
if (backward)
|
|
875
|
+
pageRows = pageRows.slice().reverse();
|
|
876
|
+
// Compute boundary cursors. With a cursor present, the opposite-direction
|
|
877
|
+
// page is known to exist; the same-direction page exists iff we saw an
|
|
878
|
+
// extra row.
|
|
879
|
+
const firstRow = pageRows[0];
|
|
880
|
+
const lastRow = pageRows[pageRows.length - 1];
|
|
881
|
+
const startCursor = firstRow ? encodeCursor(extractCursorValues(firstRow, baseKeyset)) : null;
|
|
882
|
+
const endCursor = lastRow ? encodeCursor(extractCursorValues(lastRow, baseKeyset)) : null;
|
|
883
|
+
const hasNext = backward ? cursorStr !== undefined : hasExtra;
|
|
884
|
+
const hasPrev = backward ? hasExtra : cursorStr !== undefined;
|
|
885
|
+
return {
|
|
886
|
+
items: pageRows,
|
|
887
|
+
nextCursor: hasNext ? endCursor : null,
|
|
888
|
+
prevCursor: hasPrev ? startCursor : null,
|
|
889
|
+
hasNext,
|
|
890
|
+
hasPrev,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
/** Flips every keyset column's direction (for backward cursor paging). */
|
|
894
|
+
reverseKeyset(keyset) {
|
|
895
|
+
return keyset.map((s) => ({
|
|
896
|
+
field: s.field,
|
|
897
|
+
direction: s.direction === 'asc' ? 'desc' : 'asc',
|
|
898
|
+
}));
|
|
899
|
+
}
|
|
717
900
|
/**
|
|
718
901
|
* Splits include paths into join-safe (to-one) and deferred (to-many) sets,
|
|
719
902
|
* by the cardinality of each path's first relation segment.
|
|
@@ -742,7 +925,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
742
925
|
* table) is silently ignored instead of crashing the ORM. Recurses AND/OR.
|
|
743
926
|
* No-op (pass-through) when the adapter exposes no metadata.
|
|
744
927
|
*/
|
|
745
|
-
pruneUnknownColumnFilters(filters, entity, adapter) {
|
|
928
|
+
pruneUnknownColumnFilters(filters, entity, adapter, throwOnInvalid = false) {
|
|
746
929
|
const fieldNames = new Set((adapter.getEntityFields?.(entity) ?? []).map((f) => f.name));
|
|
747
930
|
const relationNames = new Set((adapter.getEntityRelations?.(entity) ?? []).map((r) => r.name));
|
|
748
931
|
if (fieldNames.size === 0 && relationNames.size === 0)
|
|
@@ -761,7 +944,50 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
761
944
|
return dot > 0 && relationNames.has(field.slice(0, dot));
|
|
762
945
|
};
|
|
763
946
|
const prune = (clauses) => clauses
|
|
764
|
-
.filter((clause) =>
|
|
947
|
+
.filter((clause) => {
|
|
948
|
+
const known = !clause.field || isKnown(clause.field);
|
|
949
|
+
if (!known && throwOnInvalid) {
|
|
950
|
+
throw new BadRequestException(`Unknown filter column: "${clause.field}".`);
|
|
951
|
+
}
|
|
952
|
+
return known;
|
|
953
|
+
})
|
|
954
|
+
.map((clause) => ({
|
|
955
|
+
...clause,
|
|
956
|
+
...(clause.AND && { AND: prune(clause.AND) }),
|
|
957
|
+
...(clause.OR && { OR: prune(clause.OR) }),
|
|
958
|
+
}));
|
|
959
|
+
return prune(filters);
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Enforces per-field operator allowlists on a `where` ColumnFilter tree.
|
|
963
|
+
*
|
|
964
|
+
* For each clause whose `field` carries an operator restriction (declared as
|
|
965
|
+
* `{ field, operators }` in `@Filterable.allowed`), the clause's operator
|
|
966
|
+
* (after alias normalization) must be in the permitted set. A disallowed
|
|
967
|
+
* operator is dropped (default) or raises a `BadRequestException` when
|
|
968
|
+
* `throwOnInvalid` is set. Fields without a restriction (plain-string allowed
|
|
969
|
+
* entries, or no allowlist at all) pass through unchanged. Recurses AND/OR.
|
|
970
|
+
*/
|
|
971
|
+
enforceOperatorAllowlist(filters, allowed, throwOnInvalid) {
|
|
972
|
+
if (!allowed || allowed.operatorsByField.size === 0)
|
|
973
|
+
return filters;
|
|
974
|
+
const prune = (clauses) => clauses
|
|
975
|
+
.filter((clause) => {
|
|
976
|
+
// Group nodes (no field of their own) are not operator-checked here;
|
|
977
|
+
// their children are pruned via the recursion below.
|
|
978
|
+
if (!clause.field)
|
|
979
|
+
return true;
|
|
980
|
+
const permitted = allowed.operatorsByField.get(clause.field);
|
|
981
|
+
if (!permitted)
|
|
982
|
+
return true; // field allows all operators
|
|
983
|
+
const op = normalizeOperator(clause.operator);
|
|
984
|
+
if (permitted.has(op))
|
|
985
|
+
return true;
|
|
986
|
+
if (throwOnInvalid) {
|
|
987
|
+
throw new BadRequestException(`Operator "${op}" is not allowed on field "${clause.field}".`);
|
|
988
|
+
}
|
|
989
|
+
return false;
|
|
990
|
+
})
|
|
765
991
|
.map((clause) => ({
|
|
766
992
|
...clause,
|
|
767
993
|
...(clause.AND && { AND: prune(clause.AND) }),
|
|
@@ -769,6 +995,49 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
769
995
|
}));
|
|
770
996
|
return prune(filters);
|
|
771
997
|
}
|
|
998
|
+
/**
|
|
999
|
+
* Enforces a per-field operator allowlist on an auto-field value, mirroring
|
|
1000
|
+
* the adapter's value-shape interpretation:
|
|
1001
|
+
*
|
|
1002
|
+
* - scalar → `equals`
|
|
1003
|
+
* - array → `in`
|
|
1004
|
+
* - operator object (`{ gte, lte }`) → each key is an operator
|
|
1005
|
+
*
|
|
1006
|
+
* Returns the value to apply, or `undefined` when the whole auto-field should
|
|
1007
|
+
* be skipped (e.g. a scalar whose implied `equals` is not permitted, or an
|
|
1008
|
+
* operator object reduced to no permitted keys). For operator objects, only
|
|
1009
|
+
* the disallowed keys are stripped. When `throwOnInvalid` is set, a
|
|
1010
|
+
* disallowed operator raises instead of being dropped.
|
|
1011
|
+
*/
|
|
1012
|
+
enforceAutoFieldOperators(field, value, allowed, throwOnInvalid) {
|
|
1013
|
+
const permitted = allowed?.operatorsByField.get(field);
|
|
1014
|
+
if (!permitted)
|
|
1015
|
+
return value; // no restriction for this field
|
|
1016
|
+
const reject = (op) => {
|
|
1017
|
+
if (throwOnInvalid) {
|
|
1018
|
+
throw new BadRequestException(`Operator "${op}" is not allowed on field "${field}".`);
|
|
1019
|
+
}
|
|
1020
|
+
return undefined;
|
|
1021
|
+
};
|
|
1022
|
+
if (Array.isArray(value)) {
|
|
1023
|
+
return permitted.has('in') ? value : reject('in');
|
|
1024
|
+
}
|
|
1025
|
+
if (value != null && typeof value === 'object') {
|
|
1026
|
+
const entries = Object.entries(value);
|
|
1027
|
+
const kept = {};
|
|
1028
|
+
for (const [op, opVal] of entries) {
|
|
1029
|
+
const canonical = normalizeOperator(op);
|
|
1030
|
+
if (permitted.has(canonical)) {
|
|
1031
|
+
kept[op] = opVal;
|
|
1032
|
+
}
|
|
1033
|
+
else if (throwOnInvalid) {
|
|
1034
|
+
reject(canonical);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
return Object.keys(kept).length > 0 ? kept : undefined;
|
|
1038
|
+
}
|
|
1039
|
+
return permitted.has('equals') ? value : reject('equals');
|
|
1040
|
+
}
|
|
772
1041
|
/**
|
|
773
1042
|
* Applies global search for dynamic mode: auto-detects all string columns
|
|
774
1043
|
* from entity metadata (no filter class with static search config).
|
|
@@ -785,6 +1054,30 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
785
1054
|
adapter.applySearch(qb, searchTerm, columns, entity);
|
|
786
1055
|
}
|
|
787
1056
|
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Resolves the effective `throwOnInvalid` policy: per-@Filterable wins over
|
|
1059
|
+
* the module option, which defaults to `false` (silent-drop, legacy behavior).
|
|
1060
|
+
*/
|
|
1061
|
+
resolveThrowOnInvalid(FilterClass) {
|
|
1062
|
+
if (FilterClass) {
|
|
1063
|
+
const meta = getFilterableMetadata(FilterClass);
|
|
1064
|
+
if (meta?.throwOnInvalid !== undefined)
|
|
1065
|
+
return meta.throwOnInvalid;
|
|
1066
|
+
}
|
|
1067
|
+
return this.options.throwOnInvalid ?? false;
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Resolves the effective `defaultSort`: per-@Filterable wins over the module
|
|
1071
|
+
* option. Returns undefined when neither is set.
|
|
1072
|
+
*/
|
|
1073
|
+
resolveDefaultSort(FilterClass) {
|
|
1074
|
+
if (FilterClass) {
|
|
1075
|
+
const meta = getFilterableMetadata(FilterClass);
|
|
1076
|
+
if (meta?.defaultSort !== undefined)
|
|
1077
|
+
return meta.defaultSort;
|
|
1078
|
+
}
|
|
1079
|
+
return this.options.defaultSort;
|
|
1080
|
+
}
|
|
788
1081
|
handleUnknownKey(key) {
|
|
789
1082
|
const policy = this.options.onUnknownKey ?? 'ignore';
|
|
790
1083
|
if (policy === 'throw')
|
|
@@ -831,9 +1124,20 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
831
1124
|
* Validates sort fields against the allowlist (if defined) or entity metadata.
|
|
832
1125
|
* Silently skips invalid fields.
|
|
833
1126
|
*/
|
|
834
|
-
validateSorts(sorts, allowlist, adapter, entity) {
|
|
1127
|
+
validateSorts(sorts, allowlist, adapter, entity, throwOnInvalid = false) {
|
|
1128
|
+
const accept = (predicate) => {
|
|
1129
|
+
if (throwOnInvalid) {
|
|
1130
|
+
for (const s of sorts) {
|
|
1131
|
+
if (!predicate(s)) {
|
|
1132
|
+
throw new BadRequestException(`Invalid sort field: "${s.field}".`);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
return sorts;
|
|
1136
|
+
}
|
|
1137
|
+
return sorts.filter(predicate);
|
|
1138
|
+
};
|
|
835
1139
|
if (allowlist) {
|
|
836
|
-
return
|
|
1140
|
+
return accept((s) => allowlist.includes(s.field));
|
|
837
1141
|
}
|
|
838
1142
|
// No allowlist — validate against entity columns
|
|
839
1143
|
if (entity && adapter.getEntityFields) {
|
|
@@ -844,16 +1148,54 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
844
1148
|
// like `author.profile.country`. A bare relation (`author`) is rejected
|
|
845
1149
|
// for sorting (you can't order by a relation object).
|
|
846
1150
|
if (adapter.resolveFieldPath) {
|
|
847
|
-
return
|
|
1151
|
+
return accept((s) => adapter.resolveFieldPath(entity, s.field) === 'field');
|
|
848
1152
|
}
|
|
849
1153
|
// Fallback: scalar columns only.
|
|
850
1154
|
const fieldNames = new Set(fields.map((f) => f.name));
|
|
851
|
-
return
|
|
1155
|
+
return accept((s) => fieldNames.has(s.field));
|
|
852
1156
|
}
|
|
853
1157
|
}
|
|
854
1158
|
// No metadata available — pass through
|
|
855
1159
|
return sorts;
|
|
856
1160
|
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Applies a list of sorts, routing computed/virtual aliases to
|
|
1163
|
+
* `applyComputedSort` (their dev-provided SQL expression) and regular columns
|
|
1164
|
+
* to `applySort`. Order is preserved across the mix so the resulting
|
|
1165
|
+
* `ORDER BY` matches the requested column order. Regular columns are still
|
|
1166
|
+
* validated against the allowlist / entity metadata; computed aliases bypass
|
|
1167
|
+
* that check because they are dev-declared, not real columns.
|
|
1168
|
+
*/
|
|
1169
|
+
applySortsWithComputed(qb, sorts, allowlist, adapter, entity, throwOnInvalid, computed) {
|
|
1170
|
+
const hasComputedSort = !!computed && sorts.some((s) => Object.hasOwn(computed, s.field));
|
|
1171
|
+
// Fast path / backward-compatible behavior: no computed sorts → validate
|
|
1172
|
+
// all sorts and apply them in a single batched `applySort` call (preserving
|
|
1173
|
+
// the prior contract relied upon by existing tests and adapters).
|
|
1174
|
+
if (!hasComputedSort) {
|
|
1175
|
+
const validSorts = this.validateSorts(sorts, allowlist, adapter, entity, throwOnInvalid);
|
|
1176
|
+
if (validSorts.length > 0 && adapter.applySort) {
|
|
1177
|
+
adapter.applySort(qb, validSorts);
|
|
1178
|
+
}
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
// Mixed path: route computed aliases to applyComputedSort and real columns
|
|
1182
|
+
// to applySort, per item, so the resulting ORDER BY honors request order.
|
|
1183
|
+
for (const sort of sorts) {
|
|
1184
|
+
if (computed && Object.hasOwn(computed, sort.field)) {
|
|
1185
|
+
if (adapter.applyComputedSort) {
|
|
1186
|
+
adapter.applyComputedSort(qb, computed[sort.field], sort.direction);
|
|
1187
|
+
}
|
|
1188
|
+
else {
|
|
1189
|
+
this.logger.warn(`Computed sort "${sort.field}" requested but adapter does not support applyComputedSort. Skipping.`);
|
|
1190
|
+
}
|
|
1191
|
+
continue;
|
|
1192
|
+
}
|
|
1193
|
+
const valid = this.validateSorts([sort], allowlist, adapter, entity, throwOnInvalid);
|
|
1194
|
+
if (valid.length > 0 && adapter.applySort) {
|
|
1195
|
+
adapter.applySort(qb, valid);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
857
1199
|
/**
|
|
858
1200
|
* Parses raw distinct input into an array of field names.
|
|
859
1201
|
*
|
|
@@ -883,15 +1225,26 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
883
1225
|
* Validates distinct fields against the allowlist (if defined) or entity
|
|
884
1226
|
* metadata. Silently skips invalid fields to prevent arbitrary-column probing.
|
|
885
1227
|
*/
|
|
886
|
-
validateDistinct(fields, allowlist, adapter, entity) {
|
|
1228
|
+
validateDistinct(fields, allowlist, adapter, entity, throwOnInvalid = false) {
|
|
1229
|
+
const accept = (predicate) => {
|
|
1230
|
+
if (throwOnInvalid) {
|
|
1231
|
+
for (const f of fields) {
|
|
1232
|
+
if (!predicate(f)) {
|
|
1233
|
+
throw new BadRequestException(`Invalid distinct field: "${f}".`);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
return fields;
|
|
1237
|
+
}
|
|
1238
|
+
return fields.filter(predicate);
|
|
1239
|
+
};
|
|
887
1240
|
if (allowlist) {
|
|
888
|
-
return
|
|
1241
|
+
return accept((f) => allowlist.includes(f));
|
|
889
1242
|
}
|
|
890
1243
|
if (entity && adapter.getEntityFields) {
|
|
891
1244
|
const entityFields = adapter.getEntityFields(entity);
|
|
892
1245
|
if (entityFields) {
|
|
893
1246
|
const fieldNames = new Set(entityFields.map((f) => f.name));
|
|
894
|
-
return
|
|
1247
|
+
return accept((f) => fieldNames.has(f));
|
|
895
1248
|
}
|
|
896
1249
|
}
|
|
897
1250
|
// No metadata available — pass through
|