@objectstack/service-analytics 17.2.0 → 17.4.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/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { IAnalyticsService, Logger, AnalyticsDriverCapabilities, AnalyticsStrategy, AnalyticsQuery, AnalyticsResult, DatasetSelection, CubeMeta, DatasetCompareTo, StrategyContext } from '@objectstack/spec/contracts';
2
2
  export { AnalyticsDriverCapabilities, AnalyticsStrategy, DatasetSelection, StrategyContext } from '@objectstack/spec/contracts';
3
- import { Cube, FilterCondition } from '@objectstack/spec/data';
3
+ import { Cube, FilterCondition, AggregationFunction } from '@objectstack/spec/data';
4
4
  import { ExecutionContext } from '@objectstack/spec/kernel';
5
5
  import { Dataset } from '@objectstack/spec/ui';
6
6
  import { Plugin, PluginContext } from '@objectstack/core';
@@ -8,12 +8,26 @@ import { Plugin, PluginContext } from '@objectstack/core';
8
8
  /**
9
9
  * CubeRegistry — Central registry for analytics cube definitions.
10
10
  *
11
- * Cubes can be registered from two sources:
12
- * 1. **Manifest definitions** Explicit cube definitions in `objectstack.config.ts`.
13
- * 2. **Object schema inference** Auto-generated cubes from ObjectQL object schemas.
11
+ * The registry is the single source of truth for cube metadata discovery:
12
+ * `getMeta()` maps every registered cube's measure/dimension `label` onto the
13
+ * `CubeMeta` titles served by `GET /api/v1/analytics/meta`, and the strategy
14
+ * chain resolves a query's cube through it.
14
15
  *
15
- * The registry is the single source of truth for cube metadata discovery
16
- * (used by `getMeta()` and the strategy chain).
16
+ * Three sources write to it, all of them from `AnalyticsService`:
17
+ * 1. **Manifest definitions** — `AnalyticsServiceConfig.cubes` (`registerAll`),
18
+ * i.e. explicit cube definitions authored in `objectstack.config.ts`.
19
+ * 2. **Compiled datasets** (ADR-0021) — `compileDataset()`'s Cube, registered
20
+ * under the dataset's name by `queryDataset`.
21
+ * 3. **Ad-hoc query inference** — `ensureCube` / `inferCubeFromQuery` mints a
22
+ * minimal Cube from the members an `AnalyticsQuery` references, once
23
+ * `assertInferableCube` (#3867) has confirmed the name is a registered
24
+ * object. It infers from the QUERY, never from the object's field schema.
25
+ *
26
+ * This list used to read "two sources: manifest definitions, and object schema
27
+ * inference". Neither half was right: sources 2 and 3 were missing, and object
28
+ * schema inference is `inferFromObject` below, which no path in this repository
29
+ * calls (#15019). It is described at the method rather than advertised here,
30
+ * because listing it would promise a source the platform does not deliver.
17
31
  */
