@objectstack/service-analytics 17.0.0-rc.5 → 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 +5376 -0
- package/dist/index.cjs +609 -38
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +181 -11
- package/dist/index.d.ts +181 -11
- package/dist/index.js +606 -35
- package/dist/index.js.map +1 -1
- package/package.json +7 -4
package/dist/index.cjs
CHANGED
|
@@ -41,8 +41,10 @@ __export(index_exports, {
|
|
|
41
41
|
module.exports = __toCommonJS(index_exports);
|
|
42
42
|
|
|
43
43
|
// src/analytics-service.ts
|
|
44
|
-
var
|
|
44
|
+
var import_data5 = require("@objectstack/spec/data");
|
|
45
|
+
var import_ui2 = require("@objectstack/spec/ui");
|
|
45
46
|
var import_core5 = require("@objectstack/core");
|
|
47
|
+
var import_types = require("@objectstack/types");
|
|
46
48
|
|
|
47
49
|
// src/cube-registry.ts
|
|
48
50
|
var CubeRegistry = class {
|
|
@@ -162,6 +164,84 @@ var CubeRegistry = class {
|
|
|
162
164
|
// src/strategies/filter-normalizer.ts
|
|
163
165
|
var import_data = require("@objectstack/spec/data");
|
|
164
166
|
var import_api = require("@objectstack/spec/api");
|
|
167
|
+
|
|
168
|
+
// src/comparand-shape.ts
|
|
169
|
+
function isBindableComparand(value) {
|
|
170
|
+
if (value === null || value === void 0) return true;
|
|
171
|
+
const kind = typeof value;
|
|
172
|
+
if (kind === "string" || kind === "number" || kind === "bigint" || kind === "boolean") return true;
|
|
173
|
+
return value instanceof Date || ArrayBuffer.isView(value);
|
|
174
|
+
}
|
|
175
|
+
function isRenderableTextComparand(value) {
|
|
176
|
+
if (value === null || value === void 0) return true;
|
|
177
|
+
const kind = typeof value;
|
|
178
|
+
if (kind === "string" || kind === "number" || kind === "bigint" || kind === "boolean") return true;
|
|
179
|
+
return value instanceof Date;
|
|
180
|
+
}
|
|
181
|
+
function isFieldReference(value) {
|
|
182
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
183
|
+
return typeof value.$field === "string";
|
|
184
|
+
}
|
|
185
|
+
var CROSS_FIELD_COMPARISON_OPERATORS = /* @__PURE__ */ new Set([
|
|
186
|
+
"$eq",
|
|
187
|
+
"$ne",
|
|
188
|
+
"$gt",
|
|
189
|
+
"$gte",
|
|
190
|
+
"$lt",
|
|
191
|
+
"$lte"
|
|
192
|
+
]);
|
|
193
|
+
function findCrossFieldComparand(filter) {
|
|
194
|
+
return findIn(filter, "");
|
|
195
|
+
}
|
|
196
|
+
function findIn(node, field) {
|
|
197
|
+
if (!node || typeof node !== "object") return null;
|
|
198
|
+
if (Array.isArray(node)) {
|
|
199
|
+
for (const child of node) {
|
|
200
|
+
const hit = findIn(child, field);
|
|
201
|
+
if (hit) return hit;
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
if (node instanceof Date || ArrayBuffer.isView(node)) return null;
|
|
206
|
+
for (const [key, value] of Object.entries(node)) {
|
|
207
|
+
if (CROSS_FIELD_COMPARISON_OPERATORS.has(key) && isFieldReference(value)) {
|
|
208
|
+
return { op: key, field, ref: value.$field };
|
|
209
|
+
}
|
|
210
|
+
const hit = findIn(value, key.startsWith("$") ? field : key);
|
|
211
|
+
if (hit) return hit;
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
var TEXT_PATTERN_OPERATORS = /* @__PURE__ */ new Set([
|
|
216
|
+
"$contains",
|
|
217
|
+
"$notContains",
|
|
218
|
+
"$startsWith",
|
|
219
|
+
"$endsWith",
|
|
220
|
+
"$icontains"
|
|
221
|
+
]);
|
|
222
|
+
function shapePreview(value) {
|
|
223
|
+
try {
|
|
224
|
+
const json = JSON.stringify(value);
|
|
225
|
+
if (typeof json !== "string") return typeof value;
|
|
226
|
+
return json.length > 80 ? `${json.slice(0, 77)}...` : json;
|
|
227
|
+
} catch {
|
|
228
|
+
return typeof value;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function unrenderableTextComparandMessage(op, field, value) {
|
|
232
|
+
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.`;
|
|
233
|
+
}
|
|
234
|
+
function fieldReferenceComparandMessage(op, field, ref, position) {
|
|
235
|
+
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).`;
|
|
236
|
+
}
|
|
237
|
+
function fieldReferenceBetweenBoundMessage(op, field, ref, index) {
|
|
238
|
+
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).`;
|
|
239
|
+
}
|
|
240
|
+
function unbindableListMemberMessage(op, field, value, index) {
|
|
241
|
+
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).`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// src/strategies/filter-normalizer.ts
|
|
165
245
|
function invalidFilterError(message) {
|
|
166
246
|
const err = new Error(message);
|
|
167
247
|
err.code = import_api.StandardErrorCode.enum.INVALID_FILTER;
|
|
@@ -180,7 +260,11 @@ var MONGO_TO_CUBE_OP = {
|
|
|
180
260
|
$contains: "contains",
|
|
181
261
|
$notContains: "notContains",
|
|
182
262
|
$startsWith: "startsWith",
|
|
183
|
-
$endsWith: "endsWith"
|
|
263
|
+
$endsWith: "endsWith",
|
|
264
|
+
// [#6520] The case-INSENSITIVE twin, ASCII fold only. A separate cube operator
|
|
265
|
+
// rather than a flag on `contains`, because the two compile to different SQL
|
|
266
|
+
// and one name would make the renderers guess which was meant.
|
|
267
|
+
$icontains: "icontains"
|
|
184
268
|
};
|
|
185
269
|
function comparand(v) {
|
|
186
270
|
return v === void 0 ? null : v;
|
|
@@ -203,7 +287,73 @@ function andOf(children) {
|
|
|
203
287
|
if (children.length === 1) return children[0];
|
|
204
288
|
return { kind: "and", children };
|
|
205
289
|
}
|
|
290
|
+
function assertCompilableComparand(opKey, field, value) {
|
|
291
|
+
if (TEXT_PATTERN_OPERATORS.has(opKey)) {
|
|
292
|
+
if (!isRenderableTextComparand(value)) {
|
|
293
|
+
throw invalidFilterError(`[analytics] ${unrenderableTextComparandMessage(opKey, field, value)}`);
|
|
294
|
+
}
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if ((opKey === "$in" || opKey === "$nin") && Array.isArray(value)) {
|
|
298
|
+
value.forEach((member, index) => {
|
|
299
|
+
if (!isBindableComparand(member)) {
|
|
300
|
+
throw invalidFilterError(`[analytics] ${unbindableListMemberMessage(opKey, field, member, index)}`);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
function assertNoFieldReferenceComparand(opKey, field, value) {
|
|
306
|
+
if (opKey !== "$between" || !Array.isArray(value)) return;
|
|
307
|
+
value.forEach((member, index) => {
|
|
308
|
+
if (!isFieldReference(member)) return;
|
|
309
|
+
throw invalidFilterError(
|
|
310
|
+
`[analytics] ${fieldReferenceBetweenBoundMessage(opKey, field, member.$field, index)}`
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
function undefinedComparandError(field, path) {
|
|
315
|
+
return invalidFilterError(
|
|
316
|
+
`[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).`
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
function assertDefinedComparands(field, spec) {
|
|
320
|
+
const root = `"${field}"`;
|
|
321
|
+
if (spec === void 0) throw undefinedComparandError(field, root);
|
|
322
|
+
if (Array.isArray(spec)) {
|
|
323
|
+
spec.forEach((member, index) => {
|
|
324
|
+
if (member === void 0) throw undefinedComparandError(field, `${root}[${index}]`);
|
|
325
|
+
});
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (!isFilterObject(spec)) return;
|
|
329
|
+
for (const [op, opValue] of Object.entries(spec)) {
|
|
330
|
+
if (!op.startsWith("$") || op === "$null" || op === "$exists") continue;
|
|
331
|
+
const opPath = `${root}.${op}`;
|
|
332
|
+
if (opValue === void 0) throw undefinedComparandError(field, opPath);
|
|
333
|
+
if (!Array.isArray(opValue)) continue;
|
|
334
|
+
opValue.forEach((member, index) => {
|
|
335
|
+
if (member === void 0) throw undefinedComparandError(field, `${opPath}[${index}]`);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
function mixedFieldWrapperError(field, opKeys, nonOpKeys) {
|
|
340
|
+
const offending = nonOpKeys.map((k) => `"${k}"`).join(", ");
|
|
341
|
+
const rewrites = nonOpKeys.map((k) => `"${k}" \u2192 "$${k}"`).join(", ");
|
|
342
|
+
const example = nonOpKeys[0];
|
|
343
|
+
return invalidFilterError(
|
|
344
|
+
`[analytics] "${field}" mixes $-operator keys (${opKeys.join(", ")}) with non-$ sibling key(s) ${offending} in ONE field constraint \u2014 refusing to compile this filter. A $-prefixed key is an OPERATOR and a bare key is a NESTED-RELATION member; the two readings of ${offending} lead to different predicates and this module cannot tell which was meant, so any silent choice is a guess. If an operator missing its "$" was meant \u2014 the usual authoring slip \u2014 spell it with the prefix: ${rewrites}, as in { "${field}": { "$${example}": ... } }. If a nested-relation member was meant, give it a wrapper of its OWN with no $ siblings \u2014 { "${field}": { "${example}": ... } } compiles to the member "${field}.${example}" \u2014 and AND it with the operator constraint explicitly: { "$and": [{ "${field}": { "$op": ... } }, { "${field}": { "${example}": ... } }] }. This shape used to compile by silently DROPPING every non-$ sibling, and a dropped conjunct does not narrow the query, it WIDENS it: the chart included rows the author excluded, with nothing to read (#3650's failure mode, which this module refuses everywhere else). The sibling door in this package (read-scope-sql.ts) already fails closed on this exact shape \u2014 one shape, one answer (#6444).`
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
function assertUnmixedFieldWrapper(field, wrapper) {
|
|
348
|
+
const keys = Object.keys(wrapper);
|
|
349
|
+
const opKeys = keys.filter((k) => k.startsWith("$"));
|
|
350
|
+
if (opKeys.length === 0) return;
|
|
351
|
+
const nonOpKeys = keys.filter((k) => !k.startsWith("$"));
|
|
352
|
+
if (nonOpKeys.length === 0) return;
|
|
353
|
+
throw mixedFieldWrapperError(field, opKeys, nonOpKeys);
|
|
354
|
+
}
|
|
206
355
|
function fieldLeaves(key, raw) {
|
|
356
|
+
assertDefinedComparands(key, raw);
|
|
207
357
|
const out = [];
|
|
208
358
|
const leaf = (operator, values) => {
|
|
209
359
|
out.push({ kind: "leaf", member: key, operator, values });
|
|
@@ -219,6 +369,7 @@ function fieldLeaves(key, raw) {
|
|
|
219
369
|
`[analytics] "${key}" carries a field constraint with zero operators ({}). Refusing rather than reading it as "every row" or "no row" \u2014 #5240 ruled this shape refused on every backend.`
|
|
220
370
|
);
|
|
221
371
|
}
|
|
372
|
+
assertUnmixedFieldWrapper(key, wrapper);
|
|
222
373
|
const opKeys = Object.keys(wrapper).filter((k) => k.startsWith("$"));
|
|
223
374
|
if (opKeys.length > 0) {
|
|
224
375
|
for (const opKey of opKeys) {
|
|
@@ -229,6 +380,7 @@ function fieldLeaves(key, raw) {
|
|
|
229
380
|
`[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.`
|
|
230
381
|
);
|
|
231
382
|
}
|
|
383
|
+
assertNoFieldReferenceComparand(opKey, key, v2);
|
|
232
384
|
leaf("gte", [comparand(v2[0])]);
|
|
233
385
|
leaf("lte", [comparand(v2[1])]);
|
|
234
386
|
continue;
|
|
@@ -253,6 +405,7 @@ function fieldLeaves(key, raw) {
|
|
|
253
405
|
);
|
|
254
406
|
}
|
|
255
407
|
const v = wrapper[opKey];
|
|
408
|
+
assertCompilableComparand(opKey, key, v);
|
|
256
409
|
const values = Array.isArray(v) ? v.map(comparand) : [comparand(v)];
|
|
257
410
|
if (nullValueSatisfiesOperator(opKey, v) && !operatorIsNullTotal(opKey, v)) {
|
|
258
411
|
out.push({
|
|
@@ -282,7 +435,6 @@ function fieldLeaves(key, raw) {
|
|
|
282
435
|
function buildNode(cond) {
|
|
283
436
|
const children = [];
|
|
284
437
|
for (const [key, raw] of Object.entries(cond)) {
|
|
285
|
-
if (raw === void 0) continue;
|
|
286
438
|
if (key === "$and" || key === "$or") {
|
|
287
439
|
if (!Array.isArray(raw)) {
|
|
288
440
|
throw invalidFilterError(
|
|
@@ -356,6 +508,7 @@ function nullValueSatisfiesOperator(op, value) {
|
|
|
356
508
|
}
|
|
357
509
|
}
|
|
358
510
|
function operatorIsNullTotal(op, value) {
|
|
511
|
+
if (CROSS_FIELD_COMPARISON_OPERATORS.has(op) && isFieldReference(value)) return true;
|
|
359
512
|
switch (op) {
|
|
360
513
|
// Compile to `set` / `notSet` — `IS NULL` / `IS NOT NULL`, two-valued by
|
|
361
514
|
// construction, on every strategy that compiles this tree.
|
|
@@ -498,6 +651,11 @@ function likePattern(shape, value) {
|
|
|
498
651
|
const escaped = escapeLikePattern(value);
|
|
499
652
|
return shape === "starts" ? `${escaped}%` : shape === "ends" ? `%${escaped}` : `%${escaped}%`;
|
|
500
653
|
}
|
|
654
|
+
var ASCII_UPPER_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
655
|
+
var ASCII_LOWER_LETTERS = "abcdefghijklmnopqrstuvwxyz";
|
|
656
|
+
function asciiLowerSqlExpr(expr) {
|
|
657
|
+
return `translate(${expr}, '${ASCII_UPPER_LETTERS}', '${ASCII_LOWER_LETTERS}')`;
|
|
658
|
+
}
|
|
501
659
|
|
|
502
660
|
// src/read-scope-sql.ts
|
|
503
661
|
var IDENT = /^[a-z_][a-z0-9_]*$/i;
|
|
@@ -569,6 +727,9 @@ function compileNode(node, qAlias, params) {
|
|
|
569
727
|
}
|
|
570
728
|
function compileField(field, value, qAlias, params) {
|
|
571
729
|
const col = `${qAlias}.${quoteIdent(field, "field")}`;
|
|
730
|
+
assertDefinedComparands2(field, value);
|
|
731
|
+
assertBooleanFlagComparands(field, value);
|
|
732
|
+
assertNoFieldReferenceComparand2(field, value);
|
|
572
733
|
if (value === null) return `${col} IS NULL`;
|
|
573
734
|
if (typeof value !== "object" || value instanceof Date) {
|
|
574
735
|
params.push(value);
|
|
@@ -598,6 +759,66 @@ function bindLike(params, pattern) {
|
|
|
598
759
|
function nullSafeNegative(col, test) {
|
|
599
760
|
return `(${col} IS NULL OR ${test})`;
|
|
600
761
|
}
|
|
762
|
+
function assertCompilableMembers(op, field, members) {
|
|
763
|
+
members.forEach((member, index) => {
|
|
764
|
+
if (!isBindableComparand(member)) {
|
|
765
|
+
throw readScopeCompileError(`[read-scope-sql] ${unbindableListMemberMessage(op, field, member, index)}`);
|
|
766
|
+
}
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
function assertRenderableText(op, field, val) {
|
|
770
|
+
if (isRenderableTextComparand(val)) return;
|
|
771
|
+
throw readScopeCompileError(`[read-scope-sql] ${unrenderableTextComparandMessage(op, field, val)}`);
|
|
772
|
+
}
|
|
773
|
+
function undefinedComparandError2(field, path) {
|
|
774
|
+
return readScopeCompileError(
|
|
775
|
+
`[read-scope-sql] comparand at ${path} is undefined \u2014 refusing to build read scope (fail-closed). @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: undefined went into the bind list, the driver read it as SQL NULL, every comparison against NULL is UNKNOWN, and the scope matched ZERO rows in silence. Write null if the null predicate was meant ({ "${field}": null } or { "${field}": { "$null": true } }), or omit the key when the value is genuinely absent. The producer to fix is whoever BUILT this read scope \u2014 an admin-authored sharing rule / permission set, its CEL lowering, or the in-process code that assembled the FilterCondition \u2014 never the caller of this query, who cannot author it (#6050 ruling B, pushed down to this compiler by #6125).`
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
function assertDefinedComparands2(field, spec) {
|
|
779
|
+
const root = `"${field}"`;
|
|
780
|
+
if (spec === void 0) throw undefinedComparandError2(field, root);
|
|
781
|
+
if (!isFilterNode(spec)) return;
|
|
782
|
+
for (const [op, opValue] of Object.entries(spec)) {
|
|
783
|
+
if (!op.startsWith("$") || op === "$null" || op === "$exists") continue;
|
|
784
|
+
const opPath = `${root}.${op}`;
|
|
785
|
+
if (opValue === void 0) throw undefinedComparandError2(field, opPath);
|
|
786
|
+
if (!Array.isArray(opValue)) continue;
|
|
787
|
+
opValue.forEach((member, index) => {
|
|
788
|
+
if (member === void 0) throw undefinedComparandError2(field, `${opPath}[${index}]`);
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
function nonBooleanFlagComparandError(op, field, path) {
|
|
793
|
+
return readScopeCompileError(
|
|
794
|
+
`[read-scope-sql] comparand for "${op}" at ${path} is not a boolean \u2014 refusing to build read scope (fail-closed). @objectstack/spec FieldOperatorsSchema declares both $null and $exists as z.boolean(), and this compiler used to read the comparand by TRUTHINESS instead \u2014 so a non-boolean was silently sorted into one of the two declared answers rather than refused. The string "false" is TRUTHY, which is the case that matters: it landed on the side OPPOSITE the false it was written to mean, turning "rows with no ${field}" into "rows that have one" \u2014 a read scope that ADMITS the rows the policy excludes. Write the boolean itself (true or false), not a string, a number, null or undefined. The producer to fix is whoever BUILT this read scope \u2014 an admin-authored sharing rule / permission set, its CEL lowering, or the in-process code (a getReadScope option) that assembled the FilterCondition \u2014 never the caller of this query, who cannot author it (#5347 / #5369, pushed down to this compiler by #6387).`
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
function assertBooleanFlagComparands(field, spec) {
|
|
798
|
+
if (!isFilterNode(spec)) return;
|
|
799
|
+
for (const op of ["$null", "$exists"]) {
|
|
800
|
+
if (!Object.prototype.hasOwnProperty.call(spec, op)) continue;
|
|
801
|
+
if (typeof spec[op] === "boolean") continue;
|
|
802
|
+
throw nonBooleanFlagComparandError(op, field, `"${field}".${op}`);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
function assertNoFieldReferenceComparand2(field, spec) {
|
|
806
|
+
if (!isFilterNode(spec)) return;
|
|
807
|
+
for (const [op, opValue] of Object.entries(spec)) {
|
|
808
|
+
if (CROSS_FIELD_COMPARISON_OPERATORS.has(op) && isFieldReference(opValue)) {
|
|
809
|
+
throw readScopeCompileError(
|
|
810
|
+
`[read-scope-sql] ${fieldReferenceComparandMessage(op, field, opValue.$field)}`
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
if (op !== "$between" || !Array.isArray(opValue)) continue;
|
|
814
|
+
opValue.forEach((member, index) => {
|
|
815
|
+
if (!isFieldReference(member)) return;
|
|
816
|
+
throw readScopeCompileError(
|
|
817
|
+
`[read-scope-sql] ${fieldReferenceBetweenBoundMessage(op, field, member.$field, index)}`
|
|
818
|
+
);
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
}
|
|
601
822
|
function compileOperator(col, op, val, field, params) {
|
|
602
823
|
switch (op) {
|
|
603
824
|
case "$eq":
|
|
@@ -617,33 +838,70 @@ function compileOperator(col, op, val, field, params) {
|
|
|
617
838
|
case "$in": {
|
|
618
839
|
if (!Array.isArray(val)) throw readScopeCompileError(`[read-scope-sql] $in for "${field}" needs an array (fail-closed).`);
|
|
619
840
|
if (val.length === 0) return FALSE_CLAUSE;
|
|
841
|
+
assertCompilableMembers(op, field, val);
|
|
620
842
|
return `${col} IN (${val.map((v) => bind(params, v)).join(", ")})`;
|
|
621
843
|
}
|
|
622
844
|
case "$nin": {
|
|
623
845
|
if (!Array.isArray(val)) throw readScopeCompileError(`[read-scope-sql] $nin for "${field}" needs an array (fail-closed).`);
|
|
624
846
|
if (val.length === 0) return "1 = 1";
|
|
847
|
+
assertCompilableMembers(op, field, val);
|
|
625
848
|
return nullSafeNegative(col, `${col} NOT IN (${val.map((v) => bind(params, v)).join(", ")})`);
|
|
626
849
|
}
|
|
627
850
|
case "$between": {
|
|
628
851
|
if (!Array.isArray(val) || val.length !== 2) throw readScopeCompileError(`[read-scope-sql] $between for "${field}" needs [min,max] (fail-closed).`);
|
|
852
|
+
assertCompilableMembers(op, field, val);
|
|
629
853
|
return `${col} BETWEEN ${bind(params, val[0])} AND ${bind(params, val[1])}`;
|
|
630
854
|
}
|
|
631
855
|
// [#5567] The comparand is a LITERAL, so it is escaped and the escape
|
|
632
856
|
// character is bound with it. See {@link bindLike}.
|
|
857
|
+
// [#5234] …and it must be a value `String()` can render, which is asserted
|
|
858
|
+
// BEFORE `likePattern` sees it — see {@link assertRenderableText}.
|
|
633
859
|
case "$contains":
|
|
860
|
+
assertRenderableText(op, field, val);
|
|
634
861
|
return `${col} LIKE ${bindLike(params, likePattern("contains", val))}`;
|
|
862
|
+
/**
|
|
863
|
+
* [#6520] `$icontains` on the READ-SCOPE lowering — the one compiler in this
|
|
864
|
+
* package where a wrong answer is an ADR-0021 scope over-reach rather than a
|
|
865
|
+
* loose chart filter, which is why the fold is the spec's ruled one and not
|
|
866
|
+
* `LOWER()`.
|
|
867
|
+
*
|
|
868
|
+
* `assertRenderableText` first, exactly as its case-exact twin above: the
|
|
869
|
+
* comparand has to be something `String()` renders faithfully before a
|
|
870
|
+
* pattern is built from it (#5234).
|
|
871
|
+
*
|
|
872
|
+
* The fold wraps BOTH the column and the bound pattern. Folding one side
|
|
873
|
+
* only would compare a folded needle against a raw column — matching just
|
|
874
|
+
* the rows already lower-case — and on a read scope that is a row set the
|
|
875
|
+
* policy author never wrote, in the narrowing direction here but in the
|
|
876
|
+
* WIDENING direction under a `$not`.
|
|
877
|
+
*/
|
|
878
|
+
case "$icontains": {
|
|
879
|
+
assertRenderableText(op, field, val);
|
|
880
|
+
const patternRef = asciiLowerSqlExpr(bind(params, likePattern("contains", val)));
|
|
881
|
+
return `${asciiLowerSqlExpr(col)} LIKE ${patternRef} ESCAPE ${bind(params, LIKE_ESCAPE_CHAR)}`;
|
|
882
|
+
}
|
|
635
883
|
// [#5298] NULL-safe: `NOT LIKE` is UNKNOWN for a NULL column, and "does not
|
|
636
884
|
// contain" is true of a value that is not there.
|
|
637
885
|
case "$notContains":
|
|
886
|
+
assertRenderableText(op, field, val);
|
|
638
887
|
return nullSafeNegative(col, `${col} NOT LIKE ${bindLike(params, likePattern("contains", val))}`);
|
|
639
888
|
case "$startsWith":
|
|
889
|
+
assertRenderableText(op, field, val);
|
|
640
890
|
return `${col} LIKE ${bindLike(params, likePattern("starts", val))}`;
|
|
641
891
|
case "$endsWith":
|
|
892
|
+
assertRenderableText(op, field, val);
|
|
642
893
|
return `${col} LIKE ${bindLike(params, likePattern("ends", val))}`;
|
|
894
|
+
// [#6387] `val` is a boolean here — {@link assertBooleanFlagComparands}
|
|
895
|
+
// refused anything else at {@link compileField}, before this emitter runs.
|
|
896
|
+
// So `=== true` is an exhaustive TWO-WAY choice over the declared domain,
|
|
897
|
+
// not the "anything truthy is IS NULL" rule it used to be. That old rule is
|
|
898
|
+
// what put the STRING `"false"` on the side opposite the `false` it was
|
|
899
|
+
// written to mean; the identity spelling cannot, and it is the spelling
|
|
900
|
+
// {@link nullValueSatisfiesOperator} now mirrors (#5146 / #5298).
|
|
643
901
|
case "$null":
|
|
644
|
-
return val ? `${col} IS NULL` : `${col} IS NOT NULL`;
|
|
902
|
+
return val === true ? `${col} IS NULL` : `${col} IS NOT NULL`;
|
|
645
903
|
case "$exists":
|
|
646
|
-
return val ? `${col} IS NOT NULL` : `${col} IS NULL`;
|
|
904
|
+
return val === true ? `${col} IS NOT NULL` : `${col} IS NULL`;
|
|
647
905
|
default:
|
|
648
906
|
throw readScopeCompileError(`[read-scope-sql] unsupported operator "${op}" on "${field}" (fail-closed).`);
|
|
649
907
|
}
|
|
@@ -656,11 +914,22 @@ function nullValueSatisfiesOperator2(op, value) {
|
|
|
656
914
|
// Mirror image: `$ne: null` compiles to `IS NOT NULL`, which a NULL fails.
|
|
657
915
|
case "$ne":
|
|
658
916
|
return value !== null;
|
|
659
|
-
//
|
|
917
|
+
// [#6387] Identity, matching this file's emitter (see the note above).
|
|
918
|
+
// `assertBooleanFlagComparands` refuses anything but `true` / `false` before
|
|
919
|
+
// this table is consulted, so each arm is an exhaustive TWO-WAY choice over
|
|
920
|
+
// the declared domain — and the strict spelling is chosen over the lenient
|
|
921
|
+
// one it replaces for the reason #5347 gave: `Boolean(value)` and
|
|
922
|
+
// `value === true` are equivalent only while the gate upstream holds, and
|
|
923
|
+
// the lenient spelling would quietly resume answering for shapes nobody
|
|
924
|
+
// ruled on if that gate were ever moved. A NULL column satisfies `$null`
|
|
925
|
+
// exactly when the author asked for null…
|
|
660
926
|
case "$null":
|
|
661
|
-
return
|
|
927
|
+
return value === true;
|
|
928
|
+
// …and satisfies `$exists` exactly when the author asked for "no value".
|
|
929
|
+
// `$null: true` and `$exists: false` are the same question, so these two
|
|
930
|
+
// arms are correctly each other's MIRROR, not each other's copy (#5369).
|
|
662
931
|
case "$exists":
|
|
663
|
-
return
|
|
932
|
+
return value === false;
|
|
664
933
|
// Negative-polarity set / substring tests hold vacuously for an absent value.
|
|
665
934
|
case "$nin":
|
|
666
935
|
return true;
|
|
@@ -781,9 +1050,82 @@ var NativeSQLStrategy = class {
|
|
|
781
1050
|
}
|
|
782
1051
|
}
|
|
783
1052
|
}
|
|
1053
|
+
if (this.carriesCrossFieldComparison(query, ctx)) return false;
|
|
784
1054
|
const caps = ctx.queryCapabilities(query.cube);
|
|
785
1055
|
return caps.nativeSql && typeof ctx.executeRawSql === "function";
|
|
786
1056
|
}
|
|
1057
|
+
/**
|
|
1058
|
+
* [#7598] Does serving this query require the cross-field capability this
|
|
1059
|
+
* strategy declines? See the ruling recorded at {@link canHandle}.
|
|
1060
|
+
*
|
|
1061
|
+
* ⚠️ This and {@link assertNoCrossFieldComparison} read the SAME inputs
|
|
1062
|
+
* through the SAME detector, which is what makes the decline and the
|
|
1063
|
+
* fail-closed backstop unable to drift: a shape one of them recognises is a
|
|
1064
|
+
* shape the other recognises.
|
|
1065
|
+
*/
|
|
1066
|
+
carriesCrossFieldComparison(query, ctx) {
|
|
1067
|
+
return this.crossFieldComparisonIn(query, ctx) !== null;
|
|
1068
|
+
}
|
|
1069
|
+
crossFieldComparisonIn(query, ctx) {
|
|
1070
|
+
let where = null;
|
|
1071
|
+
try {
|
|
1072
|
+
where = lowerAnalyticsWhere(query);
|
|
1073
|
+
} catch {
|
|
1074
|
+
return null;
|
|
1075
|
+
}
|
|
1076
|
+
const inWhere = findCrossFieldComparand(where);
|
|
1077
|
+
if (inWhere) return { source: "the query's `where`", ...inWhere };
|
|
1078
|
+
if (typeof ctx.getReadScope !== "function") return null;
|
|
1079
|
+
const cube = query.cube ? ctx.getCube(query.cube) : void 0;
|
|
1080
|
+
if (!cube) return null;
|
|
1081
|
+
const objects = [this.extractObjectName(cube)];
|
|
1082
|
+
for (const alias of Object.keys(cube.joins ?? {})) {
|
|
1083
|
+
objects.push(cube.joins?.[alias]?.name ?? alias);
|
|
1084
|
+
}
|
|
1085
|
+
for (const objectName of objects) {
|
|
1086
|
+
const scope = ctx.getReadScope(objectName);
|
|
1087
|
+
if (scope === void 0 || scope === null) continue;
|
|
1088
|
+
const inScope = findCrossFieldComparand(scope);
|
|
1089
|
+
if (inScope) return { source: `the read scope of "${objectName}"`, ...inScope };
|
|
1090
|
+
}
|
|
1091
|
+
return null;
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* [#7598] The fail-closed backstop at the door that BINDS.
|
|
1095
|
+
*
|
|
1096
|
+
* ⚠️ **Unreachable by construction, and kept deliberately** — saying so
|
|
1097
|
+
* because #7598's brief asks that a refusal arm which has become unreachable
|
|
1098
|
+
* be named rather than left to be re-discovered. {@link canHandle} declines
|
|
1099
|
+
* every query this would fire on, and it declines using
|
|
1100
|
+
* {@link crossFieldComparisonIn} — the same walk over the same two inputs —
|
|
1101
|
+
* so `resolveStrategy` cannot hand this strategy a query carrying one.
|
|
1102
|
+
*
|
|
1103
|
+
* It is kept because of what the failure mode is if that ever stops being
|
|
1104
|
+
* true. The defect #7598 measured was not a missing error: it was a SILENT
|
|
1105
|
+
* BIND — `toSqlBindValue` JSON-stringifies the reference object, so the
|
|
1106
|
+
* statement compiled perfectly and compared a column against the text
|
|
1107
|
+
* `{"$field":"budget"}`, a value no row can hold. A routing gate that misses
|
|
1108
|
+
* a shape therefore degrades to a wrong ANSWER rather than to an error, and
|
|
1109
|
+
* that is the one class this package refuses to leave to a single guard
|
|
1110
|
+
* (Prime Directive #12 — refuse at the door, do not tolerate at the
|
|
1111
|
+
* consumer). One line, no measurable cost, and it turns a routing regression
|
|
1112
|
+
* into a loud refusal instead of an empty chart.
|
|
1113
|
+
*
|
|
1114
|
+
* Deliberately BARE — an undeclared 500, not `INVALID_FILTER` / 400 — for the
|
|
1115
|
+
* reason `buildFilterClauseSql`'s #5333 exit in `objectql-strategy.ts` gives
|
|
1116
|
+
* for the same class: the caller's filter is legal and is served on the
|
|
1117
|
+
* engine path, so an arrival here is drift between our own routing gate and
|
|
1118
|
+
* our own emitter. Billing the caller 400 for that would hide a platform bug
|
|
1119
|
+
* from 5xx alerting and tell a dashboard user to fix a filter that is fine.
|
|
1120
|
+
* Same tier as `resolveMeasureSql`'s unrecognised-`Metric.type` throw below.
|
|
1121
|
+
*/
|
|
1122
|
+
assertNoCrossFieldComparison(query, ctx) {
|
|
1123
|
+
const hit = this.crossFieldComparisonIn(query, ctx);
|
|
1124
|
+
if (!hit) return;
|
|
1125
|
+
throw new Error(
|
|
1126
|
+
`[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.`
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
787
1129
|
async execute(query, ctx) {
|
|
788
1130
|
const { sql, params } = await this.generateSql(query, ctx);
|
|
789
1131
|
const cube = ctx.getCube(query.cube);
|
|
@@ -797,6 +1139,7 @@ var NativeSQLStrategy = class {
|
|
|
797
1139
|
if (!cube) {
|
|
798
1140
|
throw new Error(`Cube not found: ${query.cube}`);
|
|
799
1141
|
}
|
|
1142
|
+
this.assertNoCrossFieldComparison(query, ctx);
|
|
800
1143
|
const params = [];
|
|
801
1144
|
const selectClauses = [];
|
|
802
1145
|
const groupByClauses = [];
|
|
@@ -1166,13 +1509,19 @@ var NativeSQLStrategy = class {
|
|
|
1166
1509
|
contains: "LIKE",
|
|
1167
1510
|
notContains: "NOT LIKE",
|
|
1168
1511
|
startsWith: "LIKE",
|
|
1169
|
-
endsWith: "LIKE"
|
|
1512
|
+
endsWith: "LIKE",
|
|
1513
|
+
// [#6520] `$icontains` — `LIKE` like its neighbours; what separates it is
|
|
1514
|
+
// the ASCII fold applied below, not the keyword.
|
|
1515
|
+
icontains: "LIKE"
|
|
1170
1516
|
};
|
|
1171
1517
|
const likeShape = {
|
|
1172
1518
|
contains: "contains",
|
|
1173
1519
|
notContains: "contains",
|
|
1174
1520
|
startsWith: "starts",
|
|
1175
|
-
endsWith: "ends"
|
|
1521
|
+
endsWith: "ends",
|
|
1522
|
+
// [#6520] Same wildcard placement as `contains`; the case fold is what
|
|
1523
|
+
// differs, and it is applied to both sides of the comparison below.
|
|
1524
|
+
icontains: "contains"
|
|
1176
1525
|
};
|
|
1177
1526
|
if (operator === "set") return `${rawCol} IS NOT NULL`;
|
|
1178
1527
|
if (operator === "notSet") return `${rawCol} IS NULL`;
|
|
@@ -1191,6 +1540,9 @@ var NativeSQLStrategy = class {
|
|
|
1191
1540
|
params.push(likePattern(shape, values[0]));
|
|
1192
1541
|
const patternRef = `$${params.length}`;
|
|
1193
1542
|
params.push(LIKE_ESCAPE_CHAR);
|
|
1543
|
+
if (operator === "icontains") {
|
|
1544
|
+
return `${asciiLowerSqlExpr(rawCol)} ${sqlOp} ${asciiLowerSqlExpr(patternRef)} ESCAPE $${params.length}`;
|
|
1545
|
+
}
|
|
1194
1546
|
return `${rawCol} ${sqlOp} ${patternRef} ESCAPE $${params.length}`;
|
|
1195
1547
|
}
|
|
1196
1548
|
if (operator === "lte") {
|
|
@@ -1224,6 +1576,7 @@ var NativeSQLStrategy = class {
|
|
|
1224
1576
|
};
|
|
1225
1577
|
|
|
1226
1578
|
// src/strategies/objectql-strategy.ts
|
|
1579
|
+
var import_data2 = require("@objectstack/spec/data");
|
|
1227
1580
|
var import_core2 = require("@objectstack/core");
|
|
1228
1581
|
|
|
1229
1582
|
// src/strategies/cross-object-rebucket.ts
|
|
@@ -1294,7 +1647,12 @@ var LIKE_SQL_OPS = {
|
|
|
1294
1647
|
contains: { sql: "LIKE", shape: "contains" },
|
|
1295
1648
|
notContains: { sql: "NOT LIKE", shape: "contains" },
|
|
1296
1649
|
startsWith: { sql: "LIKE", shape: "starts" },
|
|
1297
|
-
endsWith: { sql: "LIKE", shape: "ends" }
|
|
1650
|
+
endsWith: { sql: "LIKE", shape: "ends" },
|
|
1651
|
+
// [#6520] `$icontains`: the same escaped pattern and bound `ESCAPE` as its
|
|
1652
|
+
// four case-EXACT neighbours, with `fold` adding the ASCII-only case fold to
|
|
1653
|
+
// both sides of the comparison. The flag is on this row alone — the family
|
|
1654
|
+
// above it is case-sensitive by ruling (#4706 Q2 = A).
|
|
1655
|
+
icontains: { sql: "LIKE", shape: "contains", fold: true }
|
|
1298
1656
|
};
|
|
1299
1657
|
var ObjectQLStrategy = class {
|
|
1300
1658
|
constructor() {
|
|
@@ -1405,6 +1763,12 @@ var ObjectQLStrategy = class {
|
|
|
1405
1763
|
if (!cube) {
|
|
1406
1764
|
throw new Error(`Cube not found: ${query.cube}`);
|
|
1407
1765
|
}
|
|
1766
|
+
const crossField = findCrossFieldComparand(this.loweredWhere(query));
|
|
1767
|
+
if (crossField) {
|
|
1768
|
+
throw invalidFilterError(
|
|
1769
|
+
`[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.`
|
|
1770
|
+
);
|
|
1771
|
+
}
|
|
1408
1772
|
const selectParts = [];
|
|
1409
1773
|
const groupByParts = [];
|
|
1410
1774
|
const params = [];
|
|
@@ -1509,11 +1873,11 @@ var ObjectQLStrategy = class {
|
|
|
1509
1873
|
* predicate. `$and` makes that structurally impossible.
|
|
1510
1874
|
*/
|
|
1511
1875
|
withReadScope(objectName, filter, ctx) {
|
|
1512
|
-
const userFilter = Object.keys(filter).length > 0 ? filter : void 0;
|
|
1876
|
+
const userFilter = Object.keys(filter).length > 0 ? (0, import_data2.markFilterSubtreeProvenance)(filter, "author") : void 0;
|
|
1513
1877
|
if (typeof ctx.getReadScope !== "function") return userFilter;
|
|
1514
1878
|
const scope = ctx.getReadScope(objectName);
|
|
1515
1879
|
if (scope === void 0 || scope === null) return userFilter;
|
|
1516
|
-
const scopeFilter = scope;
|
|
1880
|
+
const scopeFilter = (0, import_data2.markFilterSubtreeProvenance)(scope, "policy");
|
|
1517
1881
|
if (!userFilter) return scopeFilter;
|
|
1518
1882
|
return { $and: [userFilter, scopeFilter] };
|
|
1519
1883
|
}
|
|
@@ -1687,6 +2051,7 @@ var ObjectQLStrategy = class {
|
|
|
1687
2051
|
if (fkValues.length === 0 || typeof ctx.executeAggregate !== "function") return map;
|
|
1688
2052
|
const idFilter = { id: { $in: fkValues } };
|
|
1689
2053
|
const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
|
|
2054
|
+
if (scope != null) (0, import_data2.markFilterSubtreeProvenance)(scope, "policy");
|
|
1690
2055
|
const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
|
|
1691
2056
|
const rows = await ctx.executeAggregate(refObject, {
|
|
1692
2057
|
groupBy: ["id", attr],
|
|
@@ -1735,7 +2100,9 @@ var ObjectQLStrategy = class {
|
|
|
1735
2100
|
params.push(likePattern(like.shape, values[0]));
|
|
1736
2101
|
const patternRef = `$${params.length}`;
|
|
1737
2102
|
params.push(LIKE_ESCAPE_CHAR);
|
|
1738
|
-
|
|
2103
|
+
const lhs = like.fold ? asciiLowerSqlExpr(col) : col;
|
|
2104
|
+
const rhs = like.fold ? asciiLowerSqlExpr(patternRef) : patternRef;
|
|
2105
|
+
return `${lhs} ${like.sql} ${rhs} ESCAPE $${params.length}`;
|
|
1739
2106
|
}
|
|
1740
2107
|
const op = SCALAR_SQL_OPS[operator];
|
|
1741
2108
|
if (!op) {
|
|
@@ -2021,6 +2388,14 @@ var ObjectQLStrategy = class {
|
|
|
2021
2388
|
* string — `String(…)`, the same normalisation `like-pattern.ts` applies at the
|
|
2022
2389
|
* two SQL emitters and `driver-sql`'s `applyLike` applies at the driver, so one
|
|
2023
2390
|
* `$contains` means one thing on every face (#5567's invariant).
|
|
2391
|
+
*
|
|
2392
|
+
* [#5234] Those four `String(…)` calls now only ever see a value that renders
|
|
2393
|
+
* faithfully: `fieldLeaves` refuses an object comparand on this family before a
|
|
2394
|
+
* leaf exists. That ordering is load-bearing rather than incidental — this arm
|
|
2395
|
+
* is a PRODUCER for the engine, so stringifying an object here would have
|
|
2396
|
+
* laundered it into `'[object Object]'` and handed a driver a perfectly
|
|
2397
|
+
* well-typed string. A strict driver downstream could never have seen the shape
|
|
2398
|
+
* it was strict about, which is why the guard sits at the door and not here.
|
|
2024
2399
|
*/
|
|
2025
2400
|
convertFilter(operator, values) {
|
|
2026
2401
|
if (operator === "set") return { $ne: null };
|
|
@@ -2029,8 +2404,27 @@ var ObjectQLStrategy = class {
|
|
|
2029
2404
|
const v0 = values[0];
|
|
2030
2405
|
const all = [...values];
|
|
2031
2406
|
switch (operator) {
|
|
2407
|
+
// [#7598] IMPLICIT equality for a literal, EXPLICIT `$eq` for a field
|
|
2408
|
+
// reference — the branch is on the COMPARAND, not on the operator, which
|
|
2409
|
+
// is the same fix and the same reasoning #7597 applied to
|
|
2410
|
+
// `parseFilterAST`, the spec's own lowering sink.
|
|
2411
|
+
//
|
|
2412
|
+
// `{ amount: 5 }` is implicit equality and every backend reads it that
|
|
2413
|
+
// way. `{ amount: { $field: 'budget' } }` is NOT: it is a field-spec
|
|
2414
|
+
// object whose only key is `$field`, which no backend reads as an
|
|
2415
|
+
// equality — `driver-sql` sees an unrecognised operator key and the
|
|
2416
|
+
// memory evaluator sees a comparand it never resolves. So the bare return
|
|
2417
|
+
// was correct for four years' worth of literals and silently wrong for
|
|
2418
|
+
// the one comparand the 2026-08-12 ruling routes HERE on purpose: with it,
|
|
2419
|
+
// `{ amount: { $eq: { $field: 'budget' } } }` — the shape
|
|
2420
|
+
// `compileCelToFilter` emits for a field-to-field CEL rule, and the shape
|
|
2421
|
+
// `canHandle` now declines native SQL for — would arrive at the driver as
|
|
2422
|
+
// something the driver cannot read, so the capability B exists to serve
|
|
2423
|
+
// would fail on its single most important spelling. Its five siblings
|
|
2424
|
+
// (`$ne`/`$gt`/`$gte`/`$lt`/`$lte`) were never affected: they emit their
|
|
2425
|
+
// operator explicitly two lines down.
|
|
2032
2426
|
case "equals":
|
|
2033
|
-
return v0;
|
|
2427
|
+
return isFieldReference(v0) ? { $eq: v0 } : v0;
|
|
2034
2428
|
case "notEquals":
|
|
2035
2429
|
return { $ne: v0 };
|
|
2036
2430
|
case "gt":
|
|
@@ -2090,6 +2484,24 @@ var ObjectQLStrategy = class {
|
|
|
2090
2484
|
extractObjectName(cube) {
|
|
2091
2485
|
return cube.sql.trim();
|
|
2092
2486
|
}
|
|
2487
|
+
/**
|
|
2488
|
+
* [#7598] The query's `where`, lowered — the same input
|
|
2489
|
+
* `NativeSQLStrategy.canHandle` scans, so the strategy that DECLINED and the
|
|
2490
|
+
* echo that refuses read one shape rather than two.
|
|
2491
|
+
*
|
|
2492
|
+
* A throw from the lowering is swallowed for the same reason it is there: the
|
|
2493
|
+
* `where` is malformed either way and `normalizeAnalyticsFilterTree` below
|
|
2494
|
+
* refuses it with the message and envelope it has always had. This helper's
|
|
2495
|
+
* only job is finding a reference, and there is none to find in a filter that
|
|
2496
|
+
* does not lower.
|
|
2497
|
+
*/
|
|
2498
|
+
loweredWhere(query) {
|
|
2499
|
+
try {
|
|
2500
|
+
return lowerAnalyticsWhere(query);
|
|
2501
|
+
} catch {
|
|
2502
|
+
return null;
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2093
2505
|
/**
|
|
2094
2506
|
* The dimensions this query PROJECTS, in the order the result carries them:
|
|
2095
2507
|
* every `dimensions` entry, then every granular `timeDimensions` entry that
|
|
@@ -2131,9 +2543,10 @@ var ObjectQLStrategy = class {
|
|
|
2131
2543
|
};
|
|
2132
2544
|
|
|
2133
2545
|
// src/dataset-compiler.ts
|
|
2134
|
-
var
|
|
2135
|
-
var
|
|
2136
|
-
var
|
|
2546
|
+
var import_data3 = require("@objectstack/spec/data");
|
|
2547
|
+
var import_ui = require("@objectstack/spec/ui");
|
|
2548
|
+
var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set();
|
|
2549
|
+
var SUPPORTED_AGGREGATES = import_data3.AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
|
|
2137
2550
|
function aggregateToMetricType(m) {
|
|
2138
2551
|
if (!m.aggregate) {
|
|
2139
2552
|
throw new Error(`[dataset-compiler] non-derived measure "${m.name}" has no aggregate`);
|
|
@@ -2167,6 +2580,7 @@ function fieldRelationshipPath(field) {
|
|
|
2167
2580
|
}
|
|
2168
2581
|
var MAX_JOIN_HOPS = 3;
|
|
2169
2582
|
var joinAlias = (path) => path.replace(/\./g, "__");
|
|
2583
|
+
var REGISTRY_LOCALE = void 0;
|
|
2170
2584
|
function compileDataset(dataset, resolver, options) {
|
|
2171
2585
|
const include = dataset.include ?? [];
|
|
2172
2586
|
const declaredDatasource = (objectName) => {
|
|
@@ -2238,7 +2652,11 @@ function compileDataset(dataset, resolver, options) {
|
|
|
2238
2652
|
assertDeclared(d.field, "dimension", d.name);
|
|
2239
2653
|
const dim = {
|
|
2240
2654
|
name: d.name,
|
|
2241
|
-
|
|
2655
|
+
// [#6761] An inline locale map is a label, not a missing one. Before this,
|
|
2656
|
+
// the `typeof === 'string'` test dropped the map and substituted the
|
|
2657
|
+
// machine name, which `/analytics/meta` then published as a display title
|
|
2658
|
+
// (`title: 'owner'` for a dimension labelled `{ en: 'Owner', … }`).
|
|
2659
|
+
label: (0, import_ui.resolveI18nLabel)(d.label, REGISTRY_LOCALE) ?? d.name,
|
|
2242
2660
|
type: dimensionType(d),
|
|
2243
2661
|
sql: d.field
|
|
2244
2662
|
};
|
|
@@ -2258,7 +2676,8 @@ function compileDataset(dataset, resolver, options) {
|
|
|
2258
2676
|
if (m.field) assertDeclared(m.field, "measure", m.name);
|
|
2259
2677
|
const metric = {
|
|
2260
2678
|
name: m.name,
|
|
2261
|
-
|
|
2679
|
+
// [#6761] Same as the dimension label above — see {@link REGISTRY_LOCALE}.
|
|
2680
|
+
label: (0, import_ui.resolveI18nLabel)(m.label, REGISTRY_LOCALE) ?? m.name,
|
|
2262
2681
|
type: aggregateToMetricType(m),
|
|
2263
2682
|
// `count` with no field aggregates over rows (*).
|
|
2264
2683
|
sql: m.field ?? "*"
|
|
@@ -2269,7 +2688,10 @@ function compileDataset(dataset, resolver, options) {
|
|
|
2269
2688
|
}
|
|
2270
2689
|
const cube = {
|
|
2271
2690
|
name: dataset.name,
|
|
2272
|
-
|
|
2691
|
+
// [#6761] The cube's own display title, same rule. `Cube.title` is optional
|
|
2692
|
+
// in the schema, but an absent dataset label already produced the machine
|
|
2693
|
+
// name here and that is not what this card changes — only the map case moves.
|
|
2694
|
+
title: (0, import_ui.resolveI18nLabel)(dataset.label, REGISTRY_LOCALE) ?? dataset.name,
|
|
2273
2695
|
sql: dataset.object,
|
|
2274
2696
|
measures,
|
|
2275
2697
|
dimensions,
|
|
@@ -2286,7 +2708,7 @@ function compileDataset(dataset, resolver, options) {
|
|
|
2286
2708
|
}
|
|
2287
2709
|
|
|
2288
2710
|
// src/dataset-executor.ts
|
|
2289
|
-
var
|
|
2711
|
+
var import_data4 = require("@objectstack/spec/data");
|
|
2290
2712
|
var import_core3 = require("@objectstack/core");
|
|
2291
2713
|
function resolveSelectionTokens(compiled, selection, context) {
|
|
2292
2714
|
const tokenCtx = (0, import_core3.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
|
|
@@ -2326,7 +2748,7 @@ function evaluateDerivedMeasures(rows, derived) {
|
|
|
2326
2748
|
}
|
|
2327
2749
|
function fillEmptyGroups(rows, columnAggregates) {
|
|
2328
2750
|
for (const [column, aggregate2] of Object.entries(columnAggregates)) {
|
|
2329
|
-
const empty = (0,
|
|
2751
|
+
const empty = (0, import_data4.emptyGroupValueFor)(aggregate2);
|
|
2330
2752
|
if (empty === void 0) continue;
|
|
2331
2753
|
for (const row of rows) if (row[column] == null) row[column] = empty;
|
|
2332
2754
|
}
|
|
@@ -2478,6 +2900,62 @@ function shiftRange(range, kind) {
|
|
|
2478
2900
|
const prevStartMs = prevEndMs - (lengthDays - 1) * DAY_MS;
|
|
2479
2901
|
return [toISODate(prevStartMs), toISODate(prevEndMs)];
|
|
2480
2902
|
}
|
|
2903
|
+
function isoWeekKeyOfUtcMs(ms) {
|
|
2904
|
+
const target = new Date(ms);
|
|
2905
|
+
const dayNum = (target.getUTCDay() + 6) % 7;
|
|
2906
|
+
target.setUTCDate(target.getUTCDate() - dayNum + 3);
|
|
2907
|
+
const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
|
|
2908
|
+
const weekNo = 1 + Math.round(
|
|
2909
|
+
((target.getTime() - firstThursday.getTime()) / DAY_MS - 3 + (firstThursday.getUTCDay() + 6) % 7) / 7
|
|
2910
|
+
);
|
|
2911
|
+
return `${target.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`;
|
|
2912
|
+
}
|
|
2913
|
+
function bucketOrdinalOfDay(ymd, granularity) {
|
|
2914
|
+
const ms = parseUTC(ymd);
|
|
2915
|
+
const d = new Date(ms);
|
|
2916
|
+
const y = d.getUTCFullYear();
|
|
2917
|
+
const m = d.getUTCMonth();
|
|
2918
|
+
switch (granularity) {
|
|
2919
|
+
case "year":
|
|
2920
|
+
return y;
|
|
2921
|
+
case "quarter":
|
|
2922
|
+
return y * 4 + Math.floor(m / 3);
|
|
2923
|
+
case "month":
|
|
2924
|
+
return y * 12 + m;
|
|
2925
|
+
// 1970-01-01 was a Thursday, so shifting by 3 days puts the Monday boundary
|
|
2926
|
+
// on a multiple of 7 and the ordinal advances exactly at each ISO week start.
|
|
2927
|
+
case "week":
|
|
2928
|
+
return Math.floor((ms + 3 * DAY_MS) / (7 * DAY_MS));
|
|
2929
|
+
case "day":
|
|
2930
|
+
default:
|
|
2931
|
+
return Math.floor(ms / DAY_MS);
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
function bucketKeyAtOrdinal(ordinal, granularity) {
|
|
2935
|
+
switch (granularity) {
|
|
2936
|
+
case "year":
|
|
2937
|
+
return String(ordinal);
|
|
2938
|
+
case "quarter":
|
|
2939
|
+
return `${Math.floor(ordinal / 4)}-Q${ordinal % 4 + 1}`;
|
|
2940
|
+
case "month":
|
|
2941
|
+
return `${Math.floor(ordinal / 12)}-${String(ordinal % 12 + 1).padStart(2, "0")}`;
|
|
2942
|
+
case "week":
|
|
2943
|
+
return isoWeekKeyOfUtcMs(ordinal * 7 * DAY_MS - 3 * DAY_MS);
|
|
2944
|
+
case "day":
|
|
2945
|
+
default:
|
|
2946
|
+
return toISODate(ordinal * DAY_MS);
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
function alignedCompareBucketKey(key, granularity, kind, currentRange, shiftedRange) {
|
|
2950
|
+
if (typeof key !== "string" || key.length === 0) return null;
|
|
2951
|
+
const span = (0, import_core3.bucketKeyToCalendarRange)(key, granularity);
|
|
2952
|
+
if (!span) return null;
|
|
2953
|
+
const targetOrdinal = kind === "previousYear" ? bucketOrdinalOfDay(shiftYear(span.start, 1), granularity) : bucketOrdinalOfDay(span.start, granularity) + (bucketOrdinalOfDay(currentRange[0], granularity) - bucketOrdinalOfDay(shiftedRange[0], granularity));
|
|
2954
|
+
const first = bucketOrdinalOfDay(currentRange[0], granularity);
|
|
2955
|
+
const last = bucketOrdinalOfDay(currentRange[1], granularity);
|
|
2956
|
+
if (targetOrdinal < first || targetOrdinal > last) return null;
|
|
2957
|
+
return bucketKeyAtOrdinal(targetOrdinal, granularity);
|
|
2958
|
+
}
|
|
2481
2959
|
var DatasetExecutor = class {
|
|
2482
2960
|
/**
|
|
2483
2961
|
* @param service - The analytics service the executor issues its queries to.
|
|
@@ -2664,6 +3142,24 @@ var DatasetExecutor = class {
|
|
|
2664
3142
|
timeDimensionsOf(compiled, dimensions) {
|
|
2665
3143
|
return dimensions.filter((d) => compiled.cube.dimensions[d]?.type === "time");
|
|
2666
3144
|
}
|
|
3145
|
+
/**
|
|
3146
|
+
* The EFFECTIVE bucket size one dimension is grouped at for this selection,
|
|
3147
|
+
* or `undefined` when it is not a date dimension or nothing states a size (in
|
|
3148
|
+
* which case the runtime groups the raw column).
|
|
3149
|
+
*
|
|
3150
|
+
* One definition, two readers, deliberately: {@link buildQuery} uses it to
|
|
3151
|
+
* decide the `GROUP BY`, and {@link runCompare} uses it to realign the
|
|
3152
|
+
* comparison pass's bucket keys (#6007). Those two MUST agree — realigning
|
|
3153
|
+
* `month` keys a query grouped by `quarter` would move every comparison value
|
|
3154
|
+
* onto a bucket that does not exist — and the way to make them agree is to
|
|
3155
|
+
* have one of them, not two that look alike.
|
|
3156
|
+
*/
|
|
3157
|
+
granularityOf(compiled, selection, name) {
|
|
3158
|
+
const cd = compiled.cube.dimensions[name];
|
|
3159
|
+
if (cd?.type !== "time") return void 0;
|
|
3160
|
+
const datasetDefault = cd.granularities?.length === 1 ? String(cd.granularities[0]) : void 0;
|
|
3161
|
+
return resolveDimensionGranularity(selection, name, datasetDefault);
|
|
3162
|
+
}
|
|
2667
3163
|
buildQuery(compiled, opts) {
|
|
2668
3164
|
const q = {
|
|
2669
3165
|
cube: compiled.cube.name,
|
|
@@ -2677,12 +3173,7 @@ var DatasetExecutor = class {
|
|
|
2677
3173
|
const selTimeDims = opts.selection.timeDimensions ?? [];
|
|
2678
3174
|
const selDims = new Set(selTimeDims.map((t) => t.dimension));
|
|
2679
3175
|
const groupedDims = new Set(opts.dimensions);
|
|
2680
|
-
const granularityFor = (name) =>
|
|
2681
|
-
const cd = compiled.cube.dimensions[name];
|
|
2682
|
-
if (cd?.type !== "time") return void 0;
|
|
2683
|
-
const datasetDefault = cd.granularities?.length === 1 ? String(cd.granularities[0]) : void 0;
|
|
2684
|
-
return resolveDimensionGranularity(opts.selection, name, datasetDefault);
|
|
2685
|
-
};
|
|
3176
|
+
const granularityFor = (name) => this.granularityOf(compiled, opts.selection, name);
|
|
2686
3177
|
const bucketsUnstatedEntry = (dimension) => groupedDims.has(dimension) || opts.selection.dateGranularity != null;
|
|
2687
3178
|
const resolvedTimeDims = selTimeDims.map((t) => {
|
|
2688
3179
|
if (t.granularity) return t;
|
|
@@ -2717,9 +3208,14 @@ var DatasetExecutor = class {
|
|
|
2717
3208
|
{ ...selection, timeDimensions: shiftedTd },
|
|
2718
3209
|
{ measures, dimensions, baseFilter, context }
|
|
2719
3210
|
);
|
|
3211
|
+
const granularity = dimensions.includes(dimension) ? this.granularityOf(compiled, selection, dimension) : void 0;
|
|
2720
3212
|
return sub.rows.map((row) => {
|
|
2721
3213
|
const out = {};
|
|
2722
3214
|
for (const dim of dimensions) out[dim] = row[dim];
|
|
3215
|
+
if (granularity) {
|
|
3216
|
+
const aligned = alignedCompareBucketKey(row[dimension], granularity, cmp.kind, range, shifted);
|
|
3217
|
+
if (aligned != null) out[dimension] = aligned;
|
|
3218
|
+
}
|
|
2723
3219
|
for (const m of measures) out[`${m}__compare`] = row[m];
|
|
2724
3220
|
return out;
|
|
2725
3221
|
});
|
|
@@ -3096,8 +3592,12 @@ function hasDeclaredErrorEnvelope(err) {
|
|
|
3096
3592
|
const e = err;
|
|
3097
3593
|
return typeof e?.status === "number" && typeof e?.code === "string" && e.code.length > 0;
|
|
3098
3594
|
}
|
|
3595
|
+
function isMissingColumnOfRelation(message) {
|
|
3596
|
+
return (0, import_types.matchMissingColumnOfRelation)(message) !== void 0;
|
|
3597
|
+
}
|
|
3099
3598
|
function isMissingSourceError(err) {
|
|
3100
3599
|
const raw = String(err?.message ?? err ?? "");
|
|
3600
|
+
if (isMissingColumnOfRelation(raw)) return false;
|
|
3101
3601
|
const msg = raw.toLowerCase();
|
|
3102
3602
|
return msg.includes("no such table") || // sqlite / libsql
|
|
3103
3603
|
/relation\s+[`"']?[A-Za-z0-9_$.]+[`"']?\s+does not exist/i.test(raw) || // postgres
|
|
@@ -3107,6 +3607,7 @@ function isMissingSourceError(err) {
|
|
|
3107
3607
|
}
|
|
3108
3608
|
function missingSourceRelation(err) {
|
|
3109
3609
|
const msg = String(err?.message ?? err ?? "");
|
|
3610
|
+
if (isMissingColumnOfRelation(msg)) return void 0;
|
|
3110
3611
|
const patterns = [
|
|
3111
3612
|
/no such table:\s*[`"'[]?([A-Za-z0-9_$.]+)/i,
|
|
3112
3613
|
// sqlite / libsql
|
|
@@ -3182,6 +3683,7 @@ var AnalyticsService = class {
|
|
|
3182
3683
|
this.getObjectFieldNames = config.getObjectFieldNames;
|
|
3183
3684
|
this.getObjectDatasource = config.getObjectDatasource;
|
|
3184
3685
|
this.isExternalObject = config.isExternalObject;
|
|
3686
|
+
this.debugSql = config.debugSql ?? (0, import_core5.getEnv)("NODE_ENV") === "development";
|
|
3185
3687
|
if (config.datasets) {
|
|
3186
3688
|
for (const ds of config.datasets) {
|
|
3187
3689
|
try {
|
|
@@ -3299,7 +3801,7 @@ var AnalyticsService = class {
|
|
|
3299
3801
|
const strategy = this.resolveStrategy(query, ctx, skip);
|
|
3300
3802
|
this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
|
|
3301
3803
|
try {
|
|
3302
|
-
return await strategy.execute(query, ctx);
|
|
3804
|
+
return this.applySqlEchoPolicy(await strategy.execute(query, ctx));
|
|
3303
3805
|
} catch (e) {
|
|
3304
3806
|
if (e?.code === "RAW_SQL_UNSUPPORTED") {
|
|
3305
3807
|
this.logger.warn(
|
|
@@ -3312,6 +3814,30 @@ var AnalyticsService = class {
|
|
|
3312
3814
|
}
|
|
3313
3815
|
}
|
|
3314
3816
|
}
|
|
3817
|
+
/**
|
|
3818
|
+
* [#8286] Withhold the executed statement unless this host enabled the echo.
|
|
3819
|
+
*
|
|
3820
|
+
* Applied at {@link query}, which is the response-assembly seam for BOTH
|
|
3821
|
+
* faces that serve callers: `/api/v1/analytics/query` calls it directly, and
|
|
3822
|
+
* `queryDataset` reaches it through `DatasetExecutor`, so a dataset response
|
|
3823
|
+
* inherits the same verdict without a second gate to keep in step.
|
|
3824
|
+
* `generateSql` — the dedicated `/api/v1/analytics/sql` dry-run route — is
|
|
3825
|
+
* deliberately NOT gated: asking for the statement is that route's entire
|
|
3826
|
+
* purpose, and it is the surface a debugging author is meant to use.
|
|
3827
|
+
*
|
|
3828
|
+
* What the echo disclosed, and why "it is only a table name" understates it:
|
|
3829
|
+
* the statement carries the compiled read scope, i.e. the SHAPE of the
|
|
3830
|
+
* isolation predicate (`"sys_user"."id" IN ($2, $3, …)` rather than an
|
|
3831
|
+
* `organization_id` comparison) plus its bound-parameter arity, which counts
|
|
3832
|
+
* the caller's own org membership. No wall was breached by it — the echo is
|
|
3833
|
+
* information disclosure, and this is the disclosure closing.
|
|
3834
|
+
*/
|
|
3835
|
+
applySqlEchoPolicy(result) {
|
|
3836
|
+
if (this.debugSql || result?.sql === void 0) return result;
|
|
3837
|
+
const withheld = { ...result };
|
|
3838
|
+
delete withheld.sql;
|
|
3839
|
+
return withheld;
|
|
3840
|
+
}
|
|
3315
3841
|
/**
|
|
3316
3842
|
* Compile a `dataset` (ADR-0021) and register its Cube + join allowlist so it
|
|
3317
3843
|
* can be queried by name. Idempotent (re-registering overwrites). Returns the
|
|
@@ -3351,6 +3877,7 @@ var AnalyticsService = class {
|
|
|
3351
3877
|
return previewResult;
|
|
3352
3878
|
}
|
|
3353
3879
|
}
|
|
3880
|
+
const requestLocale = context?.locale;
|
|
3354
3881
|
const provider = this.readScopeProvider;
|
|
3355
3882
|
const resolveScope = provider ? (targetObject) => provider(targetObject, context) : void 0;
|
|
3356
3883
|
const labelDeps = this.labelResolver ? withLabelFetchCache(this.labelResolver) : void 0;
|
|
@@ -3460,7 +3987,10 @@ var AnalyticsService = class {
|
|
|
3460
3987
|
for (const f of result.fields) {
|
|
3461
3988
|
const m = measureByName.get(f.name) ?? measureByName.get(f.name.replace(/__compare$/, ""));
|
|
3462
3989
|
if (!m) continue;
|
|
3463
|
-
if (f.label == null
|
|
3990
|
+
if (f.label == null) {
|
|
3991
|
+
const label = (0, import_ui2.resolveI18nLabel)(m.label, requestLocale);
|
|
3992
|
+
if (label !== void 0) f.label = label;
|
|
3993
|
+
}
|
|
3464
3994
|
if (f.format == null && m.format) f.format = m.format;
|
|
3465
3995
|
const fc = f;
|
|
3466
3996
|
const mc = m;
|
|
@@ -3473,7 +4003,7 @@ var AnalyticsService = class {
|
|
|
3473
4003
|
}
|
|
3474
4004
|
}
|
|
3475
4005
|
if (f.percentScale == null) {
|
|
3476
|
-
f.percentScale = m.derived?.op === "ratio" ? "fraction" : (0,
|
|
4006
|
+
f.percentScale = m.derived?.op === "ratio" ? "fraction" : (0, import_data5.percentScaleOf)(meta);
|
|
3477
4007
|
}
|
|
3478
4008
|
}
|
|
3479
4009
|
}
|
|
@@ -3489,7 +4019,9 @@ var AnalyticsService = class {
|
|
|
3489
4019
|
for (const f of result.fields) {
|
|
3490
4020
|
if (f.label != null) continue;
|
|
3491
4021
|
const d = dimByName.get(f.name) ?? dimByField.get(f.name);
|
|
3492
|
-
if (d
|
|
4022
|
+
if (!d) continue;
|
|
4023
|
+
const label = (0, import_ui2.resolveI18nLabel)(d.label, requestLocale);
|
|
4024
|
+
if (label !== void 0) f.label = label;
|
|
3493
4025
|
}
|
|
3494
4026
|
}
|
|
3495
4027
|
return result;
|
|
@@ -3569,10 +4101,10 @@ var AnalyticsService = class {
|
|
|
3569
4101
|
else this.logger.warn(message);
|
|
3570
4102
|
return;
|
|
3571
4103
|
}
|
|
3572
|
-
const stripPrefix = (m) => m.includes(".") ? m.split(".").slice(1).join(".") : m;
|
|
3573
4104
|
const extraMeasures = {};
|
|
3574
4105
|
for (const m of query.measures || []) {
|
|
3575
|
-
|
|
4106
|
+
if (cube.measures[m] || extraMeasures[m]) continue;
|
|
4107
|
+
const key = mintableMeasureKey(m, name);
|
|
3576
4108
|
if (cube.measures[key] || extraMeasures[key]) continue;
|
|
3577
4109
|
extraMeasures[key] = inferMeasure(key);
|
|
3578
4110
|
}
|
|
@@ -3621,6 +4153,13 @@ var AnalyticsService = class {
|
|
|
3621
4153
|
* the data path's `resolveQueryFields`: they are engine-assigned rather than
|
|
3622
4154
|
* declared, and a gate stricter than the engine it guards would reject
|
|
3623
4155
|
* queries that used to work.
|
|
4156
|
+
*
|
|
4157
|
+
* [#5918] Its `stripPrefix` below is deliberately NOT narrowed the way the two
|
|
4158
|
+
* MINTS were. This is a RESOLVER — it mirrors `lookupMember`'s tiers to answer
|
|
4159
|
+
* "which Metric will the strategy read", and that tier order did not change.
|
|
4160
|
+
* What changed is what can reach it: a dotted measure is now either a
|
|
4161
|
+
* `<cube>.` qualifier or a key the cube itself declares, because every other
|
|
4162
|
+
* dotted spelling is refused at the mint before this gate runs.
|
|
3624
4163
|
*/
|
|
3625
4164
|
assertMeasureFields(query, cube, declaredMeasures) {
|
|
3626
4165
|
const probe = this.getObjectFieldNames;
|
|
@@ -3909,7 +4448,7 @@ var AnalyticsService = class {
|
|
|
3909
4448
|
};
|
|
3910
4449
|
measures.count = { name: "count", label: "Count", type: "count", sql: "*" };
|
|
3911
4450
|
for (const m of query.measures || []) {
|
|
3912
|
-
const key = m
|
|
4451
|
+
const key = mintableMeasureKey(m, cubeName);
|
|
3913
4452
|
if (measures[key]) continue;
|
|
3914
4453
|
const inferred = inferMeasure(key);
|
|
3915
4454
|
measures[key] = inferred;
|
|
@@ -3963,11 +4502,28 @@ var AnalyticsService = class {
|
|
|
3963
4502
|
return strategy;
|
|
3964
4503
|
}
|
|
3965
4504
|
}
|
|
4505
|
+
const crossField = findCrossFieldComparand(lowerAnalyticsWhereQuietly(query));
|
|
3966
4506
|
throw new Error(
|
|
3967
|
-
`[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
|
|
4507
|
+
`[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."
|
|
3968
4508
|
);
|
|
3969
4509
|
}
|
|
3970
4510
|
};
|
|
4511
|
+
function lowerAnalyticsWhereQuietly(query) {
|
|
4512
|
+
try {
|
|
4513
|
+
return lowerAnalyticsWhere(query);
|
|
4514
|
+
} catch {
|
|
4515
|
+
return null;
|
|
4516
|
+
}
|
|
4517
|
+
}
|
|
4518
|
+
function mintableMeasureKey(member, cubeName) {
|
|
4519
|
+
const dot = member.indexOf(".");
|
|
4520
|
+
if (dot < 0) return member;
|
|
4521
|
+
if (member.slice(0, dot) === cubeName) return member.slice(dot + 1);
|
|
4522
|
+
throw invalidMemberError(
|
|
4523
|
+
`[Analytics] Measure '${member}' on cube '${cubeName}' is a DOTTED member, and measures do not traverse relationships \u2014 only dimensions do \u2014 so there is no related column for this to aggregate. Until #5918 the prefix was silently dropped, so the aggregate ran against '${cubeName}' itself while the result column kept the label '${member}'. Aggregate one of the object's OWN fields instead ('<field>_sum' / '_avg' / '_min' / '_max' / '_count_distinct'), or declare a Cube whose measure names the related column in its own 'sql'. The only dot a measure may carry is the '${cubeName}.' qualifier.`,
|
|
4524
|
+
{ member, param: "measures", cube: cubeName }
|
|
4525
|
+
);
|
|
4526
|
+
}
|
|
3971
4527
|
function inferMeasure(key) {
|
|
3972
4528
|
if (key === "count") {
|
|
3973
4529
|
return { name: "count", label: "Count", type: "count", sql: "*" };
|
|
@@ -4254,6 +4810,11 @@ var AnalyticsServicePlugin = class {
|
|
|
4254
4810
|
coerceTemporalFilterColumn,
|
|
4255
4811
|
relationshipResolver,
|
|
4256
4812
|
labelResolver,
|
|
4813
|
+
// [#8286] Passed through as authored — `undefined` is "this host did not
|
|
4814
|
+
// choose", which the service resolves to development-only. Defaulting it
|
|
4815
|
+
// here would be a second copy of that decision, drifting the moment one
|
|
4816
|
+
// of the two moves.
|
|
4817
|
+
debugSql: this.options.debugSql,
|
|
4257
4818
|
// Source-field metadata behind the display chains on result columns:
|
|
4258
4819
|
// ADR-0053 currency (`currencyConfig.defaultCurrency`) and percent scale
|
|
4259
4820
|
// (`max`, which is what marks whole-percent storage — objectui#3136).
|
|
@@ -4266,7 +4827,17 @@ var AnalyticsServicePlugin = class {
|
|
|
4266
4827
|
// datasource the query was routed to. Undefined ⇒ the object rides the
|
|
4267
4828
|
// default datasource (or the engine cannot answer), and the diagnostic
|
|
4268
4829
|
// says so rather than inventing a name.
|
|
4269
|
-
|
|
4830
|
+
//
|
|
4831
|
+
// [#5288] Asked of the ENGINE's resolver, not of the object's declaration.
|
|
4832
|
+
// `getObject(name).datasource` is the declared value — step 1 of the five
|
|
4833
|
+
// `getDriver` routes by — so an object placed by a `datasourceMapping`
|
|
4834
|
+
// rule, by the ADR-0057 §3.6 lifecycle split, or by its package's
|
|
4835
|
+
// `defaultDatasource` answered `'default'`, and the diagnostic named a
|
|
4836
|
+
// database the rows are not in. Recomputing those rules here instead would
|
|
4837
|
+
// be the second implementation `resolveMappedDatasource` (#4462) exists to
|
|
4838
|
+
// prevent: it drifts by one step, silently, and the drift only surfaces as
|
|
4839
|
+
// an error message pointing at the wrong database.
|
|
4840
|
+
getObjectDatasource: (objectName) => dataEngine()?.resolveEffectiveDatasource?.(objectName),
|
|
4270
4841
|
// ADR-0062 D6 — a federated object carries an `external` block (ADR-0015).
|
|
4271
4842
|
// Reported so NativeSQLStrategy declines it (its hand-compiled FROM would
|
|
4272
4843
|
// hit the wrong physical table) and the driver-correct ObjectQL path runs.
|