@objectstack/service-analytics 17.0.0-rc.6 → 17.1.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 +4823 -1
- package/README.md +127 -328
- package/dist/index.cjs +356 -45
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +151 -0
- package/dist/index.d.ts +151 -0
- package/dist/index.js +333 -17
- package/dist/index.js.map +1 -1
- package/package.json +8 -6
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
|
|
@@ -124,23 +124,105 @@ import { isFilterAST, parseFilterAST, VALID_AST_OPERATORS } from "@objectstack/s
|
|
|
124
124
|
import { StandardErrorCode } from "@objectstack/spec/api";
|
|
125
125
|
|
|
126
126
|
// src/comparand-shape.ts
|
|
127
|
+
import {
|
|
128
|
+
isUninterpretableTemporalComparand
|
|
129
|
+
} from "@objectstack/core";
|
|
130
|
+
import {
|
|
131
|
+
isAcceptedFilterComparand,
|
|
132
|
+
ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE
|
|
133
|
+
} from "@objectstack/spec/data";
|
|
127
134
|
function isBindableComparand(value) {
|
|
128
|
-
if (value ===
|
|
129
|
-
|
|
130
|
-
if (kind === "string" || kind === "number" || kind === "bigint" || kind === "boolean") return true;
|
|
131
|
-
return value instanceof Date || ArrayBuffer.isView(value);
|
|
135
|
+
if (value === void 0) return true;
|
|
136
|
+
return isAcceptedFilterComparand(value) || ArrayBuffer.isView(value);
|
|
132
137
|
}
|
|
133
138
|
function isRenderableTextComparand(value) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
139
|
+
return value === void 0 || isAcceptedFilterComparand(value);
|
|
140
|
+
}
|
|
141
|
+
function isFieldReference(value) {
|
|
142
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
143
|
+
return typeof value.$field === "string";
|
|
144
|
+
}
|
|
145
|
+
var CROSS_FIELD_COMPARISON_OPERATORS = /* @__PURE__ */ new Set([
|
|
146
|
+
"$eq",
|
|
147
|
+
"$ne",
|
|
148
|
+
"$gt",
|
|
149
|
+
"$gte",
|
|
150
|
+
"$lt",
|
|
151
|
+
"$lte"
|
|
152
|
+
]);
|
|
153
|
+
function findCrossFieldComparand(filter) {
|
|
154
|
+
return findIn(filter, "");
|
|
155
|
+
}
|
|
156
|
+
function findIn(node, field) {
|
|
157
|
+
if (!node || typeof node !== "object") return null;
|
|
158
|
+
if (Array.isArray(node)) {
|
|
159
|
+
for (const child of node) {
|
|
160
|
+
const hit = findIn(child, field);
|
|
161
|
+
if (hit) return hit;
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
if (node instanceof Date || ArrayBuffer.isView(node)) return null;
|
|
166
|
+
for (const [key, value] of Object.entries(node)) {
|
|
167
|
+
if (CROSS_FIELD_COMPARISON_OPERATORS.has(key) && isFieldReference(value)) {
|
|
168
|
+
return { op: key, field, ref: value.$field };
|
|
169
|
+
}
|
|
170
|
+
const hit = findIn(value, key.startsWith("$") ? field : key);
|
|
171
|
+
if (hit) return hit;
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
function findUninterpretableTemporalMember(filter, kindOf) {
|
|
176
|
+
return findUninterpretableIn(filter, "", kindOf);
|
|
177
|
+
}
|
|
178
|
+
function findUninterpretableIn(node, field, kindOf) {
|
|
179
|
+
if (!node || typeof node !== "object") return null;
|
|
180
|
+
if (Array.isArray(node)) {
|
|
181
|
+
for (const child of node) {
|
|
182
|
+
const hit = findUninterpretableIn(child, field, kindOf);
|
|
183
|
+
if (hit) return hit;
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
if (node instanceof Date || ArrayBuffer.isView(node)) return null;
|
|
188
|
+
if (isFieldReference(node)) return null;
|
|
189
|
+
for (const [key, value] of Object.entries(node)) {
|
|
190
|
+
const scope = key.startsWith("$") ? field : key;
|
|
191
|
+
const kind = scope ? kindOf(scope) : null;
|
|
192
|
+
if (kind) {
|
|
193
|
+
const hit2 = judgeTemporalLiterals(value, scope, kind);
|
|
194
|
+
if (hit2) return hit2;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const hit = findUninterpretableIn(value, scope, kindOf);
|
|
198
|
+
if (hit) return hit;
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
function judgeTemporalLiterals(value, field, kind) {
|
|
203
|
+
if (Array.isArray(value)) {
|
|
204
|
+
for (const member of value) {
|
|
205
|
+
const hit = judgeTemporalLiterals(member, field, kind);
|
|
206
|
+
if (hit) return hit;
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
if (value && typeof value === "object") {
|
|
211
|
+
if (value instanceof Date || ArrayBuffer.isView(value) || isFieldReference(value)) return null;
|
|
212
|
+
for (const nested of Object.values(value)) {
|
|
213
|
+
const hit = judgeTemporalLiterals(nested, field, kind);
|
|
214
|
+
if (hit) return hit;
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
return isUninterpretableTemporalComparand(kind, value) ? { field, kind, value } : null;
|
|
138
219
|
}
|
|
139
220
|
var TEXT_PATTERN_OPERATORS = /* @__PURE__ */ new Set([
|
|
140
221
|
"$contains",
|
|
141
222
|
"$notContains",
|
|
142
223
|
"$startsWith",
|
|
143
|
-
"$endsWith"
|
|
224
|
+
"$endsWith",
|
|
225
|
+
"$icontains"
|
|
144
226
|
]);
|
|
145
227
|
function shapePreview(value) {
|
|
146
228
|
try {
|
|
@@ -152,10 +234,16 @@ function shapePreview(value) {
|
|
|
152
234
|
}
|
|
153
235
|
}
|
|
154
236
|
function unrenderableTextComparandMessage(op, field, value) {
|
|
155
|
-
return `"${op}" on "${field}" matches against the TEXT of a pattern, but its comparand is ${Array.isArray(value) ? "an array" : "an object"} (${shapePreview(value)}). filter.zod.ts declares it a string (StringOperatorSchema);
|
|
237
|
+
return `"${op}" on "${field}" matches against the TEXT of a pattern, but its comparand is ${Array.isArray(value) ? "an array" : "an object"} (${shapePreview(value)}). filter.zod.ts declares it a string (StringOperatorSchema); ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} is accepted. Refusing rather than stringifying it: String({}) is "[object Object]", so the pattern that ran would be one nobody wrote \u2014 and a row storing that literal text matches it.`;
|
|
238
|
+
}
|
|
239
|
+
function fieldReferenceComparandMessage(op, field, ref, position) {
|
|
240
|
+
return `"${op}" on "${field}"${position ? ` (${position})` : ""} compares against the field reference { "$field": "${ref}" }, which this compiler does not lower into a column-to-column comparison. Refusing rather than binding it: the reference object used to become the BOUND VALUE of the comparison, so the emitted predicate compared "${field}" against the reference itself \u2014 a value no row can hold \u2014 and a read scope built from it answered the wrong row set with nothing to read. \u26A0\uFE0F This is NOT the platform declining the rule. @objectstack/spec declares this shape (FieldReferenceSchema), @objectstack/formula resolves it per record in memory, driver-sql / driver-sqlite-wasm compile it to a same-table column comparison for the six scalar operators since #5222, and since the 2026-08-12 ruling on #7598 the analytics native-SQL strategy DECLINES such a query so it routes to the ObjectQL engine path and runs there \u2014 the driver enforcing declared-only enumeration, the tenant-isolation ban and the comparison class with metadata it owns. What refuses here is this SQL lowering, whose only remaining caller is the /analytics/sql display echo; it has no faithful rendering of the predicate the engine path actually runs, and half-rendering one would describe a query that returns different rows. Run the query itself (/analytics/query) to get its rows (#7598).`;
|
|
241
|
+
}
|
|
242
|
+
function fieldReferenceBetweenBoundMessage(op, field, ref, index) {
|
|
243
|
+
return `"${op}" on "${field}" has the field reference { "$field": "${ref}" } at index ${index} of its [min, max] bounds. A range BOUND may not be a field reference on any backend: driver-sql and driver-sqlite-wasm refuse both endpoints (#5222), @objectstack/formula does not resolve a reference inside a list either \u2014 it orders the bounds against the raw reference object, which no value compares meaningfully to \u2014 and @objectstack/spec no longer declares the position at all (#7596 removed FieldReferenceSchema from the $between endpoint union, ADR-0049 declared = enforced). Refusing rather than lowering it: this compiler splits $between into its two bounds, so the reference would arrive at the driver under a "$gte" / "$lte" the author never wrote \u2014 a position the SQL drivers DO compile \u2014 and the range would quietly succeed here while the identical filter is refused everywhere else. Use a literal bound, or spell the comparison you meant as a scalar one ({ "${field}": { "$gte": { "$field": "${ref}" } } }), which IS served \u2014 on the ObjectQL engine path, where the driver enforces the #5222 rulings (#7598).`;
|
|
156
244
|
}
|
|
157
245
|
function unbindableListMemberMessage(op, field, value, index) {
|
|
158
|
-
return `"${op}" on "${field}" has a value at index ${index} of its list that cannot be bound as a SQL parameter: ${shapePreview(value)}. Every member of an $in/$nin/$between list is a comparand in its own right \u2014 use
|
|
246
|
+
return `"${op}" on "${field}" has a value at index ${index} of its list that cannot be bound as a SQL parameter: ${shapePreview(value)}. Every member of an $in/$nin/$between list is a comparand in its own right \u2014 use ${ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} (or a binary value). Refusing rather than binding it: the member can equal no stored value, so the list silently loses that entry (and a $nin loses the exclusion the caller wrote).`;
|
|
159
247
|
}
|
|
160
248
|
|
|
161
249
|
// src/strategies/filter-normalizer.ts
|
|
@@ -219,6 +307,15 @@ function assertCompilableComparand(opKey, field, value) {
|
|
|
219
307
|
});
|
|
220
308
|
}
|
|
221
309
|
}
|
|
310
|
+
function assertNoFieldReferenceComparand(opKey, field, value) {
|
|
311
|
+
if (opKey !== "$between" || !Array.isArray(value)) return;
|
|
312
|
+
value.forEach((member, index) => {
|
|
313
|
+
if (!isFieldReference(member)) return;
|
|
314
|
+
throw invalidFilterError(
|
|
315
|
+
`[analytics] ${fieldReferenceBetweenBoundMessage(opKey, field, member.$field, index)}`
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
}
|
|
222
319
|
function undefinedComparandError(field, path) {
|
|
223
320
|
return invalidFilterError(
|
|
224
321
|
`[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 +385,7 @@ function fieldLeaves(key, raw) {
|
|
|
288
385
|
`[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
386
|
);
|
|
290
387
|
}
|
|
388
|
+
assertNoFieldReferenceComparand(opKey, key, v2);
|
|
291
389
|
leaf("gte", [comparand(v2[0])]);
|
|
292
390
|
leaf("lte", [comparand(v2[1])]);
|
|
293
391
|
continue;
|
|
@@ -415,6 +513,7 @@ function nullValueSatisfiesOperator(op, value) {
|
|
|
415
513
|
}
|
|
416
514
|
}
|
|
417
515
|
function operatorIsNullTotal(op, value) {
|
|
516
|
+
if (CROSS_FIELD_COMPARISON_OPERATORS.has(op) && isFieldReference(value)) return true;
|
|
418
517
|
switch (op) {
|
|
419
518
|
// Compile to `set` / `notSet` — `IS NULL` / `IS NOT NULL`, two-valued by
|
|
420
519
|
// construction, on every strategy that compiles this tree.
|
|
@@ -635,6 +734,7 @@ function compileField(field, value, qAlias, params) {
|
|
|
635
734
|
const col = `${qAlias}.${quoteIdent(field, "field")}`;
|
|
636
735
|
assertDefinedComparands2(field, value);
|
|
637
736
|
assertBooleanFlagComparands(field, value);
|
|
737
|
+
assertNoFieldReferenceComparand2(field, value);
|
|
638
738
|
if (value === null) return `${col} IS NULL`;
|
|
639
739
|
if (typeof value !== "object" || value instanceof Date) {
|
|
640
740
|
params.push(value);
|
|
@@ -707,6 +807,23 @@ function assertBooleanFlagComparands(field, spec) {
|
|
|
707
807
|
throw nonBooleanFlagComparandError(op, field, `"${field}".${op}`);
|
|
708
808
|
}
|
|
709
809
|
}
|
|
810
|
+
function assertNoFieldReferenceComparand2(field, spec) {
|
|
811
|
+
if (!isFilterNode(spec)) return;
|
|
812
|
+
for (const [op, opValue] of Object.entries(spec)) {
|
|
813
|
+
if (CROSS_FIELD_COMPARISON_OPERATORS.has(op) && isFieldReference(opValue)) {
|
|
814
|
+
throw readScopeCompileError(
|
|
815
|
+
`[read-scope-sql] ${fieldReferenceComparandMessage(op, field, opValue.$field)}`
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
if (op !== "$between" || !Array.isArray(opValue)) continue;
|
|
819
|
+
opValue.forEach((member, index) => {
|
|
820
|
+
if (!isFieldReference(member)) return;
|
|
821
|
+
throw readScopeCompileError(
|
|
822
|
+
`[read-scope-sql] ${fieldReferenceBetweenBoundMessage(op, field, member.$field, index)}`
|
|
823
|
+
);
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
}
|
|
710
827
|
function compileOperator(col, op, val, field, params) {
|
|
711
828
|
switch (op) {
|
|
712
829
|
case "$eq":
|
|
@@ -938,9 +1055,124 @@ var NativeSQLStrategy = class {
|
|
|
938
1055
|
}
|
|
939
1056
|
}
|
|
940
1057
|
}
|
|
1058
|
+
if (this.carriesCrossFieldComparison(query, ctx)) return false;
|
|
1059
|
+
if (this.carriesUninterpretableTemporalComparand(query, ctx)) return false;
|
|
941
1060
|
const caps = ctx.queryCapabilities(query.cube);
|
|
942
1061
|
return caps.nativeSql && typeof ctx.executeRawSql === "function";
|
|
943
1062
|
}
|
|
1063
|
+
/**
|
|
1064
|
+
* [#8690] Does the query's `where` compare a declared TIME dimension against
|
|
1065
|
+
* a value no temporal storage rule can read? See the ruling at
|
|
1066
|
+
* {@link canHandle}.
|
|
1067
|
+
*
|
|
1068
|
+
* The classification comes from the CUBE, the only metadata this package has:
|
|
1069
|
+
* a dimension declares `type: 'time'` (compiled from the dataset dimension's
|
|
1070
|
+
* `type: 'date'`), and {@link lookupMember} is the same resolution every other
|
|
1071
|
+
* member lookup in this strategy uses, so "the member the gate classified"
|
|
1072
|
+
* and "the member the compiler emits" cannot drift apart.
|
|
1073
|
+
*
|
|
1074
|
+
* A `time` dimension is read with the DATETIME rule — the permissive one of
|
|
1075
|
+
* the three. That is the right direction because this is a routing decision,
|
|
1076
|
+
* not a verdict: the engine door re-judges with the field's real declared
|
|
1077
|
+
* type and has the final say, so under-classifying an exotic spelling merely
|
|
1078
|
+
* leaves today's behaviour, while over-classifying would silently move a
|
|
1079
|
+
* working dashboard off the fast path. The comparands this card measured
|
|
1080
|
+
* (`last_30_days`, `not-a-date-at-all`) are unreadable under all three rules,
|
|
1081
|
+
* so the decline fires for them whichever backing type the dimension has.
|
|
1082
|
+
*
|
|
1083
|
+
* `lowerAnalyticsWhere` rather than `query.where` raw, so the authored ARRAY
|
|
1084
|
+
* sugar is seen after `parseFilterAST` has lowered it; a THROW from that
|
|
1085
|
+
* lowering is not this gate's to answer — the filter is malformed either way
|
|
1086
|
+
* and `normalizeAnalyticsFilterTree` refuses it a moment later with the
|
|
1087
|
+
* message and envelope it has always had.
|
|
1088
|
+
*/
|
|
1089
|
+
carriesUninterpretableTemporalComparand(query, ctx) {
|
|
1090
|
+
const cube = query.cube ? ctx.getCube(query.cube) : void 0;
|
|
1091
|
+
if (!cube) return false;
|
|
1092
|
+
let where = null;
|
|
1093
|
+
try {
|
|
1094
|
+
where = lowerAnalyticsWhere(query);
|
|
1095
|
+
} catch {
|
|
1096
|
+
return false;
|
|
1097
|
+
}
|
|
1098
|
+
if (!where) return false;
|
|
1099
|
+
return findUninterpretableTemporalMember(
|
|
1100
|
+
where,
|
|
1101
|
+
(member) => this.lookupMember(cube, member, "dimension")?.type === "time" ? "datetime" : null
|
|
1102
|
+
) !== null;
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* [#7598] Does serving this query require the cross-field capability this
|
|
1106
|
+
* strategy declines? See the ruling recorded at {@link canHandle}.
|
|
1107
|
+
*
|
|
1108
|
+
* ⚠️ This and {@link assertNoCrossFieldComparison} read the SAME inputs
|
|
1109
|
+
* through the SAME detector, which is what makes the decline and the
|
|
1110
|
+
* fail-closed backstop unable to drift: a shape one of them recognises is a
|
|
1111
|
+
* shape the other recognises.
|
|
1112
|
+
*/
|
|
1113
|
+
carriesCrossFieldComparison(query, ctx) {
|
|
1114
|
+
return this.crossFieldComparisonIn(query, ctx) !== null;
|
|
1115
|
+
}
|
|
1116
|
+
crossFieldComparisonIn(query, ctx) {
|
|
1117
|
+
let where = null;
|
|
1118
|
+
try {
|
|
1119
|
+
where = lowerAnalyticsWhere(query);
|
|
1120
|
+
} catch {
|
|
1121
|
+
return null;
|
|
1122
|
+
}
|
|
1123
|
+
const inWhere = findCrossFieldComparand(where);
|
|
1124
|
+
if (inWhere) return { source: "the query's `where`", ...inWhere };
|
|
1125
|
+
if (typeof ctx.getReadScope !== "function") return null;
|
|
1126
|
+
const cube = query.cube ? ctx.getCube(query.cube) : void 0;
|
|
1127
|
+
if (!cube) return null;
|
|
1128
|
+
const objects = [this.extractObjectName(cube)];
|
|
1129
|
+
for (const alias of Object.keys(cube.joins ?? {})) {
|
|
1130
|
+
objects.push(cube.joins?.[alias]?.name ?? alias);
|
|
1131
|
+
}
|
|
1132
|
+
for (const objectName of objects) {
|
|
1133
|
+
const scope = ctx.getReadScope(objectName);
|
|
1134
|
+
if (scope === void 0 || scope === null) continue;
|
|
1135
|
+
const inScope = findCrossFieldComparand(scope);
|
|
1136
|
+
if (inScope) return { source: `the read scope of "${objectName}"`, ...inScope };
|
|
1137
|
+
}
|
|
1138
|
+
return null;
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* [#7598] The fail-closed backstop at the door that BINDS.
|
|
1142
|
+
*
|
|
1143
|
+
* ⚠️ **Unreachable by construction, and kept deliberately** — saying so
|
|
1144
|
+
* because #7598's brief asks that a refusal arm which has become unreachable
|
|
1145
|
+
* be named rather than left to be re-discovered. {@link canHandle} declines
|
|
1146
|
+
* every query this would fire on, and it declines using
|
|
1147
|
+
* {@link crossFieldComparisonIn} — the same walk over the same two inputs —
|
|
1148
|
+
* so `resolveStrategy` cannot hand this strategy a query carrying one.
|
|
1149
|
+
*
|
|
1150
|
+
* It is kept because of what the failure mode is if that ever stops being
|
|
1151
|
+
* true. The defect #7598 measured was not a missing error: it was a SILENT
|
|
1152
|
+
* BIND — `toSqlBindValue` JSON-stringifies the reference object, so the
|
|
1153
|
+
* statement compiled perfectly and compared a column against the text
|
|
1154
|
+
* `{"$field":"budget"}`, a value no row can hold. A routing gate that misses
|
|
1155
|
+
* a shape therefore degrades to a wrong ANSWER rather than to an error, and
|
|
1156
|
+
* that is the one class this package refuses to leave to a single guard
|
|
1157
|
+
* (Prime Directive #12 — refuse at the door, do not tolerate at the
|
|
1158
|
+
* consumer). One line, no measurable cost, and it turns a routing regression
|
|
1159
|
+
* into a loud refusal instead of an empty chart.
|
|
1160
|
+
*
|
|
1161
|
+
* Deliberately BARE — an undeclared 500, not `INVALID_FILTER` / 400 — for the
|
|
1162
|
+
* reason `buildFilterClauseSql`'s #5333 exit in `objectql-strategy.ts` gives
|
|
1163
|
+
* for the same class: the caller's filter is legal and is served on the
|
|
1164
|
+
* engine path, so an arrival here is drift between our own routing gate and
|
|
1165
|
+
* our own emitter. Billing the caller 400 for that would hide a platform bug
|
|
1166
|
+
* from 5xx alerting and tell a dashboard user to fix a filter that is fine.
|
|
1167
|
+
* Same tier as `resolveMeasureSql`'s unrecognised-`Metric.type` throw below.
|
|
1168
|
+
*/
|
|
1169
|
+
assertNoCrossFieldComparison(query, ctx) {
|
|
1170
|
+
const hit = this.crossFieldComparisonIn(query, ctx);
|
|
1171
|
+
if (!hit) return;
|
|
1172
|
+
throw new Error(
|
|
1173
|
+
`[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.`
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
944
1176
|
async execute(query, ctx) {
|
|
945
1177
|
const { sql, params } = await this.generateSql(query, ctx);
|
|
946
1178
|
const cube = ctx.getCube(query.cube);
|
|
@@ -954,6 +1186,7 @@ var NativeSQLStrategy = class {
|
|
|
954
1186
|
if (!cube) {
|
|
955
1187
|
throw new Error(`Cube not found: ${query.cube}`);
|
|
956
1188
|
}
|
|
1189
|
+
this.assertNoCrossFieldComparison(query, ctx);
|
|
957
1190
|
const params = [];
|
|
958
1191
|
const selectClauses = [];
|
|
959
1192
|
const groupByClauses = [];
|
|
@@ -1390,6 +1623,7 @@ var NativeSQLStrategy = class {
|
|
|
1390
1623
|
};
|
|
1391
1624
|
|
|
1392
1625
|
// src/strategies/objectql-strategy.ts
|
|
1626
|
+
import { markFilterSubtreeProvenance } from "@objectstack/spec/data";
|
|
1393
1627
|
import { nextUtcCalendarDay as nextUtcCalendarDay2 } from "@objectstack/core";
|
|
1394
1628
|
|
|
1395
1629
|
// src/strategies/cross-object-rebucket.ts
|
|
@@ -1576,6 +1810,12 @@ var ObjectQLStrategy = class {
|
|
|
1576
1810
|
if (!cube) {
|
|
1577
1811
|
throw new Error(`Cube not found: ${query.cube}`);
|
|
1578
1812
|
}
|
|
1813
|
+
const crossField = findCrossFieldComparand(this.loweredWhere(query));
|
|
1814
|
+
if (crossField) {
|
|
1815
|
+
throw invalidFilterError(
|
|
1816
|
+
`[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.`
|
|
1817
|
+
);
|
|
1818
|
+
}
|
|
1579
1819
|
const selectParts = [];
|
|
1580
1820
|
const groupByParts = [];
|
|
1581
1821
|
const params = [];
|
|
@@ -1680,11 +1920,11 @@ var ObjectQLStrategy = class {
|
|
|
1680
1920
|
* predicate. `$and` makes that structurally impossible.
|
|
1681
1921
|
*/
|
|
1682
1922
|
withReadScope(objectName, filter, ctx) {
|
|
1683
|
-
const userFilter = Object.keys(filter).length > 0 ? filter : void 0;
|
|
1923
|
+
const userFilter = Object.keys(filter).length > 0 ? markFilterSubtreeProvenance(filter, "author") : void 0;
|
|
1684
1924
|
if (typeof ctx.getReadScope !== "function") return userFilter;
|
|
1685
1925
|
const scope = ctx.getReadScope(objectName);
|
|
1686
1926
|
if (scope === void 0 || scope === null) return userFilter;
|
|
1687
|
-
const scopeFilter = scope;
|
|
1927
|
+
const scopeFilter = markFilterSubtreeProvenance(scope, "policy");
|
|
1688
1928
|
if (!userFilter) return scopeFilter;
|
|
1689
1929
|
return { $and: [userFilter, scopeFilter] };
|
|
1690
1930
|
}
|
|
@@ -1858,6 +2098,7 @@ var ObjectQLStrategy = class {
|
|
|
1858
2098
|
if (fkValues.length === 0 || typeof ctx.executeAggregate !== "function") return map;
|
|
1859
2099
|
const idFilter = { id: { $in: fkValues } };
|
|
1860
2100
|
const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
|
|
2101
|
+
if (scope != null) markFilterSubtreeProvenance(scope, "policy");
|
|
1861
2102
|
const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
|
|
1862
2103
|
const rows = await ctx.executeAggregate(refObject, {
|
|
1863
2104
|
groupBy: ["id", attr],
|
|
@@ -2210,8 +2451,27 @@ var ObjectQLStrategy = class {
|
|
|
2210
2451
|
const v0 = values[0];
|
|
2211
2452
|
const all = [...values];
|
|
2212
2453
|
switch (operator) {
|
|
2454
|
+
// [#7598] IMPLICIT equality for a literal, EXPLICIT `$eq` for a field
|
|
2455
|
+
// reference — the branch is on the COMPARAND, not on the operator, which
|
|
2456
|
+
// is the same fix and the same reasoning #7597 applied to
|
|
2457
|
+
// `parseFilterAST`, the spec's own lowering sink.
|
|
2458
|
+
//
|
|
2459
|
+
// `{ amount: 5 }` is implicit equality and every backend reads it that
|
|
2460
|
+
// way. `{ amount: { $field: 'budget' } }` is NOT: it is a field-spec
|
|
2461
|
+
// object whose only key is `$field`, which no backend reads as an
|
|
2462
|
+
// equality — `driver-sql` sees an unrecognised operator key and the
|
|
2463
|
+
// memory evaluator sees a comparand it never resolves. So the bare return
|
|
2464
|
+
// was correct for four years' worth of literals and silently wrong for
|
|
2465
|
+
// the one comparand the 2026-08-12 ruling routes HERE on purpose: with it,
|
|
2466
|
+
// `{ amount: { $eq: { $field: 'budget' } } }` — the shape
|
|
2467
|
+
// `compileCelToFilter` emits for a field-to-field CEL rule, and the shape
|
|
2468
|
+
// `canHandle` now declines native SQL for — would arrive at the driver as
|
|
2469
|
+
// something the driver cannot read, so the capability B exists to serve
|
|
2470
|
+
// would fail on its single most important spelling. Its five siblings
|
|
2471
|
+
// (`$ne`/`$gt`/`$gte`/`$lt`/`$lte`) were never affected: they emit their
|
|
2472
|
+
// operator explicitly two lines down.
|
|
2213
2473
|
case "equals":
|
|
2214
|
-
return v0;
|
|
2474
|
+
return isFieldReference(v0) ? { $eq: v0 } : v0;
|
|
2215
2475
|
case "notEquals":
|
|
2216
2476
|
return { $ne: v0 };
|
|
2217
2477
|
case "gt":
|
|
@@ -2271,6 +2531,24 @@ var ObjectQLStrategy = class {
|
|
|
2271
2531
|
extractObjectName(cube) {
|
|
2272
2532
|
return cube.sql.trim();
|
|
2273
2533
|
}
|
|
2534
|
+
/**
|
|
2535
|
+
* [#7598] The query's `where`, lowered — the same input
|
|
2536
|
+
* `NativeSQLStrategy.canHandle` scans, so the strategy that DECLINED and the
|
|
2537
|
+
* echo that refuses read one shape rather than two.
|
|
2538
|
+
*
|
|
2539
|
+
* A throw from the lowering is swallowed for the same reason it is there: the
|
|
2540
|
+
* `where` is malformed either way and `normalizeAnalyticsFilterTree` below
|
|
2541
|
+
* refuses it with the message and envelope it has always had. This helper's
|
|
2542
|
+
* only job is finding a reference, and there is none to find in a filter that
|
|
2543
|
+
* does not lower.
|
|
2544
|
+
*/
|
|
2545
|
+
loweredWhere(query) {
|
|
2546
|
+
try {
|
|
2547
|
+
return lowerAnalyticsWhere(query);
|
|
2548
|
+
} catch {
|
|
2549
|
+
return null;
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2274
2552
|
/**
|
|
2275
2553
|
* The dimensions this query PROJECTS, in the order the result carries them:
|
|
2276
2554
|
* every `dimensions` entry, then every granular `timeDimensions` entry that
|
|
@@ -3452,6 +3730,7 @@ var AnalyticsService = class {
|
|
|
3452
3730
|
this.getObjectFieldNames = config.getObjectFieldNames;
|
|
3453
3731
|
this.getObjectDatasource = config.getObjectDatasource;
|
|
3454
3732
|
this.isExternalObject = config.isExternalObject;
|
|
3733
|
+
this.debugSql = config.debugSql ?? getEnv("NODE_ENV") === "development";
|
|
3455
3734
|
if (config.datasets) {
|
|
3456
3735
|
for (const ds of config.datasets) {
|
|
3457
3736
|
try {
|
|
@@ -3569,7 +3848,7 @@ var AnalyticsService = class {
|
|
|
3569
3848
|
const strategy = this.resolveStrategy(query, ctx, skip);
|
|
3570
3849
|
this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
|
|
3571
3850
|
try {
|
|
3572
|
-
return await strategy.execute(query, ctx);
|
|
3851
|
+
return this.applySqlEchoPolicy(await strategy.execute(query, ctx));
|
|
3573
3852
|
} catch (e) {
|
|
3574
3853
|
if (e?.code === "RAW_SQL_UNSUPPORTED") {
|
|
3575
3854
|
this.logger.warn(
|
|
@@ -3582,6 +3861,30 @@ var AnalyticsService = class {
|
|
|
3582
3861
|
}
|
|
3583
3862
|
}
|
|
3584
3863
|
}
|
|
3864
|
+
/**
|
|
3865
|
+
* [#8286] Withhold the executed statement unless this host enabled the echo.
|
|
3866
|
+
*
|
|
3867
|
+
* Applied at {@link query}, which is the response-assembly seam for BOTH
|
|
3868
|
+
* faces that serve callers: `/api/v1/analytics/query` calls it directly, and
|
|
3869
|
+
* `queryDataset` reaches it through `DatasetExecutor`, so a dataset response
|
|
3870
|
+
* inherits the same verdict without a second gate to keep in step.
|
|
3871
|
+
* `generateSql` — the dedicated `/api/v1/analytics/sql` dry-run route — is
|
|
3872
|
+
* deliberately NOT gated: asking for the statement is that route's entire
|
|
3873
|
+
* purpose, and it is the surface a debugging author is meant to use.
|
|
3874
|
+
*
|
|
3875
|
+
* What the echo disclosed, and why "it is only a table name" understates it:
|
|
3876
|
+
* the statement carries the compiled read scope, i.e. the SHAPE of the
|
|
3877
|
+
* isolation predicate (`"sys_user"."id" IN ($2, $3, …)` rather than an
|
|
3878
|
+
* `organization_id` comparison) plus its bound-parameter arity, which counts
|
|
3879
|
+
* the caller's own org membership. No wall was breached by it — the echo is
|
|
3880
|
+
* information disclosure, and this is the disclosure closing.
|
|
3881
|
+
*/
|
|
3882
|
+
applySqlEchoPolicy(result) {
|
|
3883
|
+
if (this.debugSql || result?.sql === void 0) return result;
|
|
3884
|
+
const withheld = { ...result };
|
|
3885
|
+
delete withheld.sql;
|
|
3886
|
+
return withheld;
|
|
3887
|
+
}
|
|
3585
3888
|
/**
|
|
3586
3889
|
* Compile a `dataset` (ADR-0021) and register its Cube + join allowlist so it
|
|
3587
3890
|
* can be queried by name. Idempotent (re-registering overwrites). Returns the
|
|
@@ -4246,11 +4549,19 @@ var AnalyticsService = class {
|
|
|
4246
4549
|
return strategy;
|
|
4247
4550
|
}
|
|
4248
4551
|
}
|
|
4552
|
+
const crossField = findCrossFieldComparand(lowerAnalyticsWhereQuietly(query));
|
|
4249
4553
|
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
|
|
4554
|
+
`[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
4555
|
);
|
|
4252
4556
|
}
|
|
4253
4557
|
};
|
|
4558
|
+
function lowerAnalyticsWhereQuietly(query) {
|
|
4559
|
+
try {
|
|
4560
|
+
return lowerAnalyticsWhere(query);
|
|
4561
|
+
} catch {
|
|
4562
|
+
return null;
|
|
4563
|
+
}
|
|
4564
|
+
}
|
|
4254
4565
|
function mintableMeasureKey(member, cubeName) {
|
|
4255
4566
|
const dot = member.indexOf(".");
|
|
4256
4567
|
if (dot < 0) return member;
|
|
@@ -4546,6 +4857,11 @@ var AnalyticsServicePlugin = class {
|
|
|
4546
4857
|
coerceTemporalFilterColumn,
|
|
4547
4858
|
relationshipResolver,
|
|
4548
4859
|
labelResolver,
|
|
4860
|
+
// [#8286] Passed through as authored — `undefined` is "this host did not
|
|
4861
|
+
// choose", which the service resolves to development-only. Defaulting it
|
|
4862
|
+
// here would be a second copy of that decision, drifting the moment one
|
|
4863
|
+
// of the two moves.
|
|
4864
|
+
debugSql: this.options.debugSql,
|
|
4549
4865
|
// Source-field metadata behind the display chains on result columns:
|
|
4550
4866
|
// ADR-0053 currency (`currencyConfig.defaultCurrency`) and percent scale
|
|
4551
4867
|
// (`max`, which is what marks whole-percent storage — objectui#3136).
|