@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.cjs
CHANGED
|
@@ -41,9 +41,9 @@ __export(index_exports, {
|
|
|
41
41
|
module.exports = __toCommonJS(index_exports);
|
|
42
42
|
|
|
43
43
|
// src/analytics-service.ts
|
|
44
|
-
var
|
|
44
|
+
var import_data6 = require("@objectstack/spec/data");
|
|
45
45
|
var import_ui2 = require("@objectstack/spec/ui");
|
|
46
|
-
var
|
|
46
|
+
var import_core6 = require("@objectstack/core");
|
|
47
47
|
var import_types = require("@objectstack/types");
|
|
48
48
|
|
|
49
49
|
// src/cube-registry.ts
|
|
@@ -162,27 +162,104 @@ var CubeRegistry = class {
|
|
|
162
162
|
};
|
|
163
163
|
|
|
164
164
|
// src/strategies/filter-normalizer.ts
|
|
165
|
-
var
|
|
165
|
+
var import_data2 = require("@objectstack/spec/data");
|
|
166
166
|
var import_api = require("@objectstack/spec/api");
|
|
167
167
|
|
|
168
168
|
// src/comparand-shape.ts
|
|
169
|
+
var import_core = require("@objectstack/core");
|
|
170
|
+
var import_data = require("@objectstack/spec/data");
|
|
169
171
|
function isBindableComparand(value) {
|
|
170
|
-
if (value ===
|
|
171
|
-
|
|
172
|
-
if (kind === "string" || kind === "number" || kind === "bigint" || kind === "boolean") return true;
|
|
173
|
-
return value instanceof Date || ArrayBuffer.isView(value);
|
|
172
|
+
if (value === void 0) return true;
|
|
173
|
+
return (0, import_data.isAcceptedFilterComparand)(value) || ArrayBuffer.isView(value);
|
|
174
174
|
}
|
|
175
175
|
function isRenderableTextComparand(value) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
176
|
+
return value === void 0 || (0, import_data.isAcceptedFilterComparand)(value);
|
|
177
|
+
}
|
|
178
|
+
function isFieldReference(value) {
|
|
179
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
180
|
+
return typeof value.$field === "string";
|
|
181
|
+
}
|
|
182
|
+
var CROSS_FIELD_COMPARISON_OPERATORS = /* @__PURE__ */ new Set([
|
|
183
|
+
"$eq",
|
|
184
|
+
"$ne",
|
|
185
|
+
"$gt",
|
|
186
|
+
"$gte",
|
|
187
|
+
"$lt",
|
|
188
|
+
"$lte"
|
|
189
|
+
]);
|
|
190
|
+
function findCrossFieldComparand(filter) {
|
|
191
|
+
return findIn(filter, "");
|
|
192
|
+
}
|
|
193
|
+
function findIn(node, field) {
|
|
194
|
+
if (!node || typeof node !== "object") return null;
|
|
195
|
+
if (Array.isArray(node)) {
|
|
196
|
+
for (const child of node) {
|
|
197
|
+
const hit = findIn(child, field);
|
|
198
|
+
if (hit) return hit;
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
if (node instanceof Date || ArrayBuffer.isView(node)) return null;
|
|
203
|
+
for (const [key, value] of Object.entries(node)) {
|
|
204
|
+
if (CROSS_FIELD_COMPARISON_OPERATORS.has(key) && isFieldReference(value)) {
|
|
205
|
+
return { op: key, field, ref: value.$field };
|
|
206
|
+
}
|
|
207
|
+
const hit = findIn(value, key.startsWith("$") ? field : key);
|
|
208
|
+
if (hit) return hit;
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
function findUninterpretableTemporalMember(filter, kindOf) {
|
|
213
|
+
return findUninterpretableIn(filter, "", kindOf);
|
|
214
|
+
}
|
|
215
|
+
function findUninterpretableIn(node, field, kindOf) {
|
|
216
|
+
if (!node || typeof node !== "object") return null;
|
|
217
|
+
if (Array.isArray(node)) {
|
|
218
|
+
for (const child of node) {
|
|
219
|
+
const hit = findUninterpretableIn(child, field, kindOf);
|
|
220
|
+
if (hit) return hit;
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
if (node instanceof Date || ArrayBuffer.isView(node)) return null;
|
|
225
|
+
if (isFieldReference(node)) return null;
|
|
226
|
+
for (const [key, value] of Object.entries(node)) {
|
|
227
|
+
const scope = key.startsWith("$") ? field : key;
|
|
228
|
+
const kind = scope ? kindOf(scope) : null;
|
|
229
|
+
if (kind) {
|
|
230
|
+
const hit2 = judgeTemporalLiterals(value, scope, kind);
|
|
231
|
+
if (hit2) return hit2;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const hit = findUninterpretableIn(value, scope, kindOf);
|
|
235
|
+
if (hit) return hit;
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
function judgeTemporalLiterals(value, field, kind) {
|
|
240
|
+
if (Array.isArray(value)) {
|
|
241
|
+
for (const member of value) {
|
|
242
|
+
const hit = judgeTemporalLiterals(member, field, kind);
|
|
243
|
+
if (hit) return hit;
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
if (value && typeof value === "object") {
|
|
248
|
+
if (value instanceof Date || ArrayBuffer.isView(value) || isFieldReference(value)) return null;
|
|
249
|
+
for (const nested of Object.values(value)) {
|
|
250
|
+
const hit = judgeTemporalLiterals(nested, field, kind);
|
|
251
|
+
if (hit) return hit;
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
return (0, import_core.isUninterpretableTemporalComparand)(kind, value) ? { field, kind, value } : null;
|
|
180
256
|
}
|
|
181
257
|
var TEXT_PATTERN_OPERATORS = /* @__PURE__ */ new Set([
|
|
182
258
|
"$contains",
|
|
183
259
|
"$notContains",
|
|
184
260
|
"$startsWith",
|
|
185
|
-
"$endsWith"
|
|
261
|
+
"$endsWith",
|
|
262
|
+
"$icontains"
|
|
186
263
|
]);
|
|
187
264
|
function shapePreview(value) {
|
|
188
265
|
try {
|
|
@@ -194,10 +271,16 @@ function shapePreview(value) {
|
|
|
194
271
|
}
|
|
195
272
|
}
|
|
196
273
|
function unrenderableTextComparandMessage(op, field, value) {
|
|
197
|
-
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);
|
|
274
|
+
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); ${import_data.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.`;
|
|
275
|
+
}
|
|
276
|
+
function fieldReferenceComparandMessage(op, field, ref, position) {
|
|
277
|
+
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).`;
|
|
278
|
+
}
|
|
279
|
+
function fieldReferenceBetweenBoundMessage(op, field, ref, index) {
|
|
280
|
+
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).`;
|
|
198
281
|
}
|
|
199
282
|
function unbindableListMemberMessage(op, field, value, index) {
|
|
200
|
-
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
|
|
283
|
+
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 ${import_data.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).`;
|
|
201
284
|
}
|
|
202
285
|
|
|
203
286
|
// src/strategies/filter-normalizer.ts
|
|
@@ -261,6 +344,15 @@ function assertCompilableComparand(opKey, field, value) {
|
|
|
261
344
|
});
|
|
262
345
|
}
|
|
263
346
|
}
|
|
347
|
+
function assertNoFieldReferenceComparand(opKey, field, value) {
|
|
348
|
+
if (opKey !== "$between" || !Array.isArray(value)) return;
|
|
349
|
+
value.forEach((member, index) => {
|
|
350
|
+
if (!isFieldReference(member)) return;
|
|
351
|
+
throw invalidFilterError(
|
|
352
|
+
`[analytics] ${fieldReferenceBetweenBoundMessage(opKey, field, member.$field, index)}`
|
|
353
|
+
);
|
|
354
|
+
});
|
|
355
|
+
}
|
|
264
356
|
function undefinedComparandError(field, path) {
|
|
265
357
|
return invalidFilterError(
|
|
266
358
|
`[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).`
|
|
@@ -330,6 +422,7 @@ function fieldLeaves(key, raw) {
|
|
|
330
422
|
`[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.`
|
|
331
423
|
);
|
|
332
424
|
}
|
|
425
|
+
assertNoFieldReferenceComparand(opKey, key, v2);
|
|
333
426
|
leaf("gte", [comparand(v2[0])]);
|
|
334
427
|
leaf("lte", [comparand(v2[1])]);
|
|
335
428
|
continue;
|
|
@@ -457,6 +550,7 @@ function nullValueSatisfiesOperator(op, value) {
|
|
|
457
550
|
}
|
|
458
551
|
}
|
|
459
552
|
function operatorIsNullTotal(op, value) {
|
|
553
|
+
if (CROSS_FIELD_COMPARISON_OPERATORS.has(op) && isFieldReference(value)) return true;
|
|
460
554
|
switch (op) {
|
|
461
555
|
// Compile to `set` / `notSet` — `IS NULL` / `IS NOT NULL`, two-valued by
|
|
462
556
|
// construction, on every strategy that compiles this tree.
|
|
@@ -534,7 +628,7 @@ function nullSafeNegationOperand(node) {
|
|
|
534
628
|
}
|
|
535
629
|
function filterArrayNotLowerableError(where) {
|
|
536
630
|
return invalidFilterError(
|
|
537
|
-
`[analytics] received a 'where' array that is not a filter: ${JSON.stringify(where)}. A filter array is a comparison [field, operator, value], a logical node ["and"|"or", ...conditions], or a list of those \u2014 it is INPUT-ONLY sugar (spec 'FilterArray'), lowered to a FilterCondition by @objectstack/spec parseFilterAST() at every door, this one included (#5158/#5334). This value cannot be lowered, and an unapplied filter would have charted the UNFILTERED dataset. Recognised operators: ${[...
|
|
631
|
+
`[analytics] received a 'where' array that is not a filter: ${JSON.stringify(where)}. A filter array is a comparison [field, operator, value], a logical node ["and"|"or", ...conditions], or a list of those \u2014 it is INPUT-ONLY sugar (spec 'FilterArray'), lowered to a FilterCondition by @objectstack/spec parseFilterAST() at every door, this one included (#5158/#5334). This value cannot be lowered, and an unapplied filter would have charted the UNFILTERED dataset. Recognised operators: ${[...import_data2.VALID_AST_OPERATORS].sort().join(", ")}. Infix joins ([condA, "or", condB]) are NOT one of the shapes \u2014 write the prefix form ["or", condA, condB].`
|
|
538
632
|
);
|
|
539
633
|
}
|
|
540
634
|
function lowerAnalyticsWhere(query) {
|
|
@@ -543,8 +637,8 @@ function lowerAnalyticsWhere(query) {
|
|
|
543
637
|
if (!where || typeof where !== "object") return null;
|
|
544
638
|
if (Array.isArray(where)) {
|
|
545
639
|
if (where.length === 0) return null;
|
|
546
|
-
if (!(0,
|
|
547
|
-
const condition = (0,
|
|
640
|
+
if (!(0, import_data2.isFilterAST)(where)) throw filterArrayNotLowerableError(where);
|
|
641
|
+
const condition = (0, import_data2.parseFilterAST)(where);
|
|
548
642
|
if (!condition || typeof condition !== "object" || Array.isArray(condition)) {
|
|
549
643
|
throw invalidFilterError(
|
|
550
644
|
`[analytics] filter array ${JSON.stringify(where)} passed isFilterAST() but parseFilterAST() lowered it to ${JSON.stringify(condition)}. Refusing rather than charting the dataset unfiltered (#5158/#5334).`
|
|
@@ -677,6 +771,7 @@ function compileField(field, value, qAlias, params) {
|
|
|
677
771
|
const col = `${qAlias}.${quoteIdent(field, "field")}`;
|
|
678
772
|
assertDefinedComparands2(field, value);
|
|
679
773
|
assertBooleanFlagComparands(field, value);
|
|
774
|
+
assertNoFieldReferenceComparand2(field, value);
|
|
680
775
|
if (value === null) return `${col} IS NULL`;
|
|
681
776
|
if (typeof value !== "object" || value instanceof Date) {
|
|
682
777
|
params.push(value);
|
|
@@ -749,6 +844,23 @@ function assertBooleanFlagComparands(field, spec) {
|
|
|
749
844
|
throw nonBooleanFlagComparandError(op, field, `"${field}".${op}`);
|
|
750
845
|
}
|
|
751
846
|
}
|
|
847
|
+
function assertNoFieldReferenceComparand2(field, spec) {
|
|
848
|
+
if (!isFilterNode(spec)) return;
|
|
849
|
+
for (const [op, opValue] of Object.entries(spec)) {
|
|
850
|
+
if (CROSS_FIELD_COMPARISON_OPERATORS.has(op) && isFieldReference(opValue)) {
|
|
851
|
+
throw readScopeCompileError(
|
|
852
|
+
`[read-scope-sql] ${fieldReferenceComparandMessage(op, field, opValue.$field)}`
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
if (op !== "$between" || !Array.isArray(opValue)) continue;
|
|
856
|
+
opValue.forEach((member, index) => {
|
|
857
|
+
if (!isFieldReference(member)) return;
|
|
858
|
+
throw readScopeCompileError(
|
|
859
|
+
`[read-scope-sql] ${fieldReferenceBetweenBoundMessage(op, field, member.$field, index)}`
|
|
860
|
+
);
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
}
|
|
752
864
|
function compileOperator(col, op, val, field, params) {
|
|
753
865
|
switch (op) {
|
|
754
866
|
case "$eq":
|
|
@@ -949,7 +1061,7 @@ function invalidMemberError(message, meta) {
|
|
|
949
1061
|
}
|
|
950
1062
|
|
|
951
1063
|
// src/strategies/native-sql-strategy.ts
|
|
952
|
-
var
|
|
1064
|
+
var import_core2 = require("@objectstack/core");
|
|
953
1065
|
var AGGREGATE_SQL = {
|
|
954
1066
|
"count": () => "COUNT(*)",
|
|
955
1067
|
"sum": (col) => `SUM(${col})`,
|
|
@@ -980,9 +1092,124 @@ var NativeSQLStrategy = class {
|
|
|
980
1092
|
}
|
|
981
1093
|
}
|
|
982
1094
|
}
|
|
1095
|
+
if (this.carriesCrossFieldComparison(query, ctx)) return false;
|
|
1096
|
+
if (this.carriesUninterpretableTemporalComparand(query, ctx)) return false;
|
|
983
1097
|
const caps = ctx.queryCapabilities(query.cube);
|
|
984
1098
|
return caps.nativeSql && typeof ctx.executeRawSql === "function";
|
|
985
1099
|
}
|
|
1100
|
+
/**
|
|
1101
|
+
* [#8690] Does the query's `where` compare a declared TIME dimension against
|
|
1102
|
+
* a value no temporal storage rule can read? See the ruling at
|
|
1103
|
+
* {@link canHandle}.
|
|
1104
|
+
*
|
|
1105
|
+
* The classification comes from the CUBE, the only metadata this package has:
|
|
1106
|
+
* a dimension declares `type: 'time'` (compiled from the dataset dimension's
|
|
1107
|
+
* `type: 'date'`), and {@link lookupMember} is the same resolution every other
|
|
1108
|
+
* member lookup in this strategy uses, so "the member the gate classified"
|
|
1109
|
+
* and "the member the compiler emits" cannot drift apart.
|
|
1110
|
+
*
|
|
1111
|
+
* A `time` dimension is read with the DATETIME rule — the permissive one of
|
|
1112
|
+
* the three. That is the right direction because this is a routing decision,
|
|
1113
|
+
* not a verdict: the engine door re-judges with the field's real declared
|
|
1114
|
+
* type and has the final say, so under-classifying an exotic spelling merely
|
|
1115
|
+
* leaves today's behaviour, while over-classifying would silently move a
|
|
1116
|
+
* working dashboard off the fast path. The comparands this card measured
|
|
1117
|
+
* (`last_30_days`, `not-a-date-at-all`) are unreadable under all three rules,
|
|
1118
|
+
* so the decline fires for them whichever backing type the dimension has.
|
|
1119
|
+
*
|
|
1120
|
+
* `lowerAnalyticsWhere` rather than `query.where` raw, so the authored ARRAY
|
|
1121
|
+
* sugar is seen after `parseFilterAST` has lowered it; a THROW from that
|
|
1122
|
+
* lowering is not this gate's to answer — the filter is malformed either way
|
|
1123
|
+
* and `normalizeAnalyticsFilterTree` refuses it a moment later with the
|
|
1124
|
+
* message and envelope it has always had.
|
|
1125
|
+
*/
|
|
1126
|
+
carriesUninterpretableTemporalComparand(query, ctx) {
|
|
1127
|
+
const cube = query.cube ? ctx.getCube(query.cube) : void 0;
|
|
1128
|
+
if (!cube) return false;
|
|
1129
|
+
let where = null;
|
|
1130
|
+
try {
|
|
1131
|
+
where = lowerAnalyticsWhere(query);
|
|
1132
|
+
} catch {
|
|
1133
|
+
return false;
|
|
1134
|
+
}
|
|
1135
|
+
if (!where) return false;
|
|
1136
|
+
return findUninterpretableTemporalMember(
|
|
1137
|
+
where,
|
|
1138
|
+
(member) => this.lookupMember(cube, member, "dimension")?.type === "time" ? "datetime" : null
|
|
1139
|
+
) !== null;
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* [#7598] Does serving this query require the cross-field capability this
|
|
1143
|
+
* strategy declines? See the ruling recorded at {@link canHandle}.
|
|
1144
|
+
*
|
|
1145
|
+
* ⚠️ This and {@link assertNoCrossFieldComparison} read the SAME inputs
|
|
1146
|
+
* through the SAME detector, which is what makes the decline and the
|
|
1147
|
+
* fail-closed backstop unable to drift: a shape one of them recognises is a
|
|
1148
|
+
* shape the other recognises.
|
|
1149
|
+
*/
|
|
1150
|
+
carriesCrossFieldComparison(query, ctx) {
|
|
1151
|
+
return this.crossFieldComparisonIn(query, ctx) !== null;
|
|
1152
|
+
}
|
|
1153
|
+
crossFieldComparisonIn(query, ctx) {
|
|
1154
|
+
let where = null;
|
|
1155
|
+
try {
|
|
1156
|
+
where = lowerAnalyticsWhere(query);
|
|
1157
|
+
} catch {
|
|
1158
|
+
return null;
|
|
1159
|
+
}
|
|
1160
|
+
const inWhere = findCrossFieldComparand(where);
|
|
1161
|
+
if (inWhere) return { source: "the query's `where`", ...inWhere };
|
|
1162
|
+
if (typeof ctx.getReadScope !== "function") return null;
|
|
1163
|
+
const cube = query.cube ? ctx.getCube(query.cube) : void 0;
|
|
1164
|
+
if (!cube) return null;
|
|
1165
|
+
const objects = [this.extractObjectName(cube)];
|
|
1166
|
+
for (const alias of Object.keys(cube.joins ?? {})) {
|
|
1167
|
+
objects.push(cube.joins?.[alias]?.name ?? alias);
|
|
1168
|
+
}
|
|
1169
|
+
for (const objectName of objects) {
|
|
1170
|
+
const scope = ctx.getReadScope(objectName);
|
|
1171
|
+
if (scope === void 0 || scope === null) continue;
|
|
1172
|
+
const inScope = findCrossFieldComparand(scope);
|
|
1173
|
+
if (inScope) return { source: `the read scope of "${objectName}"`, ...inScope };
|
|
1174
|
+
}
|
|
1175
|
+
return null;
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* [#7598] The fail-closed backstop at the door that BINDS.
|
|
1179
|
+
*
|
|
1180
|
+
* ⚠️ **Unreachable by construction, and kept deliberately** — saying so
|
|
1181
|
+
* because #7598's brief asks that a refusal arm which has become unreachable
|
|
1182
|
+
* be named rather than left to be re-discovered. {@link canHandle} declines
|
|
1183
|
+
* every query this would fire on, and it declines using
|
|
1184
|
+
* {@link crossFieldComparisonIn} — the same walk over the same two inputs —
|
|
1185
|
+
* so `resolveStrategy` cannot hand this strategy a query carrying one.
|
|
1186
|
+
*
|
|
1187
|
+
* It is kept because of what the failure mode is if that ever stops being
|
|
1188
|
+
* true. The defect #7598 measured was not a missing error: it was a SILENT
|
|
1189
|
+
* BIND — `toSqlBindValue` JSON-stringifies the reference object, so the
|
|
1190
|
+
* statement compiled perfectly and compared a column against the text
|
|
1191
|
+
* `{"$field":"budget"}`, a value no row can hold. A routing gate that misses
|
|
1192
|
+
* a shape therefore degrades to a wrong ANSWER rather than to an error, and
|
|
1193
|
+
* that is the one class this package refuses to leave to a single guard
|
|
1194
|
+
* (Prime Directive #12 — refuse at the door, do not tolerate at the
|
|
1195
|
+
* consumer). One line, no measurable cost, and it turns a routing regression
|
|
1196
|
+
* into a loud refusal instead of an empty chart.
|
|
1197
|
+
*
|
|
1198
|
+
* Deliberately BARE — an undeclared 500, not `INVALID_FILTER` / 400 — for the
|
|
1199
|
+
* reason `buildFilterClauseSql`'s #5333 exit in `objectql-strategy.ts` gives
|
|
1200
|
+
* for the same class: the caller's filter is legal and is served on the
|
|
1201
|
+
* engine path, so an arrival here is drift between our own routing gate and
|
|
1202
|
+
* our own emitter. Billing the caller 400 for that would hide a platform bug
|
|
1203
|
+
* from 5xx alerting and tell a dashboard user to fix a filter that is fine.
|
|
1204
|
+
* Same tier as `resolveMeasureSql`'s unrecognised-`Metric.type` throw below.
|
|
1205
|
+
*/
|
|
1206
|
+
assertNoCrossFieldComparison(query, ctx) {
|
|
1207
|
+
const hit = this.crossFieldComparisonIn(query, ctx);
|
|
1208
|
+
if (!hit) return;
|
|
1209
|
+
throw new Error(
|
|
1210
|
+
`[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.`
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
986
1213
|
async execute(query, ctx) {
|
|
987
1214
|
const { sql, params } = await this.generateSql(query, ctx);
|
|
988
1215
|
const cube = ctx.getCube(query.cube);
|
|
@@ -996,6 +1223,7 @@ var NativeSQLStrategy = class {
|
|
|
996
1223
|
if (!cube) {
|
|
997
1224
|
throw new Error(`Cube not found: ${query.cube}`);
|
|
998
1225
|
}
|
|
1226
|
+
this.assertNoCrossFieldComparison(query, ctx);
|
|
999
1227
|
const params = [];
|
|
1000
1228
|
const selectClauses = [];
|
|
1001
1229
|
const groupByClauses = [];
|
|
@@ -1032,7 +1260,7 @@ var NativeSQLStrategy = class {
|
|
|
1032
1260
|
if (range.length === 2) {
|
|
1033
1261
|
const td2 = this.resolveStorageTarget(cube, td.dimension, tableName);
|
|
1034
1262
|
const column = this.temporalColumn(ctx, td2, colExpr);
|
|
1035
|
-
const nextDay = (0,
|
|
1263
|
+
const nextDay = (0, import_core2.nextUtcCalendarDay)(range[1]);
|
|
1036
1264
|
params.push(this.coerceTemporal(ctx, td2, range[0]));
|
|
1037
1265
|
const lower = `${column} >= $${params.length}`;
|
|
1038
1266
|
if (nextDay != null) {
|
|
@@ -1402,7 +1630,7 @@ var NativeSQLStrategy = class {
|
|
|
1402
1630
|
return `${rawCol} ${sqlOp} ${patternRef} ESCAPE $${params.length}`;
|
|
1403
1631
|
}
|
|
1404
1632
|
if (operator === "lte") {
|
|
1405
|
-
const nextDay = (0,
|
|
1633
|
+
const nextDay = (0, import_core2.nextUtcCalendarDay)(values[0]);
|
|
1406
1634
|
if (nextDay != null) {
|
|
1407
1635
|
params.push(this.coerceTemporal(ctx, target, nextDay));
|
|
1408
1636
|
return `${this.temporalColumn(ctx, target, rawCol)} < $${params.length}`;
|
|
@@ -1432,7 +1660,8 @@ var NativeSQLStrategy = class {
|
|
|
1432
1660
|
};
|
|
1433
1661
|
|
|
1434
1662
|
// src/strategies/objectql-strategy.ts
|
|
1435
|
-
var
|
|
1663
|
+
var import_data3 = require("@objectstack/spec/data");
|
|
1664
|
+
var import_core3 = require("@objectstack/core");
|
|
1436
1665
|
|
|
1437
1666
|
// src/strategies/cross-object-rebucket.ts
|
|
1438
1667
|
var RECOMBINABLE_METHODS = /* @__PURE__ */ new Set([
|
|
@@ -1618,6 +1847,12 @@ var ObjectQLStrategy = class {
|
|
|
1618
1847
|
if (!cube) {
|
|
1619
1848
|
throw new Error(`Cube not found: ${query.cube}`);
|
|
1620
1849
|
}
|
|
1850
|
+
const crossField = findCrossFieldComparand(this.loweredWhere(query));
|
|
1851
|
+
if (crossField) {
|
|
1852
|
+
throw invalidFilterError(
|
|
1853
|
+
`[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.`
|
|
1854
|
+
);
|
|
1855
|
+
}
|
|
1621
1856
|
const selectParts = [];
|
|
1622
1857
|
const groupByParts = [];
|
|
1623
1858
|
const params = [];
|
|
@@ -1671,7 +1906,7 @@ var ObjectQLStrategy = class {
|
|
|
1671
1906
|
);
|
|
1672
1907
|
if (filterClause) whereParts.push(filterClause);
|
|
1673
1908
|
for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
|
|
1674
|
-
const nextDay = (0,
|
|
1909
|
+
const nextDay = (0, import_core3.nextUtcCalendarDay)(bounds.$lte);
|
|
1675
1910
|
params.push(bounds.$gte, nextDay ?? bounds.$lte);
|
|
1676
1911
|
whereParts.push(
|
|
1677
1912
|
`(${field} >= $${params.length - 1} AND ${field} ${nextDay ? "<" : "<="} $${params.length})`
|
|
@@ -1722,11 +1957,11 @@ var ObjectQLStrategy = class {
|
|
|
1722
1957
|
* predicate. `$and` makes that structurally impossible.
|
|
1723
1958
|
*/
|
|
1724
1959
|
withReadScope(objectName, filter, ctx) {
|
|
1725
|
-
const userFilter = Object.keys(filter).length > 0 ? filter : void 0;
|
|
1960
|
+
const userFilter = Object.keys(filter).length > 0 ? (0, import_data3.markFilterSubtreeProvenance)(filter, "author") : void 0;
|
|
1726
1961
|
if (typeof ctx.getReadScope !== "function") return userFilter;
|
|
1727
1962
|
const scope = ctx.getReadScope(objectName);
|
|
1728
1963
|
if (scope === void 0 || scope === null) return userFilter;
|
|
1729
|
-
const scopeFilter = scope;
|
|
1964
|
+
const scopeFilter = (0, import_data3.markFilterSubtreeProvenance)(scope, "policy");
|
|
1730
1965
|
if (!userFilter) return scopeFilter;
|
|
1731
1966
|
return { $and: [userFilter, scopeFilter] };
|
|
1732
1967
|
}
|
|
@@ -1900,6 +2135,7 @@ var ObjectQLStrategy = class {
|
|
|
1900
2135
|
if (fkValues.length === 0 || typeof ctx.executeAggregate !== "function") return map;
|
|
1901
2136
|
const idFilter = { id: { $in: fkValues } };
|
|
1902
2137
|
const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
|
|
2138
|
+
if (scope != null) (0, import_data3.markFilterSubtreeProvenance)(scope, "policy");
|
|
1903
2139
|
const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
|
|
1904
2140
|
const rows = await ctx.executeAggregate(refObject, {
|
|
1905
2141
|
groupBy: ["id", attr],
|
|
@@ -2252,8 +2488,27 @@ var ObjectQLStrategy = class {
|
|
|
2252
2488
|
const v0 = values[0];
|
|
2253
2489
|
const all = [...values];
|
|
2254
2490
|
switch (operator) {
|
|
2491
|
+
// [#7598] IMPLICIT equality for a literal, EXPLICIT `$eq` for a field
|
|
2492
|
+
// reference — the branch is on the COMPARAND, not on the operator, which
|
|
2493
|
+
// is the same fix and the same reasoning #7597 applied to
|
|
2494
|
+
// `parseFilterAST`, the spec's own lowering sink.
|
|
2495
|
+
//
|
|
2496
|
+
// `{ amount: 5 }` is implicit equality and every backend reads it that
|
|
2497
|
+
// way. `{ amount: { $field: 'budget' } }` is NOT: it is a field-spec
|
|
2498
|
+
// object whose only key is `$field`, which no backend reads as an
|
|
2499
|
+
// equality — `driver-sql` sees an unrecognised operator key and the
|
|
2500
|
+
// memory evaluator sees a comparand it never resolves. So the bare return
|
|
2501
|
+
// was correct for four years' worth of literals and silently wrong for
|
|
2502
|
+
// the one comparand the 2026-08-12 ruling routes HERE on purpose: with it,
|
|
2503
|
+
// `{ amount: { $eq: { $field: 'budget' } } }` — the shape
|
|
2504
|
+
// `compileCelToFilter` emits for a field-to-field CEL rule, and the shape
|
|
2505
|
+
// `canHandle` now declines native SQL for — would arrive at the driver as
|
|
2506
|
+
// something the driver cannot read, so the capability B exists to serve
|
|
2507
|
+
// would fail on its single most important spelling. Its five siblings
|
|
2508
|
+
// (`$ne`/`$gt`/`$gte`/`$lt`/`$lte`) were never affected: they emit their
|
|
2509
|
+
// operator explicitly two lines down.
|
|
2255
2510
|
case "equals":
|
|
2256
|
-
return v0;
|
|
2511
|
+
return isFieldReference(v0) ? { $eq: v0 } : v0;
|
|
2257
2512
|
case "notEquals":
|
|
2258
2513
|
return { $ne: v0 };
|
|
2259
2514
|
case "gt":
|
|
@@ -2313,6 +2568,24 @@ var ObjectQLStrategy = class {
|
|
|
2313
2568
|
extractObjectName(cube) {
|
|
2314
2569
|
return cube.sql.trim();
|
|
2315
2570
|
}
|
|
2571
|
+
/**
|
|
2572
|
+
* [#7598] The query's `where`, lowered — the same input
|
|
2573
|
+
* `NativeSQLStrategy.canHandle` scans, so the strategy that DECLINED and the
|
|
2574
|
+
* echo that refuses read one shape rather than two.
|
|
2575
|
+
*
|
|
2576
|
+
* A throw from the lowering is swallowed for the same reason it is there: the
|
|
2577
|
+
* `where` is malformed either way and `normalizeAnalyticsFilterTree` below
|
|
2578
|
+
* refuses it with the message and envelope it has always had. This helper's
|
|
2579
|
+
* only job is finding a reference, and there is none to find in a filter that
|
|
2580
|
+
* does not lower.
|
|
2581
|
+
*/
|
|
2582
|
+
loweredWhere(query) {
|
|
2583
|
+
try {
|
|
2584
|
+
return lowerAnalyticsWhere(query);
|
|
2585
|
+
} catch {
|
|
2586
|
+
return null;
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2316
2589
|
/**
|
|
2317
2590
|
* The dimensions this query PROJECTS, in the order the result carries them:
|
|
2318
2591
|
* every `dimensions` entry, then every granular `timeDimensions` entry that
|
|
@@ -2354,10 +2627,10 @@ var ObjectQLStrategy = class {
|
|
|
2354
2627
|
};
|
|
2355
2628
|
|
|
2356
2629
|
// src/dataset-compiler.ts
|
|
2357
|
-
var
|
|
2630
|
+
var import_data4 = require("@objectstack/spec/data");
|
|
2358
2631
|
var import_ui = require("@objectstack/spec/ui");
|
|
2359
2632
|
var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set();
|
|
2360
|
-
var SUPPORTED_AGGREGATES =
|
|
2633
|
+
var SUPPORTED_AGGREGATES = import_data4.AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
|
|
2361
2634
|
function aggregateToMetricType(m) {
|
|
2362
2635
|
if (!m.aggregate) {
|
|
2363
2636
|
throw new Error(`[dataset-compiler] non-derived measure "${m.name}" has no aggregate`);
|
|
@@ -2519,11 +2792,11 @@ function compileDataset(dataset, resolver, options) {
|
|
|
2519
2792
|
}
|
|
2520
2793
|
|
|
2521
2794
|
// src/dataset-executor.ts
|
|
2522
|
-
var
|
|
2523
|
-
var
|
|
2795
|
+
var import_data5 = require("@objectstack/spec/data");
|
|
2796
|
+
var import_core4 = require("@objectstack/core");
|
|
2524
2797
|
function resolveSelectionTokens(compiled, selection, context) {
|
|
2525
|
-
const tokenCtx = (0,
|
|
2526
|
-
const resolve = (v) => (0,
|
|
2798
|
+
const tokenCtx = (0, import_core4.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
|
|
2799
|
+
const resolve = (v) => (0, import_core4.resolveFilterTokens)(v, tokenCtx);
|
|
2527
2800
|
const filter = resolve(compiled.filter);
|
|
2528
2801
|
const measureFilters = resolve(compiled.measureFilters);
|
|
2529
2802
|
const runtimeFilter = resolve(selection.runtimeFilter);
|
|
@@ -2559,7 +2832,7 @@ function evaluateDerivedMeasures(rows, derived) {
|
|
|
2559
2832
|
}
|
|
2560
2833
|
function fillEmptyGroups(rows, columnAggregates) {
|
|
2561
2834
|
for (const [column, aggregate2] of Object.entries(columnAggregates)) {
|
|
2562
|
-
const empty = (0,
|
|
2835
|
+
const empty = (0, import_data5.emptyGroupValueFor)(aggregate2);
|
|
2563
2836
|
if (empty === void 0) continue;
|
|
2564
2837
|
for (const row of rows) if (row[column] == null) row[column] = empty;
|
|
2565
2838
|
}
|
|
@@ -2759,7 +3032,7 @@ function bucketKeyAtOrdinal(ordinal, granularity) {
|
|
|
2759
3032
|
}
|
|
2760
3033
|
function alignedCompareBucketKey(key, granularity, kind, currentRange, shiftedRange) {
|
|
2761
3034
|
if (typeof key !== "string" || key.length === 0) return null;
|
|
2762
|
-
const span = (0,
|
|
3035
|
+
const span = (0, import_core4.bucketKeyToCalendarRange)(key, granularity);
|
|
2763
3036
|
if (!span) return null;
|
|
2764
3037
|
const targetOrdinal = kind === "previousYear" ? bucketOrdinalOfDay(shiftYear(span.start, 1), granularity) : bucketOrdinalOfDay(span.start, granularity) + (bucketOrdinalOfDay(currentRange[0], granularity) - bucketOrdinalOfDay(shiftedRange[0], granularity));
|
|
2765
3038
|
const first = bucketOrdinalOfDay(currentRange[0], granularity);
|
|
@@ -3223,18 +3496,18 @@ function pickDisplayField(fields) {
|
|
|
3223
3496
|
}
|
|
3224
3497
|
|
|
3225
3498
|
// src/preview-evaluator.ts
|
|
3226
|
-
var
|
|
3499
|
+
var import_core5 = require("@objectstack/core");
|
|
3227
3500
|
function compare(a, b) {
|
|
3228
3501
|
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
3229
3502
|
if (a instanceof Date || b instanceof Date) {
|
|
3230
|
-
const ai = (0,
|
|
3231
|
-
const bi = (0,
|
|
3503
|
+
const ai = (0, import_core5.utcInstantMs)(a);
|
|
3504
|
+
const bi = (0, import_core5.utcInstantMs)(b);
|
|
3232
3505
|
if (ai !== null && bi !== null) return ai - bi;
|
|
3233
3506
|
}
|
|
3234
3507
|
return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0;
|
|
3235
3508
|
}
|
|
3236
3509
|
function lteBound(value, bound) {
|
|
3237
|
-
const nextDay = (0,
|
|
3510
|
+
const nextDay = (0, import_core5.nextUtcCalendarDay)(bound);
|
|
3238
3511
|
if (nextDay != null) return compare(value, nextDay) < 0;
|
|
3239
3512
|
return compare(value, bound) <= 0;
|
|
3240
3513
|
}
|
|
@@ -3292,7 +3565,7 @@ function matchesWhere(row, where) {
|
|
|
3292
3565
|
function bucketDate(value, granularity, timezone) {
|
|
3293
3566
|
const d = new Date(String(value));
|
|
3294
3567
|
if (Number.isNaN(d.getTime())) return null;
|
|
3295
|
-
const { year: y, month, day: dayNum } = (0,
|
|
3568
|
+
const { year: y, month, day: dayNum } = (0, import_core5.calendarPartsInTzOrUtc)(d, timezone);
|
|
3296
3569
|
const m = `${month}`.padStart(2, "0");
|
|
3297
3570
|
const day = `${dayNum}`.padStart(2, "0");
|
|
3298
3571
|
switch (granularity) {
|
|
@@ -3346,7 +3619,7 @@ function evaluateAnalyticsQueryOverRows(query, cube, rows) {
|
|
|
3346
3619
|
const [start, end] = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
|
|
3347
3620
|
filtered = filtered.filter((r) => {
|
|
3348
3621
|
const v = String(r[field] ?? "");
|
|
3349
|
-
const nextDay = (0,
|
|
3622
|
+
const nextDay = (0, import_core5.nextUtcCalendarDay)(end);
|
|
3350
3623
|
const inUpper = nextDay != null ? v < nextDay : v <= `${end}~`;
|
|
3351
3624
|
return v >= String(start) && inUpper;
|
|
3352
3625
|
});
|
|
@@ -3480,7 +3753,7 @@ var AnalyticsService = class {
|
|
|
3480
3753
|
this.datasetRegistry = /* @__PURE__ */ new Map();
|
|
3481
3754
|
/** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */
|
|
3482
3755
|
this.warnedNoObjectRegistry = false;
|
|
3483
|
-
this.logger = config.logger || (0,
|
|
3756
|
+
this.logger = config.logger || (0, import_core6.createLogger)({ level: "info", format: "pretty" });
|
|
3484
3757
|
this.cubeRegistry = new CubeRegistry();
|
|
3485
3758
|
if (config.cubes) {
|
|
3486
3759
|
this.cubeRegistry.registerAll(config.cubes);
|
|
@@ -3494,6 +3767,7 @@ var AnalyticsService = class {
|
|
|
3494
3767
|
this.getObjectFieldNames = config.getObjectFieldNames;
|
|
3495
3768
|
this.getObjectDatasource = config.getObjectDatasource;
|
|
3496
3769
|
this.isExternalObject = config.isExternalObject;
|
|
3770
|
+
this.debugSql = config.debugSql ?? (0, import_core6.getEnv)("NODE_ENV") === "development";
|
|
3497
3771
|
if (config.datasets) {
|
|
3498
3772
|
for (const ds of config.datasets) {
|
|
3499
3773
|
try {
|
|
@@ -3611,7 +3885,7 @@ var AnalyticsService = class {
|
|
|
3611
3885
|
const strategy = this.resolveStrategy(query, ctx, skip);
|
|
3612
3886
|
this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
|
|
3613
3887
|
try {
|
|
3614
|
-
return await strategy.execute(query, ctx);
|
|
3888
|
+
return this.applySqlEchoPolicy(await strategy.execute(query, ctx));
|
|
3615
3889
|
} catch (e) {
|
|
3616
3890
|
if (e?.code === "RAW_SQL_UNSUPPORTED") {
|
|
3617
3891
|
this.logger.warn(
|
|
@@ -3624,6 +3898,30 @@ var AnalyticsService = class {
|
|
|
3624
3898
|
}
|
|
3625
3899
|
}
|
|
3626
3900
|
}
|
|
3901
|
+
/**
|
|
3902
|
+
* [#8286] Withhold the executed statement unless this host enabled the echo.
|
|
3903
|
+
*
|
|
3904
|
+
* Applied at {@link query}, which is the response-assembly seam for BOTH
|
|
3905
|
+
* faces that serve callers: `/api/v1/analytics/query` calls it directly, and
|
|
3906
|
+
* `queryDataset` reaches it through `DatasetExecutor`, so a dataset response
|
|
3907
|
+
* inherits the same verdict without a second gate to keep in step.
|
|
3908
|
+
* `generateSql` — the dedicated `/api/v1/analytics/sql` dry-run route — is
|
|
3909
|
+
* deliberately NOT gated: asking for the statement is that route's entire
|
|
3910
|
+
* purpose, and it is the surface a debugging author is meant to use.
|
|
3911
|
+
*
|
|
3912
|
+
* What the echo disclosed, and why "it is only a table name" understates it:
|
|
3913
|
+
* the statement carries the compiled read scope, i.e. the SHAPE of the
|
|
3914
|
+
* isolation predicate (`"sys_user"."id" IN ($2, $3, …)` rather than an
|
|
3915
|
+
* `organization_id` comparison) plus its bound-parameter arity, which counts
|
|
3916
|
+
* the caller's own org membership. No wall was breached by it — the echo is
|
|
3917
|
+
* information disclosure, and this is the disclosure closing.
|
|
3918
|
+
*/
|
|
3919
|
+
applySqlEchoPolicy(result) {
|
|
3920
|
+
if (this.debugSql || result?.sql === void 0) return result;
|
|
3921
|
+
const withheld = { ...result };
|
|
3922
|
+
delete withheld.sql;
|
|
3923
|
+
return withheld;
|
|
3924
|
+
}
|
|
3627
3925
|
/**
|
|
3628
3926
|
* Compile a `dataset` (ADR-0021) and register its Cube + join allowlist so it
|
|
3629
3927
|
* can be queried by name. Idempotent (re-registering overwrites). Returns the
|
|
@@ -3734,11 +4032,11 @@ var AnalyticsService = class {
|
|
|
3734
4032
|
else if (rangeTz === "UTC") rangeDims.push({ d, granularity, instant: false });
|
|
3735
4033
|
}
|
|
3736
4034
|
if (rangeDims.length && result.rows.length) {
|
|
3737
|
-
const bound = (ymd, instant) => instant ? new Date((0,
|
|
4035
|
+
const bound = (ymd, instant) => instant ? new Date((0, import_core6.zonedDateStartToUtcMs)(ymd, rangeTz)).toISOString() : ymd;
|
|
3738
4036
|
result.drillRanges = result.rows.map((row) => {
|
|
3739
4037
|
const ranges = {};
|
|
3740
4038
|
for (const { d, granularity, instant } of rangeDims) {
|
|
3741
|
-
const cal = (0,
|
|
4039
|
+
const cal = (0, import_core6.bucketKeyToCalendarRange)(row[d.name], granularity);
|
|
3742
4040
|
if (cal) {
|
|
3743
4041
|
ranges[d.name] = { field: d.field, gte: bound(cal.start, instant), lt: bound(cal.end, instant) };
|
|
3744
4042
|
}
|
|
@@ -3789,7 +4087,7 @@ var AnalyticsService = class {
|
|
|
3789
4087
|
}
|
|
3790
4088
|
}
|
|
3791
4089
|
if (f.percentScale == null) {
|
|
3792
|
-
f.percentScale = m.derived?.op === "ratio" ? "fraction" : (0,
|
|
4090
|
+
f.percentScale = m.derived?.op === "ratio" ? "fraction" : (0, import_data6.percentScaleOf)(meta);
|
|
3793
4091
|
}
|
|
3794
4092
|
}
|
|
3795
4093
|
}
|
|
@@ -4288,11 +4586,19 @@ var AnalyticsService = class {
|
|
|
4288
4586
|
return strategy;
|
|
4289
4587
|
}
|
|
4290
4588
|
}
|
|
4589
|
+
const crossField = findCrossFieldComparand(lowerAnalyticsWhereQuietly(query));
|
|
4291
4590
|
throw new Error(
|
|
4292
|
-
`[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
|
|
4591
|
+
`[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."
|
|
4293
4592
|
);
|
|
4294
4593
|
}
|
|
4295
4594
|
};
|
|
4595
|
+
function lowerAnalyticsWhereQuietly(query) {
|
|
4596
|
+
try {
|
|
4597
|
+
return lowerAnalyticsWhere(query);
|
|
4598
|
+
} catch {
|
|
4599
|
+
return null;
|
|
4600
|
+
}
|
|
4601
|
+
}
|
|
4296
4602
|
function mintableMeasureKey(member, cubeName) {
|
|
4297
4603
|
const dot = member.indexOf(".");
|
|
4298
4604
|
if (dot < 0) return member;
|
|
@@ -4588,6 +4894,11 @@ var AnalyticsServicePlugin = class {
|
|
|
4588
4894
|
coerceTemporalFilterColumn,
|
|
4589
4895
|
relationshipResolver,
|
|
4590
4896
|
labelResolver,
|
|
4897
|
+
// [#8286] Passed through as authored — `undefined` is "this host did not
|
|
4898
|
+
// choose", which the service resolves to development-only. Defaulting it
|
|
4899
|
+
// here would be a second copy of that decision, drifting the moment one
|
|
4900
|
+
// of the two moves.
|
|
4901
|
+
debugSql: this.options.debugSql,
|
|
4591
4902
|
// Source-field metadata behind the display chains on result columns:
|
|
4592
4903
|
// ADR-0053 currency (`currencyConfig.defaultCurrency`) and percent scale
|
|
4593
4904
|
// (`max`, which is what marks whole-percent storage — objectui#3136).
|