@dudousxd/nestjs-filter 1.30.0 → 1.32.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
@@ -93,14 +93,19 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
93
93
  options;
94
94
  contextAccessor;
95
95
  logger = new Logger(FilterRunner_1.name);
96
- adapter;
96
+ /** The application-wide adapter (`FilterModule.forRoot`'s), used by every filter that does not
97
+ * name its own — see {@link FilterableOptions.adapter}. */
98
+ defaultAdapter;
99
+ /** Adapters resolved from a filter-declared token, memoized per token: they are application-scoped
100
+ * providers, so re-resolving them per request is pure overhead. */
101
+ scopedAdapters = new Map();
97
102
  /** Per-entity metadata cache for `describe()`. Metadata is static at runtime. */
98
103
  descriptionCache = new WeakMap();
99
104
  constructor(moduleRef, options, injectedAdapter, contextAccessor) {
100
105
  this.moduleRef = moduleRef;
101
106
  this.options = options;
102
107
  this.contextAccessor = contextAccessor;
103
- this.adapter = injectedAdapter;
108
+ this.defaultAdapter = injectedAdapter;
104
109
  }
105
110
  /**
106
111
  * Soft-detects the current-request context accessor owned by
@@ -147,15 +152,47 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
147
152
  }
148
153
  adapter.applyAutoField(qb, field, tenantId);
149
154
  }
150
- resolveAdapter() {
151
- if (this.adapter)
152
- return this.adapter;
155
+ /**
156
+ * The adapter that answers for a request, given the classes it is made against.
157
+ *
158
+ * The FIRST source declaring `@Filterable({ adapter })` wins (a filter class before the entity it
159
+ * targets, since the class is the more specific declaration), and everything else falls through to
160
+ * the application-wide adapter. Sources are passed as they are known at the call site; `undefined`
161
+ * entries are skipped, so a dynamic call with no filter class behaves exactly as before.
162
+ *
163
+ * A declared token that does not resolve THROWS rather than falling back: a filter naming the
164
+ * adapter it needs, then quietly running on a different backend's, would answer with rows from the
165
+ * wrong data source — a failure that looks like a successful query.
166
+ */
167
+ resolveAdapter(...sources) {
168
+ for (const source of sources) {
169
+ const token = source ? getFilterableMetadata(source)?.adapter : undefined;
170
+ if (token === undefined)
171
+ continue;
172
+ const cached = this.scopedAdapters.get(token);
173
+ if (cached)
174
+ return cached;
175
+ let resolved = null;
176
+ try {
177
+ resolved = this.moduleRef.get(token, { strict: false });
178
+ }
179
+ catch {
180
+ resolved = null;
181
+ }
182
+ if (!resolved) {
183
+ throw new Error(`@Filterable on ${source?.name} names an adapter token that is not registered. Provide it in a module reachable from this one.`);
184
+ }
185
+ this.scopedAdapters.set(token, resolved);
186
+ return resolved;
187
+ }
188
+ if (this.defaultAdapter)
189
+ return this.defaultAdapter;
153
190
  try {
154
191
  const resolved = this.moduleRef.get(FILTER_ADAPTER, { strict: false });
155
192
  if (resolved) {
156
- this.adapter = resolved;
193
+ this.defaultAdapter = resolved;
157
194
  }
158
- return this.adapter;
195
+ return this.defaultAdapter;
159
196
  }
160
197
  catch {
161
198
  return null;
@@ -239,7 +276,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
239
276
  const cached = this.descriptionCache.get(entity);
240
277
  if (cached)
241
278
  return cached;
242
- const adapter = this.resolveAdapter();
279
+ const adapter = this.resolveAdapter(entity);
243
280
  const fields = {};
244
281
  const relations = {};
245
282
  for (const field of adapter?.getEntityFields?.(entity) ?? []) {
@@ -413,11 +450,11 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
413
450
  }
414
451
  async apply(FilterClass, input, qb, context = {}, internal = {}) {
415
452
  const filter = await this.resolveFilter(FilterClass);
416
- const adapter = this.resolveAdapter();
417
453
  // Resolved once and reused for every alias choke point below (column
418
454
  // filters, structured filter keys, sort, distinct, select) as well as
419
455
  // the computed-field/entity lookups further down.
420
456
  const filterableMeta = getFilterableMetadata(FilterClass);
457
+ const adapter = this.resolveAdapter(FilterClass, filterableMeta?.entity);
421
458
  // Merges the inline `computed` map and `@Computed` methods into one
422
459
  // alias → source registry, resolved once per apply() call.
423
460
  const computedRegistry = buildComputedRegistry(FilterClass, filter);
@@ -862,7 +899,10 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
862
899
  * via the adapter's applyRelationConstraint.
863
900
  */
864
901
  async applyRelation(RelatedFilterClass, qb, relationName, entries, context) {
865
- if (!this.adapter?.applyRelationConstraint) {
902
+ // The RELATED filter's adapter, not the outer one: a relation is constrained through the class
903
+ // that declares it, and that class may name its own backend.
904
+ const adapter = this.resolveAdapter(RelatedFilterClass);
905
+ if (!adapter?.applyRelationConstraint) {
866
906
  this.warnUnsupported(`Relation "${relationName}" provided`, 'applyRelationConstraint');
867
907
  return;
868
908
  }
@@ -870,7 +910,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
870
910
  for (const [key, value] of entries) {
871
911
  inputObj[key] = value;
872
912
  }
873
- await this.adapter.applyRelationConstraint(qb, relationName, async (relationQb) => {
913
+ await adapter.applyRelationConstraint(qb, relationName, async (relationQb) => {
874
914
  await this.apply(RelatedFilterClass, { filter: inputObj }, relationQb, context, {
875
915
  native: true,
876
916
  });
@@ -1012,7 +1052,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1012
1052
  return set;
1013
1053
  }
1014
1054
  // Introspect entity metadata to restrict auto-fields to real columns
1015
- const adapter = this.resolveAdapter();
1055
+ const adapter = this.resolveAdapter(FilterClass, meta.entity);
1016
1056
  if (adapter?.getEntityFields) {
1017
1057
  const entityFields = adapter.getEntityFields(meta.entity);
1018
1058
  if (entityFields) {
@@ -1181,7 +1221,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1181
1221
  * @returns The query builder with filters applied.
1182
1222
  */
1183
1223
  async applyDynamic(entity, input, qb, context = {}, internal = {}) {
1184
- const adapter = this.resolveAdapter();
1224
+ const adapter = this.resolveAdapter(internal.filterClass, entity);
1185
1225
  // Dynamic mode has no FilterClass — an entity class can still carry
1186
1226
  // `@Filterable` metadata (declared directly on the entity, decorating
1187
1227
  // itself) purely to supply `aliases` for endpoints that query it
@@ -1337,7 +1377,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1337
1377
  * else this method does.
1338
1378
  */
1339
1379
  async findAndCount(entity, input, opts = {}) {
1340
- const adapter = this.resolveAdapter();
1380
+ const adapter = this.resolveAdapter(entity);
1341
1381
  const qb = opts.qb ?? adapter?.createQueryBuilder(entity);
1342
1382
  const structured = this.extractStructuredInput(input);
1343
1383
  const includes = this.parseIncludes(structured.include);
@@ -1435,7 +1475,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1435
1475
  * count }[]` with `bucketEnd = bucketStart + bucket`.
1436
1476
  */
1437
1477
  async groupByCount(entity, input, opts = {}) {
1438
- const adapter = this.resolveAdapter();
1478
+ const adapter = this.resolveAdapter(opts.filterClass, entity);
1439
1479
  const structured = this.extractStructuredInput(input);
1440
1480
  const spec = this.parseGroupByCount(structured.groupByCount);
1441
1481
  if (!spec) {
@@ -1478,9 +1518,11 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1478
1518
  // Apply WHERE + search only (no sort/pagination/distinct/select — this mode
1479
1519
  // replaces entity rows). `skipSortAndPagination` also keeps applyDynamic
1480
1520
  // 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 });
1521
+ await this.applyDynamic(entity, { filter: structured.filter, search: structured.search }, qb, opts.context, { skipSortAndPagination: true, native: true, filterClass: opts.filterClass });
1482
1522
  const bucket = spec.bucket;
1483
- const rows = await adapter.groupByCount(qb, groupField, entity, bucket ? { bucket } : undefined);
1523
+ const limit = spec.limit;
1524
+ const aggregateOpts = bucket || limit ? { ...(bucket && { bucket }), ...(limit && { limit }) } : undefined;
1525
+ const rows = await adapter.groupByCount(qb, groupField, entity, aggregateOpts);
1484
1526
  if (bucket) {
1485
1527
  return rows.map((r) => {
1486
1528
  const bucketStart = Number(r.value);
@@ -1491,10 +1533,18 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1491
1533
  }
1492
1534
  /**
1493
1535
  * 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.
1536
+ * canonical `{ field, bucket?, limit? }`. Returns `null` when no usable
1537
+ * `field` is present. `bucket` is kept only when it is a finite positive
1538
+ * number — a zero/negative/NaN/non-number bucket degrades to the plain
1539
+ * (non-bucketed) group-by-count rather than emitting a divide-by-zero or
1540
+ * nonsensical width. `limit` is kept on the same terms and must additionally
1541
+ * be an integer; anything else degrades to the unbounded form, since a
1542
+ * fractional or negative row count is not a narrower question, just a
1543
+ * malformed one.
1544
+ *
1545
+ * Both arrive over the wire as strings on a GET route (`?groupByCount[limit]=20`),
1546
+ * so numeric strings are coerced before those checks — otherwise the bound a
1547
+ * caller asked for would be silently dropped by the shape test alone.
1498
1548
  */
1499
1549
  parseGroupByCount(raw) {
1500
1550
  if (!raw || typeof raw !== 'object')
@@ -1502,10 +1552,18 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1502
1552
  const obj = raw;
1503
1553
  if (typeof obj.field !== 'string' || obj.field.length === 0)
1504
1554
  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 }) };
1555
+ const numeric = (value) => {
1556
+ const n = typeof value === 'string' ? Number(value) : value;
1557
+ return typeof n === 'number' && Number.isFinite(n) && n > 0 ? n : undefined;
1558
+ };
1559
+ const bucket = numeric(obj.bucket);
1560
+ const limitValue = numeric(obj.limit);
1561
+ const limit = limitValue !== undefined && Number.isInteger(limitValue) ? limitValue : undefined;
1562
+ return {
1563
+ field: obj.field,
1564
+ ...(bucket !== undefined && { bucket }),
1565
+ ...(limit !== undefined && { limit }),
1566
+ };
1509
1567
  }
1510
1568
  /**
1511
1569
  * **Field extent**: the `MIN`/`MAX` of the requested field(s) over the rows
@@ -1571,7 +1629,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1571
1629
  // alongside its rows read and get nothing back when nobody asked.
1572
1630
  if (requested.length === 0)
1573
1631
  return {};
1574
- const adapter = this.resolveAdapter();
1632
+ const adapter = this.resolveAdapter(opts.filterClass, entity);
1575
1633
  if (!adapter?.fieldExtent) {
1576
1634
  throw new Error('extent is not supported by the active adapter (it does not implement fieldExtent()).');
1577
1635
  }
@@ -1608,7 +1666,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1608
1666
  if (targets.length === 0)
1609
1667
  return {};
1610
1668
  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 });
1669
+ await this.applyDynamic(entity, { filter: structured.filter, search: structured.search }, qb, opts.context, { skipSortAndPagination: true, native: true, filterClass: opts.filterClass });
1612
1670
  return adapter.fieldExtent(qb, targets, entity);
1613
1671
  }
1614
1672
  /**
@@ -1643,9 +1701,9 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1643
1701
  * set, and an ORDER BY on a column an aggregate SELECT no longer projects is
1644
1702
  * MySQL error 3065.
1645
1703
  */
1646
- async buildFilteredQb(entity, structured, adapter, context) {
1704
+ async buildFilteredQb(entity, structured, adapter, context, filterClass) {
1647
1705
  const qb = adapter.createQueryBuilder(entity);
1648
- await this.applyDynamic(entity, { filter: structured.filter, search: structured.search }, qb, context, { skipSortAndPagination: true, native: true });
1706
+ await this.applyDynamic(entity, { filter: structured.filter, search: structured.search }, qb, context, { skipSortAndPagination: true, native: true, filterClass });
1649
1707
  return qb;
1650
1708
  }
1651
1709
  /**
@@ -1727,7 +1785,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1727
1785
  if (!spec) {
1728
1786
  throw new BadRequestException('histogram requires a `{ field }` specification (with an optional positive `buckets` count).');
1729
1787
  }
1730
- const adapter = this.resolveAdapter();
1788
+ const adapter = this.resolveAdapter(opts.filterClass, entity);
1731
1789
  // Named separately: an adapter can plausibly have one and not the other,
1732
1790
  // and "histogram is unsupported" would send its author looking for a method
1733
1791
  // by that name, which does not and will not exist.
@@ -1755,7 +1813,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1755
1813
  }
1756
1814
  this.assertBucketable(target, entity, adapter);
1757
1815
  // ── pass 1: where do the endpoints sit ───────────────────────────────────
1758
- const extentQb = await this.buildFilteredQb(entity, structured, adapter, opts.context);
1816
+ const extentQb = await this.buildFilteredQb(entity, structured, adapter, opts.context, opts.filterClass);
1759
1817
  const measured = await adapter.fieldExtent(extentQb, [target], entity);
1760
1818
  const key = typeof target === 'string' ? target : target.alias;
1761
1819
  const bounds = measured[key];
@@ -1776,7 +1834,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1776
1834
  return { min: null, max: null, bucketWidth: null, buckets: [] };
1777
1835
  }
1778
1836
  // ── pass 2: how are the rows distributed across them ─────────────────────
1779
- const countQb = await this.buildFilteredQb(entity, structured, adapter, opts.context);
1837
+ const countQb = await this.buildFilteredQb(entity, structured, adapter, opts.context, opts.filterClass);
1780
1838
  // Degenerate span: one row, or many rows sharing one value. A width of
1781
1839
  // (max - min) / n is 0, and `FLOOR(col / 0)` is a null group on MySQL and a
1782
1840
  // division-by-zero ERROR on Postgres — so the bucketed variant must not be
@@ -2001,7 +2059,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
2001
2059
  * cannot opt out.
2002
2060
  */
2003
2061
  async findPage(entity, input, opts = {}) {
2004
- const adapter = this.resolveAdapter();
2062
+ const adapter = this.resolveAdapter(entity);
2005
2063
  if (!adapter?.getResult ||
2006
2064
  !adapter.getPrimaryKey ||
2007
2065
  !adapter.applyKeysetPagination ||