@objectstack/service-analytics 17.0.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 +444 -0
- package/README.md +136 -335
- package/dist/index.cjs +307 -64
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +113 -13
- package/dist/index.d.ts +113 -13
- package/dist/index.js +278 -30
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
package/dist/index.js
CHANGED
|
@@ -124,17 +124,19 @@ import { isFilterAST, parseFilterAST, VALID_AST_OPERATORS } from "@objectstack/s
|
|
|
124
124
|
import { StandardErrorCode } from "@objectstack/spec/api";
|
|
125
125
|
|
|
126
126
|
// src/comparand-shape.ts
|
|
127
|
+
import {
|
|
128
|
+
isUninterpretableTemporalComparand
|
|
129
|
+
} from "@objectstack/core";
|
|
130
|
+
import {
|
|
131
|
+
isAcceptedFilterComparand,
|
|
132
|
+
ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE
|
|
133
|
+
} from "@objectstack/spec/data";
|
|
127
134
|
function isBindableComparand(value) {
|
|
128
|
-
if (value ===
|
|
129
|
-
|
|
130
|
-
if (kind === "string" || kind === "number" || kind === "bigint" || kind === "boolean") return true;
|
|
131
|
-
return value instanceof Date || ArrayBuffer.isView(value);
|
|
135
|
+
if (value === void 0) return true;
|
|
136
|
+
return isAcceptedFilterComparand(value) || ArrayBuffer.isView(value);
|
|
132
137
|
}
|
|
133
138
|
function isRenderableTextComparand(value) {
|
|
134
|
-
|
|
135
|
-
const kind = typeof value;
|
|
136
|
-
if (kind === "string" || kind === "number" || kind === "bigint" || kind === "boolean") return true;
|
|
137
|
-
return value instanceof Date;
|
|
139
|
+
return value === void 0 || isAcceptedFilterComparand(value);
|
|
138
140
|
}
|
|
139
141
|
function isFieldReference(value) {
|
|
140
142
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
@@ -170,6 +172,51 @@ function findIn(node, field) {
|
|
|
170
172
|
}
|
|
171
173
|
return null;
|
|
172
174
|
}
|
|
175
|
+
function findUninterpretableTemporalMember(filter, kindOf) {
|
|
176
|
+
return findUninterpretableIn(filter, "", kindOf);
|
|
177
|
+
}
|
|
178
|
+
function findUninterpretableIn(node, field, kindOf) {
|
|
179
|
+
if (!node || typeof node !== "object") return null;
|
|
180
|
+
if (Array.isArray(node)) {
|
|
181
|
+
for (const child of node) {
|
|
182
|
+
const hit = findUninterpretableIn(child, field, kindOf);
|
|
183
|
+
if (hit) return hit;
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
if (node instanceof Date || ArrayBuffer.isView(node)) return null;
|
|
188
|
+
if (isFieldReference(node)) return null;
|
|
189
|
+
for (const [key, value] of Object.entries(node)) {
|
|
190
|
+
const scope = key.startsWith("$") ? field : key;
|
|
191
|
+
const kind = scope ? kindOf(scope) : null;
|
|
192
|
+
if (kind) {
|
|
193
|
+
const hit2 = judgeTemporalLiterals(value, scope, kind);
|
|
194
|
+
if (hit2) return hit2;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const hit = findUninterpretableIn(value, scope, kindOf);
|
|
198
|
+
if (hit) return hit;
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
function judgeTemporalLiterals(value, field, kind) {
|
|
203
|
+
if (Array.isArray(value)) {
|
|
204
|
+
for (const member of value) {
|
|
205
|
+
const hit = judgeTemporalLiterals(member, field, kind);
|
|
206
|
+
if (hit) return hit;
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
if (value && typeof value === "object") {
|
|
211
|
+
if (value instanceof Date || ArrayBuffer.isView(value) || isFieldReference(value)) return null;
|
|
212
|
+
for (const nested of Object.values(value)) {
|
|
213
|
+
const hit = judgeTemporalLiterals(nested, field, kind);
|
|
214
|
+
if (hit) return hit;
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
return isUninterpretableTemporalComparand(kind, value) ? { field, kind, value } : null;
|
|
219
|
+
}
|
|
173
220
|
var TEXT_PATTERN_OPERATORS = /* @__PURE__ */ new Set([
|
|
174
221
|
"$contains",
|
|
175
222
|
"$notContains",
|
|
@@ -187,7 +234,7 @@ function shapePreview(value) {
|
|
|
187
234
|
}
|
|
188
235
|
}
|
|
189
236
|
function unrenderableTextComparandMessage(op, field, value) {
|
|
190
|
-
return `"${op}" on "${field}" matches against the TEXT of a pattern, but its comparand is ${Array.isArray(value) ? "an array" : "an object"} (${shapePreview(value)}). filter.zod.ts declares it a string (StringOperatorSchema);
|
|
237
|
+
return `"${op}" on "${field}" matches against the TEXT of a pattern, but its comparand is ${Array.isArray(value) ? "an array" : "an object"} (${shapePreview(value)}). filter.zod.ts declares it a string (StringOperatorSchema); ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} is accepted. Refusing rather than stringifying it: String({}) is "[object Object]", so the pattern that ran would be one nobody wrote \u2014 and a row storing that literal text matches it.`;
|
|
191
238
|
}
|
|
192
239
|
function fieldReferenceComparandMessage(op, field, ref, position) {
|
|
193
240
|
return `"${op}" on "${field}"${position ? ` (${position})` : ""} compares against the field reference { "$field": "${ref}" }, which this compiler does not lower into a column-to-column comparison. Refusing rather than binding it: the reference object used to become the BOUND VALUE of the comparison, so the emitted predicate compared "${field}" against the reference itself \u2014 a value no row can hold \u2014 and a read scope built from it answered the wrong row set with nothing to read. \u26A0\uFE0F This is NOT the platform declining the rule. @objectstack/spec declares this shape (FieldReferenceSchema), @objectstack/formula resolves it per record in memory, driver-sql / driver-sqlite-wasm compile it to a same-table column comparison for the six scalar operators since #5222, and since the 2026-08-12 ruling on #7598 the analytics native-SQL strategy DECLINES such a query so it routes to the ObjectQL engine path and runs there \u2014 the driver enforcing declared-only enumeration, the tenant-isolation ban and the comparison class with metadata it owns. What refuses here is this SQL lowering, whose only remaining caller is the /analytics/sql display echo; it has no faithful rendering of the predicate the engine path actually runs, and half-rendering one would describe a query that returns different rows. Run the query itself (/analytics/query) to get its rows (#7598).`;
|
|
@@ -196,7 +243,7 @@ function fieldReferenceBetweenBoundMessage(op, field, ref, index) {
|
|
|
196
243
|
return `"${op}" on "${field}" has the field reference { "$field": "${ref}" } at index ${index} of its [min, max] bounds. A range BOUND may not be a field reference on any backend: driver-sql and driver-sqlite-wasm refuse both endpoints (#5222), @objectstack/formula does not resolve a reference inside a list either \u2014 it orders the bounds against the raw reference object, which no value compares meaningfully to \u2014 and @objectstack/spec no longer declares the position at all (#7596 removed FieldReferenceSchema from the $between endpoint union, ADR-0049 declared = enforced). Refusing rather than lowering it: this compiler splits $between into its two bounds, so the reference would arrive at the driver under a "$gte" / "$lte" the author never wrote \u2014 a position the SQL drivers DO compile \u2014 and the range would quietly succeed here while the identical filter is refused everywhere else. Use a literal bound, or spell the comparison you meant as a scalar one ({ "${field}": { "$gte": { "$field": "${ref}" } } }), which IS served \u2014 on the ObjectQL engine path, where the driver enforces the #5222 rulings (#7598).`;
|
|
197
244
|
}
|
|
198
245
|
function unbindableListMemberMessage(op, field, value, index) {
|
|
199
|
-
return `"${op}" on "${field}" has a value at index ${index} of its list that cannot be bound as a SQL parameter: ${shapePreview(value)}. Every member of an $in/$nin/$between list is a comparand in its own right \u2014 use
|
|
246
|
+
return `"${op}" on "${field}" has a value at index ${index} of its list that cannot be bound as a SQL parameter: ${shapePreview(value)}. Every member of an $in/$nin/$between list is a comparand in its own right \u2014 use ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} (or a binary value). Refusing rather than binding it: the member can equal no stored value, so the list silently loses that entry (and a $nin loses the exclusion the caller wrote).`;
|
|
200
247
|
}
|
|
201
248
|
|
|
202
249
|
// src/strategies/filter-normalizer.ts
|
|
@@ -979,14 +1026,31 @@ function invalidMemberError(message, meta) {
|
|
|
979
1026
|
// src/strategies/native-sql-strategy.ts
|
|
980
1027
|
import { nextUtcCalendarDay } from "@objectstack/core";
|
|
981
1028
|
var AGGREGATE_SQL = {
|
|
982
|
-
|
|
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})`,
|
|
983
1038
|
"sum": (col) => `SUM(${col})`,
|
|
984
1039
|
"avg": (col) => `AVG(${col})`,
|
|
985
1040
|
"min": (col) => `MIN(${col})`,
|
|
986
1041
|
"max": (col) => `MAX(${col})`,
|
|
987
1042
|
"count_distinct": (col) => `COUNT(DISTINCT ${col})`
|
|
988
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
|
+
};
|
|
989
1052
|
var SUPPORTED_AGGREGATE_SQL_KEYS = Object.keys(AGGREGATE_SQL);
|
|
1053
|
+
var CONDITIONAL_AGGREGATE_SQL_KEYS = Object.keys(CONDITIONAL_AGGREGATE_SQL);
|
|
990
1054
|
var EXPRESSION_METRIC_TYPES = /* @__PURE__ */ new Set(["number", "string", "boolean"]);
|
|
991
1055
|
var IDENTIFIER_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
992
1056
|
var NativeSQLStrategy = class {
|
|
@@ -1009,9 +1073,51 @@ var NativeSQLStrategy = class {
|
|
|
1009
1073
|
}
|
|
1010
1074
|
}
|
|
1011
1075
|
if (this.carriesCrossFieldComparison(query, ctx)) return false;
|
|
1076
|
+
if (this.carriesUninterpretableTemporalComparand(query, ctx)) return false;
|
|
1012
1077
|
const caps = ctx.queryCapabilities(query.cube);
|
|
1013
1078
|
return caps.nativeSql && typeof ctx.executeRawSql === "function";
|
|
1014
1079
|
}
|
|
1080
|
+
/**
|
|
1081
|
+
* [#8690] Does the query's `where` compare a declared TIME dimension against
|
|
1082
|
+
* a value no temporal storage rule can read? See the ruling at
|
|
1083
|
+
* {@link canHandle}.
|
|
1084
|
+
*
|
|
1085
|
+
* The classification comes from the CUBE, the only metadata this package has:
|
|
1086
|
+
* a dimension declares `type: 'time'` (compiled from the dataset dimension's
|
|
1087
|
+
* `type: 'date'`), and {@link lookupMember} is the same resolution every other
|
|
1088
|
+
* member lookup in this strategy uses, so "the member the gate classified"
|
|
1089
|
+
* and "the member the compiler emits" cannot drift apart.
|
|
1090
|
+
*
|
|
1091
|
+
* A `time` dimension is read with the DATETIME rule — the permissive one of
|
|
1092
|
+
* the three. That is the right direction because this is a routing decision,
|
|
1093
|
+
* not a verdict: the engine door re-judges with the field's real declared
|
|
1094
|
+
* type and has the final say, so under-classifying an exotic spelling merely
|
|
1095
|
+
* leaves today's behaviour, while over-classifying would silently move a
|
|
1096
|
+
* working dashboard off the fast path. The comparands this card measured
|
|
1097
|
+
* (`last_30_days`, `not-a-date-at-all`) are unreadable under all three rules,
|
|
1098
|
+
* so the decline fires for them whichever backing type the dimension has.
|
|
1099
|
+
*
|
|
1100
|
+
* `lowerAnalyticsWhere` rather than `query.where` raw, so the authored ARRAY
|
|
1101
|
+
* sugar is seen after `parseFilterAST` has lowered it; a THROW from that
|
|
1102
|
+
* lowering is not this gate's to answer — the filter is malformed either way
|
|
1103
|
+
* and `normalizeAnalyticsFilterTree` refuses it a moment later with the
|
|
1104
|
+
* message and envelope it has always had.
|
|
1105
|
+
*/
|
|
1106
|
+
carriesUninterpretableTemporalComparand(query, ctx) {
|
|
1107
|
+
const cube = query.cube ? ctx.getCube(query.cube) : void 0;
|
|
1108
|
+
if (!cube) return false;
|
|
1109
|
+
let where = null;
|
|
1110
|
+
try {
|
|
1111
|
+
where = lowerAnalyticsWhere(query);
|
|
1112
|
+
} catch {
|
|
1113
|
+
return false;
|
|
1114
|
+
}
|
|
1115
|
+
if (!where) return false;
|
|
1116
|
+
return findUninterpretableTemporalMember(
|
|
1117
|
+
where,
|
|
1118
|
+
(member) => this.lookupMember(cube, member, "dimension")?.type === "time" ? "datetime" : null
|
|
1119
|
+
) !== null;
|
|
1120
|
+
}
|
|
1015
1121
|
/**
|
|
1016
1122
|
* [#7598] Does serving this query require the cross-field capability this
|
|
1017
1123
|
* strategy declines? See the ruling recorded at {@link canHandle}.
|
|
@@ -1110,9 +1216,19 @@ var NativeSQLStrategy = class {
|
|
|
1110
1216
|
groupByClauses.push(colExpr);
|
|
1111
1217
|
}
|
|
1112
1218
|
}
|
|
1219
|
+
const datasetScope = ctx.getDatasetScope?.(query.cube);
|
|
1113
1220
|
if (query.measures && query.measures.length > 0) {
|
|
1114
1221
|
for (const measure of query.measures) {
|
|
1115
|
-
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);
|
|
1116
1232
|
selectClauses.push(`${aggExpr} AS "${measure}"`);
|
|
1117
1233
|
}
|
|
1118
1234
|
}
|
|
@@ -1126,6 +1242,17 @@ var NativeSQLStrategy = class {
|
|
|
1126
1242
|
ctx
|
|
1127
1243
|
);
|
|
1128
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
|
+
}
|
|
1129
1256
|
if (query.timeDimensions && query.timeDimensions.length > 0) {
|
|
1130
1257
|
for (const td of query.timeDimensions) {
|
|
1131
1258
|
const colExpr = this.resolveFieldSql(cube, td.dimension, tableName, joins);
|
|
@@ -1299,7 +1426,12 @@ var NativeSQLStrategy = class {
|
|
|
1299
1426
|
const raw = dim ? dim.sql : member.includes(".") ? member.split(".")[1] : member;
|
|
1300
1427
|
return this.qualifyAndRegisterJoin(raw, parentTable, joins, cube);
|
|
1301
1428
|
}
|
|
1302
|
-
|
|
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) {
|
|
1303
1435
|
const measure = this.lookupMember(cube, member, "measure");
|
|
1304
1436
|
if (!measure) {
|
|
1305
1437
|
const declared = Object.keys(cube.measures ?? {});
|
|
@@ -1309,6 +1441,13 @@ var NativeSQLStrategy = class {
|
|
|
1309
1441
|
);
|
|
1310
1442
|
}
|
|
1311
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
|
+
}
|
|
1312
1451
|
const wrap = AGGREGATE_SQL[measure.type];
|
|
1313
1452
|
if (wrap) return wrap(col);
|
|
1314
1453
|
if (EXPRESSION_METRIC_TYPES.has(measure.type)) return col;
|
|
@@ -1655,10 +1794,18 @@ var ObjectQLStrategy = class {
|
|
|
1655
1794
|
const extra = this.mergeFilterOperand(filter, field, bounds);
|
|
1656
1795
|
if (extra) conjuncts.push(extra);
|
|
1657
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
|
+
}
|
|
1658
1805
|
if (conjuncts.length > 0) {
|
|
1659
1806
|
filter.$and = [...Array.isArray(filter.$and) ? filter.$and : [], ...conjuncts];
|
|
1660
1807
|
}
|
|
1661
|
-
const plan = this.planCrossObject(cube, query,
|
|
1808
|
+
const plan = this.planCrossObject(cube, query, this.filterMemberView(cube, query, ctx));
|
|
1662
1809
|
if (plan) {
|
|
1663
1810
|
return this.executeCrossObject(cube, query, aggregations, filter, plan, ctx);
|
|
1664
1811
|
}
|
|
@@ -1735,9 +1882,7 @@ var ObjectQLStrategy = class {
|
|
|
1735
1882
|
if (td.granularity) granByDim.set(td.dimension, td.granularity);
|
|
1736
1883
|
}
|
|
1737
1884
|
const tableName = this.extractObjectName(cube);
|
|
1738
|
-
const plan = this.planCrossObject(cube, query,
|
|
1739
|
-
collectFilterLeaves(normalizeAnalyticsFilterTree(query)).map((f) => [this.resolveFieldName(cube, f.member, "any"), true])
|
|
1740
|
-
));
|
|
1885
|
+
const plan = this.planCrossObject(cube, query, this.filterMemberView(cube, query, ctx));
|
|
1741
1886
|
const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
|
|
1742
1887
|
const joinClauses = [];
|
|
1743
1888
|
const dimExpr = (dim) => {
|
|
@@ -1779,6 +1924,15 @@ var ObjectQLStrategy = class {
|
|
|
1779
1924
|
params
|
|
1780
1925
|
);
|
|
1781
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
|
+
}
|
|
1782
1936
|
for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
|
|
1783
1937
|
const nextDay = nextUtcCalendarDay2(bounds.$lte);
|
|
1784
1938
|
params.push(bounds.$gte, nextDay ?? bounds.$lte);
|
|
@@ -1846,6 +2000,68 @@ var ObjectQLStrategy = class {
|
|
|
1846
2000
|
const joinedObject = cube.joins?.[alias]?.name ?? alias;
|
|
1847
2001
|
return joinedObject !== baseObject;
|
|
1848
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
|
+
}
|
|
1849
2065
|
/**
|
|
1850
2066
|
* Plan how to serve cross-object references on this join-less path (#3654).
|
|
1851
2067
|
*
|
|
@@ -1856,21 +2072,36 @@ var ObjectQLStrategy = class {
|
|
|
1856
2072
|
* query (direct path), a plan for an in-envelope cross-object query.
|
|
1857
2073
|
*
|
|
1858
2074
|
* THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
|
|
1859
|
-
* (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
|
|
1860
2078
|
* non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
|
|
1861
2079
|
* cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
|
|
1862
|
-
* `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.
|
|
2084
|
+
*
|
|
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.
|
|
1863
2096
|
*
|
|
1864
|
-
* [#
|
|
1865
|
-
*
|
|
1866
|
-
*
|
|
1867
|
-
*
|
|
1868
|
-
*
|
|
1869
|
-
*
|
|
1870
|
-
*
|
|
1871
|
-
*
|
|
1872
|
-
* `datasetInvalidError`) because the fix is always to change or drop ONE named
|
|
1873
|
-
* member, and because they fire on `/analytics/query` where no dataset exists.
|
|
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.
|
|
1874
2105
|
*
|
|
1875
2106
|
* Detection is on RESOLVED field names, so a dotted dimension the cube
|
|
1876
2107
|
* flattens to a real column is treated as base, not cross-object.
|
|
@@ -1892,7 +2123,7 @@ var ObjectQLStrategy = class {
|
|
|
1892
2123
|
member: m,
|
|
1893
2124
|
field: this.resolveMeasureAggregation(cube, m).field
|
|
1894
2125
|
})),
|
|
1895
|
-
...Object.
|
|
2126
|
+
...Object.entries(filter).filter(([, origin]) => origin === "where").map(([f]) => ({ where: "filter", member: f, field: f }))
|
|
1896
2127
|
].filter((r) => this.isCrossObjectField(cube, r.field, baseObject));
|
|
1897
2128
|
if (nonDim.length > 0) {
|
|
1898
2129
|
throw invalidMemberError(
|
|
@@ -1906,6 +2137,13 @@ var ObjectQLStrategy = class {
|
|
|
1906
2137
|
}
|
|
1907
2138
|
);
|
|
1908
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
|
+
}
|
|
1909
2147
|
const crossDims = [];
|
|
1910
2148
|
for (const dim of query.dimensions ?? []) {
|
|
1911
2149
|
const field = this.resolveFieldName(cube, dim, "dimension");
|
|
@@ -3660,6 +3898,16 @@ var AnalyticsService = class {
|
|
|
3660
3898
|
// Prefer a compiled dataset's declared relationships (D-C join allowlist);
|
|
3661
3899
|
// fall back to any explicitly-configured provider for legacy cubes.
|
|
3662
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
|
+
},
|
|
3663
3911
|
coerceTemporalFilterValue: config.coerceTemporalFilterValue,
|
|
3664
3912
|
coerceTemporalFilterColumn: config.coerceTemporalFilterColumn,
|
|
3665
3913
|
isExternalObject: config.isExternalObject
|