@objectstack/service-analytics 17.0.0-rc.6 → 17.0.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 +4574 -1
- package/dist/index.cjs +239 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +124 -0
- package/dist/index.d.ts +124 -0
- package/dist/index.js +234 -7
- package/dist/index.js.map +1 -1
- package/package.json +7 -5
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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 { createLogger, bucketKeyToCalendarRange as bucketKeyToCalendarRange2, zonedDateStartToUtcMs } from "@objectstack/core";
|
|
4
|
+
import { createLogger, getEnv, bucketKeyToCalendarRange as bucketKeyToCalendarRange2, zonedDateStartToUtcMs } from "@objectstack/core";
|
|
5
5
|
import { matchMissingColumnOfRelation } from "@objectstack/types";
|
|
6
6
|
|
|
7
7
|
// src/cube-registry.ts
|
|
@@ -136,11 +136,46 @@ function isRenderableTextComparand(value) {
|
|
|
136
136
|
if (kind === "string" || kind === "number" || kind === "bigint" || kind === "boolean") return true;
|
|
137
137
|
return value instanceof Date;
|
|
138
138
|
}
|
|
139
|
+
function isFieldReference(value) {
|
|
140
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
141
|
+
return typeof value.$field === "string";
|
|
142
|
+
}
|
|
143
|
+
var CROSS_FIELD_COMPARISON_OPERATORS = /* @__PURE__ */ new Set([
|
|
144
|
+
"$eq",
|
|
145
|
+
"$ne",
|
|
146
|
+
"$gt",
|
|
147
|
+
"$gte",
|
|
148
|
+
"$lt",
|
|
149
|
+
"$lte"
|
|
150
|
+
]);
|
|
151
|
+
function findCrossFieldComparand(filter) {
|
|
152
|
+
return findIn(filter, "");
|
|
153
|
+
}
|
|
154
|
+
function findIn(node, field) {
|
|
155
|
+
if (!node || typeof node !== "object") return null;
|
|
156
|
+
if (Array.isArray(node)) {
|
|
157
|
+
for (const child of node) {
|
|
158
|
+
const hit = findIn(child, field);
|
|
159
|
+
if (hit) return hit;
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
if (node instanceof Date || ArrayBuffer.isView(node)) return null;
|
|
164
|
+
for (const [key, value] of Object.entries(node)) {
|
|
165
|
+
if (CROSS_FIELD_COMPARISON_OPERATORS.has(key) && isFieldReference(value)) {
|
|
166
|
+
return { op: key, field, ref: value.$field };
|
|
167
|
+
}
|
|
168
|
+
const hit = findIn(value, key.startsWith("$") ? field : key);
|
|
169
|
+
if (hit) return hit;
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
139
173
|
var TEXT_PATTERN_OPERATORS = /* @__PURE__ */ new Set([
|
|
140
174
|
"$contains",
|
|
141
175
|
"$notContains",
|
|
142
176
|
"$startsWith",
|
|
143
|
-
"$endsWith"
|
|
177
|
+
"$endsWith",
|
|
178
|
+
"$icontains"
|
|
144
179
|
]);
|
|
145
180
|
function shapePreview(value) {
|
|
146
181
|
try {
|
|
@@ -154,6 +189,12 @@ function shapePreview(value) {
|
|
|
154
189
|
function unrenderableTextComparandMessage(op, field, value) {
|
|
155
190
|
return `"${op}" on "${field}" matches against the TEXT of a pattern, but its comparand is ${Array.isArray(value) ? "an array" : "an object"} (${shapePreview(value)}). filter.zod.ts declares it a string (StringOperatorSchema); a string, number, boolean, null or Date is accepted. Refusing rather than stringifying it: String({}) is "[object Object]", so the pattern that ran would be one nobody wrote \u2014 and a row storing that literal text matches it.`;
|
|
156
191
|
}
|
|
192
|
+
function fieldReferenceComparandMessage(op, field, ref, position) {
|
|
193
|
+
return `"${op}" on "${field}"${position ? ` (${position})` : ""} compares against the field reference { "$field": "${ref}" }, which this compiler does not lower into a column-to-column comparison. Refusing rather than binding it: the reference object used to become the BOUND VALUE of the comparison, so the emitted predicate compared "${field}" against the reference itself \u2014 a value no row can hold \u2014 and a read scope built from it answered the wrong row set with nothing to read. \u26A0\uFE0F This is NOT the platform declining the rule. @objectstack/spec declares this shape (FieldReferenceSchema), @objectstack/formula resolves it per record in memory, driver-sql / driver-sqlite-wasm compile it to a same-table column comparison for the six scalar operators since #5222, and since the 2026-08-12 ruling on #7598 the analytics native-SQL strategy DECLINES such a query so it routes to the ObjectQL engine path and runs there \u2014 the driver enforcing declared-only enumeration, the tenant-isolation ban and the comparison class with metadata it owns. What refuses here is this SQL lowering, whose only remaining caller is the /analytics/sql display echo; it has no faithful rendering of the predicate the engine path actually runs, and half-rendering one would describe a query that returns different rows. Run the query itself (/analytics/query) to get its rows (#7598).`;
|
|
194
|
+
}
|
|
195
|
+
function fieldReferenceBetweenBoundMessage(op, field, ref, index) {
|
|
196
|
+
return `"${op}" on "${field}" has the field reference { "$field": "${ref}" } at index ${index} of its [min, max] bounds. A range BOUND may not be a field reference on any backend: driver-sql and driver-sqlite-wasm refuse both endpoints (#5222), @objectstack/formula does not resolve a reference inside a list either \u2014 it orders the bounds against the raw reference object, which no value compares meaningfully to \u2014 and @objectstack/spec no longer declares the position at all (#7596 removed FieldReferenceSchema from the $between endpoint union, ADR-0049 declared = enforced). Refusing rather than lowering it: this compiler splits $between into its two bounds, so the reference would arrive at the driver under a "$gte" / "$lte" the author never wrote \u2014 a position the SQL drivers DO compile \u2014 and the range would quietly succeed here while the identical filter is refused everywhere else. Use a literal bound, or spell the comparison you meant as a scalar one ({ "${field}": { "$gte": { "$field": "${ref}" } } }), which IS served \u2014 on the ObjectQL engine path, where the driver enforces the #5222 rulings (#7598).`;
|
|
197
|
+
}
|
|
157
198
|
function unbindableListMemberMessage(op, field, value, index) {
|
|
158
199
|
return `"${op}" on "${field}" has a value at index ${index} of its list that cannot be bound as a SQL parameter: ${shapePreview(value)}. Every member of an $in/$nin/$between list is a comparand in its own right \u2014 use a string, number, boolean, null, Date or binary value. Refusing rather than binding it: the member can equal no stored value, so the list silently loses that entry (and a $nin loses the exclusion the caller wrote).`;
|
|
159
200
|
}
|
|
@@ -219,6 +260,15 @@ function assertCompilableComparand(opKey, field, value) {
|
|
|
219
260
|
});
|
|
220
261
|
}
|
|
221
262
|
}
|
|
263
|
+
function assertNoFieldReferenceComparand(opKey, field, value) {
|
|
264
|
+
if (opKey !== "$between" || !Array.isArray(value)) return;
|
|
265
|
+
value.forEach((member, index) => {
|
|
266
|
+
if (!isFieldReference(member)) return;
|
|
267
|
+
throw invalidFilterError(
|
|
268
|
+
`[analytics] ${fieldReferenceBetweenBoundMessage(opKey, field, member.$field, index)}`
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
222
272
|
function undefinedComparandError(field, path) {
|
|
223
273
|
return invalidFilterError(
|
|
224
274
|
`[analytics] comparand at ${path} is undefined \u2014 refusing to compile this filter. @objectstack/spec FieldOperatorsSchema declares no undefined comparand, and in JavaScript a key whose value is undefined cannot be told apart from an ABSENT key \u2014 yet the two mean OPPOSITE things (a predicate versus no constraint at all), so there is no reading of it that is not a guess. It used to compile, two ways: in a FIELD position the key was dropped outright, so a single-key where ran with no filter at all and the chart was drawn over every row (#3650's widening, which this module refuses everywhere else); in an OPERATOR or list position it became a comparison against null, which is UNKNOWN for every row and charts nothing. Write null if the null predicate was meant ({ "${field}": null } or { "${field}": { "$null": true } }), or omit the key entirely when the value is genuinely absent \u2014 an omitted key is the same "no constraint" without the ambiguity. The producer to fix is whoever BUILT this where: undefined cannot cross JSON, so it is in-process code spreading a possibly-absent value into a filter object (#6050 ruling B, pushed down to this door by #6386).`
|
|
@@ -288,6 +338,7 @@ function fieldLeaves(key, raw) {
|
|
|
288
338
|
`[analytics] "$between" on "${key}" needs a two-element [min, max] array, got ${JSON.stringify(v2)}. Dropping the predicate would silently widen the query to every row.`
|
|
289
339
|
);
|
|
290
340
|
}
|
|
341
|
+
assertNoFieldReferenceComparand(opKey, key, v2);
|
|
291
342
|
leaf("gte", [comparand(v2[0])]);
|
|
292
343
|
leaf("lte", [comparand(v2[1])]);
|
|
293
344
|
continue;
|
|
@@ -415,6 +466,7 @@ function nullValueSatisfiesOperator(op, value) {
|
|
|
415
466
|
}
|
|
416
467
|
}
|
|
417
468
|
function operatorIsNullTotal(op, value) {
|
|
469
|
+
if (CROSS_FIELD_COMPARISON_OPERATORS.has(op) && isFieldReference(value)) return true;
|
|
418
470
|
switch (op) {
|
|
419
471
|
// Compile to `set` / `notSet` — `IS NULL` / `IS NOT NULL`, two-valued by
|
|
420
472
|
// construction, on every strategy that compiles this tree.
|
|
@@ -635,6 +687,7 @@ function compileField(field, value, qAlias, params) {
|
|
|
635
687
|
const col = `${qAlias}.${quoteIdent(field, "field")}`;
|
|
636
688
|
assertDefinedComparands2(field, value);
|
|
637
689
|
assertBooleanFlagComparands(field, value);
|
|
690
|
+
assertNoFieldReferenceComparand2(field, value);
|
|
638
691
|
if (value === null) return `${col} IS NULL`;
|
|
639
692
|
if (typeof value !== "object" || value instanceof Date) {
|
|
640
693
|
params.push(value);
|
|
@@ -707,6 +760,23 @@ function assertBooleanFlagComparands(field, spec) {
|
|
|
707
760
|
throw nonBooleanFlagComparandError(op, field, `"${field}".${op}`);
|
|
708
761
|
}
|
|
709
762
|
}
|
|
763
|
+
function assertNoFieldReferenceComparand2(field, spec) {
|
|
764
|
+
if (!isFilterNode(spec)) return;
|
|
765
|
+
for (const [op, opValue] of Object.entries(spec)) {
|
|
766
|
+
if (CROSS_FIELD_COMPARISON_OPERATORS.has(op) && isFieldReference(opValue)) {
|
|
767
|
+
throw readScopeCompileError(
|
|
768
|
+
`[read-scope-sql] ${fieldReferenceComparandMessage(op, field, opValue.$field)}`
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
if (op !== "$between" || !Array.isArray(opValue)) continue;
|
|
772
|
+
opValue.forEach((member, index) => {
|
|
773
|
+
if (!isFieldReference(member)) return;
|
|
774
|
+
throw readScopeCompileError(
|
|
775
|
+
`[read-scope-sql] ${fieldReferenceBetweenBoundMessage(op, field, member.$field, index)}`
|
|
776
|
+
);
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
}
|
|
710
780
|
function compileOperator(col, op, val, field, params) {
|
|
711
781
|
switch (op) {
|
|
712
782
|
case "$eq":
|
|
@@ -938,9 +1008,82 @@ var NativeSQLStrategy = class {
|
|
|
938
1008
|
}
|
|
939
1009
|
}
|
|
940
1010
|
}
|
|
1011
|
+
if (this.carriesCrossFieldComparison(query, ctx)) return false;
|
|
941
1012
|
const caps = ctx.queryCapabilities(query.cube);
|
|
942
1013
|
return caps.nativeSql && typeof ctx.executeRawSql === "function";
|
|
943
1014
|
}
|
|
1015
|
+
/**
|
|
1016
|
+
* [#7598] Does serving this query require the cross-field capability this
|
|
1017
|
+
* strategy declines? See the ruling recorded at {@link canHandle}.
|
|
1018
|
+
*
|
|
1019
|
+
* ⚠️ This and {@link assertNoCrossFieldComparison} read the SAME inputs
|
|
1020
|
+
* through the SAME detector, which is what makes the decline and the
|
|
1021
|
+
* fail-closed backstop unable to drift: a shape one of them recognises is a
|
|
1022
|
+
* shape the other recognises.
|
|
1023
|
+
*/
|
|
1024
|
+
carriesCrossFieldComparison(query, ctx) {
|
|
1025
|
+
return this.crossFieldComparisonIn(query, ctx) !== null;
|
|
1026
|
+
}
|
|
1027
|
+
crossFieldComparisonIn(query, ctx) {
|
|
1028
|
+
let where = null;
|
|
1029
|
+
try {
|
|
1030
|
+
where = lowerAnalyticsWhere(query);
|
|
1031
|
+
} catch {
|
|
1032
|
+
return null;
|
|
1033
|
+
}
|
|
1034
|
+
const inWhere = findCrossFieldComparand(where);
|
|
1035
|
+
if (inWhere) return { source: "the query's `where`", ...inWhere };
|
|
1036
|
+
if (typeof ctx.getReadScope !== "function") return null;
|
|
1037
|
+
const cube = query.cube ? ctx.getCube(query.cube) : void 0;
|
|
1038
|
+
if (!cube) return null;
|
|
1039
|
+
const objects = [this.extractObjectName(cube)];
|
|
1040
|
+
for (const alias of Object.keys(cube.joins ?? {})) {
|
|
1041
|
+
objects.push(cube.joins?.[alias]?.name ?? alias);
|
|
1042
|
+
}
|
|
1043
|
+
for (const objectName of objects) {
|
|
1044
|
+
const scope = ctx.getReadScope(objectName);
|
|
1045
|
+
if (scope === void 0 || scope === null) continue;
|
|
1046
|
+
const inScope = findCrossFieldComparand(scope);
|
|
1047
|
+
if (inScope) return { source: `the read scope of "${objectName}"`, ...inScope };
|
|
1048
|
+
}
|
|
1049
|
+
return null;
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* [#7598] The fail-closed backstop at the door that BINDS.
|
|
1053
|
+
*
|
|
1054
|
+
* ⚠️ **Unreachable by construction, and kept deliberately** — saying so
|
|
1055
|
+
* because #7598's brief asks that a refusal arm which has become unreachable
|
|
1056
|
+
* be named rather than left to be re-discovered. {@link canHandle} declines
|
|
1057
|
+
* every query this would fire on, and it declines using
|
|
1058
|
+
* {@link crossFieldComparisonIn} — the same walk over the same two inputs —
|
|
1059
|
+
* so `resolveStrategy` cannot hand this strategy a query carrying one.
|
|
1060
|
+
*
|
|
1061
|
+
* It is kept because of what the failure mode is if that ever stops being
|
|
1062
|
+
* true. The defect #7598 measured was not a missing error: it was a SILENT
|
|
1063
|
+
* BIND — `toSqlBindValue` JSON-stringifies the reference object, so the
|
|
1064
|
+
* statement compiled perfectly and compared a column against the text
|
|
1065
|
+
* `{"$field":"budget"}`, a value no row can hold. A routing gate that misses
|
|
1066
|
+
* a shape therefore degrades to a wrong ANSWER rather than to an error, and
|
|
1067
|
+
* that is the one class this package refuses to leave to a single guard
|
|
1068
|
+
* (Prime Directive #12 — refuse at the door, do not tolerate at the
|
|
1069
|
+
* consumer). One line, no measurable cost, and it turns a routing regression
|
|
1070
|
+
* into a loud refusal instead of an empty chart.
|
|
1071
|
+
*
|
|
1072
|
+
* Deliberately BARE — an undeclared 500, not `INVALID_FILTER` / 400 — for the
|
|
1073
|
+
* reason `buildFilterClauseSql`'s #5333 exit in `objectql-strategy.ts` gives
|
|
1074
|
+
* for the same class: the caller's filter is legal and is served on the
|
|
1075
|
+
* engine path, so an arrival here is drift between our own routing gate and
|
|
1076
|
+
* our own emitter. Billing the caller 400 for that would hide a platform bug
|
|
1077
|
+
* from 5xx alerting and tell a dashboard user to fix a filter that is fine.
|
|
1078
|
+
* Same tier as `resolveMeasureSql`'s unrecognised-`Metric.type` throw below.
|
|
1079
|
+
*/
|
|
1080
|
+
assertNoCrossFieldComparison(query, ctx) {
|
|
1081
|
+
const hit = this.crossFieldComparisonIn(query, ctx);
|
|
1082
|
+
if (!hit) return;
|
|
1083
|
+
throw new Error(
|
|
1084
|
+
`[native-sql-strategy] ${hit.source} carries a field reference { "$field": "${hit.ref}" } under "${hit.op}" on "${hit.field}", which this strategy does not compile into a column-to-column comparison \u2014 it would BIND the reference object as the comparison's value and answer a wrong row set silently (#7598). \`canHandle\` declines such a query so it routes to the ObjectQL/engine path, whose driver compiles it and enforces the #5222 rulings with metadata it owns; reaching this throw means the decline and this emitter stopped agreeing, which is our bug and must never degrade to a silent answer.`
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
944
1087
|
async execute(query, ctx) {
|
|
945
1088
|
const { sql, params } = await this.generateSql(query, ctx);
|
|
946
1089
|
const cube = ctx.getCube(query.cube);
|
|
@@ -954,6 +1097,7 @@ var NativeSQLStrategy = class {
|
|
|
954
1097
|
if (!cube) {
|
|
955
1098
|
throw new Error(`Cube not found: ${query.cube}`);
|
|
956
1099
|
}
|
|
1100
|
+
this.assertNoCrossFieldComparison(query, ctx);
|
|
957
1101
|
const params = [];
|
|
958
1102
|
const selectClauses = [];
|
|
959
1103
|
const groupByClauses = [];
|
|
@@ -1390,6 +1534,7 @@ var NativeSQLStrategy = class {
|
|
|
1390
1534
|
};
|
|
1391
1535
|
|
|
1392
1536
|
// src/strategies/objectql-strategy.ts
|
|
1537
|
+
import { markFilterSubtreeProvenance } from "@objectstack/spec/data";
|
|
1393
1538
|
import { nextUtcCalendarDay as nextUtcCalendarDay2 } from "@objectstack/core";
|
|
1394
1539
|
|
|
1395
1540
|
// src/strategies/cross-object-rebucket.ts
|
|
@@ -1576,6 +1721,12 @@ var ObjectQLStrategy = class {
|
|
|
1576
1721
|
if (!cube) {
|
|
1577
1722
|
throw new Error(`Cube not found: ${query.cube}`);
|
|
1578
1723
|
}
|
|
1724
|
+
const crossField = findCrossFieldComparand(this.loweredWhere(query));
|
|
1725
|
+
if (crossField) {
|
|
1726
|
+
throw invalidFilterError(
|
|
1727
|
+
`[analytics] cannot render display SQL for the field reference { "$field": "${crossField.ref}" } under "${crossField.op}" on "${crossField.field}". The query itself is SERVED \u2014 \`NativeSQLStrategy.canHandle\` declines a cross-field comparison so it routes to the ObjectQL engine path, where driver-sql compiles it into a column-to-column predicate written TOTAL across NULLs and enforces the #5222 rulings (#7598, maintainer ruling 2026-08-12). This renderer has no faithful rendering of that predicate: what it can emit is a comparison against the reference object as a bound VALUE, which reproduces none of the rows the query returns. Refusing rather than half-rendering \u2014 an echo that contradicts execution is worse than no echo (#3601 / #3602 / #3650). Run the query itself (/analytics/query) to get its rows.`
|
|
1728
|
+
);
|
|
1729
|
+
}
|
|
1579
1730
|
const selectParts = [];
|
|
1580
1731
|
const groupByParts = [];
|
|
1581
1732
|
const params = [];
|
|
@@ -1680,11 +1831,11 @@ var ObjectQLStrategy = class {
|
|
|
1680
1831
|
* predicate. `$and` makes that structurally impossible.
|
|
1681
1832
|
*/
|
|
1682
1833
|
withReadScope(objectName, filter, ctx) {
|
|
1683
|
-
const userFilter = Object.keys(filter).length > 0 ? filter : void 0;
|
|
1834
|
+
const userFilter = Object.keys(filter).length > 0 ? markFilterSubtreeProvenance(filter, "author") : void 0;
|
|
1684
1835
|
if (typeof ctx.getReadScope !== "function") return userFilter;
|
|
1685
1836
|
const scope = ctx.getReadScope(objectName);
|
|
1686
1837
|
if (scope === void 0 || scope === null) return userFilter;
|
|
1687
|
-
const scopeFilter = scope;
|
|
1838
|
+
const scopeFilter = markFilterSubtreeProvenance(scope, "policy");
|
|
1688
1839
|
if (!userFilter) return scopeFilter;
|
|
1689
1840
|
return { $and: [userFilter, scopeFilter] };
|
|
1690
1841
|
}
|
|
@@ -1858,6 +2009,7 @@ var ObjectQLStrategy = class {
|
|
|
1858
2009
|
if (fkValues.length === 0 || typeof ctx.executeAggregate !== "function") return map;
|
|
1859
2010
|
const idFilter = { id: { $in: fkValues } };
|
|
1860
2011
|
const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
|
|
2012
|
+
if (scope != null) markFilterSubtreeProvenance(scope, "policy");
|
|
1861
2013
|
const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
|
|
1862
2014
|
const rows = await ctx.executeAggregate(refObject, {
|
|
1863
2015
|
groupBy: ["id", attr],
|
|
@@ -2210,8 +2362,27 @@ var ObjectQLStrategy = class {
|
|
|
2210
2362
|
const v0 = values[0];
|
|
2211
2363
|
const all = [...values];
|
|
2212
2364
|
switch (operator) {
|
|
2365
|
+
// [#7598] IMPLICIT equality for a literal, EXPLICIT `$eq` for a field
|
|
2366
|
+
// reference — the branch is on the COMPARAND, not on the operator, which
|
|
2367
|
+
// is the same fix and the same reasoning #7597 applied to
|
|
2368
|
+
// `parseFilterAST`, the spec's own lowering sink.
|
|
2369
|
+
//
|
|
2370
|
+
// `{ amount: 5 }` is implicit equality and every backend reads it that
|
|
2371
|
+
// way. `{ amount: { $field: 'budget' } }` is NOT: it is a field-spec
|
|
2372
|
+
// object whose only key is `$field`, which no backend reads as an
|
|
2373
|
+
// equality — `driver-sql` sees an unrecognised operator key and the
|
|
2374
|
+
// memory evaluator sees a comparand it never resolves. So the bare return
|
|
2375
|
+
// was correct for four years' worth of literals and silently wrong for
|
|
2376
|
+
// the one comparand the 2026-08-12 ruling routes HERE on purpose: with it,
|
|
2377
|
+
// `{ amount: { $eq: { $field: 'budget' } } }` — the shape
|
|
2378
|
+
// `compileCelToFilter` emits for a field-to-field CEL rule, and the shape
|
|
2379
|
+
// `canHandle` now declines native SQL for — would arrive at the driver as
|
|
2380
|
+
// something the driver cannot read, so the capability B exists to serve
|
|
2381
|
+
// would fail on its single most important spelling. Its five siblings
|
|
2382
|
+
// (`$ne`/`$gt`/`$gte`/`$lt`/`$lte`) were never affected: they emit their
|
|
2383
|
+
// operator explicitly two lines down.
|
|
2213
2384
|
case "equals":
|
|
2214
|
-
return v0;
|
|
2385
|
+
return isFieldReference(v0) ? { $eq: v0 } : v0;
|
|
2215
2386
|
case "notEquals":
|
|
2216
2387
|
return { $ne: v0 };
|
|
2217
2388
|
case "gt":
|
|
@@ -2271,6 +2442,24 @@ var ObjectQLStrategy = class {
|
|
|
2271
2442
|
extractObjectName(cube) {
|
|
2272
2443
|
return cube.sql.trim();
|
|
2273
2444
|
}
|
|
2445
|
+
/**
|
|
2446
|
+
* [#7598] The query's `where`, lowered — the same input
|
|
2447
|
+
* `NativeSQLStrategy.canHandle` scans, so the strategy that DECLINED and the
|
|
2448
|
+
* echo that refuses read one shape rather than two.
|
|
2449
|
+
*
|
|
2450
|
+
* A throw from the lowering is swallowed for the same reason it is there: the
|
|
2451
|
+
* `where` is malformed either way and `normalizeAnalyticsFilterTree` below
|
|
2452
|
+
* refuses it with the message and envelope it has always had. This helper's
|
|
2453
|
+
* only job is finding a reference, and there is none to find in a filter that
|
|
2454
|
+
* does not lower.
|
|
2455
|
+
*/
|
|
2456
|
+
loweredWhere(query) {
|
|
2457
|
+
try {
|
|
2458
|
+
return lowerAnalyticsWhere(query);
|
|
2459
|
+
} catch {
|
|
2460
|
+
return null;
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2274
2463
|
/**
|
|
2275
2464
|
* The dimensions this query PROJECTS, in the order the result carries them:
|
|
2276
2465
|
* every `dimensions` entry, then every granular `timeDimensions` entry that
|
|
@@ -3452,6 +3641,7 @@ var AnalyticsService = class {
|
|
|
3452
3641
|
this.getObjectFieldNames = config.getObjectFieldNames;
|
|
3453
3642
|
this.getObjectDatasource = config.getObjectDatasource;
|
|
3454
3643
|
this.isExternalObject = config.isExternalObject;
|
|
3644
|
+
this.debugSql = config.debugSql ?? getEnv("NODE_ENV") === "development";
|
|
3455
3645
|
if (config.datasets) {
|
|
3456
3646
|
for (const ds of config.datasets) {
|
|
3457
3647
|
try {
|
|
@@ -3569,7 +3759,7 @@ var AnalyticsService = class {
|
|
|
3569
3759
|
const strategy = this.resolveStrategy(query, ctx, skip);
|
|
3570
3760
|
this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
|
|
3571
3761
|
try {
|
|
3572
|
-
return await strategy.execute(query, ctx);
|
|
3762
|
+
return this.applySqlEchoPolicy(await strategy.execute(query, ctx));
|
|
3573
3763
|
} catch (e) {
|
|
3574
3764
|
if (e?.code === "RAW_SQL_UNSUPPORTED") {
|
|
3575
3765
|
this.logger.warn(
|
|
@@ -3582,6 +3772,30 @@ var AnalyticsService = class {
|
|
|
3582
3772
|
}
|
|
3583
3773
|
}
|
|
3584
3774
|
}
|
|
3775
|
+
/**
|
|
3776
|
+
* [#8286] Withhold the executed statement unless this host enabled the echo.
|
|
3777
|
+
*
|
|
3778
|
+
* Applied at {@link query}, which is the response-assembly seam for BOTH
|
|
3779
|
+
* faces that serve callers: `/api/v1/analytics/query` calls it directly, and
|
|
3780
|
+
* `queryDataset` reaches it through `DatasetExecutor`, so a dataset response
|
|
3781
|
+
* inherits the same verdict without a second gate to keep in step.
|
|
3782
|
+
* `generateSql` — the dedicated `/api/v1/analytics/sql` dry-run route — is
|
|
3783
|
+
* deliberately NOT gated: asking for the statement is that route's entire
|
|
3784
|
+
* purpose, and it is the surface a debugging author is meant to use.
|
|
3785
|
+
*
|
|
3786
|
+
* What the echo disclosed, and why "it is only a table name" understates it:
|
|
3787
|
+
* the statement carries the compiled read scope, i.e. the SHAPE of the
|
|
3788
|
+
* isolation predicate (`"sys_user"."id" IN ($2, $3, …)` rather than an
|
|
3789
|
+
* `organization_id` comparison) plus its bound-parameter arity, which counts
|
|
3790
|
+
* the caller's own org membership. No wall was breached by it — the echo is
|
|
3791
|
+
* information disclosure, and this is the disclosure closing.
|
|
3792
|
+
*/
|
|
3793
|
+
applySqlEchoPolicy(result) {
|
|
3794
|
+
if (this.debugSql || result?.sql === void 0) return result;
|
|
3795
|
+
const withheld = { ...result };
|
|
3796
|
+
delete withheld.sql;
|
|
3797
|
+
return withheld;
|
|
3798
|
+
}
|
|
3585
3799
|
/**
|
|
3586
3800
|
* Compile a `dataset` (ADR-0021) and register its Cube + join allowlist so it
|
|
3587
3801
|
* can be queried by name. Idempotent (re-registering overwrites). Returns the
|
|
@@ -4246,11 +4460,19 @@ var AnalyticsService = class {
|
|
|
4246
4460
|
return strategy;
|
|
4247
4461
|
}
|
|
4248
4462
|
}
|
|
4463
|
+
const crossField = findCrossFieldComparand(lowerAnalyticsWhereQuietly(query));
|
|
4249
4464
|
throw new Error(
|
|
4250
|
-
`[Analytics] No strategy can handle query for cube "${query.cube}". Checked: ${this.strategies.map((s) => s.name).join(", ")}${skip?.size ? ` (skipped at runtime: ${[...skip].map((s) => s.name).join(", ")})` : ""}. Ensure a compatible driver is configured or a fallback service is registered
|
|
4465
|
+
`[Analytics] No strategy can handle query for cube "${query.cube}". Checked: ${this.strategies.map((s) => s.name).join(", ")}${skip?.size ? ` (skipped at runtime: ${[...skip].map((s) => s.name).join(", ")})` : ""}. ` + (crossField ? `This query's filter compares against the field reference { "$field": "${crossField.ref}" } under "${crossField.op}" on "${crossField.field}", and NativeSQLStrategy DECLINES a cross-field comparison so that it routes to the ObjectQL engine path \u2014 whose driver compiles it and enforces the #5222 rulings with metadata it owns (#7598). No such path is configured here, so the capability is unavailable on this deployment: supply an \`executeAggregate\` bridge (the plugin auto-wires one from the engine), or compare against a literal value. Every other query on this cube is unaffected. ` : "") + "Ensure a compatible driver is configured or a fallback service is registered."
|
|
4251
4466
|
);
|
|
4252
4467
|
}
|
|
4253
4468
|
};
|
|
4469
|
+
function lowerAnalyticsWhereQuietly(query) {
|
|
4470
|
+
try {
|
|
4471
|
+
return lowerAnalyticsWhere(query);
|
|
4472
|
+
} catch {
|
|
4473
|
+
return null;
|
|
4474
|
+
}
|
|
4475
|
+
}
|
|
4254
4476
|
function mintableMeasureKey(member, cubeName) {
|
|
4255
4477
|
const dot = member.indexOf(".");
|
|
4256
4478
|
if (dot < 0) return member;
|
|
@@ -4546,6 +4768,11 @@ var AnalyticsServicePlugin = class {
|
|
|
4546
4768
|
coerceTemporalFilterColumn,
|
|
4547
4769
|
relationshipResolver,
|
|
4548
4770
|
labelResolver,
|
|
4771
|
+
// [#8286] Passed through as authored — `undefined` is "this host did not
|
|
4772
|
+
// choose", which the service resolves to development-only. Defaulting it
|
|
4773
|
+
// here would be a second copy of that decision, drifting the moment one
|
|
4774
|
+
// of the two moves.
|
|
4775
|
+
debugSql: this.options.debugSql,
|
|
4549
4776
|
// Source-field metadata behind the display chains on result columns:
|
|
4550
4777
|
// ADR-0053 currency (`currencyConfig.defaultCurrency`) and percent scale
|
|
4551
4778
|
// (`max`, which is what marks whole-percent storage — objectui#3136).
|