@dudousxd/nestjs-filter 1.24.0 → 1.28.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 +155 -0
- package/README.md +2 -1
- package/dist/adapter/adapter.d.ts +74 -0
- 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/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/interceptor/apply-filter.interceptor.d.ts.map +1 -1
- package/dist/interceptor/apply-filter.interceptor.js +6 -1
- package/dist/interceptor/apply-filter.interceptor.js.map +1 -1
- package/dist/runner.d.ts +381 -4
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +768 -54
- package/dist/runner.js.map +1 -1
- package/dist/types.d.ts +147 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/skills/filter-safety/SKILL.md +20 -4
- package/skills/structured-querying/SKILL.md +14 -1
package/dist/runner.js
CHANGED
|
@@ -28,10 +28,25 @@ import { resolveDispatchTarget } from './input/dispatcher.js';
|
|
|
28
28
|
import { normalizeInput } from './input/normalizer.js';
|
|
29
29
|
import { parseSpatieInput } from './input/spatie-parser.js';
|
|
30
30
|
import { validateInput } from './input/validator.js';
|
|
31
|
-
import { normalizeOperator, validateColumnFilters } from './operators/validate-column-filter.js';
|
|
31
|
+
import { isValidFieldPath, normalizeOperator, validateColumnFilters, } from './operators/validate-column-filter.js';
|
|
32
32
|
import { buildKeyset, decodeCursor, encodeCursor, extractCursorValues, } from './pagination/cursor.js';
|
|
33
33
|
import { CONTEXT_ACCESSOR, FILTER_ADAPTER, FILTER_MODULE_OPTIONS } from './tokens.js';
|
|
34
34
|
const MATCH_ALL_SET = { has: () => true };
|
|
35
|
+
/**
|
|
36
|
+
* Bars behind a range control when the request names no `buckets` count. Ten is
|
|
37
|
+
* what a slider-width strip of bars reads as; the exact number matters less
|
|
38
|
+
* than having one, because the alternative is a caller inventing a bucket WIDTH
|
|
39
|
+
* — which is the thing it cannot compute (see {@link FilterRunner.fieldHistogram}).
|
|
40
|
+
*/
|
|
41
|
+
const DEFAULT_HISTOGRAM_BUCKETS = 10;
|
|
42
|
+
/**
|
|
43
|
+
* Ceiling on the requested bucket count. The SQL costs the same whatever the
|
|
44
|
+
* width is, but the RESULT does not: the runner materializes one entry per
|
|
45
|
+
* bucket including empty ones, so `buckets: 1e9` would be a gigabyte of JSON
|
|
46
|
+
* describing a control a thousand pixels wide. Clamped rather than rejected —
|
|
47
|
+
* the number is a rendering hint, not a semantic choice.
|
|
48
|
+
*/
|
|
49
|
+
const MAX_HISTOGRAM_BUCKETS = 1000;
|
|
35
50
|
/**
|
|
36
51
|
* Merges the inline `@Filterable.computed` map and `@Computed`-decorated
|
|
37
52
|
* methods into a single alias → {@link ComputedRegistryEntry} registry.
|
|
@@ -300,7 +315,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
300
315
|
const { computed, applyComputed, computedUnsupported } = opts;
|
|
301
316
|
const fields = this.remapFieldAliases(this.parseDistinct(rawFields), aliasMeta);
|
|
302
317
|
if (fields.length === 0)
|
|
303
|
-
return
|
|
318
|
+
return [];
|
|
304
319
|
// Split computed aliases and aggregate paths (both dev-declared or
|
|
305
320
|
// synthesized, neither a real column — same rationale as computed sorts)
|
|
306
321
|
// from plain column fields. Without the aggregate split, a path the
|
|
@@ -312,13 +327,19 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
312
327
|
const plainFields = computedAliases.length > 0 || aggregateFields.length > 0
|
|
313
328
|
? fields.filter((f) => !computed?.has(f) && parseAggregatePath(f) === null)
|
|
314
329
|
: fields;
|
|
315
|
-
|
|
330
|
+
// The fields that reached the projection, NOT the ones that were asked
|
|
331
|
+
// for: everything dropped on the way (failed validation, refused by the
|
|
332
|
+
// allowlist, an aggregate outside the auto-field set) has to stay out, so a
|
|
333
|
+
// caller can order by what this returns and still emit a legal
|
|
334
|
+
// `SELECT DISTINCT` — see `apply()`'s distinct-ordering fallback.
|
|
335
|
+
const applied = new Set();
|
|
316
336
|
if (plainFields.length > 0) {
|
|
317
337
|
if (apply && adapter && entity) {
|
|
318
338
|
const valid = this.validateDistinct(plainFields, allowed, adapter, entity, throwOnInvalid);
|
|
319
339
|
if (valid.length > 0) {
|
|
320
340
|
apply(qb, valid, entity);
|
|
321
|
-
|
|
341
|
+
for (const field of valid)
|
|
342
|
+
applied.add(field);
|
|
322
343
|
}
|
|
323
344
|
}
|
|
324
345
|
else if (apply === undefined && unsupported) {
|
|
@@ -329,8 +350,8 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
329
350
|
if (applyComputed) {
|
|
330
351
|
for (const alias of computedAliases) {
|
|
331
352
|
applyComputed(qb, alias, computed.get(alias).source);
|
|
353
|
+
applied.add(alias);
|
|
332
354
|
}
|
|
333
|
-
applied = true;
|
|
334
355
|
}
|
|
335
356
|
else if (computedUnsupported) {
|
|
336
357
|
this.warnUnsupported(computedUnsupported.feature, computedUnsupported.method);
|
|
@@ -351,14 +372,17 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
351
372
|
if (!aggregatePath)
|
|
352
373
|
continue;
|
|
353
374
|
adapter.applyAggregateDistinct(qb, aggregatePath);
|
|
354
|
-
applied
|
|
375
|
+
applied.add(field);
|
|
355
376
|
}
|
|
356
377
|
}
|
|
357
378
|
else {
|
|
358
379
|
this.warnUnsupported('Distinct on an aggregate field requested', 'applyAggregateDistinct');
|
|
359
380
|
}
|
|
360
381
|
}
|
|
361
|
-
|
|
382
|
+
// Request order, not the order the three branches above ran in: the ORDER
|
|
383
|
+
// BY a caller derives from this should read like the projection the client
|
|
384
|
+
// asked for.
|
|
385
|
+
return fields.filter((field) => applied.has(field));
|
|
362
386
|
}
|
|
363
387
|
/**
|
|
364
388
|
* Projects every `project: true` computed-registry entry into the SELECT
|
|
@@ -457,7 +481,17 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
457
481
|
const { plain: plainColumnFilters, computed: computedColumnFilters, aggregate: aggregateColumnFilters, } = this.splitSpecialColumnFilters(scopedColumnFilters, computedRegistry);
|
|
458
482
|
// Apply column filters via adapter before @FilterFor dispatch
|
|
459
483
|
if (plainColumnFilters.length > 0 && adapter?.applyColumnFilters) {
|
|
460
|
-
|
|
484
|
+
// Everything left in `plain` is claimed to be a real column path, so
|
|
485
|
+
// it has to actually BE one. Runs before the operator allowlist: a
|
|
486
|
+
// field the entity does not have has no operator policy to violate,
|
|
487
|
+
// and reporting "operator X is not allowed on ghostColumn" would
|
|
488
|
+
// name the wrong problem. Needs `entity` to check against — a filter
|
|
489
|
+
// class without `@Filterable` metadata keeps the pre-fix
|
|
490
|
+
// pass-through, same as every other metadata-dependent gate here.
|
|
491
|
+
const knownColumnFilters = filterableMeta?.entity
|
|
492
|
+
? this.pruneUnknownColumnFilters(plainColumnFilters, filterableMeta.entity, adapter, throwOnInvalidPolicy)
|
|
493
|
+
: plainColumnFilters;
|
|
494
|
+
const opAllowed = this.enforceOperatorAllowlist(knownColumnFilters, normalizedAllowed, throwOnInvalidPolicy);
|
|
461
495
|
if (opAllowed.length > 0) {
|
|
462
496
|
validateColumnFilters(opAllowed);
|
|
463
497
|
adapter.applyColumnFilters(qb, opAllowed, filterableMeta?.entity);
|
|
@@ -679,7 +713,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
679
713
|
// A computed alias in the distinct list routes to applyComputedDistinct
|
|
680
714
|
// (the computed registry bypasses column validation, like computed
|
|
681
715
|
// sorts do); plain columns still batch through applyDistinct first.
|
|
682
|
-
const
|
|
716
|
+
const distinctFields = this.applyProjection(qb, rawDistinct, {
|
|
683
717
|
entity: filterableMeta?.entity,
|
|
684
718
|
adapter,
|
|
685
719
|
allowed: FilterClass.distinct,
|
|
@@ -716,7 +750,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
716
750
|
// Skipped when a distinct projection was applied: distinct replaces
|
|
717
751
|
// entity-row output, and a computed value participates in a distinct
|
|
718
752
|
// projection only by being listed in `distinct` explicitly.
|
|
719
|
-
if (
|
|
753
|
+
if (distinctFields.length === 0) {
|
|
720
754
|
this.applyProjectedComputed(qb, computedRegistry, adapter);
|
|
721
755
|
}
|
|
722
756
|
// Apply sort — falling back to defaultSort when the client gave none.
|
|
@@ -726,9 +760,28 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
726
760
|
const sorts = parsedSorts.length > 0
|
|
727
761
|
? parsedSorts
|
|
728
762
|
: this.parseSorts(this.resolveDefaultSort(FilterClass));
|
|
729
|
-
|
|
763
|
+
// Opt-in last fallback (`distinctOrder`), for a DISTINCT projection
|
|
764
|
+
// nothing else ordered: sort ascending by what was projected. Off
|
|
765
|
+
// unless asked for — see `FilterableOptions.distinctOrder` for why the
|
|
766
|
+
// default is not to invent an ORDER BY.
|
|
767
|
+
//
|
|
768
|
+
// Derived from `distinctFields` (what the projection KEPT) rather than
|
|
769
|
+
// from the request, so every term is in the select list and the query
|
|
770
|
+
// stays legal under DISTINCT.
|
|
771
|
+
const effectiveSorts = sorts.length > 0
|
|
772
|
+
? sorts
|
|
773
|
+
: this.resolveDistinctOrder(FilterClass, internal.distinctOrder)
|
|
774
|
+
? distinctFields.map((field) => ({ field, direction: 'asc' }))
|
|
775
|
+
: [];
|
|
776
|
+
if (effectiveSorts.length > 0 && adapter?.applySort) {
|
|
730
777
|
const allowedSorts = FilterClass.sort;
|
|
731
|
-
this.applySortsWithComputed(qb,
|
|
778
|
+
this.applySortsWithComputed(qb, effectiveSorts, allowedSorts, adapter, filterableMeta?.entity,
|
|
779
|
+
// NEVER throw for the derived ordering, whatever `throwOnInvalid`
|
|
780
|
+
// says: a `sort` the CLIENT sent is its request to get wrong, but
|
|
781
|
+
// this one it never asked for. A narrowed `static sort` allowlist
|
|
782
|
+
// that excludes a projected column must drop the ORDER BY, not turn
|
|
783
|
+
// an otherwise valid distinct request into a 400.
|
|
784
|
+
sorts.length > 0 ? this.resolveThrowOnInvalid(FilterClass) : false, computedRegistry, autoFieldSet);
|
|
732
785
|
}
|
|
733
786
|
// Apply pagination
|
|
734
787
|
this.applyPagination(qb, rawPaginate, adapter);
|
|
@@ -812,6 +865,8 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
812
865
|
distinct: undefined,
|
|
813
866
|
select: undefined,
|
|
814
867
|
groupByCount: undefined,
|
|
868
|
+
extent: undefined,
|
|
869
|
+
histogram: undefined,
|
|
815
870
|
paginate: undefined,
|
|
816
871
|
};
|
|
817
872
|
}
|
|
@@ -829,6 +884,8 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
829
884
|
'distinct',
|
|
830
885
|
'select',
|
|
831
886
|
'groupByCount',
|
|
887
|
+
'extent',
|
|
888
|
+
'histogram',
|
|
832
889
|
'paginate',
|
|
833
890
|
];
|
|
834
891
|
if (STRUCTURED_KEYS.some((k) => k in inputObj)) {
|
|
@@ -840,6 +897,8 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
840
897
|
distinct: inputObj.distinct ?? undefined,
|
|
841
898
|
select: inputObj.select ?? undefined,
|
|
842
899
|
groupByCount: inputObj.groupByCount ?? undefined,
|
|
900
|
+
extent: inputObj.extent ?? undefined,
|
|
901
|
+
histogram: inputObj.histogram ?? undefined,
|
|
843
902
|
paginate: inputObj.paginate ?? undefined,
|
|
844
903
|
};
|
|
845
904
|
}
|
|
@@ -852,6 +911,8 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
852
911
|
distinct: undefined,
|
|
853
912
|
select: undefined,
|
|
854
913
|
groupByCount: undefined,
|
|
914
|
+
extent: undefined,
|
|
915
|
+
histogram: undefined,
|
|
855
916
|
paginate: undefined,
|
|
856
917
|
};
|
|
857
918
|
}
|
|
@@ -1158,7 +1219,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
1158
1219
|
});
|
|
1159
1220
|
}
|
|
1160
1221
|
// Distinct projection (SELECT DISTINCT) — validate against entity metadata
|
|
1161
|
-
this.applyProjection(qb, rawDistinct, {
|
|
1222
|
+
const distinctFields = this.applyProjection(qb, rawDistinct, {
|
|
1162
1223
|
entity,
|
|
1163
1224
|
adapter,
|
|
1164
1225
|
allowed: undefined,
|
|
@@ -1182,14 +1243,25 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
1182
1243
|
// Fall back to defaultSort when the client gave none.
|
|
1183
1244
|
const parsedSorts = this.remapSortAliases(this.parseSorts(rawSort), filterableMeta);
|
|
1184
1245
|
const sorts = parsedSorts.length > 0 ? parsedSorts : this.parseSorts(this.resolveDefaultSort());
|
|
1185
|
-
|
|
1186
|
-
|
|
1246
|
+
// Same distinct-ordering fallback `apply()` has, for the same reason —
|
|
1247
|
+
// dynamic mode's DISTINCT is no more ordered than a filter class's. Here
|
|
1248
|
+
// the caller IS the call site (there is no route decorator and no filter
|
|
1249
|
+
// class), so the per-call flag is the only way in.
|
|
1250
|
+
const effectiveSorts = sorts.length > 0
|
|
1251
|
+
? sorts
|
|
1252
|
+
: internal.distinctOrder
|
|
1253
|
+
? distinctFields.map((field) => ({ field, direction: 'asc' }))
|
|
1254
|
+
: [];
|
|
1255
|
+
if (effectiveSorts.length > 0 && adapter?.applySort) {
|
|
1256
|
+
// `throwOnInvalid` only for a sort the CLIENT sent — the derived one
|
|
1257
|
+
// drops instead of failing a request that never asked for it.
|
|
1258
|
+
const validSorts = this.validateSorts(effectiveSorts, undefined, adapter, entity, sorts.length > 0 ? throwOnInvalid : false);
|
|
1187
1259
|
if (validSorts.length > 0) {
|
|
1188
1260
|
adapter.applySort(qb, validSorts);
|
|
1189
1261
|
}
|
|
1190
1262
|
}
|
|
1191
1263
|
// Pagination
|
|
1192
|
-
this.applyPagination(qb, rawPaginate, adapter);
|
|
1264
|
+
this.applyPagination(qb, rawPaginate, adapter, internal.trustedPageSize === true);
|
|
1193
1265
|
}
|
|
1194
1266
|
return qb;
|
|
1195
1267
|
}
|
|
@@ -1205,6 +1277,14 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
1205
1277
|
*
|
|
1206
1278
|
* Requires an adapter implementing `getResultAndCount` (and `populate` for
|
|
1207
1279
|
* to-many includes). `applyDynamic` is unchanged; this is additive.
|
|
1280
|
+
*
|
|
1281
|
+
* `opts.trustedPageSize` declares that `paginate.size` was written by the
|
|
1282
|
+
* server, not received from a client, and lifts the module-level
|
|
1283
|
+
* `maxPageSize` ceiling for this call — see
|
|
1284
|
+
* {@link FilterRunner.resolvePageSize}. This is the entry point exports and
|
|
1285
|
+
* batch jobs use, so it is where the escape hatch is needed; without it they
|
|
1286
|
+
* abandon `paginate` for hand-built `limit`/`offset` and lose everything
|
|
1287
|
+
* else this method does.
|
|
1208
1288
|
*/
|
|
1209
1289
|
async findAndCount(entity, input, opts = {}) {
|
|
1210
1290
|
const adapter = this.resolveAdapter();
|
|
@@ -1225,7 +1305,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
1225
1305
|
select: structured.select,
|
|
1226
1306
|
paginate: structured.paginate,
|
|
1227
1307
|
include: joinIncludes,
|
|
1228
|
-
}, qb, opts.context, { native: true });
|
|
1308
|
+
}, qb, opts.context, { native: true, trustedPageSize: opts.trustedPageSize === true });
|
|
1229
1309
|
if (distinctFields.length > 0) {
|
|
1230
1310
|
if (!adapter?.getDistinctResultAndCount) {
|
|
1231
1311
|
throw new Error('findAndCount requires an adapter that implements getDistinctResultAndCount() for distinct projections.');
|
|
@@ -1377,6 +1457,473 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
1377
1457
|
: undefined;
|
|
1378
1458
|
return { field: obj.field, ...(bucket !== undefined && { bucket }) };
|
|
1379
1459
|
}
|
|
1460
|
+
/**
|
|
1461
|
+
* **Field extent**: the `MIN`/`MAX` of the requested field(s) over the rows
|
|
1462
|
+
* the active `where`/`search` select — what a range control (numeric slider,
|
|
1463
|
+
* date-range calendar) needs before it can place its endpoints. Reads the
|
|
1464
|
+
* `extent` structured key (`{ extent: ['price', 'createdAt'] }`, or the
|
|
1465
|
+
* comma-separated string a GET route carries, parsed exactly like `distinct`).
|
|
1466
|
+
*
|
|
1467
|
+
* Unlike {@link groupByCount} this is NOT terminal — it measures the same
|
|
1468
|
+
* filtered set the rows come from, so a route answers with rows AND extent
|
|
1469
|
+
* from two builders. Sort/pagination/distinct/select are not part of the
|
|
1470
|
+
* question and are not applied (`skipSortAndPagination`).
|
|
1471
|
+
*
|
|
1472
|
+
* **Why this lives in the runner and not in the route.** Reading
|
|
1473
|
+
* `@Body('extent')` and handing it straight to `adapter.fieldExtent` bypasses
|
|
1474
|
+
* the filter class's field governance: `static distinct` narrowing, the
|
|
1475
|
+
* entity-metadata check that rejects a bare relation or an unknown
|
|
1476
|
+
* identifier, and the alias remapping every other key gets. That is not an
|
|
1477
|
+
* injection hole — the adapter resolves names through ORM metadata — but it
|
|
1478
|
+
* IS a surface leak: a caller could measure a column the filter class
|
|
1479
|
+
* deliberately does not expose. Routing every requested field through
|
|
1480
|
+
* {@link validateDistinct} here closes it, and it is also the only place
|
|
1481
|
+
* that can tell a computed alias from a typo (see below).
|
|
1482
|
+
*
|
|
1483
|
+
* **Allowlist.** The same one `distinct` uses: the filter class's static
|
|
1484
|
+
* `distinct` list when `opts.filterClass` declares one, else the entity's
|
|
1485
|
+
* columns via adapter metadata. `distinct` and `extent` answer the same
|
|
1486
|
+
* question about a column — what values can this control offer — so a class
|
|
1487
|
+
* that has already narrowed which columns a control may read must narrow
|
|
1488
|
+
* this too, or `extent` becomes the way around that narrowing.
|
|
1489
|
+
*
|
|
1490
|
+
* **Disallowed/unknown fields are DROPPED**, and the request still answers
|
|
1491
|
+
* for the fields that survived — matching `distinct` (which drops an invalid
|
|
1492
|
+
* projected field rather than failing the query) rather than `groupByCount`
|
|
1493
|
+
* (which always rejects, because there the field IS the whole query). Under
|
|
1494
|
+
* the ambient `throwOnInvalid` policy the drop becomes a
|
|
1495
|
+
* `BadRequestException`, again as `distinct`. A dropped field is simply
|
|
1496
|
+
* absent from the result, which the `fieldExtent` contract already defines as
|
|
1497
|
+
* "not measured" — the caller cannot tell it apart from a field the adapter
|
|
1498
|
+
* could not resolve, and does not need to.
|
|
1499
|
+
*
|
|
1500
|
+
* **Computed members** route as `{ alias, source }` rather than a bare name,
|
|
1501
|
+
* mirroring {@link groupByCount}'s grouping field: the adapter measures the
|
|
1502
|
+
* dev-provided expression instead of resolving a column that does not exist.
|
|
1503
|
+
* The registry comes from `opts.filterClass` when given, else from
|
|
1504
|
+
* `@Filterable` metadata on the entity itself. Computed aliases bypass column
|
|
1505
|
+
* validation — dev-declared, never client input — exactly like computed
|
|
1506
|
+
* sort/distinct.
|
|
1507
|
+
*
|
|
1508
|
+
* Requires an adapter implementing the optional `fieldExtent` method; when
|
|
1509
|
+
* absent, a clear error is thrown rather than a silent empty answer, which a
|
|
1510
|
+
* range control would render as a collapsed (0, 0) span.
|
|
1511
|
+
*
|
|
1512
|
+
* @returns One entry per measured field keyed by field name (or computed
|
|
1513
|
+
* alias). `{}` when the request named no extent fields at all.
|
|
1514
|
+
*/
|
|
1515
|
+
async fieldExtent(entity, input, opts = {}) {
|
|
1516
|
+
const structured = this.extractStructuredInput(input);
|
|
1517
|
+
// Same parser as `distinct`: the client sends an array on a POST body and a
|
|
1518
|
+
// comma-joined string on a GET query, and both mean the same list.
|
|
1519
|
+
const requested = this.parseDistinct(structured.extent);
|
|
1520
|
+
// No `extent` key is not an error — a route can call this unconditionally
|
|
1521
|
+
// alongside its rows read and get nothing back when nobody asked.
|
|
1522
|
+
if (requested.length === 0)
|
|
1523
|
+
return {};
|
|
1524
|
+
const adapter = this.resolveAdapter();
|
|
1525
|
+
if (!adapter?.fieldExtent) {
|
|
1526
|
+
throw new Error('extent is not supported by the active adapter (it does not implement fieldExtent()).');
|
|
1527
|
+
}
|
|
1528
|
+
// Aliases resolve against the filter class when one is given (its `aliases`
|
|
1529
|
+
// are what the client was told to use), else against entity-level metadata
|
|
1530
|
+
// — the same fallback dynamic mode uses everywhere else.
|
|
1531
|
+
const aliasMeta = getFilterableMetadata(opts.filterClass ?? entity);
|
|
1532
|
+
const fields = this.remapFieldAliases(requested, aliasMeta);
|
|
1533
|
+
// Registry source mirrors groupByCount's: the DI-resolved filter class when
|
|
1534
|
+
// given (so `@Computed` methods bind), else `@Filterable` declared on the
|
|
1535
|
+
// entity itself, which is all dynamic mode has.
|
|
1536
|
+
const entityProto = Object.create(entity.prototype);
|
|
1537
|
+
const computedRegistry = opts.filterClass
|
|
1538
|
+
? buildComputedRegistry(opts.filterClass, await this.resolveFilter(opts.filterClass))
|
|
1539
|
+
: buildComputedRegistry(entity, entityProto);
|
|
1540
|
+
const allowlist = this.resolveDistinctAllowlist(opts.filterClass);
|
|
1541
|
+
const throwOnInvalid = this.resolveThrowOnInvalid(opts.filterClass);
|
|
1542
|
+
const targets = [];
|
|
1543
|
+
for (const field of fields) {
|
|
1544
|
+
const target = this.resolveMeasurableField(field, entity, adapter, allowlist, computedRegistry);
|
|
1545
|
+
if (!target) {
|
|
1546
|
+
// The rejection message names `extent` instead of borrowing distinct's
|
|
1547
|
+
// — the policy (drop, or throw when configured to) is still distinct's,
|
|
1548
|
+
// just worded for the key the client actually sent.
|
|
1549
|
+
if (throwOnInvalid) {
|
|
1550
|
+
throw new BadRequestException(`Invalid extent field: "${field}".`);
|
|
1551
|
+
}
|
|
1552
|
+
continue;
|
|
1553
|
+
}
|
|
1554
|
+
targets.push(target);
|
|
1555
|
+
}
|
|
1556
|
+
// Everything was dropped — the adapter is not called at all. An empty
|
|
1557
|
+
// `fields` list would otherwise be a `SELECT` with no aggregates.
|
|
1558
|
+
if (targets.length === 0)
|
|
1559
|
+
return {};
|
|
1560
|
+
const qb = opts.qb ?? adapter.createQueryBuilder(entity);
|
|
1561
|
+
await this.applyDynamic(entity, { filter: structured.filter, search: structured.search }, qb, opts.context, { skipSortAndPagination: true, native: true });
|
|
1562
|
+
return adapter.fieldExtent(qb, targets, entity);
|
|
1563
|
+
}
|
|
1564
|
+
/**
|
|
1565
|
+
* Resolves ONE requested field to the shape an adapter measurement takes: the
|
|
1566
|
+
* `{ alias, source }` pair of a computed member, or the validated column
|
|
1567
|
+
* name. `null` when it is neither.
|
|
1568
|
+
*
|
|
1569
|
+
* Shared by {@link fieldExtent} and {@link fieldHistogram} so both obey the
|
|
1570
|
+
* same allowlist, and so the one judgement nothing outside the runner can
|
|
1571
|
+
* make — computed alias, or typo? both are strings no column matches — is
|
|
1572
|
+
* made once. What the two callers differ on is only what `null` MEANS (a
|
|
1573
|
+
* dropped field there, a rejected request here), which is why that decision
|
|
1574
|
+
* stays with them.
|
|
1575
|
+
*
|
|
1576
|
+
* Validation runs with `throwOnInvalid: false` unconditionally: the caller
|
|
1577
|
+
* words its own rejection for the key the client actually sent, rather than
|
|
1578
|
+
* surfacing `Invalid distinct field` for a request that never said `distinct`.
|
|
1579
|
+
*/
|
|
1580
|
+
resolveMeasurableField(field, entity, adapter, allowlist, computedRegistry) {
|
|
1581
|
+
const computedEntry = computedRegistry.get(field);
|
|
1582
|
+
if (computedEntry)
|
|
1583
|
+
return { alias: field, source: computedEntry.source };
|
|
1584
|
+
const validated = this.validateDistinct([field], allowlist, adapter, entity, false);
|
|
1585
|
+
return validated[0] ?? null;
|
|
1586
|
+
}
|
|
1587
|
+
/**
|
|
1588
|
+
* A fresh builder carrying the request's WHERE/search and nothing else — the
|
|
1589
|
+
* scope every measurement in this file asks its question over.
|
|
1590
|
+
*
|
|
1591
|
+
* `skipSortAndPagination` is the load-bearing part: an aggregate over a
|
|
1592
|
+
* LIMITed builder answers for a page and looks exactly like an answer for the
|
|
1593
|
+
* set, and an ORDER BY on a column an aggregate SELECT no longer projects is
|
|
1594
|
+
* MySQL error 3065.
|
|
1595
|
+
*/
|
|
1596
|
+
async buildFilteredQb(entity, structured, adapter, context) {
|
|
1597
|
+
const qb = adapter.createQueryBuilder(entity);
|
|
1598
|
+
await this.applyDynamic(entity, { filter: structured.filter, search: structured.search }, qb, context, { skipSortAndPagination: true, native: true });
|
|
1599
|
+
return qb;
|
|
1600
|
+
}
|
|
1601
|
+
/**
|
|
1602
|
+
* The static `distinct` allowlist declared on a filter class, if any — the
|
|
1603
|
+
* one {@link applyProjection} gates the DISTINCT projection with, reused by
|
|
1604
|
+
* {@link fieldExtent} so both reads of a column obey the same narrowing.
|
|
1605
|
+
*
|
|
1606
|
+
* Read defensively rather than through a cast: `static distinct` is a plain
|
|
1607
|
+
* class property no type checks, so a class can carry anything under that
|
|
1608
|
+
* name. A non-array (or a list holding non-strings) yields `undefined`/the
|
|
1609
|
+
* string entries, which degrades to entity-metadata validation instead of
|
|
1610
|
+
* silently comparing field names against garbage and refusing everything.
|
|
1611
|
+
*/
|
|
1612
|
+
resolveDistinctAllowlist(FilterClass) {
|
|
1613
|
+
if (!FilterClass)
|
|
1614
|
+
return undefined;
|
|
1615
|
+
const declared = Reflect.get(FilterClass, 'distinct');
|
|
1616
|
+
if (!Array.isArray(declared))
|
|
1617
|
+
return undefined;
|
|
1618
|
+
return declared.filter((entry) => typeof entry === 'string');
|
|
1619
|
+
}
|
|
1620
|
+
/**
|
|
1621
|
+
* **Field histogram**: one numeric field's extent AND its bucketed
|
|
1622
|
+
* distribution over the same filtered set — the two halves of a faceted range
|
|
1623
|
+
* control, from one request. Reads the `histogram` structured key
|
|
1624
|
+
* (`{ histogram: { field: 'price', buckets: 20 } }`).
|
|
1625
|
+
*
|
|
1626
|
+
* **Why this is a method and not two calls in a route.** The halves already
|
|
1627
|
+
* exist — {@link fieldExtent} places a slider's endpoints,
|
|
1628
|
+
* {@link FilterAdapter.groupByCount}'s bucketed variant draws the
|
|
1629
|
+
* distribution behind them — but they are circular for the caller: the
|
|
1630
|
+
* bucketed variant needs a WIDTH, and a width that is not derived from the
|
|
1631
|
+
* data is either arbitrary (a hardcoded 1000 that yields two bars on one
|
|
1632
|
+
* filter and four hundred on the next) or requires the extent the caller is
|
|
1633
|
+
* asking for in the same breath. Nobody can break that cycle from outside:
|
|
1634
|
+
* you must measure, then divide. So the runner measures, then divides.
|
|
1635
|
+
*
|
|
1636
|
+
* **Two round trips, and it cannot be one.** The width is a function of the
|
|
1637
|
+
* first query's OUTPUT, so the second query's text does not exist until the
|
|
1638
|
+
* first has returned. Folding them into one statement means either a
|
|
1639
|
+
* correlated `(SELECT MAX(col)) - (SELECT MIN(col))` inside the bucket
|
|
1640
|
+
* expression — the same scan twice, once per row-group, to avoid a round trip
|
|
1641
|
+
* — or window functions the adapter contract does not have. Two plain
|
|
1642
|
+
* aggregate queries over an indexable column is the cheaper shape, and it
|
|
1643
|
+
* keeps this a composition of capabilities adapters already implement.
|
|
1644
|
+
*
|
|
1645
|
+
* **Not on the adapter contract, deliberately.** Every optional method added
|
|
1646
|
+
* to `FilterAdapter` is a cost each adapter author pays forever, and this one
|
|
1647
|
+
* would buy nothing: it is arithmetic between two existing calls, identical
|
|
1648
|
+
* for every ORM. An adapter implementing `fieldExtent` and `groupByCount`
|
|
1649
|
+
* gets this for free, and one implementing neither is told which is missing.
|
|
1650
|
+
*
|
|
1651
|
+
* **No `opts.qb`**, unlike its neighbours. Two passes need two builders — the
|
|
1652
|
+
* first is consumed by an aggregate SELECT — and a single caller-supplied
|
|
1653
|
+
* builder can only serve one of them. Silently creating a fresh builder for
|
|
1654
|
+
* the second pass would drop whatever pre-scoping the caller put on theirs,
|
|
1655
|
+
* so the distribution would describe a WIDER set than the extent: a chart
|
|
1656
|
+
* with bars outside its own axis, and nothing to make it obvious.
|
|
1657
|
+
*
|
|
1658
|
+
* Field governance is {@link fieldExtent}'s, through the same
|
|
1659
|
+
* {@link resolveMeasurableField}: alias remapping, the filter class's static
|
|
1660
|
+
* `distinct` allowlist (else entity metadata), and computed members routed as
|
|
1661
|
+
* `{ alias, source }`. The one divergence is what an invalid field means —
|
|
1662
|
+
* rejected here, as in {@link groupByCount}, because the field IS the query
|
|
1663
|
+
* and there is no partial answer to fall back to.
|
|
1664
|
+
*
|
|
1665
|
+
* **Dates are refused, not bucketed.** `fieldExtent` supports DATE columns on
|
|
1666
|
+
* purpose, but bucketing is `FLOOR(value / width)`, and a date divided by a
|
|
1667
|
+
* number is nonsense that no database announces: MySQL coerces the column to
|
|
1668
|
+
* `20240131` and buckets THAT, which produces plausible-looking bars over an
|
|
1669
|
+
* axis that skips two thirds of every year. See {@link assertBucketable}.
|
|
1670
|
+
*
|
|
1671
|
+
* @returns `{ min, max, bucketWidth, buckets }` — null ends and an empty
|
|
1672
|
+
* bucket list when no row in scope carries a value.
|
|
1673
|
+
*/
|
|
1674
|
+
async fieldHistogram(entity, input, opts = {}) {
|
|
1675
|
+
const structured = this.extractStructuredInput(input);
|
|
1676
|
+
const spec = this.parseHistogram(structured.histogram);
|
|
1677
|
+
if (!spec) {
|
|
1678
|
+
throw new BadRequestException('histogram requires a `{ field }` specification (with an optional positive `buckets` count).');
|
|
1679
|
+
}
|
|
1680
|
+
const adapter = this.resolveAdapter();
|
|
1681
|
+
// Named separately: an adapter can plausibly have one and not the other,
|
|
1682
|
+
// and "histogram is unsupported" would send its author looking for a method
|
|
1683
|
+
// by that name, which does not and will not exist.
|
|
1684
|
+
if (!adapter?.fieldExtent) {
|
|
1685
|
+
throw new Error('histogram is not supported by the active adapter (it does not implement fieldExtent()).');
|
|
1686
|
+
}
|
|
1687
|
+
if (!adapter.groupByCount) {
|
|
1688
|
+
throw new Error('histogram is not supported by the active adapter (it does not implement groupByCount()).');
|
|
1689
|
+
}
|
|
1690
|
+
// Aliases against the filter class when one is given, else entity-level
|
|
1691
|
+
// metadata — the fallback dynamic mode uses everywhere else.
|
|
1692
|
+
const aliasMeta = getFilterableMetadata(opts.filterClass ?? entity);
|
|
1693
|
+
const [remapped] = this.remapFieldAliases([spec.field], aliasMeta);
|
|
1694
|
+
const field = remapped ?? spec.field;
|
|
1695
|
+
const entityProto = Object.create(entity.prototype);
|
|
1696
|
+
const computedRegistry = opts.filterClass
|
|
1697
|
+
? buildComputedRegistry(opts.filterClass, await this.resolveFilter(opts.filterClass))
|
|
1698
|
+
: buildComputedRegistry(entity, entityProto);
|
|
1699
|
+
const target = this.resolveMeasurableField(field, entity, adapter, this.resolveDistinctAllowlist(opts.filterClass), computedRegistry);
|
|
1700
|
+
// Rejected regardless of the ambient `throwOnInvalid`, exactly as
|
|
1701
|
+
// `groupByCount` rejects its grouping field: dropping the only field there
|
|
1702
|
+
// is would leave an empty histogram that reads as "no matching rows".
|
|
1703
|
+
if (!target) {
|
|
1704
|
+
throw new BadRequestException(`Invalid histogram field: "${spec.field}".`);
|
|
1705
|
+
}
|
|
1706
|
+
this.assertBucketable(target, entity, adapter);
|
|
1707
|
+
// ── pass 1: where do the endpoints sit ───────────────────────────────────
|
|
1708
|
+
const extentQb = await this.buildFilteredQb(entity, structured, adapter, opts.context);
|
|
1709
|
+
const measured = await adapter.fieldExtent(extentQb, [target], entity);
|
|
1710
|
+
const key = typeof target === 'string' ? target : target.alias;
|
|
1711
|
+
const bounds = measured[key];
|
|
1712
|
+
// The `fieldExtent` contract defines an ABSENT key as "the adapter could not
|
|
1713
|
+
// turn this into an expression". The field passed validation, so that is a
|
|
1714
|
+
// metadata disagreement, not a client mistake — and it must not be reported
|
|
1715
|
+
// as `{ min: null }`, which the same contract defines as "no row carries a
|
|
1716
|
+
// value" and which a control would render as a legitimately empty facet.
|
|
1717
|
+
if (!bounds) {
|
|
1718
|
+
throw new Error(`histogram could not measure "${key}": the active adapter's fieldExtent() returned no entry for it.`);
|
|
1719
|
+
}
|
|
1720
|
+
const min = this.toBucketableNumber(bounds.min, key);
|
|
1721
|
+
const max = this.toBucketableNumber(bounds.max, key);
|
|
1722
|
+
// Empty set, or a column that is null throughout it. No second query: there
|
|
1723
|
+
// is nothing to bin, and a width derived from null is NaN — which reaches
|
|
1724
|
+
// SQL as `FLOOR(col / NaN)` and groups every row into one null bucket.
|
|
1725
|
+
if (min === null || max === null) {
|
|
1726
|
+
return { min: null, max: null, bucketWidth: null, buckets: [] };
|
|
1727
|
+
}
|
|
1728
|
+
// ── pass 2: how are the rows distributed across them ─────────────────────
|
|
1729
|
+
const countQb = await this.buildFilteredQb(entity, structured, adapter, opts.context);
|
|
1730
|
+
// Degenerate span: one row, or many rows sharing one value. A width of
|
|
1731
|
+
// (max - min) / n is 0, and `FLOOR(col / 0)` is a null group on MySQL and a
|
|
1732
|
+
// division-by-zero ERROR on Postgres — so the bucketed variant must not be
|
|
1733
|
+
// asked for at all. The plain group-by answers instead: with a single
|
|
1734
|
+
// distinct value it returns that one group, whose count is the bar's
|
|
1735
|
+
// height. The bucket is reported as the point `[min, min]` rather than
|
|
1736
|
+
// given a fabricated width, which would draw a bar spanning values no row
|
|
1737
|
+
// in scope has.
|
|
1738
|
+
if (min === max) {
|
|
1739
|
+
const rows = await adapter.groupByCount(countQb, target, entity);
|
|
1740
|
+
const count = rows.reduce((sum, row) => row.value === null || row.value === undefined ? sum : sum + Number(row.count), 0);
|
|
1741
|
+
return { min, max, bucketWidth: 0, buckets: [{ bucketStart: min, bucketEnd: min, count }] };
|
|
1742
|
+
}
|
|
1743
|
+
const bucketWidth = this.niceBucketWidth(max - min, spec.buckets ?? DEFAULT_HISTOGRAM_BUCKETS);
|
|
1744
|
+
const rows = await adapter.groupByCount(countQb, target, entity, { bucket: bucketWidth });
|
|
1745
|
+
return { min, max, bucketWidth, buckets: this.assembleBuckets(min, max, bucketWidth, rows) };
|
|
1746
|
+
}
|
|
1747
|
+
/**
|
|
1748
|
+
* Parses the raw `histogram` block into a canonical `{ field, buckets }`.
|
|
1749
|
+
* `null` when no usable `field` is present — the caller rejects, since a
|
|
1750
|
+
* histogram of nothing has no meaningful empty answer.
|
|
1751
|
+
*
|
|
1752
|
+
* `buckets` accepts a numeric string as well as a number: on a GET route
|
|
1753
|
+
* `?histogram[buckets]=20` arrives as text, and rejecting it there while
|
|
1754
|
+
* accepting `20` from a POST body would make the two transports disagree
|
|
1755
|
+
* about the same request. Anything unusable (absent, zero, negative, NaN,
|
|
1756
|
+
* an object) degrades to the default rather than 400ing: it is a rendering
|
|
1757
|
+
* hint, and a request that says nothing about bar count still has an answer.
|
|
1758
|
+
*/
|
|
1759
|
+
parseHistogram(raw) {
|
|
1760
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
1761
|
+
return null;
|
|
1762
|
+
const field = Reflect.get(raw, 'field');
|
|
1763
|
+
if (typeof field !== 'string' || field.length === 0)
|
|
1764
|
+
return null;
|
|
1765
|
+
const rawBuckets = Reflect.get(raw, 'buckets');
|
|
1766
|
+
const parsed = typeof rawBuckets === 'string'
|
|
1767
|
+
? Number(rawBuckets)
|
|
1768
|
+
: typeof rawBuckets === 'number'
|
|
1769
|
+
? rawBuckets
|
|
1770
|
+
: Number.NaN;
|
|
1771
|
+
const buckets = Number.isFinite(parsed) && parsed >= 1
|
|
1772
|
+
? Math.min(Math.floor(parsed), MAX_HISTOGRAM_BUCKETS)
|
|
1773
|
+
: DEFAULT_HISTOGRAM_BUCKETS;
|
|
1774
|
+
return { field, buckets };
|
|
1775
|
+
}
|
|
1776
|
+
/**
|
|
1777
|
+
* Refuses a field whose column type cannot survive `FLOOR(value / width)`,
|
|
1778
|
+
* BEFORE either query runs.
|
|
1779
|
+
*
|
|
1780
|
+
* A DATE column is the case this exists for. `fieldExtent` supports dates
|
|
1781
|
+
* deliberately (a calendar sizes itself from one), so `extent` and
|
|
1782
|
+
* `histogram` accept the same field names right up to this point — and the
|
|
1783
|
+
* failure mode without the check is not an error but a wrong answer: MySQL
|
|
1784
|
+
* coerces a date to `20240131` before dividing, so the query succeeds and
|
|
1785
|
+
* returns bars over an axis where two thirds of every year does not exist.
|
|
1786
|
+
*
|
|
1787
|
+
* Only ROOT-column metadata can answer this, so anything it cannot type — a
|
|
1788
|
+
* relation path, a JSON sub-path, a computed source — passes here and is
|
|
1789
|
+
* caught by {@link toBucketableNumber} once the extent comes back with actual
|
|
1790
|
+
* values. Refusing everything untypeable instead would reject `author.age`,
|
|
1791
|
+
* which `where`, `sort` and `distinct` all accept.
|
|
1792
|
+
*/
|
|
1793
|
+
assertBucketable(target, entity, adapter) {
|
|
1794
|
+
if (typeof target !== 'string')
|
|
1795
|
+
return;
|
|
1796
|
+
const info = adapter.getEntityFields?.(entity)?.find((f) => f.name === target);
|
|
1797
|
+
if (!info || info.type === 'number' || info.type === 'unknown')
|
|
1798
|
+
return;
|
|
1799
|
+
throw new BadRequestException(`Invalid histogram field: "${target}" is a ${info.type} column and histogram buckets are numeric (FLOOR(value / width)). Use \`extent\` for its range, or \`groupByCount\` to group by its values.`);
|
|
1800
|
+
}
|
|
1801
|
+
/**
|
|
1802
|
+
* Coerces one measured extent end to the number the width arithmetic needs,
|
|
1803
|
+
* or `null` for "no row carries a value".
|
|
1804
|
+
*
|
|
1805
|
+
* Not a `typeof value === 'number'` check, in either direction:
|
|
1806
|
+
*
|
|
1807
|
+
* - a DECIMAL column hydrates to a STRING on mysql2 and pg, so the strict
|
|
1808
|
+
* check would refuse the most ordinary histogram there is — a price;
|
|
1809
|
+
* - `Number(new Date())` is a finite epoch, so a bare numeric coercion would
|
|
1810
|
+
* wave a date extent straight through into `FLOOR(ms / width)`. Dates are
|
|
1811
|
+
* therefore tested for FIRST, by identity, not by what they coerce to.
|
|
1812
|
+
*
|
|
1813
|
+
* This is the net under {@link assertBucketable}, which only sees root-column
|
|
1814
|
+
* metadata: a computed source, a relation path or a JSON sub-path is typed by
|
|
1815
|
+
* nothing until its value arrives here.
|
|
1816
|
+
*/
|
|
1817
|
+
toBucketableNumber(value, field) {
|
|
1818
|
+
if (value === null || value === undefined)
|
|
1819
|
+
return null;
|
|
1820
|
+
if (value instanceof Date) {
|
|
1821
|
+
throw new BadRequestException(`Invalid histogram field: "${field}" measures dates and histogram buckets are numeric (FLOOR(value / width)). Use \`extent\` for its range.`);
|
|
1822
|
+
}
|
|
1823
|
+
const numeric = typeof value === 'number' || typeof value === 'bigint' || typeof value === 'string'
|
|
1824
|
+
? Number(value)
|
|
1825
|
+
: Number.NaN;
|
|
1826
|
+
if (!Number.isFinite(numeric)) {
|
|
1827
|
+
throw new BadRequestException(`Invalid histogram field: "${field}" did not measure to a finite number, so it cannot be bucketed.`);
|
|
1828
|
+
}
|
|
1829
|
+
return numeric;
|
|
1830
|
+
}
|
|
1831
|
+
/**
|
|
1832
|
+
* Derives a bucket width from the measured span and the desired bar count,
|
|
1833
|
+
* snapped to the nearest 1/2/5 × 10ⁿ step.
|
|
1834
|
+
*
|
|
1835
|
+
* The raw `span / desired` is the arithmetically correct width and the wrong
|
|
1836
|
+
* answer for a control. Two reasons, both visible to a user:
|
|
1837
|
+
*
|
|
1838
|
+
* - buckets are anchored at multiples of the width (`FLOOR(col / w) * w`,
|
|
1839
|
+
* which the adapter capability defines and which is what lets the grouping
|
|
1840
|
+
* be one expression), so an ugly width means ugly edges: a span of
|
|
1841
|
+
* 499–128000 over 10 gives 12750.1, and axis labels at 12750.1, 25500.2,
|
|
1842
|
+
* 38250.3;
|
|
1843
|
+
* - a raw width changes on every row inserted, so the bars re-partition and
|
|
1844
|
+
* visibly jump whenever the filtered set shifts slightly. A snapped width
|
|
1845
|
+
* holds still across a range of spans, which is the hysteresis a facet
|
|
1846
|
+
* that redraws on every keystroke needs.
|
|
1847
|
+
*
|
|
1848
|
+
* Snapped to the NEAREST step in log space (the √2 / √10 / √50 thresholds),
|
|
1849
|
+
* not upward: rounding up turns a span of 101 over 10 buckets into a width of
|
|
1850
|
+
* 20 and six bars, which is a worse lie about the request than eleven bars.
|
|
1851
|
+
* The count therefore lands within about √2 of `desired` in either direction
|
|
1852
|
+
* — `buckets` is a target, and the returned `bucketWidth` is authoritative.
|
|
1853
|
+
*
|
|
1854
|
+
* `span` is strictly positive here: the `min === max` case never reaches this.
|
|
1855
|
+
*/
|
|
1856
|
+
niceBucketWidth(span, desired) {
|
|
1857
|
+
const raw = span / desired;
|
|
1858
|
+
const magnitude = 10 ** Math.floor(Math.log10(raw));
|
|
1859
|
+
const normalized = raw / magnitude; // [1, 10)
|
|
1860
|
+
const step = normalized >= Math.sqrt(50)
|
|
1861
|
+
? 10
|
|
1862
|
+
: normalized >= Math.sqrt(10)
|
|
1863
|
+
? 5
|
|
1864
|
+
: normalized >= Math.SQRT2
|
|
1865
|
+
? 2
|
|
1866
|
+
: 1;
|
|
1867
|
+
return step * magnitude;
|
|
1868
|
+
}
|
|
1869
|
+
/**
|
|
1870
|
+
* Turns the adapter's sparse `{ value, count }` groups into the contiguous
|
|
1871
|
+
* ascending bucket list a chart draws.
|
|
1872
|
+
*
|
|
1873
|
+
* Three things the raw groups get wrong for this purpose:
|
|
1874
|
+
*
|
|
1875
|
+
* - **Nulls.** A row whose column is null groups under `FLOOR(NULL / w)`,
|
|
1876
|
+
* which is NULL — and `Number(null)` is 0, so passing the groups through
|
|
1877
|
+
* unfiltered plants a phantom bar at zero holding every null row.
|
|
1878
|
+
* - **Order.** `GROUP BY` has no defined output order. A histogram is a
|
|
1879
|
+
* sequence, and bars drawn in the order MySQL happened to hash them are
|
|
1880
|
+
* not a distribution.
|
|
1881
|
+
* - **Gaps.** Empty buckets produce no group at all, so a sparse list renders
|
|
1882
|
+
* as evenly spaced bars that lie about where the data sits. They are
|
|
1883
|
+
* filled with zero-count entries, which is bounded work: the bucket count
|
|
1884
|
+
* is `span / width`, and the width came from a clamped desired count.
|
|
1885
|
+
*
|
|
1886
|
+
* Groups are matched to buckets by INDEX rather than by comparing the
|
|
1887
|
+
* returned `value` to a computed edge: for a width like 0.1 the database's
|
|
1888
|
+
* `FLOOR(x / 0.1) * 0.1` and this code's `i * 0.1` differ in the last bits,
|
|
1889
|
+
* and an equality match would silently drop those buckets to zero.
|
|
1890
|
+
*/
|
|
1891
|
+
assembleBuckets(min, max, width, rows) {
|
|
1892
|
+
const firstIndex = Math.floor(min / width);
|
|
1893
|
+
const size = Math.floor(max / width) - firstIndex + 1;
|
|
1894
|
+
const counts = new Array(size).fill(0);
|
|
1895
|
+
for (const row of rows) {
|
|
1896
|
+
if (row.value === null || row.value === undefined)
|
|
1897
|
+
continue;
|
|
1898
|
+
const start = Number(row.value);
|
|
1899
|
+
if (!Number.isFinite(start))
|
|
1900
|
+
continue;
|
|
1901
|
+
const index = Math.round(start / width) - firstIndex;
|
|
1902
|
+
// Out of range means the grouping expression and this arithmetic disagree
|
|
1903
|
+
// about the field — dropped rather than widening the axis to fit it.
|
|
1904
|
+
if (index < 0 || index >= size)
|
|
1905
|
+
continue;
|
|
1906
|
+
counts[index] = (counts[index] ?? 0) + Number(row.count);
|
|
1907
|
+
}
|
|
1908
|
+
return counts.map((count, i) => {
|
|
1909
|
+
const bucketStart = this.snapEdge((firstIndex + i) * width, width);
|
|
1910
|
+
return { bucketStart, bucketEnd: this.snapEdge(bucketStart + width, width), count };
|
|
1911
|
+
});
|
|
1912
|
+
}
|
|
1913
|
+
/**
|
|
1914
|
+
* Rounds a bucket edge to the decimal precision its width implies. Every edge
|
|
1915
|
+
* is an exact multiple of a 1/2/5 × 10ⁿ width, so this cannot move one onto a
|
|
1916
|
+
* different bucket — it only sheds the binary-float residue that otherwise
|
|
1917
|
+
* labels an axis `0.30000000000000004`. Skipped entirely for widths outside
|
|
1918
|
+
* `toFixed`'s useful range, where rounding would destroy information rather
|
|
1919
|
+
* than tidy it.
|
|
1920
|
+
*/
|
|
1921
|
+
snapEdge(value, width) {
|
|
1922
|
+
const decimals = -Math.floor(Math.log10(width));
|
|
1923
|
+
if (!Number.isFinite(decimals) || decimals < 0 || decimals > 15)
|
|
1924
|
+
return value;
|
|
1925
|
+
return Number(value.toFixed(decimals));
|
|
1926
|
+
}
|
|
1380
1927
|
/**
|
|
1381
1928
|
* Runs a dynamic query with **keyset (cursor) pagination** and executes it,
|
|
1382
1929
|
* returning a stable, non-overlapping page plus opaque forward/backward
|
|
@@ -1395,6 +1942,13 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
1395
1942
|
* Requires an adapter implementing `getResult`, `getPrimaryKey`,
|
|
1396
1943
|
* `applyKeysetPagination` and `applyKeysetOrderAndLimit`. Additive — does not
|
|
1397
1944
|
* change `apply`/`applyDynamic`/`findAndCount`.
|
|
1945
|
+
*
|
|
1946
|
+
* `opts.trustedPageSize` lifts the module-level `maxPageSize` ceiling off
|
|
1947
|
+
* `first`/`last` for this call — same meaning as on {@link findAndCount},
|
|
1948
|
+
* and it belongs here for the same reason: keyset paging is the pagination a
|
|
1949
|
+
* long server-side walk SHOULD use (it is stable under concurrent writes),
|
|
1950
|
+
* so capping it would leave the recommended export path as the one that
|
|
1951
|
+
* cannot opt out.
|
|
1398
1952
|
*/
|
|
1399
1953
|
async findPage(entity, input, opts = {}) {
|
|
1400
1954
|
const adapter = this.resolveAdapter();
|
|
@@ -1414,9 +1968,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
1414
1968
|
const before = after === undefined && typeof paginate.before === 'string' ? paginate.before : undefined;
|
|
1415
1969
|
const backward = before !== undefined;
|
|
1416
1970
|
const cursorStr = after ?? before;
|
|
1417
|
-
const
|
|
1418
|
-
const requested = backward ? Number(paginate.last) : Number(paginate.first);
|
|
1419
|
-
const limit = Math.min(Math.max(1, requested || 25), maxSize);
|
|
1971
|
+
const limit = this.resolvePageSize(backward ? paginate.last : paginate.first, opts.trustedPageSize === true);
|
|
1420
1972
|
// Resolve the effective sort (falling back to defaultSort) and validate it
|
|
1421
1973
|
// against entity metadata, then append the primary key as a tiebreaker.
|
|
1422
1974
|
const parsedSorts = this.remapSortAliases(this.parseSorts(structured.sort), filterableMeta);
|
|
@@ -1502,40 +2054,117 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
1502
2054
|
* Drops `where` column-filter clauses whose field is not a known scalar
|
|
1503
2055
|
* column, relation, or dotted relation path on the entity — so a client
|
|
1504
2056
|
* filter on an absent column (e.g. a base-scope `baseId` on a base-less
|
|
1505
|
-
* table) is
|
|
1506
|
-
*
|
|
2057
|
+
* table) is dropped instead of crashing the ORM. Recurses AND/OR.
|
|
2058
|
+
*
|
|
2059
|
+
* The grammar check (`validateColumnFilters` → `isValidFieldPath`) only
|
|
2060
|
+
* proves the field name is SQL-SAFE, not that it EXISTS: `ghostColumn`
|
|
2061
|
+
* satisfies the pattern, sails through the operator allowlist, and blows up
|
|
2062
|
+
* inside the ORM. That surfaces as a 500 for the consumer, and as a mid-run
|
|
2063
|
+
* failure for a background job — both worse than not applying a constraint
|
|
2064
|
+
* the entity cannot express in the first place.
|
|
2065
|
+
*
|
|
2066
|
+
* **Policy knob: `throwOnInvalid`, not `onUnknownKey`.** Three reasons, in
|
|
2067
|
+
* order of weight: (1) `FilterModuleOptions.throwOnInvalid` is already
|
|
2068
|
+
* DOCUMENTED as covering "unknown `where` columns" — static mode was simply
|
|
2069
|
+
* never wired to it; (2) `applyDynamic` already routes this same function
|
|
2070
|
+
* through `throwOnInvalid`, so choosing the other knob would recreate, in a
|
|
2071
|
+
* new place, the very static-vs-dynamic asymmetry this fix closes; (3)
|
|
2072
|
+
* `throwOnInvalid` is overridable per-`@Filterable`, while `onUnknownKey` is
|
|
2073
|
+
* module-global — and whether a stray `where` column is a client bug or a
|
|
2074
|
+
* tolerated legacy payload is a per-endpoint judgement. `onUnknownKey` keeps
|
|
2075
|
+
* its own scope: keys of the STRUCTURED filter object, which are dispatch
|
|
2076
|
+
* targets (`@FilterFor` / auto-field / relation), not column references.
|
|
2077
|
+
*
|
|
2078
|
+
* Default (`throwOnInvalid: false`) is drop-with-a-warning rather than a
|
|
2079
|
+
* 400: it is the change that stops the crash without turning requests that
|
|
2080
|
+
* work today into errors, and the warning names the field so the drop is
|
|
2081
|
+
* observable rather than silent. Mirrors
|
|
2082
|
+
* {@link pruneBlacklistedColumnFilters}, which made the same call.
|
|
2083
|
+
*
|
|
2084
|
+
* Falls back to accept-all (with a warning) when the adapter exposes no
|
|
2085
|
+
* metadata — the same graceful degradation `resolveAutoFields` uses, so an
|
|
2086
|
+
* adapter that cannot introspect keeps its pre-fix behavior instead of
|
|
2087
|
+
* having every `where` clause dropped.
|
|
1507
2088
|
*/
|
|
1508
2089
|
pruneUnknownColumnFilters(filters, entity, adapter, throwOnInvalid = false) {
|
|
1509
2090
|
const fieldNames = new Set((adapter.getEntityFields?.(entity) ?? []).map((f) => f.name));
|
|
1510
2091
|
const relationNames = new Set((adapter.getEntityRelations?.(entity) ?? []).map((r) => r.name));
|
|
1511
|
-
if (fieldNames.size === 0 && relationNames.size === 0)
|
|
2092
|
+
if (fieldNames.size === 0 && relationNames.size === 0) {
|
|
2093
|
+
this.logger.warn(`where[] column filters on ${entity.name} cannot be validated against entity metadata. The adapter does not implement getEntityFields() or returned null. All where columns will be accepted (legacy behavior). Consider upgrading your adapter.`);
|
|
1512
2094
|
return filters;
|
|
2095
|
+
}
|
|
1513
2096
|
// When the adapter can resolve relation paths, validate the full chain
|
|
1514
2097
|
// (`author.profile.country`) so a bad deep path is dropped instead of
|
|
1515
2098
|
// reaching the ORM as an unknown column. Both scalar leaves and bare
|
|
1516
2099
|
// relations (FK / nested constraints) are filterable. Otherwise fall back
|
|
1517
|
-
// to a single-hop check
|
|
2100
|
+
// to a single-hop check on the path's ROOT segment.
|
|
2101
|
+
//
|
|
2102
|
+
// The fallback accepts a dotted path rooted at a scalar column too, not
|
|
2103
|
+
// just at a relation: `metadata.tier` is a JSON sub-path, and an adapter
|
|
2104
|
+
// without `resolveFieldPath` cannot tell a JSON column from any other
|
|
2105
|
+
// scalar. Refusing it would turn "we can't check this" into "this is
|
|
2106
|
+
// wrong" and break JSON filtering on exactly the adapters least able to
|
|
2107
|
+
// defend themselves.
|
|
2108
|
+
//
|
|
2109
|
+
// `[]` is stripped first: it is the JSON-array traversal marker
|
|
2110
|
+
// (`problems.checks[].field`, see `parseFieldPath`), never part of a
|
|
2111
|
+
// column name, so leaving it in would fail every lookup it appears in.
|
|
2112
|
+
const rootOf = (field) => {
|
|
2113
|
+
const dot = field.indexOf('.');
|
|
2114
|
+
const root = dot > 0 ? field.slice(0, dot) : field;
|
|
2115
|
+
return root.endsWith('[]') ? root.slice(0, -2) : root;
|
|
2116
|
+
};
|
|
1518
2117
|
const isKnown = adapter.resolveFieldPath
|
|
1519
|
-
? (field) => adapter.resolveFieldPath(entity, field) !== null
|
|
2118
|
+
? (field) => adapter.resolveFieldPath(entity, field.replaceAll('[]', '')) !== null
|
|
1520
2119
|
: (field) => {
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
const dot = field.indexOf('.');
|
|
1524
|
-
return dot > 0 && relationNames.has(field.slice(0, dot));
|
|
2120
|
+
const root = rootOf(field);
|
|
2121
|
+
return fieldNames.has(root) || relationNames.has(root);
|
|
1525
2122
|
};
|
|
2123
|
+
const warned = new Set();
|
|
1526
2124
|
const prune = (clauses) => clauses
|
|
1527
|
-
.
|
|
1528
|
-
const
|
|
1529
|
-
|
|
2125
|
+
.map((clause) => {
|
|
2126
|
+
const AND = clause.AND ? prune(clause.AND) : undefined;
|
|
2127
|
+
const OR = clause.OR ? prune(clause.OR) : undefined;
|
|
2128
|
+
const withGroups = {
|
|
2129
|
+
...clause,
|
|
2130
|
+
...(AND ? { AND } : {}),
|
|
2131
|
+
...(OR ? { OR } : {}),
|
|
2132
|
+
};
|
|
2133
|
+
// A MALFORMED path (`visits.$notafn`, anything outside the SQL-safe
|
|
2134
|
+
// grammar) is deliberately passed through untouched, not dropped:
|
|
2135
|
+
// `validateColumnFilters` downstream rejects it loudly, and that
|
|
2136
|
+
// rejection is a safety guarantee. Quietly swallowing SQL-unsafe
|
|
2137
|
+
// input here would trade a hard error for silence — this prune
|
|
2138
|
+
// answers "does this column exist", never "is this path legal".
|
|
2139
|
+
if (!clause.field || !isValidFieldPath(clause.field) || isKnown(clause.field)) {
|
|
2140
|
+
return withGroups;
|
|
2141
|
+
}
|
|
2142
|
+
if (throwOnInvalid) {
|
|
1530
2143
|
throw new BadRequestException(`Unknown filter column: "${clause.field}".`);
|
|
1531
2144
|
}
|
|
1532
|
-
|
|
2145
|
+
if (!warned.has(clause.field)) {
|
|
2146
|
+
warned.add(clause.field);
|
|
2147
|
+
this.logger.warn(`Column filter (where) on unknown column "${clause.field}" ignored — it is not a field or relation of ${entity.name}.`);
|
|
2148
|
+
}
|
|
2149
|
+
// The unknown LEAF goes; a group the clause carried stays. Dropping
|
|
2150
|
+
// the clause wholesale would take its children's constraints with
|
|
2151
|
+
// it, WIDENING the query — returning rows the client filtered out is
|
|
2152
|
+
// a worse failure than the 500 this replaces.
|
|
2153
|
+
if (AND?.length || OR?.length) {
|
|
2154
|
+
return {
|
|
2155
|
+
...(AND?.length ? { AND } : {}),
|
|
2156
|
+
...(OR?.length ? { OR } : {}),
|
|
2157
|
+
};
|
|
2158
|
+
}
|
|
2159
|
+
return null;
|
|
1533
2160
|
})
|
|
1534
|
-
.
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
2161
|
+
.filter((clause) => clause !== null)
|
|
2162
|
+
// Collapse group nodes (no field of their own) emptied by pruning —
|
|
2163
|
+
// an empty `{ OR: [] }` reaching the adapter is a condition with no
|
|
2164
|
+
// operands, which each ORM renders differently and none usefully.
|
|
2165
|
+
.filter((clause) => Boolean(clause.field) ||
|
|
2166
|
+
Boolean(clause.AND && clause.AND.length > 0) ||
|
|
2167
|
+
Boolean(clause.OR && clause.OR.length > 0));
|
|
1539
2168
|
return prune(filters);
|
|
1540
2169
|
}
|
|
1541
2170
|
/**
|
|
@@ -1820,6 +2449,32 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
1820
2449
|
}
|
|
1821
2450
|
return this.options.defaultSort;
|
|
1822
2451
|
}
|
|
2452
|
+
/**
|
|
2453
|
+
* Resolves the effective `distinctOrder`: the ROUTE's own
|
|
2454
|
+
* `@ApplyFilter({ distinctOrder })` wins over the filter class's
|
|
2455
|
+
* `@Filterable({ distinctOrder })`, and absent both it is off.
|
|
2456
|
+
*
|
|
2457
|
+
* There is deliberately no module-level knob. Whether a `SELECT DISTINCT`
|
|
2458
|
+
* wants an ORDER BY is a property of the endpoint reading it — one filter
|
|
2459
|
+
* class typically serves a rows route that orders itself and a distinct route
|
|
2460
|
+
* that does not — so an app-wide switch would be answering a question at the
|
|
2461
|
+
* wrong altitude, and silently, for queries whose cost it cannot see.
|
|
2462
|
+
*
|
|
2463
|
+
* Off unless asked for, for the same reason: ordering a projection the caller
|
|
2464
|
+
* never asked to order is a clause this library would be inventing, and on a
|
|
2465
|
+
* large distinct with no index on the projected column that clause is a
|
|
2466
|
+
* filesort nobody signed up for. See {@link FilterableOptions.distinctOrder}.
|
|
2467
|
+
*/
|
|
2468
|
+
resolveDistinctOrder(FilterClass, perCall) {
|
|
2469
|
+
if (perCall !== undefined)
|
|
2470
|
+
return perCall;
|
|
2471
|
+
if (FilterClass) {
|
|
2472
|
+
const meta = getFilterableMetadata(FilterClass);
|
|
2473
|
+
if (meta?.distinctOrder !== undefined)
|
|
2474
|
+
return meta.distinctOrder;
|
|
2475
|
+
}
|
|
2476
|
+
return false;
|
|
2477
|
+
}
|
|
1823
2478
|
handleUnknownKey(key) {
|
|
1824
2479
|
const policy = this.options.onUnknownKey ?? 'ignore';
|
|
1825
2480
|
if (policy === 'throw')
|
|
@@ -1828,12 +2483,41 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
1828
2483
|
this.logger.warn(`Unknown filter key: "${key}"`);
|
|
1829
2484
|
}
|
|
1830
2485
|
}
|
|
2486
|
+
/**
|
|
2487
|
+
* Narrows an already-structured sort element. The `direction` check is the
|
|
2488
|
+
* strict part: an unrecognised direction is dropped rather than coerced,
|
|
2489
|
+
* because guessing `asc` for a client that asked for something else silently
|
|
2490
|
+
* returns the wrong page of a paginated result.
|
|
2491
|
+
*/
|
|
2492
|
+
isSortItem(item) {
|
|
2493
|
+
if (item == null || typeof item !== 'object')
|
|
2494
|
+
return false;
|
|
2495
|
+
if (!('field' in item) || !('direction' in item))
|
|
2496
|
+
return false;
|
|
2497
|
+
return (typeof item.field === 'string' && (item.direction === 'asc' || item.direction === 'desc'));
|
|
2498
|
+
}
|
|
2499
|
+
/**
|
|
2500
|
+
* Parses one `"-field"` / `"field"` token into a {@link SortItem}, or
|
|
2501
|
+
* `undefined` when there is no field left after trimming (`""`, `" "`,
|
|
2502
|
+
* a bare `"-"`). Shared by both string shapes so a token means the same
|
|
2503
|
+
* thing whether it arrived inside `"a,-b"` or as `["a", "-b"]`.
|
|
2504
|
+
*/
|
|
2505
|
+
parseSortToken(token) {
|
|
2506
|
+
const trimmed = token.trim();
|
|
2507
|
+
const desc = trimmed.startsWith('-');
|
|
2508
|
+
const field = desc ? trimmed.substring(1) : trimmed;
|
|
2509
|
+
if (field.length === 0)
|
|
2510
|
+
return undefined;
|
|
2511
|
+
return { field, direction: desc ? 'desc' : 'asc' };
|
|
2512
|
+
}
|
|
1831
2513
|
/**
|
|
1832
2514
|
* Parses raw sort input into an array of SortItem objects.
|
|
1833
2515
|
*
|
|
1834
2516
|
* Supports:
|
|
1835
2517
|
* - String: `"-createdAt,name"` → `[{ field: 'createdAt', direction: 'desc' }, { field: 'name', direction: 'asc' }]`
|
|
2518
|
+
* - Array of tokens: `["-createdAt", "name"]` — same token rules as the string form
|
|
1836
2519
|
* - Array of SortItem objects: passed through as-is
|
|
2520
|
+
* - Arrays mixing the two: each element is read by its own type
|
|
1837
2521
|
* - Falsy values: returns empty array
|
|
1838
2522
|
*
|
|
1839
2523
|
* Minus prefix = desc, no prefix = asc (JSON:API convention).
|
|
@@ -1844,21 +2528,22 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
1844
2528
|
if (typeof raw === 'string') {
|
|
1845
2529
|
return raw
|
|
1846
2530
|
.split(',')
|
|
1847
|
-
.map((
|
|
1848
|
-
.filter(
|
|
1849
|
-
.map((token) => {
|
|
1850
|
-
if (token.startsWith('-')) {
|
|
1851
|
-
return { field: token.substring(1), direction: 'desc' };
|
|
1852
|
-
}
|
|
1853
|
-
return { field: token, direction: 'asc' };
|
|
1854
|
-
})
|
|
1855
|
-
.filter((s) => s.field.length > 0);
|
|
2531
|
+
.map((token) => this.parseSortToken(token))
|
|
2532
|
+
.filter((s) => s !== undefined);
|
|
1856
2533
|
}
|
|
1857
2534
|
if (Array.isArray(raw)) {
|
|
1858
|
-
return raw.
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
2535
|
+
return raw.flatMap((item) => {
|
|
2536
|
+
// A `string[]` is what a JSON:API-ish array and a legacy
|
|
2537
|
+
// `orderBy: string[]` both look like. It used to fail the
|
|
2538
|
+
// `typeof item === 'object'` test and get filtered out, so the
|
|
2539
|
+
// ORDER BY disappeared with no error, no warning and no 400 — the
|
|
2540
|
+
// grid just silently stopped sorting. Read it as tokens instead.
|
|
2541
|
+
if (typeof item === 'string') {
|
|
2542
|
+
const parsed = this.parseSortToken(item);
|
|
2543
|
+
return parsed ? [parsed] : [];
|
|
2544
|
+
}
|
|
2545
|
+
return this.isSortItem(item) ? [item] : [];
|
|
2546
|
+
});
|
|
1862
2547
|
}
|
|
1863
2548
|
return [];
|
|
1864
2549
|
}
|
|
@@ -2068,16 +2753,18 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
2068
2753
|
/**
|
|
2069
2754
|
* Applies offset or cursor pagination to the query builder.
|
|
2070
2755
|
* Cursor pagination logs a warning (not yet implemented).
|
|
2756
|
+
*
|
|
2757
|
+
* `trusted` bypasses the `maxPageSize` ceiling — see
|
|
2758
|
+
* {@link FilterRunner.resolvePageSize}.
|
|
2071
2759
|
*/
|
|
2072
|
-
applyPagination(qb, rawPaginate, adapter) {
|
|
2760
|
+
applyPagination(qb, rawPaginate, adapter, trusted = false) {
|
|
2073
2761
|
if (!rawPaginate || typeof rawPaginate !== 'object')
|
|
2074
2762
|
return;
|
|
2075
2763
|
const p = rawPaginate;
|
|
2076
2764
|
if ('page' in p && 'size' in p) {
|
|
2077
2765
|
if (!adapter?.applyOffsetPagination)
|
|
2078
2766
|
return;
|
|
2079
|
-
const
|
|
2080
|
-
const size = Math.min(Math.max(1, Number(p.size) || 25), maxSize);
|
|
2767
|
+
const size = this.resolvePageSize(p.size, trusted);
|
|
2081
2768
|
const page = Math.max(0, Number(p.page) || 0);
|
|
2082
2769
|
adapter.applyOffsetPagination(qb, page, size);
|
|
2083
2770
|
}
|
|
@@ -2085,6 +2772,33 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
|
|
|
2085
2772
|
this.logger.warn('Cursor pagination is not yet implemented. Use offset pagination.');
|
|
2086
2773
|
}
|
|
2087
2774
|
}
|
|
2775
|
+
/**
|
|
2776
|
+
* Resolves the effective page size from a requested one.
|
|
2777
|
+
*
|
|
2778
|
+
* `maxPageSize` is a MODULE-level ceiling, so one number has to answer two
|
|
2779
|
+
* different questions, and the right answers disagree. For a size that came
|
|
2780
|
+
* off an HTTP request the ceiling is the whole point: it is what stops a
|
|
2781
|
+
* client asking for a million rows. For a size that came from the server's
|
|
2782
|
+
* own code — an export writing a CSV, a scheduled report, a batch job — the
|
|
2783
|
+
* ceiling is not protection, it is a silent wrong answer: the runner is
|
|
2784
|
+
* handed `size: 10_000`, returns 100 rows, and the export loop reads
|
|
2785
|
+
* `100 < 10_000` as "table exhausted" and writes a truncated file with no
|
|
2786
|
+
* error anywhere.
|
|
2787
|
+
*
|
|
2788
|
+
* The runner cannot tell the two apart by looking at the number, so the CALL
|
|
2789
|
+
* SITE says which it is. `trusted` means "this size is server-authored, not
|
|
2790
|
+
* client input" — the one fact the caller knows and the runner never can.
|
|
2791
|
+
*
|
|
2792
|
+
* The minimum of 1 still applies either way: that is not a safety cap but a
|
|
2793
|
+
* correctness one (a `LIMIT 0` or a negative limit is not a page), and
|
|
2794
|
+
* trusting the caller's intent does not make `size: -5` mean anything.
|
|
2795
|
+
*/
|
|
2796
|
+
resolvePageSize(rawSize, trusted) {
|
|
2797
|
+
const requested = Math.max(1, Number(rawSize) || 25);
|
|
2798
|
+
if (trusted)
|
|
2799
|
+
return requested;
|
|
2800
|
+
return Math.min(requested, this.options.maxPageSize ?? 100);
|
|
2801
|
+
}
|
|
2088
2802
|
};
|
|
2089
2803
|
FilterRunner = FilterRunner_1 = __decorate([
|
|
2090
2804
|
Injectable(),
|