@dudousxd/nestjs-filter 1.23.0 → 1.25.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
@@ -32,6 +32,21 @@ import { normalizeOperator, validateColumnFilters } from './operators/validate-c
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 false;
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
- let applied = false;
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
- applied = true;
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 = true;
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
- return applied;
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
@@ -679,7 +703,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
679
703
  // A computed alias in the distinct list routes to applyComputedDistinct
680
704
  // (the computed registry bypasses column validation, like computed
681
705
  // sorts do); plain columns still batch through applyDistinct first.
682
- const distinctApplied = this.applyProjection(qb, rawDistinct, {
706
+ const distinctFields = this.applyProjection(qb, rawDistinct, {
683
707
  entity: filterableMeta?.entity,
684
708
  adapter,
685
709
  allowed: FilterClass.distinct,
@@ -716,7 +740,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
716
740
  // Skipped when a distinct projection was applied: distinct replaces
717
741
  // entity-row output, and a computed value participates in a distinct
718
742
  // projection only by being listed in `distinct` explicitly.
719
- if (!distinctApplied) {
743
+ if (distinctFields.length === 0) {
720
744
  this.applyProjectedComputed(qb, computedRegistry, adapter);
721
745
  }
722
746
  // Apply sort — falling back to defaultSort when the client gave none.
@@ -726,9 +750,28 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
726
750
  const sorts = parsedSorts.length > 0
727
751
  ? parsedSorts
728
752
  : this.parseSorts(this.resolveDefaultSort(FilterClass));
729
- if (sorts.length > 0 && adapter?.applySort) {
753
+ // Opt-in last fallback (`distinctOrder`), for a DISTINCT projection
754
+ // nothing else ordered: sort ascending by what was projected. Off
755
+ // unless asked for — see `FilterableOptions.distinctOrder` for why the
756
+ // default is not to invent an ORDER BY.
757
+ //
758
+ // Derived from `distinctFields` (what the projection KEPT) rather than
759
+ // from the request, so every term is in the select list and the query
760
+ // stays legal under DISTINCT.
761
+ const effectiveSorts = sorts.length > 0
762
+ ? sorts
763
+ : this.resolveDistinctOrder(FilterClass, internal.distinctOrder)
764
+ ? distinctFields.map((field) => ({ field, direction: 'asc' }))
765
+ : [];
766
+ if (effectiveSorts.length > 0 && adapter?.applySort) {
730
767
  const allowedSorts = FilterClass.sort;
731
- this.applySortsWithComputed(qb, sorts, allowedSorts, adapter, filterableMeta?.entity, this.resolveThrowOnInvalid(FilterClass), computedRegistry, autoFieldSet);
768
+ this.applySortsWithComputed(qb, effectiveSorts, allowedSorts, adapter, filterableMeta?.entity,
769
+ // NEVER throw for the derived ordering, whatever `throwOnInvalid`
770
+ // says: a `sort` the CLIENT sent is its request to get wrong, but
771
+ // this one it never asked for. A narrowed `static sort` allowlist
772
+ // that excludes a projected column must drop the ORDER BY, not turn
773
+ // an otherwise valid distinct request into a 400.
774
+ sorts.length > 0 ? this.resolveThrowOnInvalid(FilterClass) : false, computedRegistry, autoFieldSet);
732
775
  }
733
776
  // Apply pagination
734
777
  this.applyPagination(qb, rawPaginate, adapter);
@@ -812,6 +855,8 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
812
855
  distinct: undefined,
813
856
  select: undefined,
814
857
  groupByCount: undefined,
858
+ extent: undefined,
859
+ histogram: undefined,
815
860
  paginate: undefined,
816
861
  };
817
862
  }
@@ -829,6 +874,8 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
829
874
  'distinct',
830
875
  'select',
831
876
  'groupByCount',
877
+ 'extent',
878
+ 'histogram',
832
879
  'paginate',
833
880
  ];
834
881
  if (STRUCTURED_KEYS.some((k) => k in inputObj)) {
@@ -840,6 +887,8 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
840
887
  distinct: inputObj.distinct ?? undefined,
841
888
  select: inputObj.select ?? undefined,
842
889
  groupByCount: inputObj.groupByCount ?? undefined,
890
+ extent: inputObj.extent ?? undefined,
891
+ histogram: inputObj.histogram ?? undefined,
843
892
  paginate: inputObj.paginate ?? undefined,
844
893
  };
845
894
  }
@@ -852,6 +901,8 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
852
901
  distinct: undefined,
853
902
  select: undefined,
854
903
  groupByCount: undefined,
904
+ extent: undefined,
905
+ histogram: undefined,
855
906
  paginate: undefined,
856
907
  };
857
908
  }
@@ -1158,7 +1209,7 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1158
1209
  });
1159
1210
  }
