@objectstack/service-analytics 17.1.0 → 17.3.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 +796 -0
- package/README.md +5 -3
- package/dist/index.cjs +465 -36
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +203 -20
- package/dist/index.d.ts +203 -20
- package/dist/index.js +473 -37
- package/dist/index.js.map +1 -1
- package/package.json +16 -10
package/dist/index.d.cts
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';
|
|
@@ -328,10 +328,28 @@ interface AnalyticsServiceConfig {
|
|
|
328
328
|
*/
|
|
329
329
|
executeAggregate?: (objectName: string, options: {
|
|
330
330
|
groupBy?: string[];
|
|
331
|
+
/**
|
|
332
|
+
* The local mirror of `StrategyContext.executeAggregate`'s aggregation
|
|
333
|
+
* entries (`packages/spec/src/contracts/analytics-service.ts`), kept in
|
|
334
|
+
* lockstep with it member by member. The two that lockstep is load-bearing
|
|
335
|
+
* for:
|
|
336
|
+
*
|
|
337
|
+
* - `filter` (#10576, the #10413 phase-2 contract field) — a bridge that
|
|
338
|
+
* reconstructs the aggregation entries (as `AnalyticsServicePlugin`'s
|
|
339
|
+
* auto-bridge does, to rename `method` → the engine's `function`) MUST
|
|
340
|
+
* forward this field or a measure-scoped filter `ObjectQLStrategy`
|
|
341
|
+
* lowers never reaches storage.
|
|
342
|
+
* - `method` is the spec's OWN six-value `AggregationFunction`, not
|
|
343
|
+
* `string`: #12776 narrowed the contract, #12940 brought this mirror
|
|
344
|
+
* back into line. Widening it here again would not be a local matter —
|
|
345
|
+
* a bridge author types their handler against THIS declaration, so what
|
|
346
|
+
* they would get is a vocabulary the contract no longer has.
|
|
347
|
+
*/
|
|
331
348
|
aggregations?: Array<{
|
|
332
349
|
field: string;
|
|
333
|
-
method:
|
|
350
|
+
method: AggregationFunction;
|
|
334
351
|
alias: string;
|
|
352
|
+
filter?: Record<string, unknown>;
|
|
335
353
|
}>;
|
|
336
354
|
filter?: Record<string, unknown>;
|
|
337
355
|
/** Reference timezone (IANA) for date bucketing — ADR-0053 Phase 2. */
|
|
@@ -619,6 +637,27 @@ declare class AnalyticsService implements IAnalyticsService {
|
|
|
619
637
|
* `getReadScope(objectName)` that already knows the active tenant.
|
|
620
638
|
*/
|
|
621
639
|
private callCtx;
|
|
640
|
+
/**
|
|
641
|
+
* [#12230] Copy-on-write expansion of filter placeholders across everything
|
|
642
|
+
* a DIRECT analytics query compares on: `where` and each time dimension's
|
|
643
|
+
* `dateRange` — the same positions `DatasetExecutor.resolveSelectionTokens`
|
|
644
|
+
* covers for the dashboard door, minus the dataset-only channels it alone
|
|
645
|
+
* carries (measure filters ride the dataset-scope getter below).
|
|
646
|
+
*
|
|
647
|
+
* The input is never mutated: a query object can be caller-owned metadata
|
|
648
|
+
* (a saved report definition, a flow node's config) reused across requests,
|
|
649
|
+
* and resolving in place would bake one request's user id into every later
|
|
650
|
+
* render. Returns the SAME object when nothing resolved.
|
|
651
|
+
*/
|
|
652
|
+
private resolveQueryTokens;
|
|
653
|
+
/**
|
|
654
|
+
* [#12230] A per-request `getDatasetScope` whose answers have their filter
|
|
655
|
+
* placeholders resolved against THIS caller. See `callCtx` for why the
|
|
656
|
+
* registry's copy cannot be handed out raw. Token-free scopes pass through
|
|
657
|
+
* by reference — `resolveFilterTokens` returns its input unchanged when the
|
|
658
|
+
* tree holds no placeholder, so the common case allocates nothing.
|
|
659
|
+
*/
|
|
660
|
+
private resolvedDatasetScopeGetter;
|
|
622
661
|
/**
|
|
623
662
|
* Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
|
|
624
663
|
* AND every joined object of the query's cube, keyed by object name. This is
|
|
@@ -644,7 +683,7 @@ declare class AnalyticsService implements IAnalyticsService {
|
|
|
644
683
|
* aggregate bridge) instead of failing — or worse, fabricating empty rows.
|
|
645
684
|
* Any other error propagates untouched.
|
|
646
685
|
*/
|
|
647
|
-
query(
|
|
686
|
+
query(queryInput: AnalyticsQuery, context?: ExecutionContext): Promise<AnalyticsResult>;
|
|
648
687
|
/**
|
|
649
688
|
* [#8286] Withhold the executed statement unless this host enabled the echo.
|
|
650
689
|
*
|
|
@@ -686,7 +725,7 @@ declare class AnalyticsService implements IAnalyticsService {
|
|
|
686
725
|
/**
|
|
687
726
|
* Generate SQL for a query without executing it (dry-run).
|
|
688
727
|
*/
|
|
689
|
-
generateSql(
|
|
728
|
+
generateSql(queryInput: AnalyticsQuery, context?: ExecutionContext): Promise<{
|
|
690
729
|
sql: string;
|
|
691
730
|
params: unknown[];
|
|
692
731
|
}>;
|
|
@@ -922,10 +961,28 @@ interface AnalyticsServicePluginOptions {
|
|
|
922
961
|
*/
|
|
923
962
|
executeAggregate?: (objectName: string, options: {
|
|
924
963
|
groupBy?: string[];
|
|
964
|
+
/**
|
|
965
|
+
* The CUSTOM bridge's view of the aggregation entries — an app author's
|
|
966
|
+
* own `executeAggregate`, as opposed to the auto-bridge below. Mirrors
|
|
967
|
+
* `StrategyContext.executeAggregate`
|
|
968
|
+
* (`packages/spec/src/contracts/analytics-service.ts`) and must stay in
|
|
969
|
+
* lockstep with it; the two members that lockstep is load-bearing for:
|
|
970
|
+
*
|
|
971
|
+
* - `filter` (#10576, the #10413 contract field) — a custom bridge MUST
|
|
972
|
+
* forward it to the real engine the same way the auto-bridge does, or a
|
|
973
|
+
* measure-scoped filter this plugin lowers onto the aggregation
|
|
974
|
+
* silently never reaches storage.
|
|
975
|
+
* - `method` is the spec's OWN six-value `AggregationFunction`, not
|
|
976
|
+
* `string`: #12776 narrowed the contract, #12940 brought this mirror
|
|
977
|
+
* back into line. This is the declaration a custom-bridge author types
|
|
978
|
+
* their handler against, so it is where the compile-time vocabulary
|
|
979
|
+
* #12776 bought for strategy authors reaches them too.
|
|
980
|
+
*/
|
|
925
981
|
aggregations?: Array<{
|
|
926
982
|
field: string;
|
|
927
|
-
method:
|
|
983
|
+
method: AggregationFunction;
|
|
928
984
|
alias: string;
|
|
985
|
+
filter?: Record<string, unknown>;
|
|
929
986
|
}>;
|
|
930
987
|
filter?: Record<string, unknown>;
|
|
931
988
|
/** Reference timezone (IANA) for date bucketing — ADR-0053 Phase 2. */
|
|
@@ -1308,6 +1365,11 @@ declare class NativeSQLStrategy implements AnalyticsStrategy {
|
|
|
1308
1365
|
*/
|
|
1309
1366
|
private lookupMember;
|
|
1310
1367
|
private resolveDimensionSql;
|
|
1368
|
+
/**
|
|
1369
|
+
* @param predicate - The measure's own scoped filter, already compiled to a
|
|
1370
|
+
* SQL boolean (`null` = the measure declares none, or declares one that
|
|
1371
|
+
* constrains nothing — `compileFilterNode`'s TRUE). #10298.
|
|
1372
|
+
*/
|
|
1311
1373
|
private resolveMeasureSql;
|
|
1312
1374
|
private resolveFieldSql;
|
|
1313
1375
|
/**
|
|
@@ -1443,6 +1505,92 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
|
|
|
1443
1505
|
private withReadScope;
|
|
1444
1506
|
/** Is `field` a resolved cross-object (relationship-traversal) reference? */
|
|
1445
1507
|
private isCrossObjectField;
|
|
1508
|
+
/**
|
|
1509
|
+
* The member view {@link planCrossObject} judges a filter by: EVERY member
|
|
1510
|
+
* that will end up in the engine's predicate, structure discarded, keyed by
|
|
1511
|
+
* RESOLVED field name (#10759), valued by WHERE THE MEMBER CAME FROM
|
|
1512
|
+
* (#10861).
|
|
1513
|
+
*
|
|
1514
|
+
* Both call sites — `execute()` and `generateSql()` — are handed this and
|
|
1515
|
+
* nothing else, which is what makes the invariant `planCrossObject` states
|
|
1516
|
+
* for itself ("the preview accepts/rejects the same set") structural rather
|
|
1517
|
+
* than a coincidence maintained by hand. They used to build the view
|
|
1518
|
+
* separately: the echo flattened the tree, `execute()` passed the ENGINE
|
|
1519
|
+
* FILTER, and a filter record answers a different question — it is a
|
|
1520
|
+
* predicate to evaluate, not an inventory of members. An `$or`, a `$not` or
|
|
1521
|
+
* an unmergeable nested `$and` travels in it as one opaque `$and` entry, so
|
|
1522
|
+
* the members inside were unreadable from the outside and the envelope check
|
|
1523
|
+
* could not reject what it could not see.
|
|
1524
|
+
*
|
|
1525
|
+
* ## Three producers, one inventory (#10861, #11461)
|
|
1526
|
+
*
|
|
1527
|
+
* The caller's `where` is not the only thing that reaches `engine.aggregate`
|
|
1528
|
+
* as a predicate. Since PR #10758 the compiled dataset's own definition-level
|
|
1529
|
+
* `filter` is lowered onto `execute()`'s `conjuncts` and rendered by
|
|
1530
|
+
* `generateSql()`, so a dataset declaring `filter: { 'account.region': 'West' }`
|
|
1531
|
+
* sent `{"$and":[{"account.region":"West"}]}` to an engine that cannot join —
|
|
1532
|
+
* measured on both doors, which AGREED in accepting it, so #10759's
|
|
1533
|
+
* preview/execution symmetry had nothing to restore. Refusing it is a
|
|
1534
|
+
* widening of the refusal set, ruled by the maintainer on 2026-08-22 (Option
|
|
1535
|
+
* A, query-time refusal): fold the scope's leaves in HERE, where driver
|
|
1536
|
+
* capability is known, rather than in `dataset-compiler.ts`, which cannot see
|
|
1537
|
+
* which driver will serve the dataset and would refuse a dataset that is
|
|
1538
|
+
* perfectly legal on a native-SQL deployment.
|
|
1539
|
+
*
|
|
1540
|
+
* [#11461] #10413 phase 2 then added a THIRD producer with the same reach and
|
|
1541
|
+
* none of the coverage: a compiled measure's own `filter`, lowered onto that
|
|
1542
|
+
* measure's `aggregations[].filter` entry (#10576). This view enumerated two
|
|
1543
|
+
* origins, so the third was invisible to the envelope check and the arm of
|
|
1544
|
+
* `planCrossObject` that inspects `query.measures` reads only each measure's
|
|
1545
|
+
* resolved FIELD, never its filter. Measured on the unfixed tree, one fixture,
|
|
1546
|
+
* both doors:
|
|
1547
|
+
*
|
|
1548
|
+
* ```
|
|
1549
|
+
* BEFORE execute() ACCEPTED -> aggregations: [{field:"*",method:"count",
|
|
1550
|
+
* alias:"west_count",
|
|
1551
|
+
* filter:{"account.region":"West"}}]
|
|
1552
|
+
* -> rows [{stage:"won",total_count:3,west_count:0}]
|
|
1553
|
+
* (the truthful west_count is 2; total_count
|
|
1554
|
+
* is right, so the wrong number arrived in
|
|
1555
|
+
* the same response shape as the right one)
|
|
1556
|
+
* generateSql() ACCEPTED -> COUNT(CASE WHEN account.region = $1 THEN 1 END)
|
|
1557
|
+
* over a FROM with no join in it at all
|
|
1558
|
+
* AFTER both doors REFUSED INVALID_FIELD / 400, engine never reached
|
|
1559
|
+
* ```
|
|
1560
|
+
*
|
|
1561
|
+
* The same maintainer ruling covers it — same hazard, same physical verdict,
|
|
1562
|
+
* one more producer — so it folds in HERE for the #10861 reason and not into
|
|
1563
|
+
* `dataset-compiler.ts`, which still cannot see which driver will serve the
|
|
1564
|
+
* dataset. Only the REQUESTED measures are folded: both doors' aggregation
|
|
1565
|
+
* loops read `measureFilters[m]` for `m of query.measures` and nothing else,
|
|
1566
|
+
* so a filter declared on a measure this query never asks for reaches no
|
|
1567
|
+
* engine, and refusing on it would reject a query for a member that was never
|
|
1568
|
+
* going to be evaluated.
|
|
1569
|
+
*
|
|
1570
|
+
* Structure is discarded on purpose — a member is cross-object or it is not,
|
|
1571
|
+
* and which branch of a disjunction it sits in cannot make
|
|
1572
|
+
* `engine.aggregate` able to join it. PROVENANCE is not discarded, because it
|
|
1573
|
+
* decides what the refusal can tell the caller to go fix: `AnalyticsRequestKey`
|
|
1574
|
+
* is the analytics REQUEST vocabulary and a dataset's `filter` is not in it,
|
|
1575
|
+
* so a scope-borne member must not be reported as `param: 'where'` — see
|
|
1576
|
+
* `planCrossObject`. The value slot carries that and nothing else; it never
|
|
1577
|
+
* reaches a driver.
|
|
1578
|
+
*
|
|
1579
|
+
* Insertion order is measure-filter, then dataset-filter, then `where`, and
|
|
1580
|
+
* last write wins on a duplicate key. Two things follow, in that order of
|
|
1581
|
+
* importance. A member named by the request too keeps the CALLER's provenance,
|
|
1582
|
+
* because if it is in the request that is the actionable place to fix it. And
|
|
1583
|
+
* every shape that was refused before #11461 keeps the exact message it had:
|
|
1584
|
+
* the new origin can only ever win a key no older producer names.
|
|
1585
|
+
*
|
|
1586
|
+
* Time-dimension WINDOWS are deliberately absent (they live in
|
|
1587
|
+
* `dateRangeBounds`, not in `where`). They need no arm here: a cross-object
|
|
1588
|
+
* time dimension is refused by `planCrossObject`'s own first loop, over
|
|
1589
|
+
* `query.timeDimensions`, and refused as the time dimension the author wrote
|
|
1590
|
+
* rather than as the lowered predicate it becomes — which is the better
|
|
1591
|
+
* diagnostic and the reason that loop runs first.
|
|
1592
|
+
*/
|
|
1593
|
+
private filterMemberView;
|
|
1446
1594
|
/**
|
|
1447
1595
|
* Plan how to serve cross-object references on this join-less path (#3654).
|
|
1448
1596
|
*
|
|
@@ -1453,21 +1601,39 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
|
|
|
1453
1601
|
* query (direct path), a plan for an in-envelope cross-object query.
|
|
1454
1602
|
*
|
|
1455
1603
|
* THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
|
|
1456
|
-
* (needs a real join to evaluate), a
|
|
1457
|
-
*
|
|
1458
|
-
*
|
|
1459
|
-
* `
|
|
1460
|
-
*
|
|
1461
|
-
*
|
|
1462
|
-
*
|
|
1463
|
-
*
|
|
1464
|
-
*
|
|
1465
|
-
*
|
|
1466
|
-
*
|
|
1467
|
-
*
|
|
1468
|
-
*
|
|
1469
|
-
*
|
|
1470
|
-
*
|
|
1604
|
+
* (needs a real join to evaluate), a cross-object leaf in the DATASET's own
|
|
1605
|
+
* definition-level `filter` (#10861 — same join it does not have, arriving
|
|
1606
|
+
* from the producer PR #10758 added), a cross-object leaf in ONE MEASURE's own
|
|
1607
|
+
* `filter` (#11461 — the same join again, arriving from the producer #10413
|
|
1608
|
+
* phase 2 added), a MULTI-HOP dimension (`a.b.c`), or a non-recombinable
|
|
1609
|
+
* measure (`avg`/`count_distinct`, whose sub-bucket values cannot be merged).
|
|
1610
|
+
* A loud error beats the silent mis-bucket #3654 kills.
|
|
1611
|
+
* `generateSql()` calls this too, so the preview accepts/rejects the same set
|
|
1612
|
+
* — and since #10759 both callers derive `filter` from the one
|
|
1613
|
+
* {@link filterMemberView}, so that sentence is enforced by construction
|
|
1614
|
+
* instead of restated at two call sites.
|
|
1615
|
+
*
|
|
1616
|
+
* [#5716] All six refusals below are `invalidMemberError` — `INVALID_FIELD` /
|
|
1617
|
+
* 400, naming the member — and the four that predate #10861 keep their
|
|
1618
|
+
* MESSAGES unchanged (they are good diagnostics, and #5923's tests read
|
|
1619
|
+
* 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
|
|
1620
|
+
* reach the engine's predicate, and whether that member resolves across a
|
|
1621
|
+
* join. Neither is an internal invariant — a cube where the member exists and
|
|
1622
|
+
* a driver that could serve it are both perfectly ordinary, which is exactly
|
|
1623
|
+
* what the "run this on a native-SQL driver" half of every message says. They
|
|
1624
|
+
* are member-level rather than dataset-level (hence not `datasetInvalidError`)
|
|
1625
|
+
* because the fix is always to change or drop ONE named member, and because
|
|
1626
|
+
* four of them fire on `/analytics/query` where no dataset exists.
|
|
1627
|
+
*
|
|
1628
|
+
* [#10861, #11461] The fifth and sixth are the exceptions that prove the rule
|
|
1629
|
+
* and are written to it: they can only fire where a dataset DOES exist, and
|
|
1630
|
+
* they are the two refusals here whose member no request key named — so each
|
|
1631
|
+
* carries `cube` and no `param`, and says in its own words which document to
|
|
1632
|
+
* go and edit, the sixth naming the MEASURE inside it as well. Both stay
|
|
1633
|
+
* `INVALID_FIELD` rather than becoming `DATASET_INVALID` because the verdict
|
|
1634
|
+
* is the same physical one as their neighbours — this engine cannot join this
|
|
1635
|
+
* member — and splitting the code by PROVENANCE would make a caller branch on
|
|
1636
|
+
* three wire shapes for one capability limit.
|
|
1471
1637
|
*
|
|
1472
1638
|
* Detection is on RESOLVED field names, so a dotted dimension the cube
|
|
1473
1639
|
* flattens to a real column is treated as base, not cross-object.
|
|
@@ -1485,6 +1651,23 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
|
|
|
1485
1651
|
* are simply absent from the map (⇒ RESTRICTED bucket downstream).
|
|
1486
1652
|
*/
|
|
1487
1653
|
private resolveFkAttr;
|
|
1654
|
+
/**
|
|
1655
|
+
* A measure's aggregate, restricted to the rows its own `filter` admits
|
|
1656
|
+
* (#10413 phase 2) — the same six functions `generateSql`'s unconditional
|
|
1657
|
+
* branch renders, wrapped in a `CASE WHEN`.
|
|
1658
|
+
*
|
|
1659
|
+
* Spelled `CASE WHEN` rather than SQL-standard `FILTER (WHERE …)`, mirroring
|
|
1660
|
+
* `NativeSQLStrategy.CONDITIONAL_AGGREGATE_SQL`: this string is DOCUMENTATION
|
|
1661
|
+
* of an execution that really goes through `engine.aggregate`'s per-driver
|
|
1662
|
+
* `aggregations[].filter` lowering (#10576), not a statement this class runs
|
|
1663
|
+
* itself, so there is no reason to pick a dialect-restricted spelling over
|
|
1664
|
+
* the portable one the SQL-executing sibling already settled on.
|
|
1665
|
+
*
|
|
1666
|
+
* `count` over `*` counts a constant (`COUNT(CASE WHEN p THEN 1 END)`, since
|
|
1667
|
+
* `COUNT(CASE WHEN p THEN * END)` is not valid SQL); over a real column it
|
|
1668
|
+
* counts that column's non-null values among the admitted rows.
|
|
1669
|
+
*/
|
|
1670
|
+
private conditionalAggregateSql;
|
|
1488
1671
|
/**
|
|
1489
1672
|
* Render one normalized filter as a display SQL predicate for `generateSql`.
|
|
1490
1673
|
*
|
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';
|
|
@@ -328,10 +328,28 @@ interface AnalyticsServiceConfig {
|
|
|
328
328
|
*/
|
|
329
329
|
executeAggregate?: (objectName: string, options: {
|
|
330
330
|
groupBy?: string[];
|
|
331
|
+
/**
|
|
332
|
+
* The local mirror of `StrategyContext.executeAggregate`'s aggregation
|
|
333
|
+
* entries (`packages/spec/src/contracts/analytics-service.ts`), kept in
|
|
334
|
+
* lockstep with it member by member. The two that lockstep is load-bearing
|
|
335
|
+
* for:
|
|
336
|
+
*
|
|
337
|
+
* - `filter` (#10576, the #10413 phase-2 contract field) — a bridge that
|
|
338
|
+
* reconstructs the aggregation entries (as `AnalyticsServicePlugin`'s
|
|
339
|
+
* auto-bridge does, to rename `method` → the engine's `function`) MUST
|
|
340
|
+
* forward this field or a measure-scoped filter `ObjectQLStrategy`
|
|
341
|
+
* lowers never reaches storage.
|
|
342
|
+
* - `method` is the spec's OWN six-value `AggregationFunction`, not
|
|
343
|
+
* `string`: #12776 narrowed the contract, #12940 brought this mirror
|
|
344
|
+
* back into line. Widening it here again would not be a local matter —
|
|
345
|
+
* a bridge author types their handler against THIS declaration, so what
|
|
346
|
+
* they would get is a vocabulary the contract no longer has.
|
|
347
|
+
*/
|
|
331
348
|
aggregations?: Array<{
|
|
332
349
|
field: string;
|
|
333
|
-
method:
|
|
350
|
+
method: AggregationFunction;
|
|
334
351
|
alias: string;
|
|
352
|
+
filter?: Record<string, unknown>;
|
|
335
353
|
}>;
|
|
336
354
|
filter?: Record<string, unknown>;
|
|
337
355
|
/** Reference timezone (IANA) for date bucketing — ADR-0053 Phase 2. */
|
|
@@ -619,6 +637,27 @@ declare class AnalyticsService implements IAnalyticsService {
|
|
|
619
637
|
* `getReadScope(objectName)` that already knows the active tenant.
|
|
620
638
|
*/
|
|
621
639
|
private callCtx;
|
|
640
|
+
/**
|
|
641
|
+
* [#12230] Copy-on-write expansion of filter placeholders across everything
|
|
642
|
+
* a DIRECT analytics query compares on: `where` and each time dimension's
|
|
643
|
+
* `dateRange` — the same positions `DatasetExecutor.resolveSelectionTokens`
|
|
644
|
+
* covers for the dashboard door, minus the dataset-only channels it alone
|
|
645
|
+
* carries (measure filters ride the dataset-scope getter below).
|
|
646
|
+
*
|
|
647
|
+
* The input is never mutated: a query object can be caller-owned metadata
|
|
648
|
+
* (a saved report definition, a flow node's config) reused across requests,
|
|
649
|
+
* and resolving in place would bake one request's user id into every later
|
|
650
|
+
* render. Returns the SAME object when nothing resolved.
|
|
651
|
+
*/
|
|
652
|
+
private resolveQueryTokens;
|
|
653
|
+
/**
|
|
654
|
+
* [#12230] A per-request `getDatasetScope` whose answers have their filter
|
|
655
|
+
* placeholders resolved against THIS caller. See `callCtx` for why the
|
|
656
|
+
* registry's copy cannot be handed out raw. Token-free scopes pass through
|
|
657
|
+
* by reference — `resolveFilterTokens` returns its input unchanged when the
|
|
658
|
+
* tree holds no placeholder, so the common case allocates nothing.
|
|
659
|
+
*/
|
|
660
|
+
private resolvedDatasetScopeGetter;
|
|
622
661
|
/**
|
|
623
662
|
* Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
|
|
624
663
|
* AND every joined object of the query's cube, keyed by object name. This is
|
|
@@ -644,7 +683,7 @@ declare class AnalyticsService implements IAnalyticsService {
|
|
|
644
683
|
* aggregate bridge) instead of failing — or worse, fabricating empty rows.
|
|
645
684
|
* Any other error propagates untouched.
|
|
646
685
|
*/
|
|
647
|
-
query(
|
|
686
|
+
query(queryInput: AnalyticsQuery, context?: ExecutionContext): Promise<AnalyticsResult>;
|
|
648
687
|
/**
|
|
649
688
|
* [#8286] Withhold the executed statement unless this host enabled the echo.
|
|
650
689
|
*
|
|
@@ -686,7 +725,7 @@ declare class AnalyticsService implements IAnalyticsService {
|
|
|
686
725
|
/**
|
|
687
726
|
* Generate SQL for a query without executing it (dry-run).
|
|
688
727
|
*/
|
|
689
|
-
generateSql(
|
|
728
|
+
generateSql(queryInput: AnalyticsQuery, context?: ExecutionContext): Promise<{
|
|
690
729
|
sql: string;
|
|
691
730
|
params: unknown[];
|
|
692
731
|
}>;
|
|
@@ -922,10 +961,28 @@ interface AnalyticsServicePluginOptions {
|
|
|
922
961
|
*/
|
|
923
962
|
executeAggregate?: (objectName: string, options: {
|
|
924
963
|
groupBy?: string[];
|
|
964
|
+
/**
|
|
965
|
+
* The CUSTOM bridge's view of the aggregation entries — an app author's
|
|
966
|
+
* own `executeAggregate`, as opposed to the auto-bridge below. Mirrors
|
|
967
|
+
* `StrategyContext.executeAggregate`
|
|
968
|
+
* (`packages/spec/src/contracts/analytics-service.ts`) and must stay in
|
|
969
|
+
* lockstep with it; the two members that lockstep is load-bearing for:
|
|
970
|
+
*
|
|
971
|
+
* - `filter` (#10576, the #10413 contract field) — a custom bridge MUST
|
|
972
|
+
* forward it to the real engine the same way the auto-bridge does, or a
|
|
973
|
+
* measure-scoped filter this plugin lowers onto the aggregation
|
|
974
|
+
* silently never reaches storage.
|
|
975
|
+
* - `method` is the spec's OWN six-value `AggregationFunction`, not
|
|
976
|
+
* `string`: #12776 narrowed the contract, #12940 brought this mirror
|
|
977
|
+
* back into line. This is the declaration a custom-bridge author types
|
|
978
|
+
* their handler against, so it is where the compile-time vocabulary
|
|
979
|
+
* #12776 bought for strategy authors reaches them too.
|
|
980
|
+
*/
|
|
925
981
|
aggregations?: Array<{
|
|
926
982
|
field: string;
|
|
927
|
-
method:
|
|
983
|
+
method: AggregationFunction;
|
|
928
984
|
alias: string;
|
|
985
|
+
filter?: Record<string, unknown>;
|
|
929
986
|
}>;
|
|
930
987
|
filter?: Record<string, unknown>;
|
|
931
988
|
/** Reference timezone (IANA) for date bucketing — ADR-0053 Phase 2. */
|
|
@@ -1308,6 +1365,11 @@ declare class NativeSQLStrategy implements AnalyticsStrategy {
|
|
|
1308
1365
|
*/
|
|
1309
1366
|
private lookupMember;
|
|
1310
1367
|
private resolveDimensionSql;
|
|
1368
|
+
/**
|
|
1369
|
+
* @param predicate - The measure's own scoped filter, already compiled to a
|
|
1370
|
+
* SQL boolean (`null` = the measure declares none, or declares one that
|
|
1371
|
+
* constrains nothing — `compileFilterNode`'s TRUE). #10298.
|
|
1372
|
+
*/
|
|
1311
1373
|
private resolveMeasureSql;
|
|
1312
1374
|
private resolveFieldSql;
|
|
1313
1375
|
/**
|
|
@@ -1443,6 +1505,92 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
|
|
|
1443
1505
|
private withReadScope;
|
|
1444
1506
|
/** Is `field` a resolved cross-object (relationship-traversal) reference? */
|
|
1445
1507
|
private isCrossObjectField;
|
|
1508
|
+
/**
|
|
1509
|
+
* The member view {@link planCrossObject} judges a filter by: EVERY member
|
|
1510
|
+
* that will end up in the engine's predicate, structure discarded, keyed by
|
|
1511
|
+
* RESOLVED field name (#10759), valued by WHERE THE MEMBER CAME FROM
|
|
1512
|
+
* (#10861).
|
|
1513
|
+
*
|
|
1514
|
+
* Both call sites — `execute()` and `generateSql()` — are handed this and
|
|
1515
|
+
* nothing else, which is what makes the invariant `planCrossObject` states
|
|
1516
|
+
* for itself ("the preview accepts/rejects the same set") structural rather
|
|
1517
|
+
* than a coincidence maintained by hand. They used to build the view
|
|
1518
|
+
* separately: the echo flattened the tree, `execute()` passed the ENGINE
|
|
1519
|
+
* FILTER, and a filter record answers a different question — it is a
|
|
1520
|
+
* predicate to evaluate, not an inventory of members. An `$or`, a `$not` or
|
|
1521
|
+
* an unmergeable nested `$and` travels in it as one opaque `$and` entry, so
|
|
1522
|
+
* the members inside were unreadable from the outside and the envelope check
|
|
1523
|
+
* could not reject what it could not see.
|
|
1524
|
+
*
|
|
1525
|
+
* ## Three producers, one inventory (#10861, #11461)
|
|
1526
|
+
*
|
|
1527
|
+
* The caller's `where` is not the only thing that reaches `engine.aggregate`
|
|
1528
|
+
* as a predicate. Since PR #10758 the compiled dataset's own definition-level
|
|
1529
|
+
* `filter` is lowered onto `execute()`'s `conjuncts` and rendered by
|
|
1530
|
+
* `generateSql()`, so a dataset declaring `filter: { 'account.region': 'West' }`
|
|
1531
|
+
* sent `{"$and":[{"account.region":"West"}]}` to an engine that cannot join —
|
|
1532
|
+
* measured on both doors, which AGREED in accepting it, so #10759's
|
|
1533
|
+
* preview/execution symmetry had nothing to restore. Refusing it is a
|
|
1534
|
+
* widening of the refusal set, ruled by the maintainer on 2026-08-22 (Option
|
|
1535
|
+
* A, query-time refusal): fold the scope's leaves in HERE, where driver
|
|
1536
|
+
* capability is known, rather than in `dataset-compiler.ts`, which cannot see
|
|
1537
|
+
* which driver will serve the dataset and would refuse a dataset that is
|
|
1538
|
+
* perfectly legal on a native-SQL deployment.
|
|
1539
|
+
*
|
|
1540
|
+
* [#11461] #10413 phase 2 then added a THIRD producer with the same reach and
|
|
1541
|
+
* none of the coverage: a compiled measure's own `filter`, lowered onto that
|
|
1542
|
+
* measure's `aggregations[].filter` entry (#10576). This view enumerated two
|
|
1543
|
+
* origins, so the third was invisible to the envelope check and the arm of
|
|
1544
|
+
* `planCrossObject` that inspects `query.measures` reads only each measure's
|
|
1545
|
+
* resolved FIELD, never its filter. Measured on the unfixed tree, one fixture,
|
|
1546
|
+
* both doors:
|
|
1547
|
+
*
|
|
1548
|
+
* ```
|
|
1549
|
+
* BEFORE execute() ACCEPTED -> aggregations: [{field:"*",method:"count",
|
|
1550
|
+
* alias:"west_count",
|
|
1551
|
+
* filter:{"account.region":"West"}}]
|
|
1552
|
+
* -> rows [{stage:"won",total_count:3,west_count:0}]
|
|
1553
|
+
* (the truthful west_count is 2; total_count
|
|
1554
|
+
* is right, so the wrong number arrived in
|
|
1555
|
+
* the same response shape as the right one)
|
|
1556
|
+
* generateSql() ACCEPTED -> COUNT(CASE WHEN account.region = $1 THEN 1 END)
|
|
1557
|
+
* over a FROM with no join in it at all
|
|
1558
|
+
* AFTER both doors REFUSED INVALID_FIELD / 400, engine never reached
|
|
1559
|
+
* ```
|
|
1560
|
+
*
|
|
1561
|
+
* The same maintainer ruling covers it — same hazard, same physical verdict,
|
|
1562
|
+
* one more producer — so it folds in HERE for the #10861 reason and not into
|
|
1563
|
+
* `dataset-compiler.ts`, which still cannot see which driver will serve the
|
|
1564
|
+
* dataset. Only the REQUESTED measures are folded: both doors' aggregation
|
|
1565
|
+
* loops read `measureFilters[m]` for `m of query.measures` and nothing else,
|
|
1566
|
+
* so a filter declared on a measure this query never asks for reaches no
|
|
1567
|
+
* engine, and refusing on it would reject a query for a member that was never
|
|
1568
|
+
* going to be evaluated.
|
|
1569
|
+
*
|
|
1570
|
+
* Structure is discarded on purpose — a member is cross-object or it is not,
|
|
1571
|
+
* and which branch of a disjunction it sits in cannot make
|
|
1572
|
+
* `engine.aggregate` able to join it. PROVENANCE is not discarded, because it
|
|
1573
|
+
* decides what the refusal can tell the caller to go fix: `AnalyticsRequestKey`
|
|
1574
|
+
* is the analytics REQUEST vocabulary and a dataset's `filter` is not in it,
|
|
1575
|
+
* so a scope-borne member must not be reported as `param: 'where'` — see
|
|
1576
|
+
* `planCrossObject`. The value slot carries that and nothing else; it never
|
|
1577
|
+
* reaches a driver.
|
|
1578
|
+
*
|
|
1579
|
+
* Insertion order is measure-filter, then dataset-filter, then `where`, and
|
|
1580
|
+
* last write wins on a duplicate key. Two things follow, in that order of
|
|
1581
|
+
* importance. A member named by the request too keeps the CALLER's provenance,
|
|
1582
|
+
* because if it is in the request that is the actionable place to fix it. And
|
|
1583
|
+
* every shape that was refused before #11461 keeps the exact message it had:
|
|
1584
|
+
* the new origin can only ever win a key no older producer names.
|
|
1585
|
+
*
|
|
1586
|
+
* Time-dimension WINDOWS are deliberately absent (they live in
|
|
1587
|
+
* `dateRangeBounds`, not in `where`). They need no arm here: a cross-object
|
|
1588
|
+
* time dimension is refused by `planCrossObject`'s own first loop, over
|
|
1589
|
+
* `query.timeDimensions`, and refused as the time dimension the author wrote
|
|
1590
|
+
* rather than as the lowered predicate it becomes — which is the better
|
|
1591
|
+
* diagnostic and the reason that loop runs first.
|
|
1592
|
+
*/
|
|
1593
|
+
private filterMemberView;
|
|
1446
1594
|
/**
|
|
1447
1595
|
* Plan how to serve cross-object references on this join-less path (#3654).
|
|
1448
1596
|
*
|
|
@@ -1453,21 +1601,39 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
|
|
|
1453
1601
|
* query (direct path), a plan for an in-envelope cross-object query.
|
|
1454
1602
|
*
|
|
1455
1603
|
* THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
|
|
1456
|
-
* (needs a real join to evaluate), a
|
|
1457
|
-
*
|
|
1458
|
-
*
|
|
1459
|
-
* `
|
|
1460
|
-
*
|
|
1461
|
-
*
|
|
1462
|
-
*
|
|
1463
|
-
*
|
|
1464
|
-
*
|
|
1465
|
-
*
|
|
1466
|
-
*
|
|
1467
|
-
*
|
|
1468
|
-
*
|
|
1469
|
-
*
|
|
1470
|
-
*
|
|
1604
|
+
* (needs a real join to evaluate), a cross-object leaf in the DATASET's own
|
|
1605
|
+
* definition-level `filter` (#10861 — same join it does not have, arriving
|
|
1606
|
+
* from the producer PR #10758 added), a cross-object leaf in ONE MEASURE's own
|
|
1607
|
+
* `filter` (#11461 — the same join again, arriving from the producer #10413
|
|
1608
|
+
* phase 2 added), a MULTI-HOP dimension (`a.b.c`), or a non-recombinable
|
|
1609
|
+
* measure (`avg`/`count_distinct`, whose sub-bucket values cannot be merged).
|
|
1610
|
+
* A loud error beats the silent mis-bucket #3654 kills.
|
|
1611
|
+
* `generateSql()` calls this too, so the preview accepts/rejects the same set
|
|
1612
|
+
* — and since #10759 both callers derive `filter` from the one
|
|
1613
|
+
* {@link filterMemberView}, so that sentence is enforced by construction
|
|
1614
|
+
* instead of restated at two call sites.
|
|
1615
|
+
*
|
|
1616
|
+
* [#5716] All six refusals below are `invalidMemberError` — `INVALID_FIELD` /
|
|
1617
|
+
* 400, naming the member — and the four that predate #10861 keep their
|
|
1618
|
+
* MESSAGES unchanged (they are good diagnostics, and #5923's tests read
|
|
1619
|
+
* 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
|
|
1620
|
+
* reach the engine's predicate, and whether that member resolves across a
|
|
1621
|
+
* join. Neither is an internal invariant — a cube where the member exists and
|
|
1622
|
+
* a driver that could serve it are both perfectly ordinary, which is exactly
|
|
1623
|
+
* what the "run this on a native-SQL driver" half of every message says. They
|
|
1624
|
+
* are member-level rather than dataset-level (hence not `datasetInvalidError`)
|
|
1625
|
+
* because the fix is always to change or drop ONE named member, and because
|
|
1626
|
+
* four of them fire on `/analytics/query` where no dataset exists.
|
|
1627
|
+
*
|
|
1628
|
+
* [#10861, #11461] The fifth and sixth are the exceptions that prove the rule
|
|
1629
|
+
* and are written to it: they can only fire where a dataset DOES exist, and
|
|
1630
|
+
* they are the two refusals here whose member no request key named — so each
|
|
1631
|
+
* carries `cube` and no `param`, and says in its own words which document to
|
|
1632
|
+
* go and edit, the sixth naming the MEASURE inside it as well. Both stay
|
|
1633
|
+
* `INVALID_FIELD` rather than becoming `DATASET_INVALID` because the verdict
|
|
1634
|
+
* is the same physical one as their neighbours — this engine cannot join this
|
|
1635
|
+
* member — and splitting the code by PROVENANCE would make a caller branch on
|
|
1636
|
+
* three wire shapes for one capability limit.
|
|
1471
1637
|
*
|
|
1472
1638
|
* Detection is on RESOLVED field names, so a dotted dimension the cube
|
|
1473
1639
|
* flattens to a real column is treated as base, not cross-object.
|
|
@@ -1485,6 +1651,23 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
|
|
|
1485
1651
|
* are simply absent from the map (⇒ RESTRICTED bucket downstream).
|
|
1486
1652
|
*/
|
|
1487
1653
|
private resolveFkAttr;
|
|
1654
|
+
/**
|
|
1655
|
+
* A measure's aggregate, restricted to the rows its own `filter` admits
|
|
1656
|
+
* (#10413 phase 2) — the same six functions `generateSql`'s unconditional
|
|
1657
|
+
* branch renders, wrapped in a `CASE WHEN`.
|
|
1658
|
+
*
|
|
1659
|
+
* Spelled `CASE WHEN` rather than SQL-standard `FILTER (WHERE …)`, mirroring
|
|
1660
|
+
* `NativeSQLStrategy.CONDITIONAL_AGGREGATE_SQL`: this string is DOCUMENTATION
|
|
1661
|
+
* of an execution that really goes through `engine.aggregate`'s per-driver
|
|
1662
|
+
* `aggregations[].filter` lowering (#10576), not a statement this class runs
|
|
1663
|
+
* itself, so there is no reason to pick a dialect-restricted spelling over
|
|
1664
|
+
* the portable one the SQL-executing sibling already settled on.
|
|
1665
|
+
*
|
|
1666
|
+
* `count` over `*` counts a constant (`COUNT(CASE WHEN p THEN 1 END)`, since
|
|
1667
|
+
* `COUNT(CASE WHEN p THEN * END)` is not valid SQL); over a real column it
|
|
1668
|
+
* counts that column's non-null values among the admitted rows.
|
|
1669
|
+
*/
|
|
1670
|
+
private conditionalAggregateSql;
|
|
1488
1671
|
/**
|
|
1489
1672
|
* Render one normalized filter as a display SQL predicate for `generateSql`.
|
|
1490
1673
|
*
|