@dudousxd/nestjs-filter 1.31.0 → 1.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/runner.js CHANGED
@@ -24,6 +24,7 @@ import { getFilterableMetadata } from './decorator/filterable.decorator.js';
24
24
  import { resolveRelation } from './decorator/relations.decorator.js';
25
25
  import { getTenantScopedField } from './decorator/tenant-scoped.decorator.js';
26
26
  import { FilterMethodException, FilterNotRegisteredException, UnknownFilterKeyException, } from './errors/exceptions.js';
27
+ import { expandBracketKeys } from './input/bracket-keys.js';
27
28
  import { resolveDispatchTarget } from './input/dispatcher.js';
28
29
  import { normalizeInput } from './input/normalizer.js';
29
30
  import { parseSpatieInput } from './input/spatie-parser.js';
@@ -93,14 +94,19 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
93
94
  options;
94
95
  contextAccessor;
95
96
  logger = new Logger(FilterRunner_1.name);
96
- adapter;
97
+ /** The application-wide adapter (`FilterModule.forRoot`'s), used by every filter that does not
98
+ * name its own — see {@link FilterableOptions.adapter}. */
99
+ defaultAdapter;
100
+ /** Adapters resolved from a filter-declared token, memoized per token: they are application-scoped
101
+ * providers, so re-resolving them per request is pure overhead. */
102
+ scopedAdapters = new Map();
97
103
  /** Per-entity metadata cache for `describe()`. Metadata is static at runtime. */
98
104
  descriptionCache = new WeakMap();
99
105
  constructor(moduleRef, options, injectedAdapter, contextAccessor) {
100
106
  this.moduleRef = moduleRef;
101
107
  this.options = options;
102
108
  this.contextAccessor = contextAccessor;
103
- this.adapter = injectedAdapter;
109
+ this.defaultAdapter = injectedAdapter;
104
110
  }
