@objectstack/service-analytics 17.1.0 → 17.2.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 +195 -0
- package/README.md +5 -3
- package/dist/index.cjs +179 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +86 -13
- package/dist/index.d.ts +86 -13
- package/dist/index.js +179 -20
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
package/dist/index.d.cts
CHANGED
|
@@ -1308,6 +1308,11 @@ declare class NativeSQLStrategy implements AnalyticsStrategy {
|
|
|
1308
1308
|
*/
|
|
1309
1309
|
private lookupMember;
|
|
1310
1310
|
private resolveDimensionSql;
|
|
1311
|
+
/**
|
|
1312
|
+
* @param predicate - The measure's own scoped filter, already compiled to a
|
|
1313
|
+
* SQL boolean (`null` = the measure declares none, or declares one that
|
|
1314
|
+
* constrains nothing — `compileFilterNode`'s TRUE). #10298.
|
|
1315
|
+
*/
|
|
1311
1316
|
private resolveMeasureSql;
|
|
1312
1317
|
private resolveFieldSql;
|
|
1313
1318
|
/**
|
|
@@ -1443,6 +1448,59 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
|
|
|
1443
1448
|
private withReadScope;
|
|
1444
1449
|
/** Is `field` a resolved cross-object (relationship-traversal) reference? */
|
|
1445
1450
|
private isCrossObjectField;
|
|
1451
|
+
/**
|
|
1452
|
+
* The member view {@link planCrossObject} judges a filter by: EVERY member
|
|
1453
|
+
* that will end up in the engine's predicate, structure discarded, keyed by
|
|
1454
|
+
* RESOLVED field name (#10759), valued by WHERE THE MEMBER CAME FROM
|
|
1455
|
+
* (#10861).
|
|
1456
|
+
*
|
|
1457
|
+
* Both call sites — `execute()` and `generateSql()` — are handed this and
|
|
1458
|
+
* nothing else, which is what makes the invariant `planCrossObject` states
|
|
1459
|
+
* for itself ("the preview accepts/rejects the same set") structural rather
|
|
1460
|
+
* than a coincidence maintained by hand. They used to build the view
|
|
1461
|
+
* separately: the echo flattened the tree, `execute()` passed the ENGINE
|
|
1462
|
+
* FILTER, and a filter record answers a different question — it is a
|
|
1463
|
+
* predicate to evaluate, not an inventory of members. An `$or`, a `$not` or
|
|
1464
|
+
* an unmergeable nested `$and` travels in it as one opaque `$and` entry, so
|
|
1465
|
+
* the members inside were unreadable from the outside and the envelope check
|
|
1466
|
+
* could not reject what it could not see.
|
|
1467
|
+
*
|
|
1468
|
+
* ## Two producers, one inventory (#10861)
|
|
1469
|
+
*
|
|
1470
|
+
* The caller's `where` is not the only thing that reaches `engine.aggregate`
|
|
1471
|
+
* as a predicate. Since PR #10758 the compiled dataset's own definition-level
|
|
1472
|
+
* `filter` is lowered onto `execute()`'s `conjuncts` and rendered by
|
|
1473
|
+
* `generateSql()`, so a dataset declaring `filter: { 'account.region': 'West' }`
|
|
1474
|
+
* sent `{"$and":[{"account.region":"West"}]}` to an engine that cannot join —
|
|
1475
|
+
* measured on both doors, which AGREED in accepting it, so #10759's
|
|
1476
|
+
* preview/execution symmetry had nothing to restore. Refusing it is a
|
|
1477
|
+
* widening of the refusal set, ruled by the maintainer on 2026-08-22 (Option
|
|
1478
|
+
* A, query-time refusal): fold the scope's leaves in HERE, where driver
|
|
1479
|
+
* capability is known, rather than in `dataset-compiler.ts`, which cannot see
|
|
1480
|
+
* which driver will serve the dataset and would refuse a dataset that is
|
|
1481
|
+
* perfectly legal on a native-SQL deployment.
|
|
1482
|
+
*
|
|
1483
|
+
* Structure is discarded on purpose — a member is cross-object or it is not,
|
|
1484
|
+
* and which branch of a disjunction it sits in cannot make
|
|
1485
|
+
* `engine.aggregate` able to join it. PROVENANCE is not discarded, because it
|
|
1486
|
+
* decides what the refusal can tell the caller to go fix: `AnalyticsRequestKey`
|
|
1487
|
+
* is the analytics REQUEST vocabulary and a dataset's `filter` is not in it,
|
|
1488
|
+
* so a scope-borne member must not be reported as `param: 'where'` — see
|
|
1489
|
+
* `planCrossObject`. The value slot carries that and nothing else; it never
|
|
1490
|
+
* reaches a driver.
|
|
1491
|
+
*
|
|
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.
|
|
1495
|
+
*
|
|
1496
|
+
* Time-dimension WINDOWS are deliberately absent (they live in
|
|
1497
|
+
* `dateRangeBounds`, not in `where`). They need no arm here: a cross-object
|
|
1498
|
+
* time dimension is refused by `planCrossObject`'s own first loop, over
|
|
1499
|
+
* `query.timeDimensions`, and refused as the time dimension the author wrote
|
|
1500
|
+
* rather than as the lowered predicate it becomes — which is the better
|
|
1501
|
+
* diagnostic and the reason that loop runs first.
|
|
1502
|
+
*/
|
|
1503
|
+
private filterMemberView;
|
|
1446
1504
|
/**
|
|
1447
1505
|
* Plan how to serve cross-object references on this join-less path (#3654).
|
|
1448
1506
|
*
|
|
@@ -1453,21 +1511,36 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
|
|
|
1453
1511
|
* query (direct path), a plan for an in-envelope cross-object query.
|
|
1454
1512
|
*
|
|
1455
1513
|
* THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
|
|
1456
|
-
* (needs a real join to evaluate), a
|
|
1514
|
+
* (needs a real join to evaluate), a cross-object leaf in the DATASET's own
|
|
1515
|
+
* 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
|
|
1457
1517
|
* non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
|
|
1458
1518
|
* cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
|
|
1459
|
-
* `generateSql()` calls this too, so the preview accepts/rejects the same set
|
|
1460
|
-
*
|
|
1461
|
-
*
|
|
1462
|
-
*
|
|
1463
|
-
*
|
|
1464
|
-
*
|
|
1465
|
-
*
|
|
1466
|
-
*
|
|
1467
|
-
*
|
|
1468
|
-
*
|
|
1469
|
-
*
|
|
1470
|
-
*
|
|
1519
|
+
* `generateSql()` calls this too, so the preview accepts/rejects the same set
|
|
1520
|
+
* — and since #10759 both callers derive `filter` from the one
|
|
1521
|
+
* {@link filterMemberView}, so that sentence is enforced by construction
|
|
1522
|
+
* instead of restated at two call sites.
|
|
1523
|
+
*
|
|
1524
|
+
* [#5716] All five refusals below are `invalidMemberError` — `INVALID_FIELD` /
|
|
1525
|
+
* 400, naming the member — and the four that predate #10861 keep their
|
|
1526
|
+
* 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
|
|
1528
|
+
* reach the engine's predicate, and whether that member resolves across a
|
|
1529
|
+
* join. Neither is an internal invariant — a cube where the member exists and
|
|
1530
|
+
* a driver that could serve it are both perfectly ordinary, which is exactly
|
|
1531
|
+
* what the "run this on a native-SQL driver" half of every message says. They
|
|
1532
|
+
* are member-level rather than dataset-level (hence not `datasetInvalidError`)
|
|
1533
|
+
* because the fix is always to change or drop ONE named member, and because
|
|
1534
|
+
* four of them fire on `/analytics/query` where no dataset exists.
|
|
1535
|
+
*
|
|
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
|
|
1540
|
+
* `INVALID_FIELD` rather than becoming `DATASET_INVALID` because the verdict
|
|
1541
|
+
* is the same physical one as its neighbour — this engine cannot join this
|
|
1542
|
+
* member — and splitting the code by PROVENANCE would make a caller branch on
|
|
1543
|
+
* two wire shapes for one capability limit.
|
|
1471
1544
|
*
|
|
1472
1545
|
* Detection is on RESOLVED field names, so a dotted dimension the cube
|
|
1473
1546
|
* flattens to a real column is treated as base, not cross-object.
|
package/dist/index.d.ts
CHANGED
|
@@ -1308,6 +1308,11 @@ declare class NativeSQLStrategy implements AnalyticsStrategy {
|
|
|
1308
1308
|
*/
|
|
1309
1309
|
private lookupMember;
|
|
1310
1310
|
private resolveDimensionSql;
|
|
1311
|
+
/**
|
|
1312
|
+
* @param predicate - The measure's own scoped filter, already compiled to a
|
|
1313
|
+
* SQL boolean (`null` = the measure declares none, or declares one that
|
|
1314
|
+
* constrains nothing — `compileFilterNode`'s TRUE). #10298.
|
|
1315
|
+
*/
|
|
1311
1316
|
private resolveMeasureSql;
|
|
1312
1317
|
private resolveFieldSql;
|
|
1313
1318
|
/**
|
|
@@ -1443,6 +1448,59 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
|
|
|
1443
1448
|
private withReadScope;
|
|
1444
1449
|
/** Is `field` a resolved cross-object (relationship-traversal) reference? */
|
|
1445
1450
|
private isCrossObjectField;
|
|
1451
|
+
/**
|
|
1452
|
+
* The member view {@link planCrossObject} judges a filter by: EVERY member
|
|
1453
|
+
* that will end up in the engine's predicate, structure discarded, keyed by
|
|
1454
|
+
* RESOLVED field name (#10759), valued by WHERE THE MEMBER CAME FROM
|
|
1455
|
+
* (#10861).
|
|
1456
|
+
*
|
|
1457
|
+
* Both call sites — `execute()` and `generateSql()` — are handed this and
|
|
1458
|
+
* nothing else, which is what makes the invariant `planCrossObject` states
|
|
1459
|
+
* for itself ("the preview accepts/rejects the same set") structural rather
|
|
1460
|
+
* than a coincidence maintained by hand. They used to build the view
|
|
1461
|
+
* separately: the echo flattened the tree, `execute()` passed the ENGINE
|
|
1462
|
+
* FILTER, and a filter record answers a different question — it is a
|
|
1463
|
+
* predicate to evaluate, not an inventory of members. An `$or`, a `$not` or
|
|
1464
|
+
* an unmergeable nested `$and` travels in it as one opaque `$and` entry, so
|
|
1465
|
+
* the members inside were unreadable from the outside and the envelope check
|
|
1466
|
+
* could not reject what it could not see.
|
|
1467
|
+
*
|
|
1468
|
+
* ## Two producers, one inventory (#10861)
|
|
1469
|
+
*
|
|
1470
|
+
* The caller's `where` is not the only thing that reaches `engine.aggregate`
|
|
1471
|
+
* as a predicate. Since PR #10758 the compiled dataset's own definition-level
|
|
1472
|
+
* `filter` is lowered onto `execute()`'s `conjuncts` and rendered by
|
|
1473
|
+
* `generateSql()`, so a dataset declaring `filter: { 'account.region': 'West' }`
|
|
1474
|
+
* sent `{"$and":[{"account.region":"West"}]}` to an engine that cannot join —
|
|
1475
|
+
* measured on both doors, which AGREED in accepting it, so #10759's
|
|
1476
|
+
* preview/execution symmetry had nothing to restore. Refusing it is a
|
|
1477
|
+
* widening of the refusal set, ruled by the maintainer on 2026-08-22 (Option
|
|
1478
|
+
* A, query-time refusal): fold the scope's leaves in HERE, where driver
|
|
1479
|
+
* capability is known, rather than in `dataset-compiler.ts`, which cannot see
|
|
1480
|
+
* which driver will serve the dataset and would refuse a dataset that is
|
|
1481
|
+
* perfectly legal on a native-SQL deployment.
|
|
1482
|
+
*
|
|
1483
|
+
* Structure is discarded on purpose — a member is cross-object or it is not,
|
|
1484
|
+
* and which branch of a disjunction it sits in cannot make
|
|
1485
|
+
* `engine.aggregate` able to join it. PROVENANCE is not discarded, because it
|
|
1486
|
+
* decides what the refusal can tell the caller to go fix: `AnalyticsRequestKey`
|
|
1487
|
+
* is the analytics REQUEST vocabulary and a dataset's `filter` is not in it,
|
|
1488
|
+
* so a scope-borne member must not be reported as `param: 'where'` — see
|
|
1489
|
+
* `planCrossObject`. The value slot carries that and nothing else; it never
|
|
1490
|
+
* reaches a driver.
|
|
1491
|
+
*
|
|
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.
|
|
1495
|
+
*
|
|
1496
|
+
* Time-dimension WINDOWS are deliberately absent (they live in
|
|
1497
|
+
* `dateRangeBounds`, not in `where`). They need no arm here: a cross-object
|
|
1498
|
+
* time dimension is refused by `planCrossObject`'s own first loop, over
|
|
1499
|
+
* `query.timeDimensions`, and refused as the time dimension the author wrote
|
|
1500
|
+
* rather than as the lowered predicate it becomes — which is the better
|
|
1501
|
+
* diagnostic and the reason that loop runs first.
|
|
1502
|
+
*/
|
|
1503
|
+
private filterMemberView;
|
|
1446
1504
|
/**
|
|
1447
1505
|
* Plan how to serve cross-object references on this join-less path (#3654).
|
|
1448
1506
|
*
|
|
@@ -1453,21 +1511,36 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
|
|
|
1453
1511
|
* query (direct path), a plan for an in-envelope cross-object query.
|
|
1454
1512
|
*
|
|
1455
1513
|
* THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
|
|
1456
|
-
* (needs a real join to evaluate), a
|
|
1514
|
+
* (needs a real join to evaluate), a cross-object leaf in the DATASET's own
|
|
1515
|
+
* 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
|
|
1457
1517
|
* non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
|
|
1458
1518
|
* cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
|
|
1459
|
-
* `generateSql()` calls this too, so the preview accepts/rejects the same set
|
|
1460
|
-
*
|
|
1461
|
-
*
|
|
1462
|
-
*
|
|
1463
|
-
*
|
|
1464
|
-
*
|
|
1465
|
-
*
|
|
1466
|
-
*
|
|
1467
|
-
*
|
|
1468
|
-
*
|
|
1469
|
-
*
|
|
1470
|
-
*
|
|
1519
|
+
* `generateSql()` calls this too, so the preview accepts/rejects the same set
|
|
1520
|
+
* — and since #10759 both callers derive `filter` from the one
|
|
1521
|
+
* {@link filterMemberView}, so that sentence is enforced by construction
|
|
1522
|
+
* instead of restated at two call sites.
|
|
1523
|
+
*
|
|
1524
|
+
* [#5716] All five refusals below are `invalidMemberError` — `INVALID_FIELD` /
|
|
1525
|
+
* 400, naming the member — and the four that predate #10861 keep their
|
|
1526
|
+
* 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
|
|
1528
|
+
* reach the engine's predicate, and whether that member resolves across a
|
|
1529
|
+
* join. Neither is an internal invariant — a cube where the member exists and
|
|
1530
|
+
* a driver that could serve it are both perfectly ordinary, which is exactly
|
|
1531
|
+
* what the "run this on a native-SQL driver" half of every message says. They
|
|
1532
|
+
* are member-level rather than dataset-level (hence not `datasetInvalidError`)
|
|
1533
|
+
* because the fix is always to change or drop ONE named member, and because
|
|
1534
|
+
* four of them fire on `/analytics/query` where no dataset exists.
|
|
1535
|
+
*
|
|
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
|
|
1540
|
+
* `INVALID_FIELD` rather than becoming `DATASET_INVALID` because the verdict
|
|
1541
|
+
* is the same physical one as its neighbour — this engine cannot join this
|
|
1542
|
+
* member — and splitting the code by PROVENANCE would make a caller branch on
|
|
1543
|
+
* two wire shapes for one capability limit.
|
|
1471
1544
|
*
|
|
1472
1545
|
* Detection is on RESOLVED field names, so a dotted dimension the cube
|
|
1473
1546
|
* flattens to a real column is treated as base, not cross-object.
|
package/dist/index.js
CHANGED
|
@@ -1026,14 +1026,31 @@ function invalidMemberError(message, meta) {
|
|
|
1026
1026
|
// src/strategies/native-sql-strategy.ts
|
|
1027
1027
|
import { nextUtcCalendarDay } from "@objectstack/core";
|
|
1028
1028
|
var AGGREGATE_SQL = {
|
|
1029
|
-
|
|
1029
|
+
// [#10298] `count` takes its COLUMN when the measure declares one. The
|
|
1030
|
+
// wrapper used to discard `col` and always emit `COUNT(*)`, so a measure
|
|
1031
|
+
// written `{ aggregate: 'count', field: 'resolved_by_article' }` counted
|
|
1032
|
+
// ROWS instead of non-null values — and a deflection rate built as
|
|
1033
|
+
// `kb_resolved_count / closed_count` read 100% where the truth was 12.5%,
|
|
1034
|
+
// with the numerator and denominator printed beside it as 8 and 8. `*` is
|
|
1035
|
+
// still `COUNT(*)`: the compiler writes `sql: m.field ?? '*'`, so the star
|
|
1036
|
+
// IS the "no field declared" spelling and must keep counting rows.
|
|
1037
|
+
"count": (col) => col === "*" ? "COUNT(*)" : `COUNT(${col})`,
|
|
1030
1038
|
"sum": (col) => `SUM(${col})`,
|
|
1031
1039
|
"avg": (col) => `AVG(${col})`,
|
|
1032
1040
|
"min": (col) => `MIN(${col})`,
|
|
1033
1041
|
"max": (col) => `MAX(${col})`,
|
|
1034
1042
|
"count_distinct": (col) => `COUNT(DISTINCT ${col})`
|
|
1035
1043
|
};
|
|
1044
|
+
var CONDITIONAL_AGGREGATE_SQL = {
|
|
1045
|
+
"count": (col, pred) => `COUNT(CASE WHEN ${pred} THEN ${col === "*" ? "1" : col} END)`,
|
|
1046
|
+
"sum": (col, pred) => `SUM(CASE WHEN ${pred} THEN ${col} END)`,
|
|
1047
|
+
"avg": (col, pred) => `AVG(CASE WHEN ${pred} THEN ${col} END)`,
|
|
1048
|
+
"min": (col, pred) => `MIN(CASE WHEN ${pred} THEN ${col} END)`,
|
|
1049
|
+
"max": (col, pred) => `MAX(CASE WHEN ${pred} THEN ${col} END)`,
|
|
1050
|
+
"count_distinct": (col, pred) => `COUNT(DISTINCT CASE WHEN ${pred} THEN ${col} END)`
|
|
1051
|
+
};
|
|
1036
1052
|
var SUPPORTED_AGGREGATE_SQL_KEYS = Object.keys(AGGREGATE_SQL);
|
|
1053
|
+
var CONDITIONAL_AGGREGATE_SQL_KEYS = Object.keys(CONDITIONAL_AGGREGATE_SQL);
|
|
1037
1054
|
var EXPRESSION_METRIC_TYPES = /* @__PURE__ */ new Set(["number", "string", "boolean"]);
|
|
1038
1055
|
var IDENTIFIER_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
1039
1056
|
var NativeSQLStrategy = class {
|
|
@@ -1199,9 +1216,19 @@ var NativeSQLStrategy = class {
|
|
|
1199
1216
|
groupByClauses.push(colExpr);
|
|
1200
1217
|
}
|
|
1201
1218
|
}
|
|
1219
|
+
const datasetScope = ctx.getDatasetScope?.(query.cube);
|
|
1202
1220
|
if (query.measures && query.measures.length > 0) {
|
|
1203
1221
|
for (const measure of query.measures) {
|
|
1204
|
-
const
|
|
1222
|
+
const measureFilter = datasetScope?.measureFilters?.[measure];
|
|
1223
|
+
const predicate = measureFilter ? this.compileFilterNode(
|
|
1224
|
+
normalizeAnalyticsFilterTree({ where: measureFilter }),
|
|
1225
|
+
cube,
|
|
1226
|
+
tableName,
|
|
1227
|
+
joins,
|
|
1228
|
+
params,
|
|
1229
|
+
ctx
|
|
1230
|
+
) : null;
|
|
1231
|
+
const aggExpr = this.resolveMeasureSql(cube, measure, tableName, joins, predicate);
|
|
1205
1232
|
selectClauses.push(`${aggExpr} AS "${measure}"`);
|
|
1206
1233
|
}
|
|
1207
1234
|
}
|
|
@@ -1215,6 +1242,17 @@ var NativeSQLStrategy = class {
|
|
|
1215
1242
|
ctx
|
|
1216
1243
|
);
|
|
1217
1244
|
if (filterSql) whereClauses.push(filterSql);
|
|
1245
|
+
if (datasetScope?.filter) {
|
|
1246
|
+
const scopeSql = this.compileFilterNode(
|
|
1247
|
+
normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
|
|
1248
|
+
cube,
|
|
1249
|
+
tableName,
|
|
1250
|
+
joins,
|
|
1251
|
+
params,
|
|
1252
|
+
ctx
|
|
1253
|
+
);
|
|
1254
|
+
if (scopeSql) whereClauses.push(scopeSql);
|
|
1255
|
+
}
|
|
1218
1256
|
if (query.timeDimensions && query.timeDimensions.length > 0) {
|
|
1219
1257
|
for (const td of query.timeDimensions) {
|
|
1220
1258
|
const colExpr = this.resolveFieldSql(cube, td.dimension, tableName, joins);
|
|
@@ -1388,7 +1426,12 @@ var NativeSQLStrategy = class {
|
|
|
1388
1426
|
const raw = dim ? dim.sql : member.includes(".") ? member.split(".")[1] : member;
|
|
1389
1427
|
return this.qualifyAndRegisterJoin(raw, parentTable, joins, cube);
|
|
1390
1428
|
}
|
|
1391
|
-
|
|
1429
|
+
/**
|
|
1430
|
+
* @param predicate - The measure's own scoped filter, already compiled to a
|
|
1431
|
+
* SQL boolean (`null` = the measure declares none, or declares one that
|
|
1432
|
+
* constrains nothing — `compileFilterNode`'s TRUE). #10298.
|
|
1433
|
+
*/
|
|
1434
|
+
resolveMeasureSql(cube, member, parentTable, joins, predicate = null) {
|
|
1392
1435
|
const measure = this.lookupMember(cube, member, "measure");
|
|
1393
1436
|
if (!measure) {
|
|
1394
1437
|
const declared = Object.keys(cube.measures ?? {});
|
|
@@ -1398,6 +1441,13 @@ var NativeSQLStrategy = class {
|
|
|
1398
1441
|
);
|
|
1399
1442
|
}
|
|
1400
1443
|
const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
|
|
1444
|
+
if (predicate !== null) {
|
|
1445
|
+
const wrapConditional = CONDITIONAL_AGGREGATE_SQL[measure.type];
|
|
1446
|
+
if (wrapConditional) return wrapConditional(col, predicate);
|
|
1447
|
+
throw new Error(
|
|
1448
|
+
`[native-sql-strategy] measure "${member}" on cube "${cube.name}" carries a scoped filter, but its type "${measure.type}" has no conditional form (conditional: ${CONDITIONAL_AGGREGATE_SQL_KEYS.join(", ")}).`
|
|
1449
|
+
);
|
|
1450
|
+
}
|
|
1401
1451
|
const wrap = AGGREGATE_SQL[measure.type];
|
|
1402
1452
|
if (wrap) return wrap(col);
|
|
1403
1453
|
if (EXPRESSION_METRIC_TYPES.has(measure.type)) return col;
|
|
@@ -1744,10 +1794,18 @@ var ObjectQLStrategy = class {
|
|
|
1744
1794
|
const extra = this.mergeFilterOperand(filter, field, bounds);
|
|
1745
1795
|
if (extra) conjuncts.push(extra);
|
|
1746
1796
|
}
|
|
1797
|
+
const datasetScope = ctx.getDatasetScope?.(query.cube);
|
|
1798
|
+
if (datasetScope?.filter) {
|
|
1799
|
+
const scopeCondition = this.filterNodeToCondition(
|
|
1800
|
+
normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
|
|
1801
|
+
cube
|
|
1802
|
+
);
|
|
1803
|
+
if (scopeCondition) conjuncts.push(scopeCondition);
|
|
1804
|
+
}
|
|
1747
1805
|
if (conjuncts.length > 0) {
|
|
1748
1806
|
filter.$and = [...Array.isArray(filter.$and) ? filter.$and : [], ...conjuncts];
|
|
1749
1807
|
}
|
|
1750
|
-
const plan = this.planCrossObject(cube, query,
|
|
1808
|
+
const plan = this.planCrossObject(cube, query, this.filterMemberView(cube, query, ctx));
|
|
1751
1809
|
if (plan) {
|
|
1752
1810
|
return this.executeCrossObject(cube, query, aggregations, filter, plan, ctx);
|
|
1753
1811
|
}
|
|
@@ -1824,9 +1882,7 @@ var ObjectQLStrategy = class {
|
|
|
1824
1882
|
if (td.granularity) granByDim.set(td.dimension, td.granularity);
|
|
1825
1883
|
}
|
|
1826
1884
|
const tableName = this.extractObjectName(cube);
|
|
1827
|
-
const plan = this.planCrossObject(cube, query,
|
|
1828
|
-
collectFilterLeaves(normalizeAnalyticsFilterTree(query)).map((f) => [this.resolveFieldName(cube, f.member, "any"), true])
|
|
1829
|
-
));
|
|
1885
|
+
const plan = this.planCrossObject(cube, query, this.filterMemberView(cube, query, ctx));
|
|
1830
1886
|
const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
|
|
1831
1887
|
const joinClauses = [];
|
|
1832
1888
|
const dimExpr = (dim) => {
|
|
@@ -1868,6 +1924,15 @@ var ObjectQLStrategy = class {
|
|
|
1868
1924
|
params
|
|
1869
1925
|
);
|
|
1870
1926
|
if (filterClause) whereParts.push(filterClause);
|
|
1927
|
+
const echoedDatasetFilter = ctx.getDatasetScope?.(query.cube)?.filter;
|
|
1928
|
+
if (echoedDatasetFilter) {
|
|
1929
|
+
const scopeSql = this.renderFilterNodeSql(
|
|
1930
|
+
normalizeAnalyticsFilterTree({ where: echoedDatasetFilter }),
|
|
1931
|
+
cube,
|
|
1932
|
+
params
|
|
1933
|
+
);
|
|
1934
|
+
if (scopeSql) whereParts.push(scopeSql);
|
|
1935
|
+
}
|
|
1871
1936
|
for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
|
|
1872
1937
|
const nextDay = nextUtcCalendarDay2(bounds.$lte);
|
|
1873
1938
|
params.push(bounds.$gte, nextDay ?? bounds.$lte);
|
|
@@ -1935,6 +2000,68 @@ var ObjectQLStrategy = class {
|
|
|
1935
2000
|
const joinedObject = cube.joins?.[alias]?.name ?? alias;
|
|
1936
2001
|
return joinedObject !== baseObject;
|
|
1937
2002
|
}
|
|
2003
|
+
/**
|
|
2004
|
+
* The member view {@link planCrossObject} judges a filter by: EVERY member
|
|
2005
|
+
* that will end up in the engine's predicate, structure discarded, keyed by
|
|
2006
|
+
* RESOLVED field name (#10759), valued by WHERE THE MEMBER CAME FROM
|
|
2007
|
+
* (#10861).
|
|
2008
|
+
*
|
|
2009
|
+
* Both call sites — `execute()` and `generateSql()` — are handed this and
|
|
2010
|
+
* nothing else, which is what makes the invariant `planCrossObject` states
|
|
2011
|
+
* for itself ("the preview accepts/rejects the same set") structural rather
|
|
2012
|
+
* than a coincidence maintained by hand. They used to build the view
|
|
2013
|
+
* separately: the echo flattened the tree, `execute()` passed the ENGINE
|
|
2014
|
+
* FILTER, and a filter record answers a different question — it is a
|
|
2015
|
+
* predicate to evaluate, not an inventory of members. An `$or`, a `$not` or
|
|
2016
|
+
* an unmergeable nested `$and` travels in it as one opaque `$and` entry, so
|
|
2017
|
+
* the members inside were unreadable from the outside and the envelope check
|
|
2018
|
+
* could not reject what it could not see.
|
|
2019
|
+
*
|
|
2020
|
+
* ## Two producers, one inventory (#10861)
|
|
2021
|
+
*
|
|
2022
|
+
* The caller's `where` is not the only thing that reaches `engine.aggregate`
|
|
2023
|
+
* as a predicate. Since PR #10758 the compiled dataset's own definition-level
|
|
2024
|
+
* `filter` is lowered onto `execute()`'s `conjuncts` and rendered by
|
|
2025
|
+
* `generateSql()`, so a dataset declaring `filter: { 'account.region': 'West' }`
|
|
2026
|
+
* sent `{"$and":[{"account.region":"West"}]}` to an engine that cannot join —
|
|
2027
|
+
* measured on both doors, which AGREED in accepting it, so #10759's
|
|
2028
|
+
* preview/execution symmetry had nothing to restore. Refusing it is a
|
|
2029
|
+
* widening of the refusal set, ruled by the maintainer on 2026-08-22 (Option
|
|
2030
|
+
* A, query-time refusal): fold the scope's leaves in HERE, where driver
|
|
2031
|
+
* capability is known, rather than in `dataset-compiler.ts`, which cannot see
|
|
2032
|
+
* which driver will serve the dataset and would refuse a dataset that is
|
|
2033
|
+
* perfectly legal on a native-SQL deployment.
|
|
2034
|
+
*
|
|
2035
|
+
* Structure is discarded on purpose — a member is cross-object or it is not,
|
|
2036
|
+
* and which branch of a disjunction it sits in cannot make
|
|
2037
|
+
* `engine.aggregate` able to join it. PROVENANCE is not discarded, because it
|
|
2038
|
+
* decides what the refusal can tell the caller to go fix: `AnalyticsRequestKey`
|
|
2039
|
+
* is the analytics REQUEST vocabulary and a dataset's `filter` is not in it,
|
|
2040
|
+
* so a scope-borne member must not be reported as `param: 'where'` — see
|
|
2041
|
+
* `planCrossObject`. The value slot carries that and nothing else; it never
|
|
2042
|
+
* reaches a driver.
|
|
2043
|
+
*
|
|
2044
|
+
* Dataset leaves are inserted FIRST so a member named by BOTH producers keeps
|
|
2045
|
+
* the caller's provenance (last write wins on a duplicate key): if it is in
|
|
2046
|
+
* the request too, the request is the actionable place to fix it.
|
|
2047
|
+
*
|
|
2048
|
+
* Time-dimension WINDOWS are deliberately absent (they live in
|
|
2049
|
+
* `dateRangeBounds`, not in `where`). They need no arm here: a cross-object
|
|
2050
|
+
* time dimension is refused by `planCrossObject`'s own first loop, over
|
|
2051
|
+
* `query.timeDimensions`, and refused as the time dimension the author wrote
|
|
2052
|
+
* rather than as the lowered predicate it becomes — which is the better
|
|
2053
|
+
* diagnostic and the reason that loop runs first.
|
|
2054
|
+
*/
|
|
2055
|
+
filterMemberView(cube, query, ctx) {
|
|
2056
|
+
const datasetFilter = ctx.getDatasetScope?.(query.cube)?.filter;
|
|
2057
|
+
const leaves = (node, origin) => collectFilterLeaves(node).map(
|
|
2058
|
+
(f) => [this.resolveFieldName(cube, f.member, "any"), origin]
|
|
2059
|
+
);
|
|
2060
|
+
return Object.fromEntries([
|
|
2061
|
+
...datasetFilter ? leaves(normalizeAnalyticsFilterTree({ where: datasetFilter }), "dataset-filter") : [],
|
|
2062
|
+
...leaves(normalizeAnalyticsFilterTree(query), "where")
|
|
2063
|
+
]);
|
|
2064
|
+
}
|
|
1938
2065
|
/**
|
|
1939
2066
|
* Plan how to serve cross-object references on this join-less path (#3654).
|
|
1940
2067
|
*
|
|
@@ -1945,21 +2072,36 @@ var ObjectQLStrategy = class {
|
|
|
1945
2072
|
* query (direct path), a plan for an in-envelope cross-object query.
|
|
1946
2073
|
*
|
|
1947
2074
|
* THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
|
|
1948
|
-
* (needs a real join to evaluate), a
|
|
2075
|
+
* (needs a real join to evaluate), a cross-object leaf in the DATASET's own
|
|
2076
|
+
* definition-level `filter` (#10861 — same join it does not have, arriving
|
|
2077
|
+
* from the producer PR #10758 added), a MULTI-HOP dimension (`a.b.c`), or a
|
|
1949
2078
|
* non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
|
|
1950
2079
|
* cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
|
|
1951
|
-
* `generateSql()` calls this too, so the preview accepts/rejects the same set
|
|
2080
|
+
* `generateSql()` calls this too, so the preview accepts/rejects the same set
|
|
2081
|
+
* — and since #10759 both callers derive `filter` from the one
|
|
2082
|
+
* {@link filterMemberView}, so that sentence is enforced by construction
|
|
2083
|
+
* instead of restated at two call sites.
|
|
1952
2084
|
*
|
|
1953
|
-
* [#5716] All
|
|
1954
|
-
* 400, naming the member — and the
|
|
1955
|
-
* diagnostics, and #5923's tests read
|
|
1956
|
-
* facts and nothing else: a member
|
|
1957
|
-
*
|
|
1958
|
-
*
|
|
1959
|
-
*
|
|
1960
|
-
*
|
|
1961
|
-
*
|
|
1962
|
-
*
|
|
2085
|
+
* [#5716] All five refusals below are `invalidMemberError` — `INVALID_FIELD` /
|
|
2086
|
+
* 400, naming the member — and the four that predate #10861 keep their
|
|
2087
|
+
* MESSAGES unchanged (they are good diagnostics, and #5923's tests read
|
|
2088
|
+
* them). Each is decided by two facts and nothing else: a member that will
|
|
2089
|
+
* reach the engine's predicate, and whether that member resolves across a
|
|
2090
|
+
* join. Neither is an internal invariant — a cube where the member exists and
|
|
2091
|
+
* a driver that could serve it are both perfectly ordinary, which is exactly
|
|
2092
|
+
* what the "run this on a native-SQL driver" half of every message says. They
|
|
2093
|
+
* are member-level rather than dataset-level (hence not `datasetInvalidError`)
|
|
2094
|
+
* because the fix is always to change or drop ONE named member, and because
|
|
2095
|
+
* four of them fire on `/analytics/query` where no dataset exists.
|
|
2096
|
+
*
|
|
2097
|
+
* [#10861] The fifth is the exception that proves the rule and is written to
|
|
2098
|
+
* it: it can only fire where a dataset DOES exist, and it is the one refusal
|
|
2099
|
+
* here whose member no request key named — so it carries `cube` and no
|
|
2100
|
+
* `param`, and says in its own words which document to go and edit. It stays
|
|
2101
|
+
* `INVALID_FIELD` rather than becoming `DATASET_INVALID` because the verdict
|
|
2102
|
+
* is the same physical one as its neighbour — this engine cannot join this
|
|
2103
|
+
* member — and splitting the code by PROVENANCE would make a caller branch on
|
|
2104
|
+
* two wire shapes for one capability limit.
|
|
1963
2105
|
*
|
|
1964
2106
|
* Detection is on RESOLVED field names, so a dotted dimension the cube
|
|
1965
2107
|
* flattens to a real column is treated as base, not cross-object.
|
|
@@ -1981,7 +2123,7 @@ var ObjectQLStrategy = class {
|
|
|
1981
2123
|
member: m,
|
|
1982
2124
|
field: this.resolveMeasureAggregation(cube, m).field
|
|
1983
2125
|
})),
|
|
1984
|
-
...Object.
|
|
2126
|
+
...Object.entries(filter).filter(([, origin]) => origin === "where").map(([f]) => ({ where: "filter", member: f, field: f }))
|
|
1985
2127
|
].filter((r) => this.isCrossObjectField(cube, r.field, baseObject));
|
|
1986
2128
|
if (nonDim.length > 0) {
|
|
1987
2129
|
throw invalidMemberError(
|
|
@@ -1995,6 +2137,13 @@ var ObjectQLStrategy = class {
|
|
|
1995
2137
|
}
|
|
1996
2138
|
);
|
|
1997
2139
|
}
|
|
2140
|
+
const scopeCross = Object.entries(filter).filter(([field, origin]) => origin === "dataset-filter" && this.isCrossObjectField(cube, field, baseObject)).map(([field]) => field);
|
|
2141
|
+
if (scopeCross.length > 0) {
|
|
2142
|
+
throw invalidMemberError(
|
|
2143
|
+
`[Analytics] ObjectQLStrategy cannot evaluate the cross-object filter ("${scopeCross[0]}") that dataset "${cube.name}" declares at its definition level \u2014 the engine cannot join in an aggregate, so this predicate matches nothing and the answer would be neither the scoped number nor the unscoped one. Nothing in the request names it: remove the cross-object leaf from the dataset's own \`filter\`, or serve this dataset on a native-SQL driver, where the same definition is valid.`,
|
|
2144
|
+
{ member: scopeCross[0], cube: cube.name }
|
|
2145
|
+
);
|
|
2146
|
+
}
|
|
1998
2147
|
const crossDims = [];
|
|
1999
2148
|
for (const dim of query.dimensions ?? []) {
|
|
2000
2149
|
const field = this.resolveFieldName(cube, dim, "dimension");
|
|
@@ -3749,6 +3898,16 @@ var AnalyticsService = class {
|
|
|
3749
3898
|
// Prefer a compiled dataset's declared relationships (D-C join allowlist);
|
|
3750
3899
|
// fall back to any explicitly-configured provider for legacy cubes.
|
|
3751
3900
|
getAllowedRelationships: (cubeName) => this.datasetRegistry.get(cubeName)?.allowedRelationships ?? config.getAllowedRelationships?.(cubeName),
|
|
3901
|
+
// [#10298] The compiled dataset's definition-level filter and its
|
|
3902
|
+
// per-measure filters — the half of the declaration the Cube model has
|
|
3903
|
+
// no room for. Same shape and same registry as `getAllowedRelationships`
|
|
3904
|
+
// directly above: answered for a cube that IS a compiled dataset,
|
|
3905
|
+
// `undefined` for every other cube.
|
|
3906
|
+
getDatasetScope: (cubeName) => {
|
|
3907
|
+
const compiled = this.datasetRegistry.get(cubeName);
|
|
3908
|
+
if (!compiled) return void 0;
|
|
3909
|
+
return { filter: compiled.filter, measureFilters: compiled.measureFilters };
|
|
3910
|
+
},
|
|
3752
3911
|
coerceTemporalFilterValue: config.coerceTemporalFilterValue,
|
|
3753
3912
|
coerceTemporalFilterColumn: config.coerceTemporalFilterColumn,
|
|
3754
3913
|
isExternalObject: config.isExternalObject
|