18
32
  declare class CubeRegistry {
19
33
  private cubes;
@@ -34,14 +48,36 @@ declare class CubeRegistry {
34
48
  /** Remove all cubes. */
35
49
  clear(): void;
36
50
  /**
37
- * Auto-generate a cube definition from an object schema.
38
- *
39
- * Heuristic rules:
40
- * - `number` fields `sum`, `avg`, `min`, `max` measures
41
- * - `boolean` fields `count` measure (count where true)
42
- * - All non-computed fields dimensions
43
- * - `date`/`datetime` fields time dimensions with standard granularities
44
- * - A default `count` measure is always added
51
+ * Auto-generate a cube definition from an object's FIELD SCHEMA, and register
52
+ * it under `objectName`.
53
+ *
54
+ * ⚠️ Nothing in this repository calls this the only in-tree caller is a unit
55
+ * test, and every cube the platform registers itself comes from one of the
56
+ * three sources named on the class above (#15019). That is not the same thing
57
+ * as unreachable: `CubeRegistry` is exported from the package entry and
58
+ * `AnalyticsService.cubeRegistry` is public, so a consumer of
59
+ * `@objectstack/service-analytics` can call it, and what it mints does reach
60
+ * the wire — `getMeta()` serves the labels below as `CubeMeta` titles. Whether
61
+ * this published method is removed or wired up as a real cube source is #15019.
62
+ *
63
+ * Heuristic rules, measured by driving the built package (the list this
64
+ * replaces claimed three behaviours the code does not have — `min`/`max`
65
+ * measures, a `count` measure for booleans, and a computed-field exclusion):
66
+ * - `number` / `currency` / `percent` fields → one `sum` and one `avg` measure
67
+ * each, labelled with the field's label plus ` (Sum)` / ` (Avg)`. No `min`
68
+ * or `max` measure is minted.
69
+ * - EVERY field becomes a dimension; there is no computed-field exclusion (the
70
+ * `fields` parameter carries no flag one could exclude on).
71
+ * - `boolean` fields become a `boolean` DIMENSION and nothing else — no count
72
+ * measure is minted for them.
73
+ * - `date` / `datetime` fields → `time` dimensions granulated
74
+ * day/week/month/quarter/year.
75
+ * - A default `count` measure labelled `Count` is always added.
76
+ *
77
+ * Those three defaults (`Count`, and the two composites) are English literals
78
+ * with no i18n hook; #14492's ruling listed the `Count` one as a site to carry
79
+ * the `builtinAggregate` discriminator, and it was left alone because no
80
+ * in-repo path reaches it.
45
81
  *
46
82
  * @param objectName - The snake_case object name (used as table/cube name)
47
83
  * @param fields - Array of field descriptors `{ name, type, label? }`
@@ -328,10 +364,28 @@ interface AnalyticsServiceConfig {
328
364
  */
329
365
  executeAggregate?: (objectName: string, options: {
330
366
  groupBy?: string[];
367
+ /**
368
+ * The local mirror of `StrategyContext.executeAggregate`'s aggregation
369
+ * entries (`packages/spec/src/contracts/analytics-service.ts`), kept in
370
+ * lockstep with it member by member. The two that lockstep is load-bearing
371
+ * for:
372
+ *
373
+ * - `filter` (#10576, the #10413 phase-2 contract field) — a bridge that
374
+ * reconstructs the aggregation entries (as `AnalyticsServicePlugin`'s
375
+ * auto-bridge does, to rename `method` → the engine's `function`) MUST
376
+ * forward this field or a measure-scoped filter `ObjectQLStrategy`
377
+ * lowers never reaches storage.
378
+ * - `method` is the spec's OWN six-value `AggregationFunction`, not
379
+ * `string`: #12776 narrowed the contract, #12940 brought this mirror
380
+ * back into line. Widening it here again would not be a local matter —
381
+ * a bridge author types their handler against THIS declaration, so what
382
+ * they would get is a vocabulary the contract no longer has.
383
+ */
331
384
  aggregations?: Array<{
332
385
  field: string;
333
- method: string;
386
+ method: AggregationFunction;
334
387
  alias: string;
388
+ filter?: Record<string, unknown>;
335
389
  }>;
336
390
  filter?: Record<string, unknown>;
337
391
  /** Reference timezone (IANA) for date bucketing — ADR-0053 Phase 2. */
@@ -502,6 +556,24 @@ interface AnalyticsServiceConfig {
502
556
  defaultCurrency?: string;
503
557
  max?: number;
504
558
  } | undefined;
559
+ /**
560
+ * [#15684] The SQL dialect of the datasource backing `object` — `'sqlite'`,
561
+ * `'postgres'`, `'mysql'`, or `undefined` when the host cannot answer.
562
+ *
563
+ * The three SQL compilers need it for ONE thing: the case-EXACT text family
564
+ * (`$contains` / `$notContains` / `$startsWith` / `$endsWith`, #4706 Q2 = A)
565
+ * has no construct that is case-exact and parses on every dialect. A plain
566
+ * `LIKE` is case-exact on Postgres alone — SQLite folds ASCII case
567
+ * unconditionally, so the query's own `where` AND the RLS read scope
568
+ * admitted rows the predicate excludes there (over-reach, #3948). The
569
+ * per-dialect table lives in `text-match-sql.ts`.
570
+ *
571
+ * Answered by the plugin from the driver that will EXECUTE the statement, so
572
+ * the driver stays the single source of truth for its own dialect. A host
573
+ * that wires nothing keeps the `LIKE` the compilers always emitted —
574
+ * "cannot answer, do not block".
575
+ */
576
+ sqlDialect?: (object: string) => string | undefined;
505
577
  /** Pre-defined datasets to compile + register at construction (ADR-0021). */
506
578
  datasets?: Dataset[];
507
579
  /**
@@ -619,6 +691,27 @@ declare class AnalyticsService implements IAnalyticsService {
619
691
  * `getReadScope(objectName)` that already knows the active tenant.
620
692
  */
621
693
  private callCtx;
694
+ /**
695
+ * [#12230] Copy-on-write expansion of filter placeholders across everything
696
+ * a DIRECT analytics query compares on: `where` and each time dimension's
697
+ * `dateRange` — the same positions `DatasetExecutor.resolveSelectionTokens`
698
+ * covers for the dashboard door, minus the dataset-only channels it alone
699
+ * carries (measure filters ride the dataset-scope getter below).
700
+ *
701
+ * The input is never mutated: a query object can be caller-owned metadata
702
+ * (a saved report definition, a flow node's config) reused across requests,
703
+ * and resolving in place would bake one request's user id into every later
704
+ * render. Returns the SAME object when nothing resolved.
705
+ */
706
+ private resolveQueryTokens;
707
+ /**
708
+ * [#12230] A per-request `getDatasetScope` whose answers have their filter
709
+ * placeholders resolved against THIS caller. See `callCtx` for why the
710
+ * registry's copy cannot be handed out raw. Token-free scopes pass through
711
+ * by reference — `resolveFilterTokens` returns its input unchanged when the
712
+ * tree holds no placeholder, so the common case allocates nothing.
713
+ */
714
+ private resolvedDatasetScopeGetter;
622
715
  /**
623
716
  * Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
624
717
  * AND every joined object of the query's cube, keyed by object name. This is
@@ -644,7 +737,7 @@ declare class AnalyticsService implements IAnalyticsService {
644
737
  * aggregate bridge) instead of failing — or worse, fabricating empty rows.
645
738
  * Any other error propagates untouched.
646
739
  */
647
- query(query: AnalyticsQuery, context?: ExecutionContext): Promise<AnalyticsResult>;
740
+ query(queryInput: AnalyticsQuery, context?: ExecutionContext): Promise<AnalyticsResult>;
648
741
  /**
649
742
  * [#8286] Withhold the executed statement unless this host enabled the echo.
650
743
  *
@@ -679,6 +772,42 @@ declare class AnalyticsService implements IAnalyticsService {
679
772
  queryDataset(dataset: Dataset, selection: DatasetSelection, context?: ExecutionContext, options?: {
680
773
  previewDrafts?: boolean;
681
774
  }): Promise<AnalyticsResult>;
775
+ /**
776
+ * The dataset dimensions this selection GROUPED THE GRID BY, resolved against
777
+ * the dataset definition. Shared by drill metadata, row-value label
778
+ * resolution and — through {@link enrichResultColumns} — the dimension column
779
+ * headers, so all three answer "which dimensions" the same way.
780
+ */
781
+ private selectedDimensions;
782
+ /**
783
+ * ADR-0021 — describe the result's COLUMNS from the dataset's own authored
784
+ * definition: a measure's `label` / `format` / `currency` / `percentScale` /
785
+ * `builtinAggregate` and the `type` its aggregate really returns, then a
786
+ * dimension column's header `label`.
787
+ *
788
+ * **Every key here is read off the DATASET** (the authored measure or
789
+ * dimension) **and `sourceFieldMeta`** (the source object's declared field
790
+ * metadata). Not one is read off `result.rows`. That is what makes this one
791
+ * seam serve both paths that produce a dataset response — the live engine
792
+ * query and the ADR-0037 P3 draft-data preview — and it is why #16097 was a
793
+ * defect rather than a deliberate omission: the preview branch returns ~250
794
+ * lines before this ran, so a response over drafted seed rows carried none of
795
+ * these keys and a renderer fell back to humanizing the raw measure name and
796
+ * guessing a percent scale from magnitude — the exact failures #5537,
797
+ * objectui#3136 and #14492 each closed on the live path.
798
+ *
799
+ * Extracted rather than copied onto the second path, for the reason the
800
+ * `type` correction below already gives for living here at all: this is ONE
801
+ * rule holding both halves of the question, and a per-path copy would be two
802
+ * implementations of it, free to drift.
803
+ *
804
+ * ⛔ Not here, and deliberately: dimension VALUE label resolution
805
+ * ({@link resolveDimensionLabels}), which rewrites the grouped value in each
806
+ * ROW. That reads the rows, it is the one enrichment a seed-draft row set can
807
+ * make unnecessary, and the preview path skips it on purpose — see the note
808
+ * at that early return.
809
+ */
810
+ private enrichResultColumns;
682
811
  /**
683
812
  * Get cube metadata for discovery.
684
813
  */
@@ -686,7 +815,7 @@ declare class AnalyticsService implements IAnalyticsService {
686
815
  /**
687
816
  * Generate SQL for a query without executing it (dry-run).
688
817
  */
689
- generateSql(query: AnalyticsQuery, context?: ExecutionContext): Promise<{
818
+ generateSql(queryInput: AnalyticsQuery, context?: ExecutionContext): Promise<{
690
819
  sql: string;
691
820
  params: unknown[];
692
821
  }>;
@@ -922,10 +1051,28 @@ interface AnalyticsServicePluginOptions {
922
1051
  */
923
1052
  executeAggregate?: (objectName: string, options: {
924
1053
  groupBy?: string[];
1054
+ /**
1055
+ * The CUSTOM bridge's view of the aggregation entries — an app author's
1056
+ * own `executeAggregate`, as opposed to the auto-bridge below. Mirrors
1057
+ * `StrategyContext.executeAggregate`
1058
+ * (`packages/spec/src/contracts/analytics-service.ts`) and must stay in
1059
+ * lockstep with it; the two members that lockstep is load-bearing for:
1060
+ *
1061
+ * - `filter` (#10576, the #10413 contract field) — a custom bridge MUST
1062
+ * forward it to the real engine the same way the auto-bridge does, or a
1063
+ * measure-scoped filter this plugin lowers onto the aggregation
1064
+ * silently never reaches storage.
1065
+ * - `method` is the spec's OWN six-value `AggregationFunction`, not
1066
+ * `string`: #12776 narrowed the contract, #12940 brought this mirror
1067
+ * back into line. This is the declaration a custom-bridge author types
1068
+ * their handler against, so it is where the compile-time vocabulary
1069
+ * #12776 bought for strategy authors reaches them too.
1070
+ */
925
1071
  aggregations?: Array<{
926
1072
  field: string;
927
- method: string;
1073
+ method: AggregationFunction;
928
1074
  alias: string;
1075
+ filter?: Record<string, unknown>;
929
1076
  }>;
930
1077
  filter?: Record<string, unknown>;
931
1078
  /** Reference timezone (IANA) for date bucketing — ADR-0053 Phase 2. */
@@ -1164,7 +1311,37 @@ declare class DatasetExecutor {
1164
1311
  */
1165
1312
  declare function mergeByDimensions(base: Record<string, unknown>[], extra: Record<string, unknown>[], dimensions: string[], valueColumns: string[]): Record<string, unknown>[];
1166
1313
 
1167
- declare function compileScopedFilterToSql(filter: FilterCondition, alias: string): {
1314
+ /**
1315
+ * [#14079] What a caller can tell this compiler about the columns a read scope
1316
+ * names, beyond the scope itself.
1317
+ */
1318
+ interface ReadScopeCompileOptions {
1319
+ /**
1320
+ * Is `field` a column whose STORED value is never text — a declared numeric
1321
+ * or boolean scalar (`NON_TEXT_STORED_VALUE_TYPES`, `@objectstack/spec`)?
1322
+ * When it answers `true`, a text operator over that column compiles to the
1323
+ * contract's constant (`1 = 0` for the positive operators, `1 = 1` for
1324
+ * `$notContains`) instead of a `LIKE` that coerces on SQLite and is refused
1325
+ * at query time on Postgres (SQLSTATE 42883). Absent, or answering `false`,
1326
+ * keeps the `LIKE` — the answer for a text column and for a caller that
1327
+ * cannot classify one (`non-text-column.ts` carries the argument).
1328
+ */
1329
+ nonTextColumn?: (field: string) => boolean;
1330
+ /**
1331
+ * [#15684] The SQL dialect that will EXECUTE this scope — `'sqlite'` /
1332
+ * `'postgres'` / `'mysql'`. Anything else, including absent, is `'unknown'`
1333
+ * and keeps the plain `LIKE` this compiler always emitted.
1334
+ *
1335
+ * The case-EXACT text family (#4706 Q2 = A) needs it: SQLite's `LIKE` folds
1336
+ * ASCII case unconditionally, so a policy written `{ name: { $contains:
1337
+ * 'acme' } }` ADMITTED `ACME Corp` there — rows the predicate excludes,
1338
+ * which on a read scope is over-reach (#3948), not a loose filter. The
1339
+ * per-dialect construct table lives in `text-match-sql.ts`; both of this
1340
+ * compiler's consumers fill this in from the driver that owns the object.
1341
+ */
1342
+ dialect?: string;
1343
+ }
1344
+ declare function compileScopedFilterToSql(filter: FilterCondition, alias: string, options?: ReadScopeCompileOptions): {
1168
1345
  sql: string;
1169
1346
  params: unknown[];
1170
1347
  };
@@ -1465,7 +1642,7 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1465
1642
  * the members inside were unreadable from the outside and the envelope check
1466
1643
  * could not reject what it could not see.
1467
1644
  *
1468
- * ## Two producers, one inventory (#10861)
1645
+ * ## Three producers, one inventory (#10861, #11461)
1469
1646
  *
1470
1647
  * The caller's `where` is not the only thing that reaches `engine.aggregate`
1471
1648
  * as a predicate. Since PR #10758 the compiled dataset's own definition-level
@@ -1480,6 +1657,36 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1480
1657
  * which driver will serve the dataset and would refuse a dataset that is
1481
1658
  * perfectly legal on a native-SQL deployment.
1482
1659
  *
1660
+ * [#11461] #10413 phase 2 then added a THIRD producer with the same reach and
1661
+ * none of the coverage: a compiled measure's own `filter`, lowered onto that
1662
+ * measure's `aggregations[].filter` entry (#10576). This view enumerated two
1663
+ * origins, so the third was invisible to the envelope check and the arm of
1664
+ * `planCrossObject` that inspects `query.measures` reads only each measure's
1665
+ * resolved FIELD, never its filter. Measured on the unfixed tree, one fixture,
1666
+ * both doors:
1667
+ *
1668
+ * ```
1669
+ * BEFORE execute() ACCEPTED -> aggregations: [{field:"*",method:"count",
1670
+ * alias:"west_count",
1671
+ * filter:{"account.region":"West"}}]
1672
+ * -> rows [{stage:"won",total_count:3,west_count:0}]
1673
+ * (the truthful west_count is 2; total_count
1674
+ * is right, so the wrong number arrived in
1675
+ * the same response shape as the right one)
1676
+ * generateSql() ACCEPTED -> COUNT(CASE WHEN account.region = $1 THEN 1 END)
1677
+ * over a FROM with no join in it at all
1678
+ * AFTER both doors REFUSED INVALID_FIELD / 400, engine never reached
1679
+ * ```
1680
+ *
1681
+ * The same maintainer ruling covers it — same hazard, same physical verdict,
1682
+ * one more producer — so it folds in HERE for the #10861 reason and not into
1683
+ * `dataset-compiler.ts`, which still cannot see which driver will serve the
1684
+ * dataset. Only the REQUESTED measures are folded: both doors' aggregation
1685
+ * loops read `measureFilters[m]` for `m of query.measures` and nothing else,
1686
+ * so a filter declared on a measure this query never asks for reaches no
1687
+ * engine, and refusing on it would reject a query for a member that was never
1688
+ * going to be evaluated.
1689
+ *
1483
1690
  * Structure is discarded on purpose — a member is cross-object or it is not,
1484
1691
  * and which branch of a disjunction it sits in cannot make
1485
1692
  * `engine.aggregate` able to join it. PROVENANCE is not discarded, because it
@@ -1489,9 +1696,12 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1489
1696
  * `planCrossObject`. The value slot carries that and nothing else; it never
1490
1697
  * reaches a driver.
1491
1698
  *
1492
- * Dataset leaves are inserted FIRST so a member named by BOTH producers keeps
1493
- * the caller's provenance (last write wins on a duplicate key): if it is in
1494
- * the request too, the request is the actionable place to fix it.
1699
+ * Insertion order is measure-filter, then dataset-filter, then `where`, and
1700
+ * last write wins on a duplicate key. Two things follow, in that order of
1701
+ * importance. A member named by the request too keeps the CALLER's provenance,
1702
+ * because if it is in the request that is the actionable place to fix it. And
1703
+ * every shape that was refused before #11461 keeps the exact message it had:
1704
+ * the new origin can only ever win a key no older producer names.
1495
1705
  *
1496
1706
  * Time-dimension WINDOWS are deliberately absent (they live in
1497
1707
  * `dateRangeBounds`, not in `where`). They need no arm here: a cross-object
@@ -1513,18 +1723,20 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1513
1723
  * THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
1514
1724
  * (needs a real join to evaluate), a cross-object leaf in the DATASET's own
1515
1725
  * definition-level `filter` (#10861 — same join it does not have, arriving
1516
- * from the producer PR #10758 added), a MULTI-HOP dimension (`a.b.c`), or a
1517
- * non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
1518
- * cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
1726
+ * from the producer PR #10758 added), a cross-object leaf in ONE MEASURE's own
1727
+ * `filter` (#11461 the same join again, arriving from the producer #10413
1728
+ * phase 2 added), a MULTI-HOP dimension (`a.b.c`), or a non-recombinable
1729
+ * measure (`avg`/`count_distinct`, whose sub-bucket values cannot be merged).
1730
+ * A loud error beats the silent mis-bucket #3654 kills.
1519
1731
  * `generateSql()` calls this too, so the preview accepts/rejects the same set
1520
1732
  * — and since #10759 both callers derive `filter` from the one
1521
1733
  * {@link filterMemberView}, so that sentence is enforced by construction
1522
1734
  * instead of restated at two call sites.
1523
1735
  *
1524
- * [#5716] All five refusals below are `invalidMemberError` — `INVALID_FIELD` /
1736
+ * [#5716] All six refusals below are `invalidMemberError` — `INVALID_FIELD` /
1525
1737
  * 400, naming the member — and the four that predate #10861 keep their
1526
1738
  * MESSAGES unchanged (they are good diagnostics, and #5923's tests read
1527
- * them). Each is decided by two facts and nothing else: a member that will
1739
+ * them); so does #10861's own, which #11461 left untouched beside it. Each is decided by two facts and nothing else: a member that will
1528
1740
  * reach the engine's predicate, and whether that member resolves across a
1529
1741
  * join. Neither is an internal invariant — a cube where the member exists and
1530
1742
  * a driver that could serve it are both perfectly ordinary, which is exactly
@@ -1533,14 +1745,15 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1533
1745
  * because the fix is always to change or drop ONE named member, and because
1534
1746
  * four of them fire on `/analytics/query` where no dataset exists.
1535
1747
  *
1536
- * [#10861] The fifth is the exception that proves the rule and is written to
1537
- * it: it can only fire where a dataset DOES exist, and it is the one refusal
1538
- * here whose member no request key named — so it carries `cube` and no
1539
- * `param`, and says in its own words which document to go and edit. It stays
1748
+ * [#10861, #11461] The fifth and sixth are the exceptions that prove the rule
1749
+ * and are written to it: they can only fire where a dataset DOES exist, and
1750
+ * they are the two refusals here whose member no request key named — so each
1751
+ * carries `cube` and no `param`, and says in its own words which document to
1752
+ * go and edit, the sixth naming the MEASURE inside it as well. Both stay
1540
1753
  * `INVALID_FIELD` rather than becoming `DATASET_INVALID` because the verdict
1541
- * is the same physical one as its neighbour — this engine cannot join this
1754
+ * is the same physical one as their neighbours — this engine cannot join this
1542
1755
  * member — and splitting the code by PROVENANCE would make a caller branch on
1543
- * two wire shapes for one capability limit.
1756
+ * three wire shapes for one capability limit.
1544
1757
  *
1545
1758
  * Detection is on RESOLVED field names, so a dotted dimension the cube
1546
1759
  * flattens to a real column is treated as base, not cross-object.
@@ -1558,6 +1771,23 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1558
1771
  * are simply absent from the map (⇒ RESTRICTED bucket downstream).
1559
1772
  */
1560
1773
  private resolveFkAttr;
1774
+ /**
1775
+ * A measure's aggregate, restricted to the rows its own `filter` admits
1776
+ * (#10413 phase 2) — the same six functions `generateSql`'s unconditional
1777
+ * branch renders, wrapped in a `CASE WHEN`.
1778
+ *
1779
+ * Spelled `CASE WHEN` rather than SQL-standard `FILTER (WHERE …)`, mirroring
1780
+ * `NativeSQLStrategy.CONDITIONAL_AGGREGATE_SQL`: this string is DOCUMENTATION
1781
+ * of an execution that really goes through `engine.aggregate`'s per-driver
1782
+ * `aggregations[].filter` lowering (#10576), not a statement this class runs
1783
+ * itself, so there is no reason to pick a dialect-restricted spelling over
1784
+ * the portable one the SQL-executing sibling already settled on.
1785
+ *
1786
+ * `count` over `*` counts a constant (`COUNT(CASE WHEN p THEN 1 END)`, since
1787
+ * `COUNT(CASE WHEN p THEN * END)` is not valid SQL); over a real column it
1788
+ * counts that column's non-null values among the admitted rows.
1789
+ */
1790
+ private conditionalAggregateSql;
1561
1791
  /**
1562
1792
  * Render one normalized filter as a display SQL predicate for `generateSql`.
1563
1793
  *
@@ -1592,6 +1822,17 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
1592
1822
  * 3. `<field>` — bare column on the cube's table.
1593
1823
  */
1594
1824
  private lookupMember;
1825
+ /**
1826
+ * [#14079] The (object, field) a filter member binds against — the echo's
1827
+ * copy of `NativeSQLStrategy.resolveStorageTarget`, kept beside the
1828
+ * `LIKE_SQL_OPS` table for the same reason that table is a copy: this file
1829
+ * renders a description of the statement THAT compiler produces, and the
1830
+ * declared-type test both apply is keyed by object and field. A dotted
1831
+ * `sql` is a relationship path (ADR-0071): every segment but the last is a
1832
+ * hop whose join alias is the dot-to-`__` spelling the dataset compiler keys
1833
+ * `cube.joins` by, the last is the column.
1834
+ */
1835
+ private resolveStorageTarget;
1595
1836
  private resolveFieldName;
1596
1837
  private resolveMeasureAggregation;
1597
1838
  /**