105
111
  /**
106
112
  * Soft-detects the current-request context accessor owned by
@@ -147,15 +153,47 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
147
153
  }
148
154
  adapter.applyAutoField(qb, field, tenantId);
149
155
  }
150
- resolveAdapter() {
151
- if (this.adapter)
152
- return this.adapter;
156
+ /**
157
+ * The adapter that answers for a request, given the classes it is made against.
158
+ *
159
+ * The FIRST source declaring `@Filterable({ adapter })` wins (a filter class before the entity it
160
+ * targets, since the class is the more specific declaration), and everything else falls through to
161
+ * the application-wide adapter. Sources are passed as they are known at the call site; `undefined`
162
+ * entries are skipped, so a dynamic call with no filter class behaves exactly as before.
163
+ *
164
+ * A declared token that does not resolve THROWS rather than falling back: a filter naming the
165
+ * adapter it needs, then quietly running on a different backend's, would answer with rows from the
166
+ * wrong data source — a failure that looks like a successful query.
167
+ */
168
+ resolveAdapter(...sources) {
169
+ for (const source of sources) {
170
+ const token = source ? getFilterableMetadata(source)?.adapter : undefined;
171
+ if (token === undefined)
172
+ continue;
173
+ const cached = this.scopedAdapters.get(token);
174
+ if (cached)
175
+ return cached;
176
+ let resolved = null;
177
+ try {
178
+ resolved = this.moduleRef.get(token, { strict: false });
179
+ }
180
+ catch {
181
+ resolved = null;
182
+ }
183
+ if (!resolved) {
184
+ throw new Error(`@Filterable on ${source?.name} names an adapter token that is not registered. Provide it in a module reachable from this one.`);
185
+ }
186
+ this.scopedAdapters.set(token, resolved);
187
+ return resolved;
188
+ }
189
+ if (this.defaultAdapter)
190
+ return this.defaultAdapter;
153
191
  try {
154
192
  const resolved = this.moduleRef.get(FILTER_ADAPTER, { strict: false });
155
193
  if (resolved) {
156
- this.adapter = resolved;
194
+ this.defaultAdapter = resolved;
157
195
  }
158
- return this.adapter;
196
+ return this.defaultAdapter;
159
197
  }
160
198
  catch {
161
199
  return null;
@@ -239,7 +277,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
239
277
  const cached = this.descriptionCache.get(entity);
240
278
  if (cached)
241
279
  return cached;
242
- const adapter = this.resolveAdapter();
280
+ const adapter = this.resolveAdapter(entity);
243
281
  const fields = {};
244
282
  const relations = {};
245
283
  for (const field of adapter?.getEntityFields?.(entity) ?? []) {
@@ -413,11 +451,11 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
413
451
  }
414
452
  async apply(FilterClass, input, qb, context = {}, internal = {}) {
415
453
  const filter = await this.resolveFilter(FilterClass);
416
- const adapter = this.resolveAdapter();
417
454
  // Resolved once and reused for every alias choke point below (column
418
455
  // filters, structured filter keys, sort, distinct, select) as well as
419
456
  // the computed-field/entity lookups further down.
420
457
  const filterableMeta = getFilterableMetadata(FilterClass);
458
+ const adapter = this.resolveAdapter(FilterClass, filterableMeta?.entity);
421
459
  // Merges the inline `computed` map and `@Computed` methods into one
422
460
  // alias → source registry, resolved once per apply() call.
423
461
  const computedRegistry = buildComputedRegistry(FilterClass, filter);
@@ -862,7 +900,10 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
862
900
  * via the adapter's applyRelationConstraint.
863
901
  */
864
902
  async applyRelation(RelatedFilterClass, qb, relationName, entries, context) {
865
- if (!this.adapter?.applyRelationConstraint) {
903
+ // The RELATED filter's adapter, not the outer one: a relation is constrained through the class
904
+ // that declares it, and that class may name its own backend.
905
+ const adapter = this.resolveAdapter(RelatedFilterClass);
906
+ if (!adapter?.applyRelationConstraint) {
866
907
  this.warnUnsupported(`Relation "${relationName}" provided`, 'applyRelationConstraint');
867
908
  return;
868
909
  }
@@ -870,7 +911,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
870
911
  for (const [key, value] of entries) {
871
912
  inputObj[key] = value;
872
913
  }
873
- await this.adapter.applyRelationConstraint(qb, relationName, async (relationQb) => {
914
+ await adapter.applyRelationConstraint(qb, relationName, async (relationQb) => {
874
915
  await this.apply(RelatedFilterClass, { filter: inputObj }, relationQb, context, {
875
916
  native: true,
876
917
  });
@@ -884,10 +925,19 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
884
925
  * - Any other shape is treated as the filter portion directly (backward compat for internal calls)
885
926
  */
886
927
  extractStructuredInput(input, internal = {}) {
928
+ // Bracket-encoded flat keys first (`filter[where][0][field]`), which is what a GET carries on a
929
+ // host whose query parser did not expand them — Express 5's default. A no-op for everything
930
+ // else, and it has to run before the structured-key detection below: unexpanded, `filter` is not
931
+ // a key at all, the whole bag reads as a bare filter, and every predicate is silently dropped.
932
+ const expanded = input && typeof input === 'object' && !Array.isArray(input)
933
+ ? expandBracketKeys(input)
934
+ : input;
887
935
  // Opt-in spatie / JSON:API input format. Internal re-dispatch calls
888
936
  // (relation constraints, findPage/findAndCount forwards) pass already-native
889
937
  // structured input and set `internal.native` to bypass re-parsing.
890
- const source = !internal.native && this.options.inputFormat === 'spatie' ? parseSpatieInput(input) : input;
938
+ const source = !internal.native && this.options.inputFormat === 'spatie'
939
+ ? parseSpatieInput(expanded)
940
+ : expanded;
891
941
  if (source == null || typeof source !== 'object') {
892
942
  return {
893
943
  filter: source,
@@ -1012,7 +1062,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1012
1062
  return set;
1013
1063
  }
1014
1064
  // Introspect entity metadata to restrict auto-fields to real columns
1015
- const adapter = this.resolveAdapter();
1065
+ const adapter = this.resolveAdapter(FilterClass, meta.entity);
1016
1066
  if (adapter?.getEntityFields) {
1017
1067
  const entityFields = adapter.getEntityFields(meta.entity);
1018
1068
  if (entityFields) {
@@ -1181,7 +1231,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1181
1231
  * @returns The query builder with filters applied.
1182
1232
  */
1183
1233
  async applyDynamic(entity, input, qb, context = {}, internal = {}) {
1184
- const adapter = this.resolveAdapter();
1234
+ const adapter = this.resolveAdapter(internal.filterClass, entity);
1185
1235
  // Dynamic mode has no FilterClass — an entity class can still carry
1186
1236
  // `@Filterable` metadata (declared directly on the entity, decorating
1187
1237
  // itself) purely to supply `aliases` for endpoints that query it
@@ -1337,7 +1387,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1337
1387
  * else this method does.
1338
1388
  */
1339
1389
  async findAndCount(entity, input, opts = {}) {
1340
- const adapter = this.resolveAdapter();
1390
+ const adapter = this.resolveAdapter(entity);
1341
1391
  const qb = opts.qb ?? adapter?.createQueryBuilder(entity);
1342
1392
  const structured = this.extractStructuredInput(input);
1343
1393
  const includes = this.parseIncludes(structured.include);
@@ -1435,7 +1485,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1435
1485
  * count }[]` with `bucketEnd = bucketStart + bucket`.
1436
1486
  */
1437
1487
  async groupByCount(entity, input, opts = {}) {
1438
- const adapter = this.resolveAdapter();
1488
+ const adapter = this.resolveAdapter(opts.filterClass, entity);
1439
1489
  const structured = this.extractStructuredInput(input);
1440
1490
  const spec = this.parseGroupByCount(structured.groupByCount);
1441
1491
  if (!spec) {
@@ -1478,9 +1528,11 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1478
1528
  // Apply WHERE + search only (no sort/pagination/distinct/select — this mode
1479
1529
  // replaces entity rows). `skipSortAndPagination` also keeps applyDynamic
1480
1530
  // from ordering/slicing the pre-aggregation query.
1481
- await this.applyDynamic(entity, { filter: structured.filter, search: structured.search }, qb, opts.context, { skipSortAndPagination: true, native: true });
1531
+ await this.applyDynamic(entity, { filter: structured.filter, search: structured.search }, qb, opts.context, { skipSortAndPagination: true, native: true, filterClass: opts.filterClass });
1482
1532
  const bucket = spec.bucket;
1483
- const rows = await adapter.groupByCount(qb, groupField, entity, bucket ? { bucket } : undefined);
1533
+ const limit = spec.limit;
1534
+ const aggregateOpts = bucket || limit ? { ...(bucket && { bucket }), ...(limit && { limit }) } : undefined;
1535
+ const rows = await adapter.groupByCount(qb, groupField, entity, aggregateOpts);
1484
1536
  if (bucket) {
1485
1537
  return rows.map((r) => {
1486
1538
  const bucketStart = Number(r.value);
@@ -1491,10 +1543,18 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1491
1543
  }
1492
1544
  /**
1493
1545
  * Parses and validates the raw `groupByCount` structured-input block into a
1494
- * canonical `{ field, bucket? }`. Returns `null` when no usable `field` is
1495
- * present. `bucket` is kept only when it is a finite positive number — a
1496
- * zero/negative/NaN/non-number bucket degrades to the plain (non-bucketed)
1497
- * group-by-count rather than emitting a divide-by-zero or nonsensical width.
1546
+ * canonical `{ field, bucket?, limit? }`. Returns `null` when no usable
1547
+ * `field` is present. `bucket` is kept only when it is a finite positive
1548
+ * number — a zero/negative/NaN/non-number bucket degrades to the plain
1549
+ * (non-bucketed) group-by-count rather than emitting a divide-by-zero or
1550
+ * nonsensical width. `limit` is kept on the same terms and must additionally
1551
+ * be an integer; anything else degrades to the unbounded form, since a
1552
+ * fractional or negative row count is not a narrower question, just a
1553
+ * malformed one.
1554
+ *
1555
+ * Both arrive over the wire as strings on a GET route (`?groupByCount[limit]=20`),
1556
+ * so numeric strings are coerced before those checks — otherwise the bound a
1557
+ * caller asked for would be silently dropped by the shape test alone.
1498
1558
  */
1499
1559
  parseGroupByCount(raw) {
1500
1560
  if (!raw || typeof raw !== 'object')
@@ -1502,10 +1562,18 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1502
1562
  const obj = raw;
1503
1563
  if (typeof obj.field !== 'string' || obj.field.length === 0)
1504
1564
  return null;
1505
- const bucket = typeof obj.bucket === 'number' && Number.isFinite(obj.bucket) && obj.bucket > 0
1506
- ? obj.bucket
1507
- : undefined;
1508
- return { field: obj.field, ...(bucket !== undefined && { bucket }) };
1565
+ const numeric = (value) => {
1566
+ const n = typeof value === 'string' ? Number(value) : value;
1567
+ return typeof n === 'number' && Number.isFinite(n) && n > 0 ? n : undefined;
1568
+ };
1569
+ const bucket = numeric(obj.bucket);
1570
+ const limitValue = numeric(obj.limit);
1571
+ const limit = limitValue !== undefined && Number.isInteger(limitValue) ? limitValue : undefined;
1572
+ return {
1573
+ field: obj.field,
1574
+ ...(bucket !== undefined && { bucket }),
1575
+ ...(limit !== undefined && { limit }),
1576
+ };
1509
1577
  }
1510
1578
  /**
1511
1579
  * **Field extent**: the `MIN`/`MAX` of the requested field(s) over the rows
@@ -1571,7 +1639,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1571
1639
  // alongside its rows read and get nothing back when nobody asked.
1572
1640
  if (requested.length === 0)
1573
1641
  return {};
1574
- const adapter = this.resolveAdapter();
1642
+ const adapter = this.resolveAdapter(opts.filterClass, entity);
1575
1643
  if (!adapter?.fieldExtent) {
1576
1644
  throw new Error('extent is not supported by the active adapter (it does not implement fieldExtent()).');
1577
1645
  }
@@ -1608,7 +1676,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1608
1676
  if (targets.length === 0)
1609
1677
  return {};
1610
1678
  const qb = opts.qb ?? adapter.createQueryBuilder(entity);
1611
- await this.applyDynamic(entity, { filter: structured.filter, search: structured.search }, qb, opts.context, { skipSortAndPagination: true, native: true });
1679
+ await this.applyDynamic(entity, { filter: structured.filter, search: structured.search }, qb, opts.context, { skipSortAndPagination: true, native: true, filterClass: opts.filterClass });
1612
1680
  return adapter.fieldExtent(qb, targets, entity);
1613
1681
  }
1614
1682
  /**
@@ -1643,9 +1711,9 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1643
1711
  * set, and an ORDER BY on a column an aggregate SELECT no longer projects is
1644
1712
  * MySQL error 3065.
1645
1713
  */
1646
- async buildFilteredQb(entity, structured, adapter, context) {
1714
+ async buildFilteredQb(entity, structured, adapter, context, filterClass) {
1647
1715
  const qb = adapter.createQueryBuilder(entity);
1648
- await this.applyDynamic(entity, { filter: structured.filter, search: structured.search }, qb, context, { skipSortAndPagination: true, native: true });
1716
+ await this.applyDynamic(entity, { filter: structured.filter, search: structured.search }, qb, context, { skipSortAndPagination: true, native: true, filterClass });
1649
1717
  return qb;
1650
1718
  }
1651
1719
  /**
@@ -1727,7 +1795,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1727
1795
  if (!spec) {
1728
1796
  throw new BadRequestException('histogram requires a `{ field }` specification (with an optional positive `buckets` count).');
1729
1797
  }
1730
- const adapter = this.resolveAdapter();
1798
+ const adapter = this.resolveAdapter(opts.filterClass, entity);
1731
1799
  // Named separately: an adapter can plausibly have one and not the other,
1732
1800
  // and "histogram is unsupported" would send its author looking for a method
1733
1801
  // by that name, which does not and will not exist.
@@ -1755,7 +1823,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1755
1823
  }
1756
1824
  this.assertBucketable(target, entity, adapter);
1757
1825
  // ── pass 1: where do the endpoints sit ───────────────────────────────────
1758
- const extentQb = await this.buildFilteredQb(entity, structured, adapter, opts.context);
1826
+ const extentQb = await this.buildFilteredQb(entity, structured, adapter, opts.context, opts.filterClass);
1759
1827
  const measured = await adapter.fieldExtent(extentQb, [target], entity);
1760
1828
  const key = typeof target === 'string' ? target : target.alias;
1761
1829
  const bounds = measured[key];
@@ -1776,7 +1844,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1776
1844
  return { min: null, max: null, bucketWidth: null, buckets: [] };
1777
1845
  }
1778
1846
  // ── pass 2: how are the rows distributed across them ─────────────────────
1779
- const countQb = await this.buildFilteredQb(entity, structured, adapter, opts.context);
1847
+ const countQb = await this.buildFilteredQb(entity, structured, adapter, opts.context, opts.filterClass);
1780
1848
  // Degenerate span: one row, or many rows sharing one value. A width of
1781
1849
  // (max - min) / n is 0, and `FLOOR(col / 0)` is a null group on MySQL and a
1782
1850
  // division-by-zero ERROR on Postgres — so the bucketed variant must not be
@@ -2001,7 +2069,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
2001
2069
  * cannot opt out.
2002
2070
  */
2003
2071
  async findPage(entity, input, opts = {}) {
2004
- const adapter = this.resolveAdapter();
2072
+ const adapter = this.resolveAdapter(entity);
2005
2073
  if (!adapter?.getResult ||
2006
2074
  !adapter.getPrimaryKey ||
2007
2075
  !adapter.applyKeysetPagination ||