1160
1211
  // Distinct projection (SELECT DISTINCT) — validate against entity metadata
1161
- this.applyProjection(qb, rawDistinct, {
1212
+ const distinctFields = this.applyProjection(qb, rawDistinct, {
1162
1213
  entity,
1163
1214
  adapter,
1164
1215
  allowed: undefined,
@@ -1182,8 +1233,19 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1182
1233
  // Fall back to defaultSort when the client gave none.
1183
1234
  const parsedSorts = this.remapSortAliases(this.parseSorts(rawSort), filterableMeta);
1184
1235
  const sorts = parsedSorts.length > 0 ? parsedSorts : this.parseSorts(this.resolveDefaultSort());
1185
- if (sorts.length > 0 && adapter?.applySort) {
1186
- const validSorts = this.validateSorts(sorts, undefined, adapter, entity, throwOnInvalid);
1236
+ // Same distinct-ordering fallback `apply()` has, for the same reason —
1237
+ // dynamic mode's DISTINCT is no more ordered than a filter class's. Here
1238
+ // the caller IS the call site (there is no route decorator and no filter
1239
+ // class), so the per-call flag is the only way in.
1240
+ const effectiveSorts = sorts.length > 0
1241
+ ? sorts
1242
+ : internal.distinctOrder
1243
+ ? distinctFields.map((field) => ({ field, direction: 'asc' }))
1244
+ : [];
1245
+ if (effectiveSorts.length > 0 && adapter?.applySort) {
1246
+ // `throwOnInvalid` only for a sort the CLIENT sent — the derived one
1247
+ // drops instead of failing a request that never asked for it.
1248
+ const validSorts = this.validateSorts(effectiveSorts, undefined, adapter, entity, sorts.length > 0 ? throwOnInvalid : false);
1187
1249
  if (validSorts.length > 0) {
1188
1250
  adapter.applySort(qb, validSorts);
1189
1251
  }
@@ -1377,6 +1439,473 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1377
1439
  : undefined;
1378
1440
  return { field: obj.field, ...(bucket !== undefined && { bucket }) };
1379
1441
  }
1442
+ /**
1443
+ * **Field extent**: the `MIN`/`MAX` of the requested field(s) over the rows
1444
+ * the active `where`/`search` select — what a range control (numeric slider,
1445
+ * date-range calendar) needs before it can place its endpoints. Reads the
1446
+ * `extent` structured key (`{ extent: ['price', 'createdAt'] }`, or the
1447
+ * comma-separated string a GET route carries, parsed exactly like `distinct`).
1448
+ *
1449
+ * Unlike {@link groupByCount} this is NOT terminal — it measures the same
1450
+ * filtered set the rows come from, so a route answers with rows AND extent
1451
+ * from two builders. Sort/pagination/distinct/select are not part of the
1452
+ * question and are not applied (`skipSortAndPagination`).
1453
+ *
1454
+ * **Why this lives in the runner and not in the route.** Reading
1455
+ * `@Body('extent')` and handing it straight to `adapter.fieldExtent` bypasses
1456
+ * the filter class's field governance: `static distinct` narrowing, the
1457
+ * entity-metadata check that rejects a bare relation or an unknown
1458
+ * identifier, and the alias remapping every other key gets. That is not an
1459
+ * injection hole — the adapter resolves names through ORM metadata — but it
1460
+ * IS a surface leak: a caller could measure a column the filter class
1461
+ * deliberately does not expose. Routing every requested field through
1462
+ * {@link validateDistinct} here closes it, and it is also the only place
1463
+ * that can tell a computed alias from a typo (see below).
1464
+ *
1465
+ * **Allowlist.** The same one `distinct` uses: the filter class's static
1466
+ * `distinct` list when `opts.filterClass` declares one, else the entity's
1467
+ * columns via adapter metadata. `distinct` and `extent` answer the same
1468
+ * question about a column — what values can this control offer — so a class
1469
+ * that has already narrowed which columns a control may read must narrow
1470
+ * this too, or `extent` becomes the way around that narrowing.
1471
+ *
1472
+ * **Disallowed/unknown fields are DROPPED**, and the request still answers
1473
+ * for the fields that survived — matching `distinct` (which drops an invalid
1474
+ * projected field rather than failing the query) rather than `groupByCount`
1475
+ * (which always rejects, because there the field IS the whole query). Under
1476
+ * the ambient `throwOnInvalid` policy the drop becomes a
1477
+ * `BadRequestException`, again as `distinct`. A dropped field is simply
1478
+ * absent from the result, which the `fieldExtent` contract already defines as
1479
+ * "not measured" — the caller cannot tell it apart from a field the adapter
1480
+ * could not resolve, and does not need to.
1481
+ *
1482
+ * **Computed members** route as `{ alias, source }` rather than a bare name,
1483
+ * mirroring {@link groupByCount}'s grouping field: the adapter measures the
1484
+ * dev-provided expression instead of resolving a column that does not exist.
1485
+ * The registry comes from `opts.filterClass` when given, else from
1486
+ * `@Filterable` metadata on the entity itself. Computed aliases bypass column
1487
+ * validation — dev-declared, never client input — exactly like computed
1488
+ * sort/distinct.
1489
+ *
1490
+ * Requires an adapter implementing the optional `fieldExtent` method; when
1491
+ * absent, a clear error is thrown rather than a silent empty answer, which a
1492
+ * range control would render as a collapsed (0, 0) span.
1493
+ *
1494
+ * @returns One entry per measured field keyed by field name (or computed
1495
+ * alias). `{}` when the request named no extent fields at all.
1496
+ */
1497
+ async fieldExtent(entity, input, opts = {}) {
1498
+ const structured = this.extractStructuredInput(input);
1499
+ // Same parser as `distinct`: the client sends an array on a POST body and a
1500
+ // comma-joined string on a GET query, and both mean the same list.
1501
+ const requested = this.parseDistinct(structured.extent);
1502
+ // No `extent` key is not an error — a route can call this unconditionally
1503
+ // alongside its rows read and get nothing back when nobody asked.
1504
+ if (requested.length === 0)
1505
+ return {};
1506
+ const adapter = this.resolveAdapter();
1507
+ if (!adapter?.fieldExtent) {
1508
+ throw new Error('extent is not supported by the active adapter (it does not implement fieldExtent()).');
1509
+ }
1510
+ // Aliases resolve against the filter class when one is given (its `aliases`
1511
+ // are what the client was told to use), else against entity-level metadata
1512
+ // — the same fallback dynamic mode uses everywhere else.
1513
+ const aliasMeta = getFilterableMetadata(opts.filterClass ?? entity);
1514
+ const fields = this.remapFieldAliases(requested, aliasMeta);
1515
+ // Registry source mirrors groupByCount's: the DI-resolved filter class when
1516
+ // given (so `@Computed` methods bind), else `@Filterable` declared on the
1517
+ // entity itself, which is all dynamic mode has.
1518
+ const entityProto = Object.create(entity.prototype);
1519
+ const computedRegistry = opts.filterClass
1520
+ ? buildComputedRegistry(opts.filterClass, await this.resolveFilter(opts.filterClass))
1521
+ : buildComputedRegistry(entity, entityProto);
1522
+ const allowlist = this.resolveDistinctAllowlist(opts.filterClass);
1523
+ const throwOnInvalid = this.resolveThrowOnInvalid(opts.filterClass);
1524
+ const targets = [];
1525
+ for (const field of fields) {
1526
+ const target = this.resolveMeasurableField(field, entity, adapter, allowlist, computedRegistry);
1527
+ if (!target) {
1528
+ // The rejection message names `extent` instead of borrowing distinct's
1529
+ // — the policy (drop, or throw when configured to) is still distinct's,
1530
+ // just worded for the key the client actually sent.
1531
+ if (throwOnInvalid) {
1532
+ throw new BadRequestException(`Invalid extent field: "${field}".`);
1533
+ }
1534
+ continue;
1535
+ }
1536
+ targets.push(target);
1537
+ }
1538
+ // Everything was dropped — the adapter is not called at all. An empty
1539
+ // `fields` list would otherwise be a `SELECT` with no aggregates.
1540
+ if (targets.length === 0)
1541
+ return {};
1542
+ const qb = opts.qb ?? adapter.createQueryBuilder(entity);
1543
+ await this.applyDynamic(entity, { filter: structured.filter, search: structured.search }, qb, opts.context, { skipSortAndPagination: true, native: true });
1544
+ return adapter.fieldExtent(qb, targets, entity);
1545
+ }
1546
+ /**
1547
+ * Resolves ONE requested field to the shape an adapter measurement takes: the
1548
+ * `{ alias, source }` pair of a computed member, or the validated column
1549
+ * name. `null` when it is neither.
1550
+ *
1551
+ * Shared by {@link fieldExtent} and {@link fieldHistogram} so both obey the
1552
+ * same allowlist, and so the one judgement nothing outside the runner can
1553
+ * make — computed alias, or typo? both are strings no column matches — is
1554
+ * made once. What the two callers differ on is only what `null` MEANS (a
1555
+ * dropped field there, a rejected request here), which is why that decision
1556
+ * stays with them.
1557
+ *
1558
+ * Validation runs with `throwOnInvalid: false` unconditionally: the caller
1559
+ * words its own rejection for the key the client actually sent, rather than
1560
+ * surfacing `Invalid distinct field` for a request that never said `distinct`.
1561
+ */
1562
+ resolveMeasurableField(field, entity, adapter, allowlist, computedRegistry) {
1563
+ const computedEntry = computedRegistry.get(field);
1564
+ if (computedEntry)
1565
+ return { alias: field, source: computedEntry.source };
1566
+ const validated = this.validateDistinct([field], allowlist, adapter, entity, false);
1567
+ return validated[0] ?? null;
1568
+ }
1569
+ /**
1570
+ * A fresh builder carrying the request's WHERE/search and nothing else — the
1571
+ * scope every measurement in this file asks its question over.
1572
+ *
1573
+ * `skipSortAndPagination` is the load-bearing part: an aggregate over a
1574
+ * LIMITed builder answers for a page and looks exactly like an answer for the
1575
+ * set, and an ORDER BY on a column an aggregate SELECT no longer projects is
1576
+ * MySQL error 3065.
1577
+ */
1578
+ async buildFilteredQb(entity, structured, adapter, context) {
1579
+ const qb = adapter.createQueryBuilder(entity);
1580
+ await this.applyDynamic(entity, { filter: structured.filter, search: structured.search }, qb, context, { skipSortAndPagination: true, native: true });
1581
+ return qb;
1582
+ }
1583
+ /**
1584
+ * The static `distinct` allowlist declared on a filter class, if any — the
1585
+ * one {@link applyProjection} gates the DISTINCT projection with, reused by
1586
+ * {@link fieldExtent} so both reads of a column obey the same narrowing.
1587
+ *
1588
+ * Read defensively rather than through a cast: `static distinct` is a plain
1589
+ * class property no type checks, so a class can carry anything under that
1590
+ * name. A non-array (or a list holding non-strings) yields `undefined`/the
1591
+ * string entries, which degrades to entity-metadata validation instead of
1592
+ * silently comparing field names against garbage and refusing everything.
1593
+ */
1594
+ resolveDistinctAllowlist(FilterClass) {
1595
+ if (!FilterClass)
1596
+ return undefined;
1597
+ const declared = Reflect.get(FilterClass, 'distinct');
1598
+ if (!Array.isArray(declared))
1599
+ return undefined;
1600
+ return declared.filter((entry) => typeof entry === 'string');
1601
+ }
1602
+ /**
1603
+ * **Field histogram**: one numeric field's extent AND its bucketed
1604
+ * distribution over the same filtered set — the two halves of a faceted range
1605
+ * control, from one request. Reads the `histogram` structured key
1606
+ * (`{ histogram: { field: 'price', buckets: 20 } }`).
1607
+ *
1608
+ * **Why this is a method and not two calls in a route.** The halves already
1609
+ * exist — {@link fieldExtent} places a slider's endpoints,
1610
+ * {@link FilterAdapter.groupByCount}'s bucketed variant draws the
1611
+ * distribution behind them — but they are circular for the caller: the
1612
+ * bucketed variant needs a WIDTH, and a width that is not derived from the
1613
+ * data is either arbitrary (a hardcoded 1000 that yields two bars on one
1614
+ * filter and four hundred on the next) or requires the extent the caller is
1615
+ * asking for in the same breath. Nobody can break that cycle from outside:
1616
+ * you must measure, then divide. So the runner measures, then divides.
1617
+ *
1618
+ * **Two round trips, and it cannot be one.** The width is a function of the
1619
+ * first query's OUTPUT, so the second query's text does not exist until the
1620
+ * first has returned. Folding them into one statement means either a
1621
+ * correlated `(SELECT MAX(col)) - (SELECT MIN(col))` inside the bucket
1622
+ * expression — the same scan twice, once per row-group, to avoid a round trip
1623
+ * — or window functions the adapter contract does not have. Two plain
1624
+ * aggregate queries over an indexable column is the cheaper shape, and it
1625
+ * keeps this a composition of capabilities adapters already implement.
1626
+ *
1627
+ * **Not on the adapter contract, deliberately.** Every optional method added
1628
+ * to `FilterAdapter` is a cost each adapter author pays forever, and this one
1629
+ * would buy nothing: it is arithmetic between two existing calls, identical
1630
+ * for every ORM. An adapter implementing `fieldExtent` and `groupByCount`
1631
+ * gets this for free, and one implementing neither is told which is missing.
1632
+ *
1633
+ * **No `opts.qb`**, unlike its neighbours. Two passes need two builders — the
1634
+ * first is consumed by an aggregate SELECT — and a single caller-supplied
1635
+ * builder can only serve one of them. Silently creating a fresh builder for
1636
+ * the second pass would drop whatever pre-scoping the caller put on theirs,
1637
+ * so the distribution would describe a WIDER set than the extent: a chart
1638
+ * with bars outside its own axis, and nothing to make it obvious.
1639
+ *
1640
+ * Field governance is {@link fieldExtent}'s, through the same
1641
+ * {@link resolveMeasurableField}: alias remapping, the filter class's static
1642
+ * `distinct` allowlist (else entity metadata), and computed members routed as
1643
+ * `{ alias, source }`. The one divergence is what an invalid field means —
1644
+ * rejected here, as in {@link groupByCount}, because the field IS the query
1645
+ * and there is no partial answer to fall back to.
1646
+ *
1647
+ * **Dates are refused, not bucketed.** `fieldExtent` supports DATE columns on
1648
+ * purpose, but bucketing is `FLOOR(value / width)`, and a date divided by a
1649
+ * number is nonsense that no database announces: MySQL coerces the column to
1650
+ * `20240131` and buckets THAT, which produces plausible-looking bars over an
1651
+ * axis that skips two thirds of every year. See {@link assertBucketable}.
1652
+ *
1653
+ * @returns `{ min, max, bucketWidth, buckets }` — null ends and an empty
1654
+ * bucket list when no row in scope carries a value.
1655
+ */
1656
+ async fieldHistogram(entity, input, opts = {}) {
1657
+ const structured = this.extractStructuredInput(input);
1658
+ const spec = this.parseHistogram(structured.histogram);
1659
+ if (!spec) {
1660
+ throw new BadRequestException('histogram requires a `{ field }` specification (with an optional positive `buckets` count).');
1661
+ }
1662
+ const adapter = this.resolveAdapter();
1663
+ // Named separately: an adapter can plausibly have one and not the other,
1664
+ // and "histogram is unsupported" would send its author looking for a method
1665
+ // by that name, which does not and will not exist.
1666
+ if (!adapter?.fieldExtent) {
1667
+ throw new Error('histogram is not supported by the active adapter (it does not implement fieldExtent()).');
1668
+ }
1669
+ if (!adapter.groupByCount) {
1670
+ throw new Error('histogram is not supported by the active adapter (it does not implement groupByCount()).');
1671
+ }
1672
+ // Aliases against the filter class when one is given, else entity-level
1673
+ // metadata — the fallback dynamic mode uses everywhere else.
1674
+ const aliasMeta = getFilterableMetadata(opts.filterClass ?? entity);
1675
+ const [remapped] = this.remapFieldAliases([spec.field], aliasMeta);
1676
+ const field = remapped ?? spec.field;
1677
+ const entityProto = Object.create(entity.prototype);
1678
+ const computedRegistry = opts.filterClass
1679
+ ? buildComputedRegistry(opts.filterClass, await this.resolveFilter(opts.filterClass))
1680
+ : buildComputedRegistry(entity, entityProto);
1681
+ const target = this.resolveMeasurableField(field, entity, adapter, this.resolveDistinctAllowlist(opts.filterClass), computedRegistry);
1682
+ // Rejected regardless of the ambient `throwOnInvalid`, exactly as
1683
+ // `groupByCount` rejects its grouping field: dropping the only field there
1684
+ // is would leave an empty histogram that reads as "no matching rows".
1685
+ if (!target) {
1686
+ throw new BadRequestException(`Invalid histogram field: "${spec.field}".`);
1687
+ }
1688
+ this.assertBucketable(target, entity, adapter);
1689
+ // ── pass 1: where do the endpoints sit ───────────────────────────────────
1690
+ const extentQb = await this.buildFilteredQb(entity, structured, adapter, opts.context);
1691
+ const measured = await adapter.fieldExtent(extentQb, [target], entity);
1692
+ const key = typeof target === 'string' ? target : target.alias;
1693
+ const bounds = measured[key];
1694
+ // The `fieldExtent` contract defines an ABSENT key as "the adapter could not
1695
+ // turn this into an expression". The field passed validation, so that is a
1696
+ // metadata disagreement, not a client mistake — and it must not be reported
1697
+ // as `{ min: null }`, which the same contract defines as "no row carries a
1698
+ // value" and which a control would render as a legitimately empty facet.
1699
+ if (!bounds) {
1700
+ throw new Error(`histogram could not measure "${key}": the active adapter's fieldExtent() returned no entry for it.`);
1701
+ }
1702
+ const min = this.toBucketableNumber(bounds.min, key);
1703
+ const max = this.toBucketableNumber(bounds.max, key);
1704
+ // Empty set, or a column that is null throughout it. No second query: there
1705
+ // is nothing to bin, and a width derived from null is NaN — which reaches
1706
+ // SQL as `FLOOR(col / NaN)` and groups every row into one null bucket.
1707
+ if (min === null || max === null) {
1708
+ return { min: null, max: null, bucketWidth: null, buckets: [] };
1709
+ }
1710
+ // ── pass 2: how are the rows distributed across them ─────────────────────
1711
+ const countQb = await this.buildFilteredQb(entity, structured, adapter, opts.context);
1712
+ // Degenerate span: one row, or many rows sharing one value. A width of
1713
+ // (max - min) / n is 0, and `FLOOR(col / 0)` is a null group on MySQL and a
1714
+ // division-by-zero ERROR on Postgres — so the bucketed variant must not be
1715
+ // asked for at all. The plain group-by answers instead: with a single
1716
+ // distinct value it returns that one group, whose count is the bar's
1717
+ // height. The bucket is reported as the point `[min, min]` rather than
1718
+ // given a fabricated width, which would draw a bar spanning values no row
1719
+ // in scope has.
1720
+ if (min === max) {
1721
+ const rows = await adapter.groupByCount(countQb, target, entity);
1722
+ const count = rows.reduce((sum, row) => row.value === null || row.value === undefined ? sum : sum + Number(row.count), 0);
1723
+ return { min, max, bucketWidth: 0, buckets: [{ bucketStart: min, bucketEnd: min, count }] };
1724
+ }
1725
+ const bucketWidth = this.niceBucketWidth(max - min, spec.buckets ?? DEFAULT_HISTOGRAM_BUCKETS);
1726
+ const rows = await adapter.groupByCount(countQb, target, entity, { bucket: bucketWidth });
1727
+ return { min, max, bucketWidth, buckets: this.assembleBuckets(min, max, bucketWidth, rows) };
1728
+ }
1729
+ /**
1730
+ * Parses the raw `histogram` block into a canonical `{ field, buckets }`.
1731
+ * `null` when no usable `field` is present — the caller rejects, since a
1732
+ * histogram of nothing has no meaningful empty answer.
1733
+ *
1734
+ * `buckets` accepts a numeric string as well as a number: on a GET route
1735
+ * `?histogram[buckets]=20` arrives as text, and rejecting it there while
1736
+ * accepting `20` from a POST body would make the two transports disagree
1737
+ * about the same request. Anything unusable (absent, zero, negative, NaN,
1738
+ * an object) degrades to the default rather than 400ing: it is a rendering
1739
+ * hint, and a request that says nothing about bar count still has an answer.
1740
+ */
1741
+ parseHistogram(raw) {
1742
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
1743
+ return null;
1744
+ const field = Reflect.get(raw, 'field');
1745
+ if (typeof field !== 'string' || field.length === 0)
1746
+ return null;
1747
+ const rawBuckets = Reflect.get(raw, 'buckets');
1748
+ const parsed = typeof rawBuckets === 'string'
1749
+ ? Number(rawBuckets)
1750
+ : typeof rawBuckets === 'number'
1751
+ ? rawBuckets
1752
+ : Number.NaN;
1753
+ const buckets = Number.isFinite(parsed) && parsed >= 1
1754
+ ? Math.min(Math.floor(parsed), MAX_HISTOGRAM_BUCKETS)
1755
+ : DEFAULT_HISTOGRAM_BUCKETS;
1756
+ return { field, buckets };
1757
+ }
1758
+ /**
1759
+ * Refuses a field whose column type cannot survive `FLOOR(value / width)`,
1760
+ * BEFORE either query runs.
1761
+ *
1762
+ * A DATE column is the case this exists for. `fieldExtent` supports dates
1763
+ * deliberately (a calendar sizes itself from one), so `extent` and
1764
+ * `histogram` accept the same field names right up to this point — and the
1765
+ * failure mode without the check is not an error but a wrong answer: MySQL
1766
+ * coerces a date to `20240131` before dividing, so the query succeeds and
1767
+ * returns bars over an axis where two thirds of every year does not exist.
1768
+ *
1769
+ * Only ROOT-column metadata can answer this, so anything it cannot type — a
1770
+ * relation path, a JSON sub-path, a computed source — passes here and is
1771
+ * caught by {@link toBucketableNumber} once the extent comes back with actual
1772
+ * values. Refusing everything untypeable instead would reject `author.age`,
1773
+ * which `where`, `sort` and `distinct` all accept.
1774
+ */
1775
+ assertBucketable(target, entity, adapter) {
1776
+ if (typeof target !== 'string')
1777
+ return;
1778
+ const info = adapter.getEntityFields?.(entity)?.find((f) => f.name === target);
1779
+ if (!info || info.type === 'number' || info.type === 'unknown')
1780
+ return;
1781
+ 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.`);
1782
+ }
1783
+ /**
1784
+ * Coerces one measured extent end to the number the width arithmetic needs,
1785
+ * or `null` for "no row carries a value".
1786
+ *
1787
+ * Not a `typeof value === 'number'` check, in either direction:
1788
+ *
1789
+ * - a DECIMAL column hydrates to a STRING on mysql2 and pg, so the strict
1790
+ * check would refuse the most ordinary histogram there is — a price;
1791
+ * - `Number(new Date())` is a finite epoch, so a bare numeric coercion would
1792
+ * wave a date extent straight through into `FLOOR(ms / width)`. Dates are
1793
+ * therefore tested for FIRST, by identity, not by what they coerce to.
1794
+ *
1795
+ * This is the net under {@link assertBucketable}, which only sees root-column
1796
+ * metadata: a computed source, a relation path or a JSON sub-path is typed by
1797
+ * nothing until its value arrives here.
1798
+ */
1799
+ toBucketableNumber(value, field) {
1800
+ if (value === null || value === undefined)
1801
+ return null;
1802
+ if (value instanceof Date) {
1803
+ throw new BadRequestException(`Invalid histogram field: "${field}" measures dates and histogram buckets are numeric (FLOOR(value / width)). Use \`extent\` for its range.`);
1804
+ }
1805
+ const numeric = typeof value === 'number' || typeof value === 'bigint' || typeof value === 'string'
1806
+ ? Number(value)
1807
+ : Number.NaN;
1808
+ if (!Number.isFinite(numeric)) {
1809
+ throw new BadRequestException(`Invalid histogram field: "${field}" did not measure to a finite number, so it cannot be bucketed.`);
1810
+ }
1811
+ return numeric;
1812
+ }
1813
+ /**
1814
+ * Derives a bucket width from the measured span and the desired bar count,
1815
+ * snapped to the nearest 1/2/5 × 10ⁿ step.
1816
+ *
1817
+ * The raw `span / desired` is the arithmetically correct width and the wrong
1818
+ * answer for a control. Two reasons, both visible to a user:
1819
+ *
1820
+ * - buckets are anchored at multiples of the width (`FLOOR(col / w) * w`,
1821
+ * which the adapter capability defines and which is what lets the grouping
1822
+ * be one expression), so an ugly width means ugly edges: a span of
1823
+ * 499–128000 over 10 gives 12750.1, and axis labels at 12750.1, 25500.2,
1824
+ * 38250.3;
1825
+ * - a raw width changes on every row inserted, so the bars re-partition and
1826
+ * visibly jump whenever the filtered set shifts slightly. A snapped width
1827
+ * holds still across a range of spans, which is the hysteresis a facet
1828
+ * that redraws on every keystroke needs.
1829
+ *
1830
+ * Snapped to the NEAREST step in log space (the √2 / √10 / √50 thresholds),
1831
+ * not upward: rounding up turns a span of 101 over 10 buckets into a width of
1832
+ * 20 and six bars, which is a worse lie about the request than eleven bars.
1833
+ * The count therefore lands within about √2 of `desired` in either direction
1834
+ * — `buckets` is a target, and the returned `bucketWidth` is authoritative.
1835
+ *
1836
+ * `span` is strictly positive here: the `min === max` case never reaches this.
1837
+ */
1838
+ niceBucketWidth(span, desired) {
1839
+ const raw = span / desired;
1840
+ const magnitude = 10 ** Math.floor(Math.log10(raw));
1841
+ const normalized = raw / magnitude; // [1, 10)
1842
+ const step = normalized >= Math.sqrt(50)
1843
+ ? 10
1844
+ : normalized >= Math.sqrt(10)
1845
+ ? 5
1846
+ : normalized >= Math.SQRT2
1847
+ ? 2
1848
+ : 1;
1849
+ return step * magnitude;
1850
+ }
1851
+ /**
1852
+ * Turns the adapter's sparse `{ value, count }` groups into the contiguous
1853
+ * ascending bucket list a chart draws.
1854
+ *
1855
+ * Three things the raw groups get wrong for this purpose:
1856
+ *
1857
+ * - **Nulls.** A row whose column is null groups under `FLOOR(NULL / w)`,
1858
+ * which is NULL — and `Number(null)` is 0, so passing the groups through
1859
+ * unfiltered plants a phantom bar at zero holding every null row.
1860
+ * - **Order.** `GROUP BY` has no defined output order. A histogram is a
1861
+ * sequence, and bars drawn in the order MySQL happened to hash them are
1862
+ * not a distribution.
1863
+ * - **Gaps.** Empty buckets produce no group at all, so a sparse list renders
1864
+ * as evenly spaced bars that lie about where the data sits. They are
1865
+ * filled with zero-count entries, which is bounded work: the bucket count
1866
+ * is `span / width`, and the width came from a clamped desired count.
1867
+ *
1868
+ * Groups are matched to buckets by INDEX rather than by comparing the
1869
+ * returned `value` to a computed edge: for a width like 0.1 the database's
1870
+ * `FLOOR(x / 0.1) * 0.1` and this code's `i * 0.1` differ in the last bits,
1871
+ * and an equality match would silently drop those buckets to zero.
1872
+ */
1873
+ assembleBuckets(min, max, width, rows) {
1874
+ const firstIndex = Math.floor(min / width);
1875
+ const size = Math.floor(max / width) - firstIndex + 1;
1876
+ const counts = new Array(size).fill(0);
1877
+ for (const row of rows) {
1878
+ if (row.value === null || row.value === undefined)
1879
+ continue;
1880
+ const start = Number(row.value);
1881
+ if (!Number.isFinite(start))
1882
+ continue;
1883
+ const index = Math.round(start / width) - firstIndex;
1884
+ // Out of range means the grouping expression and this arithmetic disagree
1885
+ // about the field — dropped rather than widening the axis to fit it.
1886
+ if (index < 0 || index >= size)
1887
+ continue;
1888
+ counts[index] = (counts[index] ?? 0) + Number(row.count);
1889
+ }
1890
+ return counts.map((count, i) => {
1891
+ const bucketStart = this.snapEdge((firstIndex + i) * width, width);
1892
+ return { bucketStart, bucketEnd: this.snapEdge(bucketStart + width, width), count };
1893
+ });
1894
+ }
1895
+ /**
1896
+ * Rounds a bucket edge to the decimal precision its width implies. Every edge
1897
+ * is an exact multiple of a 1/2/5 × 10ⁿ width, so this cannot move one onto a
1898
+ * different bucket — it only sheds the binary-float residue that otherwise
1899
+ * labels an axis `0.30000000000000004`. Skipped entirely for widths outside
1900
+ * `toFixed`'s useful range, where rounding would destroy information rather
1901
+ * than tidy it.
1902
+ */
1903
+ snapEdge(value, width) {
1904
+ const decimals = -Math.floor(Math.log10(width));
1905
+ if (!Number.isFinite(decimals) || decimals < 0 || decimals > 15)
1906
+ return value;
1907
+ return Number(value.toFixed(decimals));
1908
+ }
1380
1909
  /**
1381
1910
  * Runs a dynamic query with **keyset (cursor) pagination** and executes it,
1382
1911
  * returning a stable, non-overlapping page plus opaque forward/backward
@@ -1820,6 +2349,32 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
1820
2349
  }
1821
2350
  return this.options.defaultSort;
1822
2351
  }
2352
+ /**
2353
+ * Resolves the effective `distinctOrder`: the ROUTE's own
2354
+ * `@ApplyFilter({ distinctOrder })` wins over the filter class's
2355
+ * `@Filterable({ distinctOrder })`, and absent both it is off.
2356
+ *
2357
+ * There is deliberately no module-level knob. Whether a `SELECT DISTINCT`
2358
+ * wants an ORDER BY is a property of the endpoint reading it — one filter
2359
+ * class typically serves a rows route that orders itself and a distinct route
2360
+ * that does not — so an app-wide switch would be answering a question at the
2361
+ * wrong altitude, and silently, for queries whose cost it cannot see.
2362
+ *
2363
+ * Off unless asked for, for the same reason: ordering a projection the caller
2364
+ * never asked to order is a clause this library would be inventing, and on a
2365
+ * large distinct with no index on the projected column that clause is a
2366
+ * filesort nobody signed up for. See {@link FilterableOptions.distinctOrder}.
2367
+ */
2368
+ resolveDistinctOrder(FilterClass, perCall) {
2369
+ if (perCall !== undefined)
2370
+ return perCall;
2371
+ if (FilterClass) {
2372
+ const meta = getFilterableMetadata(FilterClass);
2373
+ if (meta?.distinctOrder !== undefined)
2374
+ return meta.distinctOrder;
2375
+ }
2376
+ return false;
2377
+ }
1823
2378
  handleUnknownKey(key) {
1824
2379
  const policy = this.options.onUnknownKey ?? 'ignore';
1825
2380
  if (policy === 'throw')
@@ -2046,12 +2601,16 @@ let FilterRunner = FilterRunner_1 = class FilterRunner {
2046
2601
  // dropped before reaching the query builder and the route answered with
2047
2602
  // a page of full rows instead of a column of values.
2048
2603
  //
2049
- // A bare relation (`author`) resolves to 'relation' and stays rejected
2050
- // there is no single column to project. So does a JSON sub-path
2051
- // ('json'): projecting one needs an extract expression the distinct
2052
- // path does not build.
2604
+ // A JSON sub-path ('json') is accepted too an adapter that can
2605
+ // resolve one can compile the extract expression the projection needs,
2606
+ // and a dropdown over `searchAttributes.origin` is the same request as
2607
+ // one over a relation column.
2608
+ //
2609
+ // A bare relation (`author`) resolves to 'relation' and stays rejected:
2610
+ // there is no single column to project.
2053
2611
  if (adapter.resolveFieldPath) {
2054
- return accept((f) => adapter.resolveFieldPath(entity, f) === 'field');
2612
+ const kind = (f) => adapter.resolveFieldPath(entity, f);
2613
+ return accept((f) => kind(f) === 'field' || kind(f) === 'json');
2055
2614
  }
2056
2615
  // Fallback: scalar columns only.
2057
2616
  const fieldNames = new Set(entityFields.map((f) => f.name));