@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.cjs
CHANGED
|
@@ -724,6 +724,53 @@ function compileScopedFilterToSql(filter, alias) {
|
|
|
724
724
|
const sql = compileNode(filter, quotedAlias, params);
|
|
725
725
|
return { sql, params };
|
|
726
726
|
}
|
|
727
|
+
function emptyMembershipFinding(spec, negated, path) {
|
|
728
|
+
if (Array.isArray(spec)) {
|
|
729
|
+
return spec.length === 0 && negated ? { path: `${path}: []`, kind: "negatedIn" } : null;
|
|
730
|
+
}
|
|
731
|
+
if (spec === null || typeof spec !== "object") return null;
|
|
732
|
+
const rec = spec;
|
|
733
|
+
if (Array.isArray(rec.$nin) && rec.$nin.length === 0) return { path: `${path}.$nin`, kind: "nin" };
|
|
734
|
+
if (Array.isArray(rec.$in) && rec.$in.length === 0 && negated) {
|
|
735
|
+
return { path: `${path}.$in`, kind: "negatedIn" };
|
|
736
|
+
}
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
function findEmptyMembership(node, negated, path) {
|
|
740
|
+
if (node === null || typeof node !== "object" || Array.isArray(node)) return null;
|
|
741
|
+
const rec = node;
|
|
742
|
+
const bare = emptyMembershipFinding(rec, negated, path.length > 0 ? path : "<root>");
|
|
743
|
+
if (bare) return bare;
|
|
744
|
+
for (const [key, value] of Object.entries(rec)) {
|
|
745
|
+
const here = path.length > 0 ? `${path}.${key}` : key;
|
|
746
|
+
if (key === "$not") {
|
|
747
|
+
const found = findEmptyMembership(value, !negated, here);
|
|
748
|
+
if (found) return found;
|
|
749
|
+
} else if (key === "$and" || key === "$or") {
|
|
750
|
+
if (!Array.isArray(value)) continue;
|
|
751
|
+
for (let i = 0; i < value.length; i++) {
|
|
752
|
+
const found = findEmptyMembership(value[i], negated, `${here}[${i}]`);
|
|
753
|
+
if (found) return found;
|
|
754
|
+
}
|
|
755
|
+
} else if (!key.startsWith("$")) {
|
|
756
|
+
const found = emptyMembershipFinding(value, negated, here);
|
|
757
|
+
if (found) return found;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return null;
|
|
761
|
+
}
|
|
762
|
+
function assertReadScopeCannotVacate(scope, objectName) {
|
|
763
|
+
const found = findEmptyMembership(scope, false, "");
|
|
764
|
+
if (found === null) return;
|
|
765
|
+
if (found.kind === "nin") {
|
|
766
|
+
throw readScopeCompileError(
|
|
767
|
+
`[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).`
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
throw readScopeCompileError(
|
|
771
|
+
`[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).`
|
|
772
|
+
);
|
|
773
|
+
}
|
|
727
774
|
function compileSub(node, qAlias) {
|
|
728
775
|
const params = [];
|
|
729
776
|
const sql = compileNode(node, qAlias, params);
|
|
@@ -885,7 +932,7 @@ function compileOperator(col, op, val, field, params) {
|
|
|
885
932
|
}
|
|
886
933
|
case "$nin": {
|
|
887
934
|
if (!Array.isArray(val)) throw readScopeCompileError(`[read-scope-sql] $nin for "${field}" needs an array (fail-closed).`);
|
|
888
|
-
if (val.length === 0)
|
|
935
|
+
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).`);
|
|
889
936
|
assertCompilableMembers(op, field, val);
|
|
890
937
|
return nullSafeNegative(col, `${col} NOT IN (${val.map((v) => bind(params, v)).join(", ")})`);
|
|
891
938
|
}
|
|
@@ -1364,6 +1411,7 @@ var NativeSQLStrategy = class {
|
|
|
1364
1411
|
const filter = ctx.getReadScope(objectName);
|
|
1365
1412
|
if (filter === void 0 || filter === null) return;
|
|
1366
1413
|
const { sql, params: scopeParams } = compileScopedFilterToSql(filter, alias);
|
|
1414
|
+
assertReadScopeCannotVacate(filter, objectName);
|
|
1367
1415
|
if (!sql) return;
|
|
1368
1416
|
let i = 0;
|
|
1369
1417
|
const rendered = sql.replace(/\?/g, () => {
|
|
@@ -1817,11 +1865,16 @@ var ObjectQLStrategy = class {
|
|
|
1817
1865
|
for (const [dim, gran] of granByDim) {
|
|
1818
1866
|
groupBy.push({ field: this.resolveFieldName(cube, dim, "dimension"), dateGranularity: gran });
|
|
1819
1867
|
}
|
|
1868
|
+
const datasetScope = ctx.getDatasetScope?.(query.cube);
|
|
1820
1869
|
const aggregations = [];
|
|
1821
1870
|
if (query.measures && query.measures.length > 0) {
|
|
1822
1871
|
for (const measure of query.measures) {
|
|
1823
1872
|
const { field, method } = this.resolveMeasureAggregation(cube, measure);
|
|
1824
|
-
|
|
1873
|
+
const measureFilter = datasetScope?.measureFilters?.[measure];
|
|
1874
|
+
const filterCondition = measureFilter ? this.filterNodeToCondition(normalizeAnalyticsFilterTree({ where: measureFilter }), cube) : null;
|
|
1875
|
+
aggregations.push(
|
|
1876
|
+
filterCondition ? { field, method, alias: measure, filter: filterCondition } : { field, method, alias: measure }
|
|
1877
|
+
);
|
|
1825
1878
|
}
|
|
1826
1879
|
}
|
|
1827
1880
|
const filter = {};
|
|
@@ -1831,7 +1884,6 @@ var ObjectQLStrategy = class {
|
|
|
1831
1884
|
const extra = this.mergeFilterOperand(filter, field, bounds);
|
|
1832
1885
|
if (extra) conjuncts.push(extra);
|
|
1833
1886
|
}
|
|
1834
|
-
const datasetScope = ctx.getDatasetScope?.(query.cube);
|
|
1835
1887
|
if (datasetScope?.filter) {
|
|
1836
1888
|
const scopeCondition = this.filterNodeToCondition(
|
|
1837
1889
|
normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
|
|
@@ -1920,6 +1972,7 @@ var ObjectQLStrategy = class {
|
|
|
1920
1972
|
}
|
|
1921
1973
|
const tableName = this.extractObjectName(cube);
|
|
1922
1974
|
const plan = this.planCrossObject(cube, query, this.filterMemberView(cube, query, ctx));
|
|
1975
|
+
const datasetScope = ctx.getDatasetScope?.(query.cube);
|
|
1923
1976
|
const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
|
|
1924
1977
|
const joinClauses = [];
|
|
1925
1978
|
const dimExpr = (dim) => {
|
|
@@ -1950,7 +2003,9 @@ var ObjectQLStrategy = class {
|
|
|
1950
2003
|
if (query.measures) {
|
|
1951
2004
|
for (const m of query.measures) {
|
|
1952
2005
|
const { field, method } = this.resolveMeasureAggregation(cube, m);
|
|
1953
|
-
const
|
|
2006
|
+
const measureFilter = datasetScope?.measureFilters?.[m];
|
|
2007
|
+
const predicate = measureFilter ? this.renderFilterNodeSql(normalizeAnalyticsFilterTree({ where: measureFilter }), cube, params) : null;
|
|
2008
|
+
const aggSql = predicate ? this.conditionalAggregateSql(method, field, predicate) : method === "count" ? "COUNT(*)" : method === "count_distinct" ? `COUNT(DISTINCT ${field})` : `${method.toUpperCase()}(${field})`;
|
|
1954
2009
|
selectParts.push(`${aggSql} AS "${m}"`);
|
|
1955
2010
|
}
|
|
1956
2011
|
}
|
|
@@ -1961,10 +2016,9 @@ var ObjectQLStrategy = class {
|
|
|
1961
2016
|
params
|
|
1962
2017
|
);
|
|
1963
2018
|
if (filterClause) whereParts.push(filterClause);
|
|
1964
|
-
|
|
1965
|
-
if (echoedDatasetFilter) {
|
|
2019
|
+
if (datasetScope?.filter) {
|
|
1966
2020
|
const scopeSql = this.renderFilterNodeSql(
|
|
1967
|
-
normalizeAnalyticsFilterTree({ where:
|
|
2021
|
+
normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
|
|
1968
2022
|
cube,
|
|
1969
2023
|
params
|
|
1970
2024
|
);
|
|
@@ -1980,6 +2034,7 @@ var ObjectQLStrategy = class {
|
|
|
1980
2034
|
const scope = ctx.getReadScope?.(tableName);
|
|
1981
2035
|
if (scope != null) {
|
|
1982
2036
|
const { sql: scopeSql, params: scopeParams } = compileScopedFilterToSql(scope, tableName);
|
|
2037
|
+
assertReadScopeCannotVacate(scope, tableName);
|
|
1983
2038
|
if (scopeSql) {
|
|
1984
2039
|
let i = 0;
|
|
1985
2040
|
const rendered = scopeSql.replace(/\?/g, () => {
|
|
@@ -2026,6 +2081,7 @@ var ObjectQLStrategy = class {
|
|
|
2026
2081
|
if (typeof ctx.getReadScope !== "function") return userFilter;
|
|
2027
2082
|
const scope = ctx.getReadScope(objectName);
|
|
2028
2083
|
if (scope === void 0 || scope === null) return userFilter;
|
|
2084
|
+
assertReadScopeCannotVacate(scope, objectName);
|
|
2029
2085
|
const scopeFilter = (0, import_data3.markFilterSubtreeProvenance)(scope, "policy");
|
|
2030
2086
|
if (!userFilter) return scopeFilter;
|
|
2031
2087
|
return { $and: [userFilter, scopeFilter] };
|
|
@@ -2054,7 +2110,7 @@ var ObjectQLStrategy = class {
|
|
|
2054
2110
|
* the members inside were unreadable from the outside and the envelope check
|
|
2055
2111
|
* could not reject what it could not see.
|
|
2056
2112
|
*
|
|
2057
|
-
* ##
|
|
2113
|
+
* ## Three producers, one inventory (#10861, #11461)
|
|
2058
2114
|
*
|
|
2059
2115
|
* The caller's `where` is not the only thing that reaches `engine.aggregate`
|
|
2060
2116
|
* as a predicate. Since PR #10758 the compiled dataset's own definition-level
|
|
@@ -2069,6 +2125,36 @@ var ObjectQLStrategy = class {
|
|
|
2069
2125
|
* which driver will serve the dataset and would refuse a dataset that is
|
|
2070
2126
|
* perfectly legal on a native-SQL deployment.
|
|
2071
2127
|
*
|
|
2128
|
+
* [#11461] #10413 phase 2 then added a THIRD producer with the same reach and
|
|
2129
|
+
* none of the coverage: a compiled measure's own `filter`, lowered onto that
|
|
2130
|
+
* measure's `aggregations[].filter` entry (#10576). This view enumerated two
|
|
2131
|
+
* origins, so the third was invisible to the envelope check and the arm of
|
|
2132
|
+
* `planCrossObject` that inspects `query.measures` reads only each measure's
|
|
2133
|
+
* resolved FIELD, never its filter. Measured on the unfixed tree, one fixture,
|
|
2134
|
+
* both doors:
|
|
2135
|
+
*
|
|
2136
|
+
* ```
|
|
2137
|
+
* BEFORE execute() ACCEPTED -> aggregations: [{field:"*",method:"count",
|
|
2138
|
+
* alias:"west_count",
|
|
2139
|
+
* filter:{"account.region":"West"}}]
|
|
2140
|
+
* -> rows [{stage:"won",total_count:3,west_count:0}]
|
|
2141
|
+
* (the truthful west_count is 2; total_count
|
|
2142
|
+
* is right, so the wrong number arrived in
|
|
2143
|
+
* the same response shape as the right one)
|
|
2144
|
+
* generateSql() ACCEPTED -> COUNT(CASE WHEN account.region = $1 THEN 1 END)
|
|
2145
|
+
* over a FROM with no join in it at all
|
|
2146
|
+
* AFTER both doors REFUSED INVALID_FIELD / 400, engine never reached
|
|
2147
|
+
* ```
|
|
2148
|
+
*
|
|
2149
|
+
* The same maintainer ruling covers it — same hazard, same physical verdict,
|
|
2150
|
+
* one more producer — so it folds in HERE for the #10861 reason and not into
|
|
2151
|
+
* `dataset-compiler.ts`, which still cannot see which driver will serve the
|
|
2152
|
+
* dataset. Only the REQUESTED measures are folded: both doors' aggregation
|
|
2153
|
+
* loops read `measureFilters[m]` for `m of query.measures` and nothing else,
|
|
2154
|
+
* so a filter declared on a measure this query never asks for reaches no
|
|
2155
|
+
* engine, and refusing on it would reject a query for a member that was never
|
|
2156
|
+
* going to be evaluated.
|
|
2157
|
+
*
|
|
2072
2158
|
* Structure is discarded on purpose — a member is cross-object or it is not,
|
|
2073
2159
|
* and which branch of a disjunction it sits in cannot make
|
|
2074
2160
|
* `engine.aggregate` able to join it. PROVENANCE is not discarded, because it
|
|
@@ -2078,9 +2164,12 @@ var ObjectQLStrategy = class {
|
|
|
2078
2164
|
* `planCrossObject`. The value slot carries that and nothing else; it never
|
|
2079
2165
|
* reaches a driver.
|
|
2080
2166
|
*
|
|
2081
|
-
*
|
|
2082
|
-
*
|
|
2083
|
-
*
|
|
2167
|
+
* Insertion order is measure-filter, then dataset-filter, then `where`, and
|
|
2168
|
+
* last write wins on a duplicate key. Two things follow, in that order of
|
|
2169
|
+
* importance. A member named by the request too keeps the CALLER's provenance,
|
|
2170
|
+
* because if it is in the request that is the actionable place to fix it. And
|
|
2171
|
+
* every shape that was refused before #11461 keeps the exact message it had:
|
|
2172
|
+
* the new origin can only ever win a key no older producer names.
|
|
2084
2173
|
*
|
|
2085
2174
|
* Time-dimension WINDOWS are deliberately absent (they live in
|
|
2086
2175
|
* `dateRangeBounds`, not in `where`). They need no arm here: a cross-object
|
|
@@ -2090,13 +2179,21 @@ var ObjectQLStrategy = class {
|
|
|
2090
2179
|
* diagnostic and the reason that loop runs first.
|
|
2091
2180
|
*/
|
|
2092
2181
|
filterMemberView(cube, query, ctx) {
|
|
2093
|
-
const
|
|
2182
|
+
const datasetScope = ctx.getDatasetScope?.(query.cube);
|
|
2094
2183
|
const leaves = (node, origin) => collectFilterLeaves(node).map(
|
|
2095
2184
|
(f) => [this.resolveFieldName(cube, f.member, "any"), origin]
|
|
2096
2185
|
);
|
|
2186
|
+
const measureLeaves = (query.measures ?? []).flatMap((m) => {
|
|
2187
|
+
const measureFilter = datasetScope?.measureFilters?.[m];
|
|
2188
|
+
return measureFilter ? leaves(
|
|
2189
|
+
normalizeAnalyticsFilterTree({ where: measureFilter }),
|
|
2190
|
+
{ kind: "measure-filter", measure: m }
|
|
2191
|
+
) : [];
|
|
2192
|
+
});
|
|
2097
2193
|
return Object.fromEntries([
|
|
2098
|
-
...
|
|
2099
|
-
...leaves(normalizeAnalyticsFilterTree(
|
|
2194
|
+
...measureLeaves,
|
|
2195
|
+
...datasetScope?.filter ? leaves(normalizeAnalyticsFilterTree({ where: datasetScope.filter }), { kind: "dataset-filter" }) : [],
|
|
2196
|
+
...leaves(normalizeAnalyticsFilterTree(query), { kind: "where" })
|
|
2100
2197
|
]);
|
|
2101
2198
|
}
|
|
2102
2199
|
/**
|
|
@@ -2111,18 +2208,20 @@ var ObjectQLStrategy = class {
|
|
|
2111
2208
|
* THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
|
|
2112
2209
|
* (needs a real join to evaluate), a cross-object leaf in the DATASET's own
|
|
2113
2210
|
* definition-level `filter` (#10861 — same join it does not have, arriving
|
|
2114
|
-
* from the producer PR #10758 added), a
|
|
2115
|
-
*
|
|
2116
|
-
*
|
|
2211
|
+
* from the producer PR #10758 added), a cross-object leaf in ONE MEASURE's own
|
|
2212
|
+
* `filter` (#11461 — the same join again, arriving from the producer #10413
|
|
2213
|
+
* phase 2 added), a MULTI-HOP dimension (`a.b.c`), or a non-recombinable
|
|
2214
|
+
* measure (`avg`/`count_distinct`, whose sub-bucket values cannot be merged).
|
|
2215
|
+
* A loud error beats the silent mis-bucket #3654 kills.
|
|
2117
2216
|
* `generateSql()` calls this too, so the preview accepts/rejects the same set
|
|
2118
2217
|
* — and since #10759 both callers derive `filter` from the one
|
|
2119
2218
|
* {@link filterMemberView}, so that sentence is enforced by construction
|
|
2120
2219
|
* instead of restated at two call sites.
|
|
2121
2220
|
*
|
|
2122
|
-
* [#5716] All
|
|
2221
|
+
* [#5716] All six refusals below are `invalidMemberError` — `INVALID_FIELD` /
|
|
2123
2222
|
* 400, naming the member — and the four that predate #10861 keep their
|
|
2124
2223
|
* 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
|
|
2224
|
+
* 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
|
|
2126
2225
|
* reach the engine's predicate, and whether that member resolves across a
|
|
2127
2226
|
* join. Neither is an internal invariant — a cube where the member exists and
|
|
2128
2227
|
* a driver that could serve it are both perfectly ordinary, which is exactly
|
|
@@ -2131,14 +2230,15 @@ var ObjectQLStrategy = class {
|
|
|
2131
2230
|
* because the fix is always to change or drop ONE named member, and because
|
|
2132
2231
|
* four of them fire on `/analytics/query` where no dataset exists.
|
|
2133
2232
|
*
|
|
2134
|
-
* [#10861] The fifth
|
|
2135
|
-
* it:
|
|
2136
|
-
* here whose member no request key named — so
|
|
2137
|
-
* `param`, and says in its own words which document to
|
|
2233
|
+
* [#10861, #11461] The fifth and sixth are the exceptions that prove the rule
|
|
2234
|
+
* and are written to it: they can only fire where a dataset DOES exist, and
|
|
2235
|
+
* they are the two refusals here whose member no request key named — so each
|
|
2236
|
+
* carries `cube` and no `param`, and says in its own words which document to
|
|
2237
|
+
* go and edit, the sixth naming the MEASURE inside it as well. Both stay
|
|
2138
2238
|
* `INVALID_FIELD` rather than becoming `DATASET_INVALID` because the verdict
|
|
2139
|
-
* is the same physical one as
|
|
2239
|
+
* is the same physical one as their neighbours — this engine cannot join this
|
|
2140
2240
|
* member — and splitting the code by PROVENANCE would make a caller branch on
|
|
2141
|
-
*
|
|
2241
|
+
* three wire shapes for one capability limit.
|
|
2142
2242
|
*
|
|
2143
2243
|
* Detection is on RESOLVED field names, so a dotted dimension the cube
|
|
2144
2244
|
* flattens to a real column is treated as base, not cross-object.
|
|
@@ -2160,7 +2260,7 @@ var ObjectQLStrategy = class {
|
|
|
2160
2260
|
member: m,
|
|
2161
2261
|
field: this.resolveMeasureAggregation(cube, m).field
|
|
2162
2262
|
})),
|
|
2163
|
-
...Object.entries(filter).filter(([, origin]) => origin === "where").map(([f]) => ({ where: "filter", member: f, field: f }))
|
|
2263
|
+
...Object.entries(filter).filter(([, origin]) => origin.kind === "where").map(([f]) => ({ where: "filter", member: f, field: f }))
|
|
2164
2264
|
].filter((r) => this.isCrossObjectField(cube, r.field, baseObject));
|
|
2165
2265
|
if (nonDim.length > 0) {
|
|
2166
2266
|
throw invalidMemberError(
|
|
@@ -2174,13 +2274,23 @@ var ObjectQLStrategy = class {
|
|
|
2174
2274
|
}
|
|
2175
2275
|
);
|
|
2176
2276
|
}
|
|
2177
|
-
const scopeCross = Object.entries(filter).filter(([field, origin]) => origin === "dataset-filter" && this.isCrossObjectField(cube, field, baseObject)).map(([field]) => field);
|
|
2277
|
+
const scopeCross = Object.entries(filter).filter(([field, origin]) => origin.kind === "dataset-filter" && this.isCrossObjectField(cube, field, baseObject)).map(([field]) => field);
|
|
2178
2278
|
if (scopeCross.length > 0) {
|
|
2179
2279
|
throw invalidMemberError(
|
|
2180
2280
|
`[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
2281
|
{ member: scopeCross[0], cube: cube.name }
|
|
2182
2282
|
);
|
|
2183
2283
|
}
|
|
2284
|
+
const measureCross = Object.entries(filter).flatMap(
|
|
2285
|
+
([field, origin]) => origin.kind === "measure-filter" && this.isCrossObjectField(cube, field, baseObject) ? [{ field, measure: origin.measure }] : []
|
|
2286
|
+
);
|
|
2287
|
+
if (measureCross.length > 0) {
|
|
2288
|
+
const { field, measure } = measureCross[0];
|
|
2289
|
+
throw invalidMemberError(
|
|
2290
|
+
`[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.`,
|
|
2291
|
+
{ member: field, cube: cube.name }
|
|
2292
|
+
);
|
|
2293
|
+
}
|
|
2184
2294
|
const crossDims = [];
|
|
2185
2295
|
for (const dim of query.dimensions ?? []) {
|
|
2186
2296
|
const field = this.resolveFieldName(cube, dim, "dimension");
|
|
@@ -2284,6 +2394,7 @@ var ObjectQLStrategy = class {
|
|
|
2284
2394
|
if (fkValues.length === 0 || typeof ctx.executeAggregate !== "function") return map;
|
|
2285
2395
|
const idFilter = { id: { $in: fkValues } };
|
|
2286
2396
|
const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
|
|
2397
|
+
if (scope != null) assertReadScopeCannotVacate(scope, refObject);
|
|
2287
2398
|
if (scope != null) (0, import_data3.markFilterSubtreeProvenance)(scope, "policy");
|
|
2288
2399
|
const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
|
|
2289
2400
|
const rows = await ctx.executeAggregate(refObject, {
|
|
@@ -2297,6 +2408,45 @@ var ObjectQLStrategy = class {
|
|
|
2297
2408
|
}
|
|
2298
2409
|
return map;
|
|
2299
2410
|
}
|
|
2411
|
+
/**
|
|
2412
|
+
* A measure's aggregate, restricted to the rows its own `filter` admits
|
|
2413
|
+
* (#10413 phase 2) — the same six functions `generateSql`'s unconditional
|
|
2414
|
+
* branch renders, wrapped in a `CASE WHEN`.
|
|
2415
|
+
*
|
|
2416
|
+
* Spelled `CASE WHEN` rather than SQL-standard `FILTER (WHERE …)`, mirroring
|
|
2417
|
+
* `NativeSQLStrategy.CONDITIONAL_AGGREGATE_SQL`: this string is DOCUMENTATION
|
|
2418
|
+
* of an execution that really goes through `engine.aggregate`'s per-driver
|
|
2419
|
+
* `aggregations[].filter` lowering (#10576), not a statement this class runs
|
|
2420
|
+
* itself, so there is no reason to pick a dialect-restricted spelling over
|
|
2421
|
+
* the portable one the SQL-executing sibling already settled on.
|
|
2422
|
+
*
|
|
2423
|
+
* `count` over `*` counts a constant (`COUNT(CASE WHEN p THEN 1 END)`, since
|
|
2424
|
+
* `COUNT(CASE WHEN p THEN * END)` is not valid SQL); over a real column it
|
|
2425
|
+
* counts that column's non-null values among the admitted rows.
|
|
2426
|
+
*/
|
|
2427
|
+
conditionalAggregateSql(method, col, pred) {
|
|
2428
|
+
const target = col === "*" ? "1" : col;
|
|
2429
|
+
switch (method) {
|
|
2430
|
+
case "count":
|
|
2431
|
+
return `COUNT(CASE WHEN ${pred} THEN ${target} END)`;
|
|
2432
|
+
case "count_distinct":
|
|
2433
|
+
return `COUNT(DISTINCT CASE WHEN ${pred} THEN ${col} END)`;
|
|
2434
|
+
case "sum":
|
|
2435
|
+
return `SUM(CASE WHEN ${pred} THEN ${col} END)`;
|
|
2436
|
+
case "avg":
|
|
2437
|
+
return `AVG(CASE WHEN ${pred} THEN ${col} END)`;
|
|
2438
|
+
case "min":
|
|
2439
|
+
return `MIN(CASE WHEN ${pred} THEN ${col} END)`;
|
|
2440
|
+
case "max":
|
|
2441
|
+
return `MAX(CASE WHEN ${pred} THEN ${col} END)`;
|
|
2442
|
+
// Closed vocabulary, same posture as `resolveMeasureAggregation`'s
|
|
2443
|
+
// callers: `method` comes only from that function, whose own aggTypes
|
|
2444
|
+
// list is exactly these six, so this default is unreachable rather than
|
|
2445
|
+
// a silent fallback for a method this table forgot.
|
|
2446
|
+
default:
|
|
2447
|
+
return `${method.toUpperCase()}(CASE WHEN ${pred} THEN ${col} END)`;
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2300
2450
|
/**
|
|
2301
2451
|
* Render one normalized filter as a display SQL predicate for `generateSql`.
|
|
2302
2452
|
*
|
|
@@ -2388,8 +2538,20 @@ var ObjectQLStrategy = class {
|
|
|
2388
2538
|
resolveMeasureAggregation(cube, measureName) {
|
|
2389
2539
|
const direct = this.lookupMember(cube, measureName, "measure");
|
|
2390
2540
|
if (direct) {
|
|
2541
|
+
if (EXPRESSION_METRIC_TYPES.has(direct.type)) {
|
|
2542
|
+
throw invalidMemberError(
|
|
2543
|
+
`[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.`,
|
|
2544
|
+
{ member: measureName, param: "measures", cube: cube.name }
|
|
2545
|
+
);
|
|
2546
|
+
}
|
|
2391
2547
|
return {
|
|
2392
2548
|
field: direct.sql.replace(/^\$/, ""),
|
|
2549
|
+
// The assertion, not a parse: for a CubeSchema-legal cube the type
|
|
2550
|
+
// partition above leaves exactly the six `AggregationFunction` values.
|
|
2551
|
+
// An enum-INVALID type (host drift, the comment above) still flows
|
|
2552
|
+
// through unchecked ON PURPOSE — adding a method allowlist here would
|
|
2553
|
+
// re-blame the caller with a 400 for OUR bug, so the cast keeps the
|
|
2554
|
+
// compile-time contract (#12776) without changing that posture.
|
|
2393
2555
|
method: direct.type === "count_distinct" ? "count_distinct" : direct.type
|
|
2394
2556
|
};
|
|
2395
2557
|
}
|
|
@@ -2403,7 +2565,7 @@ var ObjectQLStrategy = class {
|
|
|
2403
2565
|
if (candidate && candidate.type === type) {
|
|
2404
2566
|
return {
|
|
2405
2567
|
field: candidate.sql.replace(/^\$/, ""),
|
|
2406
|
-
method:
|
|
2568
|
+
method: type
|
|
2407
2569
|
};
|
|
2408
2570
|
}
|
|
2409
2571
|
}
|
|
@@ -3967,15 +4129,59 @@ var AnalyticsService = class {
|
|
|
3967
4129
|
* current request's ExecutionContext (ADR-0021 D-C). The strategy then sees a
|
|
3968
4130
|
* `getReadScope(objectName)` that already knows the active tenant.
|
|
3969
4131
|
*/
|
|
3970
|
-
async callCtx(query, context) {
|
|
3971
|
-
|
|
4132
|
+
async callCtx(query, context, tokenCtx) {
|
|
4133
|
+
const getDatasetScope = this.resolvedDatasetScopeGetter(tokenCtx);
|
|
4134
|
+
if (!this.readScopeProvider) return { ...this.baseCtx, context, getDatasetScope };
|
|
3972
4135
|
const scopes = await this.resolveReadScopes(query, context);
|
|
3973
4136
|
return {
|
|
3974
4137
|
...this.baseCtx,
|
|
3975
4138
|
context,
|
|
4139
|
+
getDatasetScope,
|
|
3976
4140
|
getReadScope: (objectName) => scopes.get(objectName) ?? null
|
|
3977
4141
|
};
|
|
3978
4142
|
}
|
|
4143
|
+
/**
|
|
4144
|
+
* [#12230] Copy-on-write expansion of filter placeholders across everything
|
|
4145
|
+
* a DIRECT analytics query compares on: `where` and each time dimension's
|
|
4146
|
+
* `dateRange` — the same positions `DatasetExecutor.resolveSelectionTokens`
|
|
4147
|
+
* covers for the dashboard door, minus the dataset-only channels it alone
|
|
4148
|
+
* carries (measure filters ride the dataset-scope getter below).
|
|
4149
|
+
*
|
|
4150
|
+
* The input is never mutated: a query object can be caller-owned metadata
|
|
4151
|
+
* (a saved report definition, a flow node's config) reused across requests,
|
|
4152
|
+
* and resolving in place would bake one request's user id into every later
|
|
4153
|
+
* render. Returns the SAME object when nothing resolved.
|
|
4154
|
+
*/
|
|
4155
|
+
resolveQueryTokens(query, tokenCtx) {
|
|
4156
|
+
const where = (0, import_core6.resolveFilterTokens)(query.where, tokenCtx);
|
|
4157
|
+
const timeDimensions = query.timeDimensions?.map((td) => {
|
|
4158
|
+
if (td.dateRange == null) return td;
|
|
4159
|
+
const dateRange = (0, import_core6.resolveFilterTokens)(td.dateRange, tokenCtx);
|
|
4160
|
+
return dateRange === td.dateRange ? td : { ...td, dateRange };
|
|
4161
|
+
});
|
|
4162
|
+
const tdChanged = timeDimensions !== void 0 && timeDimensions.some((td, i) => td !== query.timeDimensions[i]);
|
|
4163
|
+
if (where === query.where && !tdChanged) return query;
|
|
4164
|
+
const out = { ...query };
|
|
4165
|
+
if (where !== query.where) out.where = where;
|
|
4166
|
+
if (tdChanged) out.timeDimensions = timeDimensions;
|
|
4167
|
+
return out;
|
|
4168
|
+
}
|
|
4169
|
+
/**
|
|
4170
|
+
* [#12230] A per-request `getDatasetScope` whose answers have their filter
|
|
4171
|
+
* placeholders resolved against THIS caller. See `callCtx` for why the
|
|
4172
|
+
* registry's copy cannot be handed out raw. Token-free scopes pass through
|
|
4173
|
+
* by reference — `resolveFilterTokens` returns its input unchanged when the
|
|
4174
|
+
* tree holds no placeholder, so the common case allocates nothing.
|
|
4175
|
+
*/
|
|
4176
|
+
resolvedDatasetScopeGetter(tokenCtx) {
|
|
4177
|
+
return (cubeName) => {
|
|
4178
|
+
const scope = this.baseCtx.getDatasetScope?.(cubeName);
|
|
4179
|
+
if (!scope) return scope;
|
|
4180
|
+
const filter = (0, import_core6.resolveFilterTokens)(scope.filter, tokenCtx);
|
|
4181
|
+
const measureFilters = (0, import_core6.resolveFilterTokens)(scope.measureFilters, tokenCtx);
|
|
4182
|
+
return filter === scope.filter && measureFilters === scope.measureFilters ? scope : { filter, measureFilters };
|
|
4183
|
+
};
|
|
4184
|
+
}
|
|
3979
4185
|
/**
|
|
3980
4186
|
* Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
|
|
3981
4187
|
* AND every joined object of the query's cube, keyed by object name. This is
|
|
@@ -4033,12 +4239,14 @@ var AnalyticsService = class {
|
|
|
4033
4239
|
* aggregate bridge) instead of failing — or worse, fabricating empty rows.
|
|
4034
4240
|
* Any other error propagates untouched.
|
|
4035
4241
|
*/
|
|
4036
|
-
async query(
|
|
4037
|
-
if (!
|
|
4242
|
+
async query(queryInput, context) {
|
|
4243
|
+
if (!queryInput.cube) {
|
|
4038
4244
|
throw new Error("Cube name is required in analytics query");
|
|
4039
4245
|
}
|
|
4246
|
+
const tokenCtx = (0, import_core6.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
|
|
4247
|
+
const query = this.resolveQueryTokens(queryInput, tokenCtx);
|
|
4040
4248
|
this.ensureCube(query);
|
|
4041
|
-
const ctx = await this.callCtx(query, context);
|
|
4249
|
+
const ctx = await this.callCtx(query, context, tokenCtx);
|
|
4042
4250
|
let skip;
|
|
4043
4251
|
for (; ; ) {
|
|
4044
4252
|
const strategy = this.resolveStrategy(query, ctx, skip);
|
|
@@ -4234,6 +4442,7 @@ var AnalyticsService = class {
|
|
|
4234
4442
|
const label = (0, import_ui2.resolveI18nLabel)(m.label, requestLocale);
|
|
4235
4443
|
if (label !== void 0) f.label = label;
|
|
4236
4444
|
}
|
|
4445
|
+
if (f.builtinAggregate == null && m.label == null && m.aggregate) f.builtinAggregate = m.aggregate;
|
|
4237
4446
|
if (f.format == null && m.format) f.format = m.format;
|
|
4238
4447
|
const fc = f;
|
|
4239
4448
|
const mc = m;
|
|
@@ -4292,12 +4501,14 @@ var AnalyticsService = class {
|
|
|
4292
4501
|
/**
|
|
4293
4502
|
* Generate SQL for a query without executing it (dry-run).
|
|
4294
4503
|
*/
|
|
4295
|
-
async generateSql(
|
|
4296
|
-
if (!
|
|
4504
|
+
async generateSql(queryInput, context) {
|
|
4505
|
+
if (!queryInput.cube) {
|
|
4297
4506
|
throw new Error("Cube name is required for SQL generation");
|
|
4298
4507
|
}
|
|
4508
|
+
const tokenCtx = (0, import_core6.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
|
|
4509
|
+
const query = this.resolveQueryTokens(queryInput, tokenCtx);
|
|
4299
4510
|
this.ensureCube(query);
|
|
4300
|
-
const ctx = await this.callCtx(query, context);
|
|
4511
|
+
const ctx = await this.callCtx(query, context, tokenCtx);
|
|
4301
4512
|
const strategy = this.resolveStrategy(query, ctx);
|
|
4302
4513
|
this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" \u2192 ${strategy.name}`);
|
|
4303
4514
|
return strategy.generateSql(query, ctx);
|
|
@@ -4811,6 +5022,16 @@ var FallbackDelegateStrategy = class {
|
|
|
4811
5022
|
};
|
|
4812
5023
|
|
|
4813
5024
|
// src/plugin.ts
|
|
5025
|
+
var import_data7 = require("@objectstack/spec/data");
|
|
5026
|
+
function parseEngineAggregateFunction(method, alias) {
|
|
5027
|
+
const parsed = import_data7.AggregationFunction.safeParse(method);
|
|
5028
|
+
if (!parsed.success) {
|
|
5029
|
+
throw new Error(
|
|
5030
|
+
`[Analytics] The aggregate bridge cannot forward the aggregation "${alias}": "${method}" is not one of the engine's aggregate functions (${import_data7.AggregationFunction.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.`
|
|
5031
|
+
);
|
|
5032
|
+
}
|
|
5033
|
+
return parsed.data;
|
|
5034
|
+
}
|
|
4814
5035
|
var AnalyticsServicePlugin = class {
|
|
4815
5036
|
constructor(options = {}) {
|
|
4816
5037
|
this.name = "com.objectstack.service-analytics";
|
|
@@ -4868,10 +5089,58 @@ var AnalyticsServicePlugin = class {
|
|
|
4868
5089
|
const rows = await engine.aggregate(objectName, {
|
|
4869
5090
|
where: filter,
|
|
4870
5091
|
groupBy,
|
|
5092
|
+
// [#10413 phase 2 / #10576] `a.filter` is the per-aggregation
|
|
5093
|
+
// predicate `ObjectQLStrategy` lowers a measure's own scoped
|
|
5094
|
+
// `filter` into. This map already renames `method` → `function`
|
|
5095
|
+
// for the engine's own vocabulary; dropping `filter` here — as this
|
|
5096
|
+
// bridge did before this line existed — would have made the
|
|
5097
|
+
// strategy's lowering a NO-OP on every real deployment that boots
|
|
5098
|
+
// through this auto-bridge (the default path: `new
|
|
5099
|
+
// AnalyticsServicePlugin({ cubes })` with no custom
|
|
5100
|
+
// `executeAggregate`), passing every unit test that stubs
|
|
5101
|
+
// `executeAggregate` directly while silently dropping the filter in
|
|
5102
|
+
// production — the exact declared-≠-enforced shape Prime Directive
|
|
5103
|
+
// #10 calls out. Omitted (not `filter: undefined`) when the
|
|
5104
|
+
// aggregation carries none, matching the engine's own
|
|
5105
|
+
// vacuous-filter convention.
|
|
4871
5106
|
aggregations: aggregations?.map((a) => ({
|
|
4872
|
-
function
|
|
5107
|
+
// [#11833] `function` is the engine contract's SIX-value
|
|
5108
|
+
// `AggregationFunction`. This bridge's own input declared
|
|
5109
|
+
// `method: string` when that history was written
|
|
5110
|
+
// (`StrategyContext.executeAggregate`, spec
|
|
5111
|
+
// `contracts/analytics-service.ts`), so the two ends of this
|
|
5112
|
+
// rename spoke different vocabularies: narrowing the engine side
|
|
5113
|
+
// to the contract turned the forward into a compile error — the
|
|
5114
|
+
// correct signal, and the one the deleted structural type hid by
|
|
5115
|
+
// declaring `function: string` on both sides.
|
|
5116
|
+
//
|
|
5117
|
+
// Since #12776 (contract) and #12940 (this plugin's own config
|
|
5118
|
+
// mirror above), BOTH ends declare the enum, so the rename is
|
|
5119
|
+
// enum-to-enum and the parse below is defence in depth behind a
|
|
5120
|
+
// compile-time check rather than the only check — see
|
|
5121
|
+
// `parseEngineAggregateFunction` for why erased types still leave
|
|
5122
|
+
// it load-bearing.
|
|
5123
|
+
//
|
|
5124
|
+
// It was closed by PARSING with the spec enum itself rather than
|
|
5125
|
+
// by widening back to `string` (what hid it) or casting past it
|
|
5126
|
+
// (which keeps the hole and adds a lie). `AggregationFunction` is
|
|
5127
|
+
// the same schema `AggregationNodeSchema.function` is built from,
|
|
5128
|
+
// so there is one vocabulary, and its own error map already
|
|
5129
|
+
// carries the `array_agg`/`string_agg` retirement prescriptions.
|
|
5130
|
+
//
|
|
5131
|
+
// TIERING, deliberately: the reachable producer of a non-aggregate
|
|
5132
|
+
// method — a custom-SQL measure (`AggregationMetricType`
|
|
5133
|
+
// `number`/`string`/`boolean`) — is already refused upstream with a
|
|
5134
|
+
// caller-blaming 400 by `ObjectQLStrategy.resolveMeasureAggregation`
|
|
5135
|
+
// (#12209). Anything still arriving here is host drift, which that
|
|
5136
|
+
// refusal's docblock assigns to the undeclared-500 tier — so this
|
|
5137
|
+
// throws rather than re-blaming the caller, and it answers loudly
|
|
5138
|
+
// instead of letting the engine answer `null` per bucket under the
|
|
5139
|
+
// author's own measure name (the #4157 class).
|
|
5140
|
+
function: parseEngineAggregateFunction(a.method, a.alias),
|
|
4873
5141
|
field: a.field,
|
|
4874
|
-
alias: a.alias
|
|
5142
|
+
alias: a.alias,
|
|
5143
|
+
...a.filter ? { filter: a.filter } : {}
|
|
4875
5144
|
})),
|
|
4876
5145
|
// ADR-0053 Phase 2: thread the reference tz so date buckets resolve on
|
|
4877
5146
|
// that zone's calendar days (engine buckets in-memory when non-UTC).
|
|
@@ -4973,6 +5242,7 @@ var AnalyticsServicePlugin = class {
|
|
|
4973
5242
|
const map = /* @__PURE__ */ new Map();
|
|
4974
5243
|
const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
|
|
4975
5244
|
if (!displayField || !executeAggregate || ids.length === 0) return map;
|
|
5245
|
+
if (scope) assertReadScopeCannotVacate(scope, targetObject);
|
|
4976
5246
|
const CHUNK = 500;
|
|
4977
5247
|
for (let i = 0; i < ids.length; i += CHUNK) {
|
|
4978
5248
|
const idFilter = { id: { $in: ids.slice(i, i + CHUNK) } };
|