@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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,200 @@
|
|
|
1
1
|
# Changelog — @objectstack/service-analytics
|
|
2
2
|
|
|
3
|
+
## 17.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 57e4571: **BREAKING**: `/analytics/query` now refuses a cross-object filter nested inside a
|
|
8
|
+
combinator on the ObjectQL path, instead of silently answering the wrong number
|
|
9
|
+
(#10759).
|
|
10
|
+
|
|
11
|
+
`ObjectQLStrategy` runs one cross-object envelope check, from two call sites.
|
|
12
|
+
`generateSql()` (the `/analytics/sql` preview) asked it about every member the
|
|
13
|
+
`where` touches, flattened out of the filter tree. `execute()` asked it about the
|
|
14
|
+
built engine filter — where an AND-ed leaf sits at the top level and is seen, but
|
|
15
|
+
anything structural (an `$or`, a `$not`, a nested `$and` that cannot merge) has
|
|
16
|
+
been folded into `filter.$and`, so the only key readable for it was the literal
|
|
17
|
+
`$and`, which is never a field name.
|
|
18
|
+
|
|
19
|
+
One query therefore got two answers, measured over one fixture in one run:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
where: { $or: [{ 'account.region': 'West' }, { stage: 'won' }] }
|
|
23
|
+
|
|
24
|
+
before /analytics/sql 400 INVALID_FIELD cross-object filter "account.region"
|
|
25
|
+
/analytics/query 200, rows
|
|
26
|
+
after both 400 INVALID_FIELD cross-object filter "account.region"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`engine.aggregate` cannot join. The half that returned rows was not answering the
|
|
30
|
+
cross-object query: the disjunct naming a column the base object does not have
|
|
31
|
+
can never match, so the query silently collapsed to its remaining branches and
|
|
32
|
+
reported a narrower figure as if it were the answer. Both call sites now derive
|
|
33
|
+
the member list from one shared view, so the invariant the strategy already
|
|
34
|
+
stated for itself — the preview accepts and rejects the same set the execution
|
|
35
|
+
door does — holds by construction rather than by two call sites agreeing.
|
|
36
|
+
|
|
37
|
+
Who is affected: a deployment whose driver reports `objectqlAggregate` but not
|
|
38
|
+
`nativeSql` (Mongo, the memory driver), running an analytics query that puts a
|
|
39
|
+
related object's field inside `$or` or `$not`. Such a query now returns
|
|
40
|
+
`400 INVALID_FIELD` naming the member. The refusal already existed and already
|
|
41
|
+
had these words; what changed is that the execution door reaches it too. Nothing
|
|
42
|
+
an author writes in metadata changes, no stored shape is affected, and queries
|
|
43
|
+
whose combinators name only base-object fields are untouched — that set is pinned
|
|
44
|
+
in `crossobject-conjunct-refusal.test.ts` alongside the new refusal, because a
|
|
45
|
+
fix that refused every combinator would have looked identical from the refusal
|
|
46
|
+
side alone.
|
|
47
|
+
|
|
48
|
+
The remedy for an affected query is the one the error message has always carried:
|
|
49
|
+
run it on a native-SQL driver, which can join, or drop the cross-object member
|
|
50
|
+
from the filter.
|
|
51
|
+
|
|
52
|
+
<!-- adr-0087: not-required (no-migration-prescription) A runtime query-shape refusal on /analytics/query, not a metadata surface: no authorable key, export or config field is removed or renamed, so `objectstack migrate meta` has nothing to rewrite and an upgrader has no stored shape to convert. The affected input is an ad-hoc request body, and the error itself names the member and the two ways out. -->
|
|
53
|
+
- 13a3dca: **BREAKING**: on the ObjectQL path, a compiled dataset whose definition-level
|
|
54
|
+
`filter` is itself cross-object is now refused by both analytics doors instead
|
|
55
|
+
of reaching `engine.aggregate` with a predicate it cannot join (#10861).
|
|
56
|
+
|
|
57
|
+
PR #10758 gave the dataset's own definition-level `filter` a route onto this
|
|
58
|
+
door for the first time. That route was outside the member view the cross-object
|
|
59
|
+
envelope check judges, so nothing ever saw it:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
dataset: object 'opportunity', include: ['account'],
|
|
63
|
+
filter: { 'account.region': 'West' }
|
|
64
|
+
|
|
65
|
+
before /analytics/query 200, rows -> engine.aggregate received
|
|
66
|
+
{"$and":[{"account.region":"West"}]}
|
|
67
|
+
/analytics/sql 200, SQL
|
|
68
|
+
after both 400 INVALID_FIELD, member "account.region",
|
|
69
|
+
cube "<dataset>"; the engine is never reached
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`engine.aggregate` cannot join. `account.region` is not a column of
|
|
73
|
+
`opportunity`, so on any driver that evaluates the predicate honestly it matches
|
|
74
|
+
nothing, and the widget answered a number that was neither the scoped number nor
|
|
75
|
+
the unscoped one — with no error anywhere. That is the silent mis-bucket #3654's
|
|
76
|
+
loud refusal exists to prevent, arriving through a producer #3654 predates.
|
|
77
|
+
|
|
78
|
+
**Breaking, and argued rather than assumed.** A query that returns `200` with
|
|
79
|
+
rows today starts answering `400`, on a *saved* dataset rather than on anything
|
|
80
|
+
in the request — a dashboard that renders today can start showing an error. That
|
|
81
|
+
is the strongest reading of "breaking" and it is why this is called out here
|
|
82
|
+
rather than filed as a quiet fix. What is *not* lost is any correct answer: the
|
|
83
|
+
rows that stop being served were already wrong, and wrong in the way that hides
|
|
84
|
+
itself. The refusal names the member, names the dataset, and says the same
|
|
85
|
+
definition is valid on a native-SQL deployment, so the operator has somewhere to
|
|
86
|
+
go; the previous behaviour gave them a plausible number and nothing to notice.
|
|
87
|
+
Rejecting the dataset at compile time in `dataset-compiler.ts` was considered and
|
|
88
|
+
not taken (maintainer ruling, 2026-08-22): the compiler cannot see which driver
|
|
89
|
+
will serve the dataset, and the same definition is legal on a native-SQL one.
|
|
90
|
+
|
|
91
|
+
Who is affected: a deployment whose driver reports `objectqlAggregate` but not
|
|
92
|
+
`nativeSql` (Mongo, the memory driver), serving a dataset whose definition-level
|
|
93
|
+
`filter` names a field on a related object. Nothing an author writes changes
|
|
94
|
+
shape, no stored document is rewritten, and an **ordinary** dataset scope
|
|
95
|
+
(`filter: { is_deleted: false }`) still passes both doors and still reaches the
|
|
96
|
+
engine carrying its predicate — that direction is pinned one character away from
|
|
97
|
+
the new refusal in `crossobject-conjunct-refusal.test.ts`, because an
|
|
98
|
+
implementation that refused *every* dataset scope would look identical from the
|
|
99
|
+
refusal side alone and would break every scoped dataset shipping today.
|
|
100
|
+
|
|
101
|
+
<!-- adr-0087: not-required (no-migration-prescription) No authorable surface is
|
|
102
|
+
retired, renamed or re-shaped: `DatasetSchema`'s `filter` key stays exactly as it
|
|
103
|
+
is, every stored dataset document stays valid as written, and the very same
|
|
104
|
+
document remains correct on a native-SQL deployment. There is therefore nothing
|
|
105
|
+
`objectstack migrate meta` could rewrite — a mechanical rewrite would have to
|
|
106
|
+
know which driver will serve the dataset, which is precisely the capability the
|
|
107
|
+
2026-08-22 ruling records as invisible to the compile-time placement. This is a
|
|
108
|
+
query-time refusal on one driver family, not a surface retirement, so the ledger
|
|
109
|
+
has no entry to carry and the upgrade guide has no prescription to print. -->
|
|
110
|
+
|
|
111
|
+
### Patch Changes
|
|
112
|
+
|
|
113
|
+
- 7bf3fb7: Point every documentation link in these packages' published READMEs — and in
|
|
114
|
+
the project `create-objectstack` scaffolds — at the canonical docs origin
|
|
115
|
+
`https://objectstack.ai`, replacing the `docs.objectstack.ai` spelling.
|
|
116
|
+
|
|
117
|
+
Both spellings reach the same pages (the alias redirects to the apex,
|
|
118
|
+
path-preserving), so no link was broken. The reason it needs a release rather
|
|
119
|
+
than an in-repo fix alone: a README ships inside the npm tarball, so the
|
|
120
|
+
version already on npm keeps showing the old host to every reader of the
|
|
121
|
+
package page until a new one is published.
|
|
122
|
+
- 112a8c6: Apply a dataset's definition-level `filter` on the ObjectQL analytics path
|
|
123
|
+
(#10413, phase 1). `/api/v1/analytics/query` served by a driver that reports
|
|
124
|
+
`objectqlAggregate` but not `nativeSql` (MongoDB, the memory driver) reached
|
|
125
|
+
`engine.aggregate` with no `filter` key at all: the dataset's own scope — a
|
|
126
|
+
`filter: { is_deleted: false }` on the dataset definition — was dropped, so
|
|
127
|
+
every measure aggregated the whole table while the dashboard door, on the same
|
|
128
|
+
cube and the same measure names, answered the scoped numbers. The scope is now
|
|
129
|
+
ANDed into the strategy's whole-call filter (never merged key-by-key, so a
|
|
130
|
+
caller's own `where` and the time windows cannot be overwritten by it), and the
|
|
131
|
+
representative SQL echo renders it too.
|
|
132
|
+
|
|
133
|
+
Per-MEASURE `filter`s on this path are still not applied: an
|
|
134
|
+
`engine.aggregate` aggregation is `{ field, method, alias }` and cannot carry a
|
|
135
|
+
predicate of its own. Widening that contract is #10576; lowering the measure
|
|
136
|
+
filters into it is phase 2 of #10413. The native-SQL path already applies both
|
|
137
|
+
(#10298).
|
|
138
|
+
- 6439f8b: Analytics measures are now compiled from everything they declare — `aggregate`, `field` **and** `filter` — on both the dashboard path and `POST /api/v1/analytics/query`.
|
|
139
|
+
|
|
140
|
+
**Reported figures change, and the new ones are the declared ones.** Two corrections, both of which move numbers a dashboard or an API consumer is already reading:
|
|
141
|
+
|
|
142
|
+
- A measure written `{ aggregate: 'count', field: 'some_column' }` used to compile to `COUNT(*)` and count **rows**. It now compiles to `COUNT("some_column")` and counts **non-null values**. Any such measure will report the same number as before or a **smaller** one, and a rate built on top of it (a numerator over a total) will drop accordingly — a "100%" tile whose column was mostly empty was reading its own denominator.
|
|
143
|
+
- `POST /api/v1/analytics/query` used to drop every per-measure `filter`, and the dataset's definition-level `filter` with it, returning unfiltered aggregates under the author's measure names. It now applies both, so the endpoint answers what the dashboard already answered for the same cube. Figures pulled through the API — agent tools, exports, downstream reports — will move to the filtered values; a measure declaring `filter: { stage: 'closed_won' }` stops counting every row.
|
|
144
|
+
|
|
145
|
+
Measures that declare no `field` still compile to `COUNT(*)`, and a cube that is not a compiled dataset (an inferred or manifest cube) emits byte-for-byte the statement it did before. Measure filters lower to portable `CASE WHEN` conditional aggregates rather than `FILTER (WHERE …)`, which MySQL does not have.
|
|
146
|
+
|
|
147
|
+
If a saved figure or a screenshot disagrees with what the platform now reports, the new number is the one the metadata declares.
|
|
148
|
+
- Updated dependencies [6936d07]
|
|
149
|
+
- Updated dependencies [59eb04d]
|
|
150
|
+
- Updated dependencies [9f05b7d]
|
|
151
|
+
- Updated dependencies [3b2af5e]
|
|
152
|
+
- Updated dependencies [7d2d112]
|
|
153
|
+
- Updated dependencies [5fa0d72]
|
|
154
|
+
- Updated dependencies [02b3b07]
|
|
155
|
+
- Updated dependencies [46d34ab]
|
|
156
|
+
- Updated dependencies [914c413]
|
|
157
|
+
- Updated dependencies [55809a0]
|
|
158
|
+
- Updated dependencies [ee2ff45]
|
|
159
|
+
- Updated dependencies [47cd3ec]
|
|
160
|
+
- Updated dependencies [52db1d1]
|
|
161
|
+
- Updated dependencies [5649efb]
|
|
162
|
+
- Updated dependencies [9d7d2de]
|
|
163
|
+
- Updated dependencies [c815c50]
|
|
164
|
+
- Updated dependencies [795ea05]
|
|
165
|
+
- Updated dependencies [2306a76]
|
|
166
|
+
- Updated dependencies [e5ea701]
|
|
167
|
+
- Updated dependencies [a40dcc1]
|
|
168
|
+
- Updated dependencies [def0d3e]
|
|
169
|
+
- Updated dependencies [8d0bb79]
|
|
170
|
+
- Updated dependencies [5acb58d]
|
|
171
|
+
- Updated dependencies [2e3cf95]
|
|
172
|
+
- Updated dependencies [4c93387]
|
|
173
|
+
- Updated dependencies [504c8d5]
|
|
174
|
+
- Updated dependencies [a037f7c]
|
|
175
|
+
- Updated dependencies [3ee8ddf]
|
|
176
|
+
- Updated dependencies [16cef97]
|
|
177
|
+
- Updated dependencies [a79bd35]
|
|
178
|
+
- Updated dependencies [6ceaa4b]
|
|
179
|
+
- Updated dependencies [15ea214]
|
|
180
|
+
- Updated dependencies [de19489]
|
|
181
|
+
- Updated dependencies [c684d00]
|
|
182
|
+
- Updated dependencies [923c424]
|
|
183
|
+
- Updated dependencies [1ec36b7]
|
|
184
|
+
- Updated dependencies [5f2e54c]
|
|
185
|
+
- Updated dependencies [189373b]
|
|
186
|
+
- Updated dependencies [35ad101]
|
|
187
|
+
- Updated dependencies [ceb33a9]
|
|
188
|
+
- Updated dependencies [73d9795]
|
|
189
|
+
- Updated dependencies [8012960]
|
|
190
|
+
- Updated dependencies [f34f56b]
|
|
191
|
+
- Updated dependencies [f399618]
|
|
192
|
+
- Updated dependencies [75e9301]
|
|
193
|
+
- Updated dependencies [2810695]
|
|
194
|
+
- @objectstack/spec@17.2.0
|
|
195
|
+
- @objectstack/core@17.2.0
|
|
196
|
+
- @objectstack/types@17.2.0
|
|
197
|
+
|
|
3
198
|
## 17.1.0
|
|
4
199
|
|
|
5
200
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -103,8 +103,10 @@ is rejected rather than dropped.
|
|
|
103
103
|
| `timezone` | `string?` | IANA name. No default — an absent timezone means the engine resolves it. |
|
|
104
104
|
|
|
105
105
|
There is no `filters` key and no `aggregations` key. `filters` is rejected at the REST
|
|
106
|
-
door with a 400 naming `where
|
|
107
|
-
`filters
|
|
106
|
+
door with a 400 naming `where`. There is no per-metric filter key either — the cube
|
|
107
|
+
metric's `filters` was removed (#10414: no strategy ever read it); fold a per-metric
|
|
108
|
+
condition into the metric's own `sql` expression, or use an ADR-0021 dataset measure's
|
|
109
|
+
structured `filter`.
|
|
108
110
|
|
|
109
111
|
```typescript
|
|
110
112
|
const revenueByStatus = await analytics.query({
|
|
@@ -191,4 +193,4 @@ Apache-2.0. See [LICENSING.md](../../../LICENSING.md).
|
|
|
191
193
|
|
|
192
194
|
- [@objectstack/objectql](../../objectql/)
|
|
193
195
|
- [@objectstack/driver-memory](../../drivers/driver-memory/) — ships `InMemoryStrategy`
|
|
194
|
-
- [Analytics Guide](https://
|
|
196
|
+
- [Analytics Guide](https://objectstack.ai/docs/data-modeling/analytics)
|
package/dist/index.cjs
CHANGED
|
@@ -1063,14 +1063,31 @@ function invalidMemberError(message, meta) {
|
|
|
1063
1063
|
// src/strategies/native-sql-strategy.ts
|
|
1064
1064
|
var import_core2 = require("@objectstack/core");
|
|
1065
1065
|
var AGGREGATE_SQL = {
|
|
1066
|
-
|
|
1066
|
+
// [#10298] `count` takes its COLUMN when the measure declares one. The
|
|
1067
|
+
// wrapper used to discard `col` and always emit `COUNT(*)`, so a measure
|
|
1068
|
+
// written `{ aggregate: 'count', field: 'resolved_by_article' }` counted
|
|
1069
|
+
// ROWS instead of non-null values — and a deflection rate built as
|
|
1070
|
+
// `kb_resolved_count / closed_count` read 100% where the truth was 12.5%,
|
|
1071
|
+
// with the numerator and denominator printed beside it as 8 and 8. `*` is
|
|
1072
|
+
// still `COUNT(*)`: the compiler writes `sql: m.field ?? '*'`, so the star
|
|
1073
|
+
// IS the "no field declared" spelling and must keep counting rows.
|
|
1074
|
+
"count": (col) => col === "*" ? "COUNT(*)" : `COUNT(${col})`,
|
|
1067
1075
|
"sum": (col) => `SUM(${col})`,
|
|
1068
1076
|
"avg": (col) => `AVG(${col})`,
|
|
1069
1077
|
"min": (col) => `MIN(${col})`,
|
|
1070
1078
|
"max": (col) => `MAX(${col})`,
|
|
1071
1079
|
"count_distinct": (col) => `COUNT(DISTINCT ${col})`
|
|
1072
1080
|
};
|
|
1081
|
+
var CONDITIONAL_AGGREGATE_SQL = {
|
|
1082
|
+
"count": (col, pred) => `COUNT(CASE WHEN ${pred} THEN ${col === "*" ? "1" : col} END)`,
|
|
1083
|
+
"sum": (col, pred) => `SUM(CASE WHEN ${pred} THEN ${col} END)`,
|
|
1084
|
+
"avg": (col, pred) => `AVG(CASE WHEN ${pred} THEN ${col} END)`,
|
|
1085
|
+
"min": (col, pred) => `MIN(CASE WHEN ${pred} THEN ${col} END)`,
|
|
1086
|
+
"max": (col, pred) => `MAX(CASE WHEN ${pred} THEN ${col} END)`,
|
|
1087
|
+
"count_distinct": (col, pred) => `COUNT(DISTINCT CASE WHEN ${pred} THEN ${col} END)`
|
|
1088
|
+
};
|
|
1073
1089
|
var SUPPORTED_AGGREGATE_SQL_KEYS = Object.keys(AGGREGATE_SQL);
|
|
1090
|
+
var CONDITIONAL_AGGREGATE_SQL_KEYS = Object.keys(CONDITIONAL_AGGREGATE_SQL);
|
|
1074
1091
|
var EXPRESSION_METRIC_TYPES = /* @__PURE__ */ new Set(["number", "string", "boolean"]);
|
|
1075
1092
|
var IDENTIFIER_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
1076
1093
|
var NativeSQLStrategy = class {
|
|
@@ -1236,9 +1253,19 @@ var NativeSQLStrategy = class {
|
|
|
1236
1253
|
groupByClauses.push(colExpr);
|
|
1237
1254
|
}
|
|
1238
1255
|
}
|
|
1256
|
+
const datasetScope = ctx.getDatasetScope?.(query.cube);
|
|
1239
1257
|
if (query.measures && query.measures.length > 0) {
|
|
1240
1258
|
for (const measure of query.measures) {
|
|
1241
|
-
const
|
|
1259
|
+
const measureFilter = datasetScope?.measureFilters?.[measure];
|
|
1260
|
+
const predicate = measureFilter ? this.compileFilterNode(
|
|
1261
|
+
normalizeAnalyticsFilterTree({ where: measureFilter }),
|
|
1262
|
+
cube,
|
|
1263
|
+
tableName,
|
|
1264
|
+
joins,
|
|
1265
|
+
params,
|
|
1266
|
+
ctx
|
|
1267
|
+
) : null;
|
|
1268
|
+
const aggExpr = this.resolveMeasureSql(cube, measure, tableName, joins, predicate);
|
|
1242
1269
|
selectClauses.push(`${aggExpr} AS "${measure}"`);
|
|
1243
1270
|
}
|
|
1244
1271
|
}
|
|
@@ -1252,6 +1279,17 @@ var NativeSQLStrategy = class {
|
|
|
1252
1279
|
ctx
|
|
1253
1280
|
);
|
|
1254
1281
|
if (filterSql) whereClauses.push(filterSql);
|
|
1282
|
+
if (datasetScope?.filter) {
|
|
1283
|
+
const scopeSql = this.compileFilterNode(
|
|
1284
|
+
normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
|
|
1285
|
+
cube,
|
|
1286
|
+
tableName,
|
|
1287
|
+
joins,
|
|
1288
|
+
params,
|
|
1289
|
+
ctx
|
|
1290
|
+
);
|
|
1291
|
+
if (scopeSql) whereClauses.push(scopeSql);
|
|
1292
|
+
}
|
|
1255
1293
|
if (query.timeDimensions && query.timeDimensions.length > 0) {
|
|
1256
1294
|
for (const td of query.timeDimensions) {
|
|
1257
1295
|
const colExpr = this.resolveFieldSql(cube, td.dimension, tableName, joins);
|
|
@@ -1425,7 +1463,12 @@ var NativeSQLStrategy = class {
|
|
|
1425
1463
|
const raw = dim ? dim.sql : member.includes(".") ? member.split(".")[1] : member;
|
|
1426
1464
|
return this.qualifyAndRegisterJoin(raw, parentTable, joins, cube);
|
|
1427
1465
|
}
|
|
1428
|
-
|
|
1466
|
+
/**
|
|
1467
|
+
* @param predicate - The measure's own scoped filter, already compiled to a
|
|
1468
|
+
* SQL boolean (`null` = the measure declares none, or declares one that
|
|
1469
|
+
* constrains nothing — `compileFilterNode`'s TRUE). #10298.
|
|
1470
|
+
*/
|
|
1471
|
+
resolveMeasureSql(cube, member, parentTable, joins, predicate = null) {
|
|
1429
1472
|
const measure = this.lookupMember(cube, member, "measure");
|
|
1430
1473
|
if (!measure) {
|
|
1431
1474
|
const declared = Object.keys(cube.measures ?? {});
|
|
@@ -1435,6 +1478,13 @@ var NativeSQLStrategy = class {
|
|
|
1435
1478
|
);
|
|
1436
1479
|
}
|
|
1437
1480
|
const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
|
|
1481
|
+
if (predicate !== null) {
|
|
1482
|
+
const wrapConditional = CONDITIONAL_AGGREGATE_SQL[measure.type];
|
|
1483
|
+
if (wrapConditional) return wrapConditional(col, predicate);
|
|
1484
|
+
throw new Error(
|
|
1485
|
+
`[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(", ")}).`
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1438
1488
|
const wrap = AGGREGATE_SQL[measure.type];
|
|
1439
1489
|
if (wrap) return wrap(col);
|
|
1440
1490
|
if (EXPRESSION_METRIC_TYPES.has(measure.type)) return col;
|
|
@@ -1781,10 +1831,18 @@ var ObjectQLStrategy = class {
|
|
|
1781
1831
|
const extra = this.mergeFilterOperand(filter, field, bounds);
|
|
1782
1832
|
if (extra) conjuncts.push(extra);
|
|
1783
1833
|
}
|
|
1834
|
+
const datasetScope = ctx.getDatasetScope?.(query.cube);
|
|
1835
|
+
if (datasetScope?.filter) {
|
|
1836
|
+
const scopeCondition = this.filterNodeToCondition(
|
|
1837
|
+
normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
|
|
1838
|
+
cube
|
|
1839
|
+
);
|
|
1840
|
+
if (scopeCondition) conjuncts.push(scopeCondition);
|
|
1841
|
+
}
|
|
1784
1842
|
if (conjuncts.length > 0) {
|
|
1785
1843
|
filter.$and = [...Array.isArray(filter.$and) ? filter.$and : [], ...conjuncts];
|
|
1786
1844
|
}
|
|
1787
|
-
const plan = this.planCrossObject(cube, query,
|
|
1845
|
+
const plan = this.planCrossObject(cube, query, this.filterMemberView(cube, query, ctx));
|
|
1788
1846
|
if (plan) {
|
|
1789
1847
|
return this.executeCrossObject(cube, query, aggregations, filter, plan, ctx);
|
|
1790
1848
|
}
|
|
@@ -1861,9 +1919,7 @@ var ObjectQLStrategy = class {
|
|
|
1861
1919
|
if (td.granularity) granByDim.set(td.dimension, td.granularity);
|
|
1862
1920
|
}
|
|
1863
1921
|
const tableName = this.extractObjectName(cube);
|
|
1864
|
-
const plan = this.planCrossObject(cube, query,
|
|
1865
|
-
collectFilterLeaves(normalizeAnalyticsFilterTree(query)).map((f) => [this.resolveFieldName(cube, f.member, "any"), true])
|
|
1866
|
-
));
|
|
1922
|
+
const plan = this.planCrossObject(cube, query, this.filterMemberView(cube, query, ctx));
|
|
1867
1923
|
const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
|
|
1868
1924
|
const joinClauses = [];
|
|
1869
1925
|
const dimExpr = (dim) => {
|
|
@@ -1905,6 +1961,15 @@ var ObjectQLStrategy = class {
|
|
|
1905
1961
|
params
|
|
1906
1962
|
);
|
|
1907
1963
|
if (filterClause) whereParts.push(filterClause);
|
|
1964
|
+
const echoedDatasetFilter = ctx.getDatasetScope?.(query.cube)?.filter;
|
|
1965
|
+
if (echoedDatasetFilter) {
|
|
1966
|
+
const scopeSql = this.renderFilterNodeSql(
|
|
1967
|
+
normalizeAnalyticsFilterTree({ where: echoedDatasetFilter }),
|
|
1968
|
+
cube,
|
|
1969
|
+
params
|
|
1970
|
+
);
|
|
1971
|
+
if (scopeSql) whereParts.push(scopeSql);
|
|
1972
|
+
}
|
|
1908
1973
|
for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
|
|
1909
1974
|
const nextDay = (0, import_core3.nextUtcCalendarDay)(bounds.$lte);
|
|
1910
1975
|
params.push(bounds.$gte, nextDay ?? bounds.$lte);
|
|
@@ -1972,6 +2037,68 @@ var ObjectQLStrategy = class {
|
|
|
1972
2037
|
const joinedObject = cube.joins?.[alias]?.name ?? alias;
|
|
1973
2038
|
return joinedObject !== baseObject;
|
|
1974
2039
|
}
|
|
2040
|
+
/**
|
|
2041
|
+
* The member view {@link planCrossObject} judges a filter by: EVERY member
|
|
2042
|
+
* that will end up in the engine's predicate, structure discarded, keyed by
|
|
2043
|
+
* RESOLVED field name (#10759), valued by WHERE THE MEMBER CAME FROM
|
|
2044
|
+
* (#10861).
|
|
2045
|
+
*
|
|
2046
|
+
* Both call sites — `execute()` and `generateSql()` — are handed this and
|
|
2047
|
+
* nothing else, which is what makes the invariant `planCrossObject` states
|
|
2048
|
+
* for itself ("the preview accepts/rejects the same set") structural rather
|
|
2049
|
+
* than a coincidence maintained by hand. They used to build the view
|
|
2050
|
+
* separately: the echo flattened the tree, `execute()` passed the ENGINE
|
|
2051
|
+
* FILTER, and a filter record answers a different question — it is a
|
|
2052
|
+
* predicate to evaluate, not an inventory of members. An `$or`, a `$not` or
|
|
2053
|
+
* an unmergeable nested `$and` travels in it as one opaque `$and` entry, so
|
|
2054
|
+
* the members inside were unreadable from the outside and the envelope check
|
|
2055
|
+
* could not reject what it could not see.
|
|
2056
|
+
*
|
|
2057
|
+
* ## Two producers, one inventory (#10861)
|
|
2058
|
+
*
|
|
2059
|
+
* The caller's `where` is not the only thing that reaches `engine.aggregate`
|
|
2060
|
+
* as a predicate. Since PR #10758 the compiled dataset's own definition-level
|
|
2061
|
+
* `filter` is lowered onto `execute()`'s `conjuncts` and rendered by
|
|
2062
|
+
* `generateSql()`, so a dataset declaring `filter: { 'account.region': 'West' }`
|
|
2063
|
+
* sent `{"$and":[{"account.region":"West"}]}` to an engine that cannot join —
|
|
2064
|
+
* measured on both doors, which AGREED in accepting it, so #10759's
|
|
2065
|
+
* preview/execution symmetry had nothing to restore. Refusing it is a
|
|
2066
|
+
* widening of the refusal set, ruled by the maintainer on 2026-08-22 (Option
|
|
2067
|
+
* A, query-time refusal): fold the scope's leaves in HERE, where driver
|
|
2068
|
+
* capability is known, rather than in `dataset-compiler.ts`, which cannot see
|
|
2069
|
+
* which driver will serve the dataset and would refuse a dataset that is
|
|
2070
|
+
* perfectly legal on a native-SQL deployment.
|
|
2071
|
+
*
|
|
2072
|
+
* Structure is discarded on purpose — a member is cross-object or it is not,
|
|
2073
|
+
* and which branch of a disjunction it sits in cannot make
|
|
2074
|
+
* `engine.aggregate` able to join it. PROVENANCE is not discarded, because it
|
|
2075
|
+
* decides what the refusal can tell the caller to go fix: `AnalyticsRequestKey`
|
|
2076
|
+
* is the analytics REQUEST vocabulary and a dataset's `filter` is not in it,
|
|
2077
|
+
* so a scope-borne member must not be reported as `param: 'where'` — see
|
|
2078
|
+
* `planCrossObject`. The value slot carries that and nothing else; it never
|
|
2079
|
+
* reaches a driver.
|
|
2080
|
+
*
|
|
2081
|
+
* Dataset leaves are inserted FIRST so a member named by BOTH producers keeps
|
|
2082
|
+
* the caller's provenance (last write wins on a duplicate key): if it is in
|
|
2083
|
+
* the request too, the request is the actionable place to fix it.
|
|
2084
|
+
*
|
|
2085
|
+
* Time-dimension WINDOWS are deliberately absent (they live in
|
|
2086
|
+
* `dateRangeBounds`, not in `where`). They need no arm here: a cross-object
|
|
2087
|
+
* time dimension is refused by `planCrossObject`'s own first loop, over
|
|
2088
|
+
* `query.timeDimensions`, and refused as the time dimension the author wrote
|
|
2089
|
+
* rather than as the lowered predicate it becomes — which is the better
|
|
2090
|
+
* diagnostic and the reason that loop runs first.
|
|
2091
|
+
*/
|
|
2092
|
+
filterMemberView(cube, query, ctx) {
|
|
2093
|
+
const datasetFilter = ctx.getDatasetScope?.(query.cube)?.filter;
|
|
2094
|
+
const leaves = (node, origin) => collectFilterLeaves(node).map(
|
|
2095
|
+
(f) => [this.resolveFieldName(cube, f.member, "any"), origin]
|
|
2096
|
+
);
|
|
2097
|
+
return Object.fromEntries([
|
|
2098
|
+
...datasetFilter ? leaves(normalizeAnalyticsFilterTree({ where: datasetFilter }), "dataset-filter") : [],
|
|
2099
|
+
...leaves(normalizeAnalyticsFilterTree(query), "where")
|
|
2100
|
+
]);
|
|
2101
|
+
}
|
|
1975
2102
|
/**
|
|
1976
2103
|
* Plan how to serve cross-object references on this join-less path (#3654).
|
|
1977
2104
|
*
|
|
@@ -1982,21 +2109,36 @@ var ObjectQLStrategy = class {
|
|
|
1982
2109
|
* query (direct path), a plan for an in-envelope cross-object query.
|
|
1983
2110
|
*
|
|
1984
2111
|
* THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
|
|
1985
|
-
* (needs a real join to evaluate), a
|
|
2112
|
+
* (needs a real join to evaluate), a cross-object leaf in the DATASET's own
|
|
2113
|
+
* definition-level `filter` (#10861 — same join it does not have, arriving
|
|
2114
|
+
* from the producer PR #10758 added), a MULTI-HOP dimension (`a.b.c`), or a
|
|
1986
2115
|
* non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
|
|
1987
2116
|
* cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
|
|
1988
|
-
* `generateSql()` calls this too, so the preview accepts/rejects the same set
|
|
2117
|
+
* `generateSql()` calls this too, so the preview accepts/rejects the same set
|
|
2118
|
+
* — and since #10759 both callers derive `filter` from the one
|
|
2119
|
+
* {@link filterMemberView}, so that sentence is enforced by construction
|
|
2120
|
+
* instead of restated at two call sites.
|
|
1989
2121
|
*
|
|
1990
|
-
* [#5716] All
|
|
1991
|
-
* 400, naming the member — and the
|
|
1992
|
-
* diagnostics, and #5923's tests read
|
|
1993
|
-
* facts and nothing else: a member
|
|
1994
|
-
*
|
|
1995
|
-
*
|
|
1996
|
-
*
|
|
1997
|
-
*
|
|
1998
|
-
*
|
|
1999
|
-
*
|
|
2122
|
+
* [#5716] All five refusals below are `invalidMemberError` — `INVALID_FIELD` /
|
|
2123
|
+
* 400, naming the member — and the four that predate #10861 keep their
|
|
2124
|
+
* MESSAGES unchanged (they are good diagnostics, and #5923's tests read
|
|
2125
|
+
* them). Each is decided by two facts and nothing else: a member that will
|
|
2126
|
+
* reach the engine's predicate, and whether that member resolves across a
|
|
2127
|
+
* join. Neither is an internal invariant — a cube where the member exists and
|
|
2128
|
+
* a driver that could serve it are both perfectly ordinary, which is exactly
|
|
2129
|
+
* what the "run this on a native-SQL driver" half of every message says. They
|
|
2130
|
+
* are member-level rather than dataset-level (hence not `datasetInvalidError`)
|
|
2131
|
+
* because the fix is always to change or drop ONE named member, and because
|
|
2132
|
+
* four of them fire on `/analytics/query` where no dataset exists.
|
|
2133
|
+
*
|
|
2134
|
+
* [#10861] The fifth is the exception that proves the rule and is written to
|
|
2135
|
+
* it: it can only fire where a dataset DOES exist, and it is the one refusal
|
|
2136
|
+
* here whose member no request key named — so it carries `cube` and no
|
|
2137
|
+
* `param`, and says in its own words which document to go and edit. It stays
|
|
2138
|
+
* `INVALID_FIELD` rather than becoming `DATASET_INVALID` because the verdict
|
|
2139
|
+
* is the same physical one as its neighbour — this engine cannot join this
|
|
2140
|
+
* member — and splitting the code by PROVENANCE would make a caller branch on
|
|
2141
|
+
* two wire shapes for one capability limit.
|
|
2000
2142
|
*
|
|
2001
2143
|
* Detection is on RESOLVED field names, so a dotted dimension the cube
|
|
2002
2144
|
* flattens to a real column is treated as base, not cross-object.
|
|
@@ -2018,7 +2160,7 @@ var ObjectQLStrategy = class {
|
|
|
2018
2160
|
member: m,
|
|
2019
2161
|
field: this.resolveMeasureAggregation(cube, m).field
|
|
2020
2162
|
})),
|
|
2021
|
-
...Object.
|
|
2163
|
+
...Object.entries(filter).filter(([, origin]) => origin === "where").map(([f]) => ({ where: "filter", member: f, field: f }))
|
|
2022
2164
|
].filter((r) => this.isCrossObjectField(cube, r.field, baseObject));
|
|
2023
2165
|
if (nonDim.length > 0) {
|
|
2024
2166
|
throw invalidMemberError(
|
|
@@ -2032,6 +2174,13 @@ var ObjectQLStrategy = class {
|
|
|
2032
2174
|
}
|
|
2033
2175
|
);
|
|
2034
2176
|
}
|
|
2177
|
+
const scopeCross = Object.entries(filter).filter(([field, origin]) => origin === "dataset-filter" && this.isCrossObjectField(cube, field, baseObject)).map(([field]) => field);
|
|
2178
|
+
if (scopeCross.length > 0) {
|
|
2179
|
+
throw invalidMemberError(
|
|
2180
|
+
`[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.`,
|
|
2181
|
+
{ member: scopeCross[0], cube: cube.name }
|
|
2182
|
+
);
|
|
2183
|
+
}
|
|
2035
2184
|
const crossDims = [];
|
|
2036
2185
|
for (const dim of query.dimensions ?? []) {
|
|
2037
2186
|
const field = this.resolveFieldName(cube, dim, "dimension");
|
|
@@ -3786,6 +3935,16 @@ var AnalyticsService = class {
|
|
|
3786
3935
|
// Prefer a compiled dataset's declared relationships (D-C join allowlist);
|
|
3787
3936
|
// fall back to any explicitly-configured provider for legacy cubes.
|
|
3788
3937
|
getAllowedRelationships: (cubeName) => this.datasetRegistry.get(cubeName)?.allowedRelationships ?? config.getAllowedRelationships?.(cubeName),
|
|
3938
|
+
// [#10298] The compiled dataset's definition-level filter and its
|
|
3939
|
+
// per-measure filters — the half of the declaration the Cube model has
|
|
3940
|
+
// no room for. Same shape and same registry as `getAllowedRelationships`
|
|
3941
|
+
// directly above: answered for a cube that IS a compiled dataset,
|
|
3942
|
+
// `undefined` for every other cube.
|
|
3943
|
+
getDatasetScope: (cubeName) => {
|
|
3944
|
+
const compiled = this.datasetRegistry.get(cubeName);
|
|
3945
|
+
if (!compiled) return void 0;
|
|
3946
|
+
return { filter: compiled.filter, measureFilters: compiled.measureFilters };
|
|
3947
|
+
},
|
|
3789
3948
|
coerceTemporalFilterValue: config.coerceTemporalFilterValue,
|
|
3790
3949
|
coerceTemporalFilterColumn: config.coerceTemporalFilterColumn,
|
|
3791
3950
|
isExternalObject: config.isExternalObject
|