@objectstack/service-analytics 17.2.0 → 17.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +601 -0
- package/dist/index.cjs +308 -38
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +130 -20
- package/dist/index.d.ts +130 -20
- package/dist/index.js +316 -39
- package/dist/index.js.map +1 -1
- package/package.json +16 -10
package/dist/index.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
// src/analytics-service.ts
|
|
2
2
|
import { percentScaleOf } from "@objectstack/spec/data";
|
|
3
3
|
import { resolveI18nLabel as resolveI18nLabel2 } from "@objectstack/spec/ui";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
createLogger,
|
|
6
|
+
getEnv,
|
|
7
|
+
bucketKeyToCalendarRange as bucketKeyToCalendarRange2,
|
|
8
|
+
zonedDateStartToUtcMs,
|
|
9
|
+
filterTokenContextFrom as filterTokenContextFrom2,
|
|
10
|
+
resolveFilterTokens as resolveFilterTokens2
|
|
11
|
+
} from "@objectstack/core";
|
|
5
12
|
import { matchMissingColumnOfRelation } from "@objectstack/types";
|
|
6
13
|
|
|
7
14
|
// src/cube-registry.ts
|
|
@@ -687,6 +694,53 @@ function compileScopedFilterToSql(filter, alias) {
|
|
|
687
694
|
const sql = compileNode(filter, quotedAlias, params);
|
|
688
695
|
return { sql, params };
|
|
689
696
|
}
|
|
697
|
+
function emptyMembershipFinding(spec, negated, path) {
|
|
698
|
+
if (Array.isArray(spec)) {
|
|
699
|
+
return spec.length === 0 && negated ? { path: `${path}: []`, kind: "negatedIn" } : null;
|
|
700
|
+
}
|
|
701
|
+
if (spec === null || typeof spec !== "object") return null;
|
|
702
|
+
const rec = spec;
|
|
703
|
+
if (Array.isArray(rec.$nin) && rec.$nin.length === 0) return { path: `${path}.$nin`, kind: "nin" };
|
|
704
|
+
if (Array.isArray(rec.$in) && rec.$in.length === 0 && negated) {
|
|
705
|
+
return { path: `${path}.$in`, kind: "negatedIn" };
|
|
706
|
+
}
|
|
707
|
+
return null;
|
|
708
|
+
}
|
|
709
|
+
function findEmptyMembership(node, negated, path) {
|
|
710
|
+
if (node === null || typeof node !== "object" || Array.isArray(node)) return null;
|
|
711
|
+
const rec = node;
|
|
712
|
+
const bare = emptyMembershipFinding(rec, negated, path.length > 0 ? path : "<root>");
|
|
713
|
+
if (bare) return bare;
|
|
714
|
+
for (const [key, value] of Object.entries(rec)) {
|
|
715
|
+
const here = path.length > 0 ? `${path}.${key}` : key;
|
|
716
|
+
if (key === "$not") {
|
|
717
|
+
const found = findEmptyMembership(value, !negated, here);
|
|
718
|
+
if (found) return found;
|
|
719
|
+
} else if (key === "$and" || key === "$or") {
|
|
720
|
+
if (!Array.isArray(value)) continue;
|
|
721
|
+
for (let i = 0; i < value.length; i++) {
|
|
722
|
+
const found = findEmptyMembership(value[i], negated, `${here}[${i}]`);
|
|
723
|
+
if (found) return found;
|
|
724
|
+
}
|
|
725
|
+
} else if (!key.startsWith("$")) {
|
|
726
|
+
const found = emptyMembershipFinding(value, negated, here);
|
|
727
|
+
if (found) return found;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
function assertReadScopeCannotVacate(scope, objectName) {
|
|
733
|
+
const found = findEmptyMembership(scope, false, "");
|
|
734
|
+
if (found === null) return;
|
|
735
|
+
if (found.kind === "nin") {
|
|
736
|
+
throw readScopeCompileError(
|
|
737
|
+
`[read-scope-sql] read scope for "${objectName}" has an empty $nin at ${found.path} \u2014 an empty exclusion excludes nothing, so the engine lowers that clause to constant TRUE and the scope does not bind as written. Refused at every polarity, matching this module's own $nin arm (fail-closed).`
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
throw readScopeCompileError(
|
|
741
|
+
`[read-scope-sql] read scope for "${objectName}" has an empty $in under negation at ${found.path} \u2014 an empty membership matches nothing, so its negation matches every row and the read scope admits the whole table (fail-closed).`
|
|
742
|
+
);
|
|
743
|
+
}
|
|
690
744
|
function compileSub(node, qAlias) {
|
|
691
745
|
const params = [];
|
|
692
746
|
const sql = compileNode(node, qAlias, params);
|
|
@@ -848,7 +902,7 @@ function compileOperator(col, op, val, field, params) {
|
|
|
848
902
|
}
|
|
849
903
|
case "$nin": {
|
|
850
904
|
if (!Array.isArray(val)) throw readScopeCompileError(`[read-scope-sql] $nin for "${field}" needs an array (fail-closed).`);
|
|
851
|
-
if (val.length === 0)
|
|
905
|
+
if (val.length === 0) throw readScopeCompileError(`[read-scope-sql] $nin for "${field}" is empty \u2014 an empty exclusion excludes nothing and would compile the read scope to constant TRUE (fail-closed).`);
|
|
852
906
|
assertCompilableMembers(op, field, val);
|
|
853
907
|
return nullSafeNegative(col, `${col} NOT IN (${val.map((v) => bind(params, v)).join(", ")})`);
|
|
854
908
|
}
|
|
@@ -1327,6 +1381,7 @@ var NativeSQLStrategy = class {
|
|
|
1327
1381
|
const filter = ctx.getReadScope(objectName);
|
|
1328
1382
|
if (filter === void 0 || filter === null) return;
|
|
1329
1383
|
const { sql, params: scopeParams } = compileScopedFilterToSql(filter, alias);
|
|
1384
|
+
assertReadScopeCannotVacate(filter, objectName);
|
|
1330
1385
|
if (!sql) return;
|
|
1331
1386
|
let i = 0;
|
|
1332
1387
|
const rendered = sql.replace(/\?/g, () => {
|
|
@@ -1780,11 +1835,16 @@ var ObjectQLStrategy = class {
|
|
|
1780
1835
|
for (const [dim, gran] of granByDim) {
|
|
1781
1836
|
groupBy.push({ field: this.resolveFieldName(cube, dim, "dimension"), dateGranularity: gran });
|
|
1782
1837
|
}
|
|
1838
|
+
const datasetScope = ctx.getDatasetScope?.(query.cube);
|
|
1783
1839
|
const aggregations = [];
|
|
1784
1840
|
if (query.measures && query.measures.length > 0) {
|
|
1785
1841
|
for (const measure of query.measures) {
|
|
1786
1842
|
const { field, method } = this.resolveMeasureAggregation(cube, measure);
|
|
1787
|
-
|
|
1843
|
+
const measureFilter = datasetScope?.measureFilters?.[measure];
|
|
1844
|
+
const filterCondition = measureFilter ? this.filterNodeToCondition(normalizeAnalyticsFilterTree({ where: measureFilter }), cube) : null;
|
|
1845
|
+
aggregations.push(
|
|
1846
|
+
filterCondition ? { field, method, alias: measure, filter: filterCondition } : { field, method, alias: measure }
|
|
1847
|
+
);
|
|
1788
1848
|
}
|
|
1789
1849
|
}
|
|
1790
1850
|
const filter = {};
|
|
@@ -1794,7 +1854,6 @@ var ObjectQLStrategy = class {
|
|
|
1794
1854
|
const extra = this.mergeFilterOperand(filter, field, bounds);
|
|
1795
1855
|
if (extra) conjuncts.push(extra);
|
|
1796
1856
|
}
|
|
1797
|
-
const datasetScope = ctx.getDatasetScope?.(query.cube);
|
|
1798
1857
|
if (datasetScope?.filter) {
|
|
1799
1858
|
const scopeCondition = this.filterNodeToCondition(
|
|
1800
1859
|
normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
|
|
@@ -1883,6 +1942,7 @@ var ObjectQLStrategy = class {
|
|
|
1883
1942
|
}
|
|
1884
1943
|
const tableName = this.extractObjectName(cube);
|
|
1885
1944
|
const plan = this.planCrossObject(cube, query, this.filterMemberView(cube, query, ctx));
|
|
1945
|
+
const datasetScope = ctx.getDatasetScope?.(query.cube);
|
|
1886
1946
|
const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
|
|
1887
1947
|
const joinClauses = [];
|
|
1888
1948
|
const dimExpr = (dim) => {
|
|
@@ -1913,7 +1973,9 @@ var ObjectQLStrategy = class {
|
|
|
1913
1973
|
if (query.measures) {
|
|
1914
1974
|
for (const m of query.measures) {
|
|
1915
1975
|
const { field, method } = this.resolveMeasureAggregation(cube, m);
|
|
1916
|
-
const
|
|
1976
|
+
const measureFilter = datasetScope?.measureFilters?.[m];
|
|
1977
|
+
const predicate = measureFilter ? this.renderFilterNodeSql(normalizeAnalyticsFilterTree({ where: measureFilter }), cube, params) : null;
|
|
1978
|
+
const aggSql = predicate ? this.conditionalAggregateSql(method, field, predicate) : method === "count" ? "COUNT(*)" : method === "count_distinct" ? `COUNT(DISTINCT ${field})` : `${method.toUpperCase()}(${field})`;
|
|
1917
1979
|
selectParts.push(`${aggSql} AS "${m}"`);
|
|
1918
1980
|
}
|
|
1919
1981
|
}
|
|
@@ -1924,10 +1986,9 @@ var ObjectQLStrategy = class {
|
|
|
1924
1986
|
params
|
|
1925
1987
|
);
|
|
1926
1988
|
if (filterClause) whereParts.push(filterClause);
|
|
1927
|
-
|
|
1928
|
-
if (echoedDatasetFilter) {
|
|
1989
|
+
if (datasetScope?.filter) {
|
|
1929
1990
|
const scopeSql = this.renderFilterNodeSql(
|
|
1930
|
-
normalizeAnalyticsFilterTree({ where:
|
|
1991
|
+
normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
|
|
1931
1992
|
cube,
|
|
1932
1993
|
params
|
|
1933
1994
|
);
|
|
@@ -1943,6 +2004,7 @@ var ObjectQLStrategy = class {
|
|
|
1943
2004
|
const scope = ctx.getReadScope?.(tableName);
|
|
1944
2005
|
if (scope != null) {
|
|
1945
2006
|
const { sql: scopeSql, params: scopeParams } = compileScopedFilterToSql(scope, tableName);
|
|
2007
|
+
assertReadScopeCannotVacate(scope, tableName);
|
|
1946
2008
|
if (scopeSql) {
|
|
1947
2009
|
let i = 0;
|
|
1948
2010
|
const rendered = scopeSql.replace(/\?/g, () => {
|
|
@@ -1989,6 +2051,7 @@ var ObjectQLStrategy = class {
|
|
|
1989
2051
|
if (typeof ctx.getReadScope !== "function") return userFilter;
|
|
1990
2052
|
const scope = ctx.getReadScope(objectName);
|
|
1991
2053
|
if (scope === void 0 || scope === null) return userFilter;
|
|
2054
|
+
assertReadScopeCannotVacate(scope, objectName);
|
|
1992
2055
|
const scopeFilter = markFilterSubtreeProvenance(scope, "policy");
|
|
1993
2056
|
if (!userFilter) return scopeFilter;
|
|
1994
2057
|
return { $and: [userFilter, scopeFilter] };
|
|
@@ -2017,7 +2080,7 @@ var ObjectQLStrategy = class {
|
|
|
2017
2080
|
* the members inside were unreadable from the outside and the envelope check
|
|
2018
2081
|
* could not reject what it could not see.
|
|
2019
2082
|
*
|
|
2020
|
-
* ##
|
|
2083
|
+
* ## Three producers, one inventory (#10861, #11461)
|
|
2021
2084
|
*
|
|
2022
2085
|
* The caller's `where` is not the only thing that reaches `engine.aggregate`
|
|
2023
2086
|
* as a predicate. Since PR #10758 the compiled dataset's own definition-level
|
|
@@ -2032,6 +2095,36 @@ var ObjectQLStrategy = class {
|
|
|
2032
2095
|
* which driver will serve the dataset and would refuse a dataset that is
|
|
2033
2096
|
* perfectly legal on a native-SQL deployment.
|
|
2034
2097
|
*
|
|
2098
|
+
* [#11461] #10413 phase 2 then added a THIRD producer with the same reach and
|
|
2099
|
+
* none of the coverage: a compiled measure's own `filter`, lowered onto that
|
|
2100
|
+
* measure's `aggregations[].filter` entry (#10576). This view enumerated two
|
|
2101
|
+
* origins, so the third was invisible to the envelope check and the arm of
|
|
2102
|
+
* `planCrossObject` that inspects `query.measures` reads only each measure's
|
|
2103
|
+
* resolved FIELD, never its filter. Measured on the unfixed tree, one fixture,
|
|
2104
|
+
* both doors:
|
|
2105
|
+
*
|
|
2106
|
+
* ```
|
|
2107
|
+
* BEFORE execute() ACCEPTED -> aggregations: [{field:"*",method:"count",
|
|
2108
|
+
* alias:"west_count",
|
|
2109
|
+
* filter:{"account.region":"West"}}]
|
|
2110
|
+
* -> rows [{stage:"won",total_count:3,west_count:0}]
|
|
2111
|
+
* (the truthful west_count is 2; total_count
|
|
2112
|
+
* is right, so the wrong number arrived in
|
|
2113
|
+
* the same response shape as the right one)
|
|
2114
|
+
* generateSql() ACCEPTED -> COUNT(CASE WHEN account.region = $1 THEN 1 END)
|
|
2115
|
+
* over a FROM with no join in it at all
|
|
2116
|
+
* AFTER both doors REFUSED INVALID_FIELD / 400, engine never reached
|
|
2117
|
+
* ```
|
|
2118
|
+
*
|
|
2119
|
+
* The same maintainer ruling covers it — same hazard, same physical verdict,
|
|
2120
|
+
* one more producer — so it folds in HERE for the #10861 reason and not into
|
|
2121
|
+
* `dataset-compiler.ts`, which still cannot see which driver will serve the
|
|
2122
|
+
* dataset. Only the REQUESTED measures are folded: both doors' aggregation
|
|
2123
|
+
* loops read `measureFilters[m]` for `m of query.measures` and nothing else,
|
|
2124
|
+
* so a filter declared on a measure this query never asks for reaches no
|
|
2125
|
+
* engine, and refusing on it would reject a query for a member that was never
|
|
2126
|
+
* going to be evaluated.
|
|
2127
|
+
*
|
|
2035
2128
|
* Structure is discarded on purpose — a member is cross-object or it is not,
|
|
2036
2129
|
* and which branch of a disjunction it sits in cannot make
|
|
2037
2130
|
* `engine.aggregate` able to join it. PROVENANCE is not discarded, because it
|
|
@@ -2041,9 +2134,12 @@ var ObjectQLStrategy = class {
|
|
|
2041
2134
|
* `planCrossObject`. The value slot carries that and nothing else; it never
|
|
2042
2135
|
* reaches a driver.
|
|
2043
2136
|
*
|
|
2044
|
-
*
|
|
2045
|
-
*
|
|
2046
|
-
*
|
|
2137
|
+
* Insertion order is measure-filter, then dataset-filter, then `where`, and
|
|
2138
|
+
* last write wins on a duplicate key. Two things follow, in that order of
|
|
2139
|
+
* importance. A member named by the request too keeps the CALLER's provenance,
|
|
2140
|
+
* because if it is in the request that is the actionable place to fix it. And
|
|
2141
|
+
* every shape that was refused before #11461 keeps the exact message it had:
|
|
2142
|
+
* the new origin can only ever win a key no older producer names.
|
|
2047
2143
|
*
|
|
2048
2144
|
* Time-dimension WINDOWS are deliberately absent (they live in
|
|
2049
2145
|
* `dateRangeBounds`, not in `where`). They need no arm here: a cross-object
|
|
@@ -2053,13 +2149,21 @@ var ObjectQLStrategy = class {
|
|
|
2053
2149
|
* diagnostic and the reason that loop runs first.
|
|
2054
2150
|
*/
|
|
2055
2151
|
filterMemberView(cube, query, ctx) {
|
|
2056
|
-
const
|
|
2152
|
+
const datasetScope = ctx.getDatasetScope?.(query.cube);
|
|
2057
2153
|
const leaves = (node, origin) => collectFilterLeaves(node).map(
|
|
2058
2154
|
(f) => [this.resolveFieldName(cube, f.member, "any"), origin]
|
|
2059
2155
|
);
|
|
2156
|
+
const measureLeaves = (query.measures ?? []).flatMap((m) => {
|
|
2157
|
+
const measureFilter = datasetScope?.measureFilters?.[m];
|
|
2158
|
+
return measureFilter ? leaves(
|
|
2159
|
+
normalizeAnalyticsFilterTree({ where: measureFilter }),
|
|
2160
|
+
{ kind: "measure-filter", measure: m }
|
|
2161
|
+
) : [];
|
|
2162
|
+
});
|
|
2060
2163
|
return Object.fromEntries([
|
|
2061
|
-
...
|
|
2062
|
-
...leaves(normalizeAnalyticsFilterTree(
|
|
2164
|
+
...measureLeaves,
|
|
2165
|
+
...datasetScope?.filter ? leaves(normalizeAnalyticsFilterTree({ where: datasetScope.filter }), { kind: "dataset-filter" }) : [],
|
|
2166
|
+
...leaves(normalizeAnalyticsFilterTree(query), { kind: "where" })
|
|
2063
2167
|
]);
|
|
2064
2168
|
}
|
|
2065
2169
|
/**
|
|
@@ -2074,18 +2178,20 @@ var ObjectQLStrategy = class {
|
|
|
2074
2178
|
* THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
|
|
2075
2179
|
* (needs a real join to evaluate), a cross-object leaf in the DATASET's own
|
|
2076
2180
|
* definition-level `filter` (#10861 — same join it does not have, arriving
|
|
2077
|
-
* from the producer PR #10758 added), a
|
|
2078
|
-
*
|
|
2079
|
-
*
|
|
2181
|
+
* from the producer PR #10758 added), a cross-object leaf in ONE MEASURE's own
|
|
2182
|
+
* `filter` (#11461 — the same join again, arriving from the producer #10413
|
|
2183
|
+
* phase 2 added), a MULTI-HOP dimension (`a.b.c`), or a non-recombinable
|
|
2184
|
+
* measure (`avg`/`count_distinct`, whose sub-bucket values cannot be merged).
|
|
2185
|
+
* A loud error beats the silent mis-bucket #3654 kills.
|
|
2080
2186
|
* `generateSql()` calls this too, so the preview accepts/rejects the same set
|
|
2081
2187
|
* — and since #10759 both callers derive `filter` from the one
|
|
2082
2188
|
* {@link filterMemberView}, so that sentence is enforced by construction
|
|
2083
2189
|
* instead of restated at two call sites.
|
|
2084
2190
|
*
|
|
2085
|
-
* [#5716] All
|
|
2191
|
+
* [#5716] All six refusals below are `invalidMemberError` — `INVALID_FIELD` /
|
|
2086
2192
|
* 400, naming the member — and the four that predate #10861 keep their
|
|
2087
2193
|
* 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
|
|
2194
|
+
* them); so does #10861's own, which #11461 left untouched beside it. Each is decided by two facts and nothing else: a member that will
|
|
2089
2195
|
* reach the engine's predicate, and whether that member resolves across a
|
|
2090
2196
|
* join. Neither is an internal invariant — a cube where the member exists and
|
|
2091
2197
|
* a driver that could serve it are both perfectly ordinary, which is exactly
|
|
@@ -2094,14 +2200,15 @@ var ObjectQLStrategy = class {
|
|
|
2094
2200
|
* because the fix is always to change or drop ONE named member, and because
|
|
2095
2201
|
* four of them fire on `/analytics/query` where no dataset exists.
|
|
2096
2202
|
*
|
|
2097
|
-
* [#10861] The fifth
|
|
2098
|
-
* it:
|
|
2099
|
-
* here whose member no request key named — so
|
|
2100
|
-
* `param`, and says in its own words which document to
|
|
2203
|
+
* [#10861, #11461] The fifth and sixth are the exceptions that prove the rule
|
|
2204
|
+
* and are written to it: they can only fire where a dataset DOES exist, and
|
|
2205
|
+
* they are the two refusals here whose member no request key named — so each
|
|
2206
|
+
* carries `cube` and no `param`, and says in its own words which document to
|
|
2207
|
+
* go and edit, the sixth naming the MEASURE inside it as well. Both stay
|
|
2101
2208
|
* `INVALID_FIELD` rather than becoming `DATASET_INVALID` because the verdict
|
|
2102
|
-
* is the same physical one as
|
|
2209
|
+
* is the same physical one as their neighbours — this engine cannot join this
|
|
2103
2210
|
* member — and splitting the code by PROVENANCE would make a caller branch on
|
|
2104
|
-
*
|
|
2211
|
+
* three wire shapes for one capability limit.
|
|
2105
2212
|
*
|
|
2106
2213
|
* Detection is on RESOLVED field names, so a dotted dimension the cube
|
|
2107
2214
|
* flattens to a real column is treated as base, not cross-object.
|
|
@@ -2123,7 +2230,7 @@ var ObjectQLStrategy = class {
|
|
|
2123
2230
|
member: m,
|
|
2124
2231
|
field: this.resolveMeasureAggregation(cube, m).field
|
|
2125
2232
|
})),
|
|
2126
|
-
...Object.entries(filter).filter(([, origin]) => origin === "where").map(([f]) => ({ where: "filter", member: f, field: f }))
|
|
2233
|
+
...Object.entries(filter).filter(([, origin]) => origin.kind === "where").map(([f]) => ({ where: "filter", member: f, field: f }))
|
|
2127
2234
|
].filter((r) => this.isCrossObjectField(cube, r.field, baseObject));
|
|
2128
2235
|
if (nonDim.length > 0) {
|
|
2129
2236
|
throw invalidMemberError(
|
|
@@ -2137,13 +2244,23 @@ var ObjectQLStrategy = class {
|
|
|
2137
2244
|
}
|
|
2138
2245
|
);
|
|
2139
2246
|
}
|
|
2140
|
-
const scopeCross = Object.entries(filter).filter(([field, origin]) => origin === "dataset-filter" && this.isCrossObjectField(cube, field, baseObject)).map(([field]) => field);
|
|
2247
|
+
const scopeCross = Object.entries(filter).filter(([field, origin]) => origin.kind === "dataset-filter" && this.isCrossObjectField(cube, field, baseObject)).map(([field]) => field);
|
|
2141
2248
|
if (scopeCross.length > 0) {
|
|
2142
2249
|
throw invalidMemberError(
|
|
2143
2250
|
`[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
2251
|
{ member: scopeCross[0], cube: cube.name }
|
|
2145
2252
|
);
|
|
2146
2253
|
}
|
|
2254
|
+
const measureCross = Object.entries(filter).flatMap(
|
|
2255
|
+
([field, origin]) => origin.kind === "measure-filter" && this.isCrossObjectField(cube, field, baseObject) ? [{ field, measure: origin.measure }] : []
|
|
2256
|
+
);
|
|
2257
|
+
if (measureCross.length > 0) {
|
|
2258
|
+
const { field, measure } = measureCross[0];
|
|
2259
|
+
throw invalidMemberError(
|
|
2260
|
+
`[Analytics] ObjectQLStrategy cannot evaluate the cross-object filter ("${field}") that dataset "${cube.name}" declares on its measure "${measure}" \u2014 the engine cannot join in an aggregate, so this measure would be counted over a predicate that matches nothing and would answer 0 rather than the scoped number. Remove the cross-object leaf from that measure's own \`filter\`, or serve this dataset on a native-SQL driver, where the same definition is valid.`,
|
|
2261
|
+
{ member: field, cube: cube.name }
|
|
2262
|
+
);
|
|
2263
|
+
}
|
|
2147
2264
|
const crossDims = [];
|
|
2148
2265
|
for (const dim of query.dimensions ?? []) {
|
|
2149
2266
|
const field = this.resolveFieldName(cube, dim, "dimension");
|
|
@@ -2247,6 +2364,7 @@ var ObjectQLStrategy = class {
|
|
|
2247
2364
|
if (fkValues.length === 0 || typeof ctx.executeAggregate !== "function") return map;
|
|
2248
2365
|
const idFilter = { id: { $in: fkValues } };
|
|
2249
2366
|
const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
|
|
2367
|
+
if (scope != null) assertReadScopeCannotVacate(scope, refObject);
|
|
2250
2368
|
if (scope != null) markFilterSubtreeProvenance(scope, "policy");
|
|
2251
2369
|
const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
|
|
2252
2370
|
const rows = await ctx.executeAggregate(refObject, {
|
|
@@ -2260,6 +2378,45 @@ var ObjectQLStrategy = class {
|
|
|
2260
2378
|
}
|
|
2261
2379
|
return map;
|
|
2262
2380
|
}
|
|
2381
|
+
/**
|
|
2382
|
+
* A measure's aggregate, restricted to the rows its own `filter` admits
|
|
2383
|
+
* (#10413 phase 2) — the same six functions `generateSql`'s unconditional
|
|
2384
|
+
* branch renders, wrapped in a `CASE WHEN`.
|
|
2385
|
+
*
|
|
2386
|
+
* Spelled `CASE WHEN` rather than SQL-standard `FILTER (WHERE …)`, mirroring
|
|
2387
|
+
* `NativeSQLStrategy.CONDITIONAL_AGGREGATE_SQL`: this string is DOCUMENTATION
|
|
2388
|
+
* of an execution that really goes through `engine.aggregate`'s per-driver
|
|
2389
|
+
* `aggregations[].filter` lowering (#10576), not a statement this class runs
|
|
2390
|
+
* itself, so there is no reason to pick a dialect-restricted spelling over
|
|
2391
|
+
* the portable one the SQL-executing sibling already settled on.
|
|
2392
|
+
*
|
|
2393
|
+
* `count` over `*` counts a constant (`COUNT(CASE WHEN p THEN 1 END)`, since
|
|
2394
|
+
* `COUNT(CASE WHEN p THEN * END)` is not valid SQL); over a real column it
|
|
2395
|
+
* counts that column's non-null values among the admitted rows.
|
|
2396
|
+
*/
|
|
2397
|
+
conditionalAggregateSql(method, col, pred) {
|
|
2398
|
+
const target = col === "*" ? "1" : col;
|
|
2399
|
+
switch (method) {
|
|
2400
|
+
case "count":
|
|
2401
|
+
return `COUNT(CASE WHEN ${pred} THEN ${target} END)`;
|
|
2402
|
+
case "count_distinct":
|
|
2403
|
+
return `COUNT(DISTINCT CASE WHEN ${pred} THEN ${col} END)`;
|
|
2404
|
+
case "sum":
|
|
2405
|
+
return `SUM(CASE WHEN ${pred} THEN ${col} END)`;
|
|
2406
|
+
case "avg":
|
|
2407
|
+
return `AVG(CASE WHEN ${pred} THEN ${col} END)`;
|
|
2408
|
+
case "min":
|
|
2409
|
+
return `MIN(CASE WHEN ${pred} THEN ${col} END)`;
|
|
2410
|
+
case "max":
|
|
2411
|
+
return `MAX(CASE WHEN ${pred} THEN ${col} END)`;
|
|
2412
|
+
// Closed vocabulary, same posture as `resolveMeasureAggregation`'s
|
|
2413
|
+
// callers: `method` comes only from that function, whose own aggTypes
|
|
2414
|
+
// list is exactly these six, so this default is unreachable rather than
|
|
2415
|
+
// a silent fallback for a method this table forgot.
|
|
2416
|
+
default:
|
|
2417
|
+
return `${method.toUpperCase()}(CASE WHEN ${pred} THEN ${col} END)`;
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2263
2420
|
/**
|
|
2264
2421
|
* Render one normalized filter as a display SQL predicate for `generateSql`.
|
|
2265
2422
|
*
|
|
@@ -2351,8 +2508,20 @@ var ObjectQLStrategy = class {
|
|
|
2351
2508
|
resolveMeasureAggregation(cube, measureName) {
|
|
2352
2509
|
const direct = this.lookupMember(cube, measureName, "measure");
|
|
2353
2510
|
if (direct) {
|
|
2511
|
+
if (EXPRESSION_METRIC_TYPES.has(direct.type)) {
|
|
2512
|
+
throw invalidMemberError(
|
|
2513
|
+
`[Analytics] ObjectQLStrategy cannot evaluate the custom-SQL measure ("${measureName}") \u2014 its type "${direct.type}" declares a raw SQL expression, which the engine aggregate AST cannot carry; served anyway it would answer null for every bucket under the measure's own name. Use an aggregate measure (count/sum/avg/min/max/count_distinct), or run on a native-SQL driver.`,
|
|
2514
|
+
{ member: measureName, param: "measures", cube: cube.name }
|
|
2515
|
+
);
|
|
2516
|
+
}
|
|
2354
2517
|
return {
|
|
2355
2518
|
field: direct.sql.replace(/^\$/, ""),
|
|
2519
|
+
// The assertion, not a parse: for a CubeSchema-legal cube the type
|
|
2520
|
+
// partition above leaves exactly the six `AggregationFunction` values.
|
|
2521
|
+
// An enum-INVALID type (host drift, the comment above) still flows
|
|
2522
|
+
// through unchecked ON PURPOSE — adding a method allowlist here would
|
|
2523
|
+
// re-blame the caller with a 400 for OUR bug, so the cast keeps the
|
|
2524
|
+
// compile-time contract (#12776) without changing that posture.
|
|
2356
2525
|
method: direct.type === "count_distinct" ? "count_distinct" : direct.type
|
|
2357
2526
|
};
|
|
2358
2527
|
}
|
|
@@ -2366,7 +2535,7 @@ var ObjectQLStrategy = class {
|
|
|
2366
2535
|
if (candidate && candidate.type === type) {
|
|
2367
2536
|
return {
|
|
2368
2537
|
field: candidate.sql.replace(/^\$/, ""),
|
|
2369
|
-
method:
|
|
2538
|
+
method: type
|
|
2370
2539
|
};
|
|
2371
2540
|
}
|
|
2372
2541
|
}
|
|
@@ -3930,15 +4099,59 @@ var AnalyticsService = class {
|
|
|
3930
4099
|
* current request's ExecutionContext (ADR-0021 D-C). The strategy then sees a
|
|
3931
4100
|
* `getReadScope(objectName)` that already knows the active tenant.
|
|
3932
4101
|
*/
|
|
3933
|
-
async callCtx(query, context) {
|
|
3934
|
-
|
|
4102
|
+
async callCtx(query, context, tokenCtx) {
|
|
4103
|
+
const getDatasetScope = this.resolvedDatasetScopeGetter(tokenCtx);
|
|
4104
|
+
if (!this.readScopeProvider) return { ...this.baseCtx, context, getDatasetScope };
|
|
3935
4105
|
const scopes = await this.resolveReadScopes(query, context);
|
|
3936
4106
|
return {
|
|
3937
4107
|
...this.baseCtx,
|
|
3938
4108
|
context,
|
|
4109
|
+
getDatasetScope,
|
|
3939
4110
|
getReadScope: (objectName) => scopes.get(objectName) ?? null
|
|
3940
4111
|
};
|
|
3941
4112
|
}
|
|
4113
|
+
/**
|
|
4114
|
+
* [#12230] Copy-on-write expansion of filter placeholders across everything
|
|
4115
|
+
* a DIRECT analytics query compares on: `where` and each time dimension's
|
|
4116
|
+
* `dateRange` — the same positions `DatasetExecutor.resolveSelectionTokens`
|
|
4117
|
+
* covers for the dashboard door, minus the dataset-only channels it alone
|
|
4118
|
+
* carries (measure filters ride the dataset-scope getter below).
|
|
4119
|
+
*
|
|
4120
|
+
* The input is never mutated: a query object can be caller-owned metadata
|
|
4121
|
+
* (a saved report definition, a flow node's config) reused across requests,
|
|
4122
|
+
* and resolving in place would bake one request's user id into every later
|
|
4123
|
+
* render. Returns the SAME object when nothing resolved.
|
|
4124
|
+
*/
|
|
4125
|
+
resolveQueryTokens(query, tokenCtx) {
|
|
4126
|
+
const where = resolveFilterTokens2(query.where, tokenCtx);
|
|
4127
|
+
const timeDimensions = query.timeDimensions?.map((td) => {
|
|
4128
|
+
if (td.dateRange == null) return td;
|
|
4129
|
+
const dateRange = resolveFilterTokens2(td.dateRange, tokenCtx);
|
|
4130
|
+
return dateRange === td.dateRange ? td : { ...td, dateRange };
|
|
4131
|
+
});
|
|
4132
|
+
const tdChanged = timeDimensions !== void 0 && timeDimensions.some((td, i) => td !== query.timeDimensions[i]);
|
|
4133
|
+
if (where === query.where && !tdChanged) return query;
|
|
4134
|
+
const out = { ...query };
|
|
4135
|
+
if (where !== query.where) out.where = where;
|
|
4136
|
+
if (tdChanged) out.timeDimensions = timeDimensions;
|
|
4137
|
+
return out;
|
|
4138
|
+
}
|
|
4139
|
+
/**
|
|
4140
|
+
* [#12230] A per-request `getDatasetScope` whose answers have their filter
|
|
4141
|
+
* placeholders resolved against THIS caller. See `callCtx` for why the
|
|
4142
|
+
* registry's copy cannot be handed out raw. Token-free scopes pass through
|
|
4143
|
+
* by reference — `resolveFilterTokens` returns its input unchanged when the
|
|
4144
|
+
* tree holds no placeholder, so the common case allocates nothing.
|
|
4145
|
+
*/
|
|
4146
|
+
resolvedDatasetScopeGetter(tokenCtx) {
|
|
4147
|
+
return (cubeName) => {
|
|
4148
|
+
const scope = this.baseCtx.getDatasetScope?.(cubeName);
|
|
4149
|
+
if (!scope) return scope;
|
|
4150
|
+
const filter = resolveFilterTokens2(scope.filter, tokenCtx);
|
|
4151
|
+
const measureFilters = resolveFilterTokens2(scope.measureFilters, tokenCtx);
|
|
4152
|
+
return filter === scope.filter && measureFilters === scope.measureFilters ? scope : { filter, measureFilters };
|
|
4153
|
+
};
|
|
4154
|
+
}
|
|
3942
4155
|
/**
|
|
3943
4156
|
* Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
|
|
3944
4157
|
* AND every joined object of the query's cube, keyed by object name. This is
|
|
@@ -3996,12 +4209,14 @@ var AnalyticsService = class {
|
|
|
3996
4209
|
* aggregate bridge) instead of failing — or worse, fabricating empty rows.
|
|
3997
4210
|
* Any other error propagates untouched.
|
|
3998
4211
|
*/
|
|
3999
|
-
async query(
|
|
4000
|
-
if (!
|
|
4212
|
+
async query(queryInput, context) {
|
|
4213
|
+
if (!queryInput.cube) {
|
|
4001
4214
|
throw new Error("Cube name is required in analytics query");
|
|
4002
4215
|
}
|
|
4216
|
+
const tokenCtx = filterTokenContextFrom2(context, /* @__PURE__ */ new Date());
|
|
4217
|
+
const query = this.resolveQueryTokens(queryInput, tokenCtx);
|
|
4003
4218
|
this.ensureCube(query);
|
|
4004
|
-
const ctx = await this.callCtx(query, context);
|
|
4219
|
+
const ctx = await this.callCtx(query, context, tokenCtx);
|
|
4005
4220
|
let skip;
|
|
4006
4221
|
for (; ; ) {
|
|
4007
4222
|
const strategy = this.resolveStrategy(query, ctx, skip);
|
|
@@ -4197,6 +4412,7 @@ var AnalyticsService = class {
|
|
|
4197
4412
|
const label = resolveI18nLabel2(m.label, requestLocale);
|
|
4198
4413
|
if (label !== void 0) f.label = label;
|
|
4199
4414
|
}
|
|
4415
|
+
if (f.builtinAggregate == null && m.label == null && m.aggregate) f.builtinAggregate = m.aggregate;
|
|
4200
4416
|
if (f.format == null && m.format) f.format = m.format;
|
|
4201
4417
|
const fc = f;
|
|
4202
4418
|
const mc = m;
|
|
@@ -4255,12 +4471,14 @@ var AnalyticsService = class {
|
|
|
4255
4471
|
/**
|
|
4256
4472
|
* Generate SQL for a query without executing it (dry-run).
|
|
4257
4473
|
*/
|
|
4258
|
-
async generateSql(
|
|
4259
|
-
if (!
|
|
4474
|
+
async generateSql(queryInput, context) {
|
|
4475
|
+
if (!queryInput.cube) {
|
|
4260
4476
|
throw new Error("Cube name is required for SQL generation");
|
|
4261
4477
|
}
|
|
4478
|
+
const tokenCtx = filterTokenContextFrom2(context, /* @__PURE__ */ new Date());
|
|
4479
|
+
const query = this.resolveQueryTokens(queryInput, tokenCtx);
|
|
4262
4480
|
this.ensureCube(query);
|
|
4263
|
-
const ctx = await this.callCtx(query, context);
|
|
4481
|
+
const ctx = await this.callCtx(query, context, tokenCtx);
|
|
4264
4482
|
const strategy = this.resolveStrategy(query, ctx);
|
|
4265
4483
|
this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" \u2192 ${strategy.name}`);
|
|
4266
4484
|
return strategy.generateSql(query, ctx);
|
|
@@ -4774,6 +4992,16 @@ var FallbackDelegateStrategy = class {
|
|
|
4774
4992
|
};
|
|
4775
4993
|
|
|
4776
4994
|
// src/plugin.ts
|
|
4995
|
+
import { AggregationFunction as AggregationFunction2 } from "@objectstack/spec/data";
|
|
4996
|
+
function parseEngineAggregateFunction(method, alias) {
|
|
4997
|
+
const parsed = AggregationFunction2.safeParse(method);
|
|
4998
|
+
if (!parsed.success) {
|
|
4999
|
+
throw new Error(
|
|
5000
|
+
`[Analytics] The aggregate bridge cannot forward the aggregation "${alias}": "${method}" is not one of the engine's aggregate functions (${AggregationFunction2.options.join(", ")}). A custom-SQL measure is refused earlier, with a caller-facing diagnostic, by ObjectQLStrategy; reaching this point means the analytics layer produced a method the engine contract does not declare.`
|
|
5001
|
+
);
|
|
5002
|
+
}
|
|
5003
|
+
return parsed.data;
|
|
5004
|
+
}
|
|
4777
5005
|
var AnalyticsServicePlugin = class {
|
|
4778
5006
|
constructor(options = {}) {
|
|
4779
5007
|
this.name = "com.objectstack.service-analytics";
|
|
@@ -4831,10 +5059,58 @@ var AnalyticsServicePlugin = class {
|
|
|
4831
5059
|
const rows = await engine.aggregate(objectName, {
|
|
4832
5060
|
where: filter,
|
|
4833
5061
|
groupBy,
|
|
5062
|
+
// [#10413 phase 2 / #10576] `a.filter` is the per-aggregation
|
|
5063
|
+
// predicate `ObjectQLStrategy` lowers a measure's own scoped
|
|
5064
|
+
// `filter` into. This map already renames `method` → `function`
|
|
5065
|
+
// for the engine's own vocabulary; dropping `filter` here — as this
|
|
5066
|
+
// bridge did before this line existed — would have made the
|
|
5067
|
+
// strategy's lowering a NO-OP on every real deployment that boots
|
|
5068
|
+
// through this auto-bridge (the default path: `new
|
|
5069
|
+
// AnalyticsServicePlugin({ cubes })` with no custom
|
|
5070
|
+
// `executeAggregate`), passing every unit test that stubs
|
|
5071
|
+
// `executeAggregate` directly while silently dropping the filter in
|
|
5072
|
+
// production — the exact declared-≠-enforced shape Prime Directive
|
|
5073
|
+
// #10 calls out. Omitted (not `filter: undefined`) when the
|
|
5074
|
+
// aggregation carries none, matching the engine's own
|
|
5075
|
+
// vacuous-filter convention.
|
|
4834
5076
|
aggregations: aggregations?.map((a) => ({
|
|
4835
|
-
function
|
|
5077
|
+
// [#11833] `function` is the engine contract's SIX-value
|
|
5078
|
+
// `AggregationFunction`. This bridge's own input declared
|
|
5079
|
+
// `method: string` when that history was written
|
|
5080
|
+
// (`StrategyContext.executeAggregate`, spec
|
|
5081
|
+
// `contracts/analytics-service.ts`), so the two ends of this
|
|
5082
|
+
// rename spoke different vocabularies: narrowing the engine side
|
|
5083
|
+
// to the contract turned the forward into a compile error — the
|
|
5084
|
+
// correct signal, and the one the deleted structural type hid by
|
|
5085
|
+
// declaring `function: string` on both sides.
|
|
5086
|
+
//
|
|
5087
|
+
// Since #12776 (contract) and #12940 (this plugin's own config
|
|
5088
|
+
// mirror above), BOTH ends declare the enum, so the rename is
|
|
5089
|
+
// enum-to-enum and the parse below is defence in depth behind a
|
|
5090
|
+
// compile-time check rather than the only check — see
|
|
5091
|
+
// `parseEngineAggregateFunction` for why erased types still leave
|
|
5092
|
+
// it load-bearing.
|
|
5093
|
+
//
|
|
5094
|
+
// It was closed by PARSING with the spec enum itself rather than
|
|
5095
|
+
// by widening back to `string` (what hid it) or casting past it
|
|
5096
|
+
// (which keeps the hole and adds a lie). `AggregationFunction` is
|
|
5097
|
+
// the same schema `AggregationNodeSchema.function` is built from,
|
|
5098
|
+
// so there is one vocabulary, and its own error map already
|
|
5099
|
+
// carries the `array_agg`/`string_agg` retirement prescriptions.
|
|
5100
|
+
//
|
|
5101
|
+
// TIERING, deliberately: the reachable producer of a non-aggregate
|
|
5102
|
+
// method — a custom-SQL measure (`AggregationMetricType`
|
|
5103
|
+
// `number`/`string`/`boolean`) — is already refused upstream with a
|
|
5104
|
+
// caller-blaming 400 by `ObjectQLStrategy.resolveMeasureAggregation`
|
|
5105
|
+
// (#12209). Anything still arriving here is host drift, which that
|
|
5106
|
+
// refusal's docblock assigns to the undeclared-500 tier — so this
|
|
5107
|
+
// throws rather than re-blaming the caller, and it answers loudly
|
|
5108
|
+
// instead of letting the engine answer `null` per bucket under the
|
|
5109
|
+
// author's own measure name (the #4157 class).
|
|
5110
|
+
function: parseEngineAggregateFunction(a.method, a.alias),
|
|
4836
5111
|
field: a.field,
|
|
4837
|
-
alias: a.alias
|
|
5112
|
+
alias: a.alias,
|
|
5113
|
+
...a.filter ? { filter: a.filter } : {}
|
|
4838
5114
|
})),
|
|
4839
5115
|
// ADR-0053 Phase 2: thread the reference tz so date buckets resolve on
|
|
4840
5116
|
// that zone's calendar days (engine buckets in-memory when non-UTC).
|
|
@@ -4936,6 +5212,7 @@ var AnalyticsServicePlugin = class {
|
|
|
4936
5212
|
const map = /* @__PURE__ */ new Map();
|
|
4937
5213
|
const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
|
|
4938
5214
|
if (!displayField || !executeAggregate || ids.length === 0) return map;
|
|
5215
|
+
if (scope) assertReadScopeCannotVacate(scope, targetObject);
|
|
4939
5216
|
const CHUNK = 500;
|
|
4940
5217
|
for (let i = 0; i < ids.length; i += CHUNK) {
|
|
4941
5218
|
const idFilter = { id: { $in: ids.slice(i, i + CHUNK) } };
|