@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/CHANGELOG.md +69 -0
- package/dist/adapter/adapter.d.ts +15 -1
- package/dist/adapter/adapter.d.ts.map +1 -1
- package/dist/decorator/filterable.decorator.d.ts.map +1 -1
- package/dist/decorator/filterable.decorator.js +1 -0
- package/dist/decorator/filterable.decorator.js.map +1 -1
- package/dist/input/bracket-keys.d.ts +11 -0
- package/dist/input/bracket-keys.d.ts.map +1 -0
- package/dist/input/bracket-keys.js +103 -0
- package/dist/input/bracket-keys.js.map +1 -0
- package/dist/interceptor/apply-filter.interceptor.d.ts +10 -0
- package/dist/interceptor/apply-filter.interceptor.d.ts.map +1 -1
- package/dist/interceptor/apply-filter.interceptor.js +36 -6
- package/dist/interceptor/apply-filter.interceptor.js.map +1 -1
- package/dist/runner.d.ts +38 -5
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +102 -34
- package/dist/runner.js.map +1 -1
- package/dist/types.d.ts +37 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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.
|
|
194
|
+
this.defaultAdapter = resolved;
|
|
157
195
|
}
|
|
158
|
-
return this.
|
|
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
|
-
|
|
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
|
|
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'
|
|
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
|
|
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
|
|
1495
|
-
* present. `bucket` is kept only when it is a finite positive
|
|
1496
|
-
* zero/negative/NaN/non-number bucket degrades to the plain
|
|
1497
|
-
* group-by-count rather than emitting a divide-by-zero or
|
|
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
|
|
1506
|
-
?
|
|
1507
|
-
: undefined;
|
|
1508
|
-
|
|
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 ||
|