@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/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
- validateColumnFilters(columnFilters);
174
- adapter.applyColumnFilters(qb, columnFilters);
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
- adapter.applyAutoField(qb, key, value);
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 sort
303
- const sorts = this.parseSorts(rawSort);
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
- const validSorts = this.validateSorts(sorts, allowedSorts, adapter, filterableMeta?.entity);
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
- if (input == null || typeof input !== 'object') {
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: input,
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 = input;
389
- // Detect structured format: must have a 'filter' key (even if undefined/null)
390
- if ('filter' in inputObj) {
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: input,
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
- if (meta.allowed) {
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 meta.allowed) {
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
- // Sort — validate against entity metadata only (no filter class)
663
- const sorts = this.parseSorts(rawSort);
664
- if (sorts.length > 0 && adapter?.applySort) {
665
- const validSorts = this.validateSorts(sorts, undefined, adapter, entity);
666
- if (validSorts.length > 0) {
667
- adapter.applySort(qb, validSorts);
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) => !clause.field || isKnown(clause.field))
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 sorts.filter((s) => allowlist.includes(s.field));
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 sorts.filter((s) => adapter.resolveFieldPath(entity, s.field) === 'field');
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 sorts.filter((s) => fieldNames.has(s.field));
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 fields.filter((f) => allowlist.includes(f));
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 fields.filter((f) => fieldNames.has(f));
1247
+ return accept((f) => fieldNames.has(f));
895
1248
  }
896
1249
  }
897
1250
  // No metadata available — pass through