@objectstack/service-analytics 17.0.0-rc.4 → 17.0.0-rc.6
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 +816 -0
- package/dist/index.cjs +371 -27
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +57 -11
- package/dist/index.d.ts +57 -11
- package/dist/index.js +374 -30
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
package/dist/index.cjs
CHANGED
|
@@ -42,7 +42,9 @@ module.exports = __toCommonJS(index_exports);
|
|
|
42
42
|
|
|
43
43
|
// src/analytics-service.ts
|
|
44
44
|
var import_data4 = 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,43 @@ 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
|
+
var TEXT_PATTERN_OPERATORS = /* @__PURE__ */ new Set([
|
|
182
|
+
"$contains",
|
|
183
|
+
"$notContains",
|
|
184
|
+
"$startsWith",
|
|
185
|
+
"$endsWith"
|
|
186
|
+
]);
|
|
187
|
+
function shapePreview(value) {
|
|
188
|
+
try {
|
|
189
|
+
const json = JSON.stringify(value);
|
|
190
|
+
if (typeof json !== "string") return typeof value;
|
|
191
|
+
return json.length > 80 ? `${json.slice(0, 77)}...` : json;
|
|
192
|
+
} catch {
|
|
193
|
+
return typeof value;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
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); 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.`;
|
|
198
|
+
}
|
|
199
|
+
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 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).`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// src/strategies/filter-normalizer.ts
|
|
165
204
|
function invalidFilterError(message) {
|
|
166
205
|
const err = new Error(message);
|
|
167
206
|
err.code = import_api.StandardErrorCode.enum.INVALID_FILTER;
|
|
@@ -180,7 +219,11 @@ var MONGO_TO_CUBE_OP = {
|
|
|
180
219
|
$contains: "contains",
|
|
181
220
|
$notContains: "notContains",
|
|
182
221
|
$startsWith: "startsWith",
|
|
183
|
-
$endsWith: "endsWith"
|
|
222
|
+
$endsWith: "endsWith",
|
|
223
|
+
// [#6520] The case-INSENSITIVE twin, ASCII fold only. A separate cube operator
|
|
224
|
+
// rather than a flag on `contains`, because the two compile to different SQL
|
|
225
|
+
// and one name would make the renderers guess which was meant.
|
|
226
|
+
$icontains: "icontains"
|
|
184
227
|
};
|
|
185
228
|
function comparand(v) {
|
|
186
229
|
return v === void 0 ? null : v;
|
|
@@ -203,7 +246,64 @@ function andOf(children) {
|
|
|
203
246
|
if (children.length === 1) return children[0];
|
|
204
247
|
return { kind: "and", children };
|
|
205
248
|
}
|
|
249
|
+
function assertCompilableComparand(opKey, field, value) {
|
|
250
|
+
if (TEXT_PATTERN_OPERATORS.has(opKey)) {
|
|
251
|
+
if (!isRenderableTextComparand(value)) {
|
|
252
|
+
throw invalidFilterError(`[analytics] ${unrenderableTextComparandMessage(opKey, field, value)}`);
|
|
253
|
+
}
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if ((opKey === "$in" || opKey === "$nin") && Array.isArray(value)) {
|
|
257
|
+
value.forEach((member, index) => {
|
|
258
|
+
if (!isBindableComparand(member)) {
|
|
259
|
+
throw invalidFilterError(`[analytics] ${unbindableListMemberMessage(opKey, field, member, index)}`);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function undefinedComparandError(field, path) {
|
|
265
|
+
return invalidFilterError(
|
|
266
|
+
`[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).`
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
function assertDefinedComparands(field, spec) {
|
|
270
|
+
const root = `"${field}"`;
|
|
271
|
+
if (spec === void 0) throw undefinedComparandError(field, root);
|
|
272
|
+
if (Array.isArray(spec)) {
|
|
273
|
+
spec.forEach((member, index) => {
|
|
274
|
+
if (member === void 0) throw undefinedComparandError(field, `${root}[${index}]`);
|
|
275
|
+
});
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (!isFilterObject(spec)) return;
|
|
279
|
+
for (const [op, opValue] of Object.entries(spec)) {
|
|
280
|
+
if (!op.startsWith("$") || op === "$null" || op === "$exists") continue;
|
|
281
|
+
const opPath = `${root}.${op}`;
|
|
282
|
+
if (opValue === void 0) throw undefinedComparandError(field, opPath);
|
|
283
|
+
if (!Array.isArray(opValue)) continue;
|
|
284
|
+
opValue.forEach((member, index) => {
|
|
285
|
+
if (member === void 0) throw undefinedComparandError(field, `${opPath}[${index}]`);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function mixedFieldWrapperError(field, opKeys, nonOpKeys) {
|
|
290
|
+
const offending = nonOpKeys.map((k) => `"${k}"`).join(", ");
|
|
291
|
+
const rewrites = nonOpKeys.map((k) => `"${k}" \u2192 "$${k}"`).join(", ");
|
|
292
|
+
const example = nonOpKeys[0];
|
|
293
|
+
return invalidFilterError(
|
|
294
|
+
`[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).`
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
function assertUnmixedFieldWrapper(field, wrapper) {
|
|
298
|
+
const keys = Object.keys(wrapper);
|
|
299
|
+
const opKeys = keys.filter((k) => k.startsWith("$"));
|
|
300
|
+
if (opKeys.length === 0) return;
|
|
301
|
+
const nonOpKeys = keys.filter((k) => !k.startsWith("$"));
|
|
302
|
+
if (nonOpKeys.length === 0) return;
|
|
303
|
+
throw mixedFieldWrapperError(field, opKeys, nonOpKeys);
|
|
304
|
+
}
|
|
206
305
|
function fieldLeaves(key, raw) {
|
|
306
|
+
assertDefinedComparands(key, raw);
|
|
207
307
|
const out = [];
|
|
208
308
|
const leaf = (operator, values) => {
|
|
209
309
|
out.push({ kind: "leaf", member: key, operator, values });
|
|
@@ -219,6 +319,7 @@ function fieldLeaves(key, raw) {
|
|
|
219
319
|
`[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
320
|
);
|
|
221
321
|
}
|
|
322
|
+
assertUnmixedFieldWrapper(key, wrapper);
|
|
222
323
|
const opKeys = Object.keys(wrapper).filter((k) => k.startsWith("$"));
|
|
223
324
|
if (opKeys.length > 0) {
|
|
224
325
|
for (const opKey of opKeys) {
|
|
@@ -253,6 +354,7 @@ function fieldLeaves(key, raw) {
|
|
|
253
354
|
);
|
|
254
355
|
}
|
|
255
356
|
const v = wrapper[opKey];
|
|
357
|
+
assertCompilableComparand(opKey, key, v);
|
|
256
358
|
const values = Array.isArray(v) ? v.map(comparand) : [comparand(v)];
|
|
257
359
|
if (nullValueSatisfiesOperator(opKey, v) && !operatorIsNullTotal(opKey, v)) {
|
|
258
360
|
out.push({
|
|
@@ -282,7 +384,6 @@ function fieldLeaves(key, raw) {
|
|
|
282
384
|
function buildNode(cond) {
|
|
283
385
|
const children = [];
|
|
284
386
|
for (const [key, raw] of Object.entries(cond)) {
|
|
285
|
-
if (raw === void 0) continue;
|
|
286
387
|
if (key === "$and" || key === "$or") {
|
|
287
388
|
if (!Array.isArray(raw)) {
|
|
288
389
|
throw invalidFilterError(
|
|
@@ -498,6 +599,11 @@ function likePattern(shape, value) {
|
|
|
498
599
|
const escaped = escapeLikePattern(value);
|
|
499
600
|
return shape === "starts" ? `${escaped}%` : shape === "ends" ? `%${escaped}` : `%${escaped}%`;
|
|
500
601
|
}
|
|
602
|
+
var ASCII_UPPER_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
603
|
+
var ASCII_LOWER_LETTERS = "abcdefghijklmnopqrstuvwxyz";
|
|
604
|
+
function asciiLowerSqlExpr(expr) {
|
|
605
|
+
return `translate(${expr}, '${ASCII_UPPER_LETTERS}', '${ASCII_LOWER_LETTERS}')`;
|
|
606
|
+
}
|
|
501
607
|
|
|
502
608
|
// src/read-scope-sql.ts
|
|
503
609
|
var IDENT = /^[a-z_][a-z0-9_]*$/i;
|
|
@@ -569,6 +675,8 @@ function compileNode(node, qAlias, params) {
|
|
|
569
675
|
}
|
|
570
676
|
function compileField(field, value, qAlias, params) {
|
|
571
677
|
const col = `${qAlias}.${quoteIdent(field, "field")}`;
|
|
678
|
+
assertDefinedComparands2(field, value);
|
|
679
|
+
assertBooleanFlagComparands(field, value);
|
|
572
680
|
if (value === null) return `${col} IS NULL`;
|
|
573
681
|
if (typeof value !== "object" || value instanceof Date) {
|
|
574
682
|
params.push(value);
|
|
@@ -598,6 +706,49 @@ function bindLike(params, pattern) {
|
|
|
598
706
|
function nullSafeNegative(col, test) {
|
|
599
707
|
return `(${col} IS NULL OR ${test})`;
|
|
600
708
|
}
|
|
709
|
+
function assertCompilableMembers(op, field, members) {
|
|
710
|
+
members.forEach((member, index) => {
|
|
711
|
+
if (!isBindableComparand(member)) {
|
|
712
|
+
throw readScopeCompileError(`[read-scope-sql] ${unbindableListMemberMessage(op, field, member, index)}`);
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
function assertRenderableText(op, field, val) {
|
|
717
|
+
if (isRenderableTextComparand(val)) return;
|
|
718
|
+
throw readScopeCompileError(`[read-scope-sql] ${unrenderableTextComparandMessage(op, field, val)}`);
|
|
719
|
+
}
|
|
720
|
+
function undefinedComparandError2(field, path) {
|
|
721
|
+
return readScopeCompileError(
|
|
722
|
+
`[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).`
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
function assertDefinedComparands2(field, spec) {
|
|
726
|
+
const root = `"${field}"`;
|
|
727
|
+
if (spec === void 0) throw undefinedComparandError2(field, root);
|
|
728
|
+
if (!isFilterNode(spec)) return;
|
|
729
|
+
for (const [op, opValue] of Object.entries(spec)) {
|
|
730
|
+
if (!op.startsWith("$") || op === "$null" || op === "$exists") continue;
|
|
731
|
+
const opPath = `${root}.${op}`;
|
|
732
|
+
if (opValue === void 0) throw undefinedComparandError2(field, opPath);
|
|
733
|
+
if (!Array.isArray(opValue)) continue;
|
|
734
|
+
opValue.forEach((member, index) => {
|
|
735
|
+
if (member === void 0) throw undefinedComparandError2(field, `${opPath}[${index}]`);
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
function nonBooleanFlagComparandError(op, field, path) {
|
|
740
|
+
return readScopeCompileError(
|
|
741
|
+
`[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).`
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
function assertBooleanFlagComparands(field, spec) {
|
|
745
|
+
if (!isFilterNode(spec)) return;
|
|
746
|
+
for (const op of ["$null", "$exists"]) {
|
|
747
|
+
if (!Object.prototype.hasOwnProperty.call(spec, op)) continue;
|
|
748
|
+
if (typeof spec[op] === "boolean") continue;
|
|
749
|
+
throw nonBooleanFlagComparandError(op, field, `"${field}".${op}`);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
601
752
|
function compileOperator(col, op, val, field, params) {
|
|
602
753
|
switch (op) {
|
|
603
754
|
case "$eq":
|
|
@@ -617,33 +768,70 @@ function compileOperator(col, op, val, field, params) {
|
|
|
617
768
|
case "$in": {
|
|
618
769
|
if (!Array.isArray(val)) throw readScopeCompileError(`[read-scope-sql] $in for "${field}" needs an array (fail-closed).`);
|
|
619
770
|
if (val.length === 0) return FALSE_CLAUSE;
|
|
771
|
+
assertCompilableMembers(op, field, val);
|
|
620
772
|
return `${col} IN (${val.map((v) => bind(params, v)).join(", ")})`;
|
|
621
773
|
}
|
|
622
774
|
case "$nin": {
|
|
623
775
|
if (!Array.isArray(val)) throw readScopeCompileError(`[read-scope-sql] $nin for "${field}" needs an array (fail-closed).`);
|
|
624
776
|
if (val.length === 0) return "1 = 1";
|
|
777
|
+
assertCompilableMembers(op, field, val);
|
|
625
778
|
return nullSafeNegative(col, `${col} NOT IN (${val.map((v) => bind(params, v)).join(", ")})`);
|
|
626
779
|
}
|
|
627
780
|
case "$between": {
|
|
628
781
|
if (!Array.isArray(val) || val.length !== 2) throw readScopeCompileError(`[read-scope-sql] $between for "${field}" needs [min,max] (fail-closed).`);
|
|
782
|
+
assertCompilableMembers(op, field, val);
|
|
629
783
|
return `${col} BETWEEN ${bind(params, val[0])} AND ${bind(params, val[1])}`;
|
|
630
784
|
}
|
|
631
785
|
// [#5567] The comparand is a LITERAL, so it is escaped and the escape
|
|
632
786
|
// character is bound with it. See {@link bindLike}.
|
|
787
|
+
// [#5234] …and it must be a value `String()` can render, which is asserted
|
|
788
|
+
// BEFORE `likePattern` sees it — see {@link assertRenderableText}.
|
|
633
789
|
case "$contains":
|
|
790
|
+
assertRenderableText(op, field, val);
|
|
634
791
|
return `${col} LIKE ${bindLike(params, likePattern("contains", val))}`;
|
|
792
|
+
/**
|
|
793
|
+
* [#6520] `$icontains` on the READ-SCOPE lowering — the one compiler in this
|
|
794
|
+
* package where a wrong answer is an ADR-0021 scope over-reach rather than a
|
|
795
|
+
* loose chart filter, which is why the fold is the spec's ruled one and not
|
|
796
|
+
* `LOWER()`.
|
|
797
|
+
*
|
|
798
|
+
* `assertRenderableText` first, exactly as its case-exact twin above: the
|
|
799
|
+
* comparand has to be something `String()` renders faithfully before a
|
|
800
|
+
* pattern is built from it (#5234).
|
|
801
|
+
*
|
|
802
|
+
* The fold wraps BOTH the column and the bound pattern. Folding one side
|
|
803
|
+
* only would compare a folded needle against a raw column — matching just
|
|
804
|
+
* the rows already lower-case — and on a read scope that is a row set the
|
|
805
|
+
* policy author never wrote, in the narrowing direction here but in the
|
|
806
|
+
* WIDENING direction under a `$not`.
|
|
807
|
+
*/
|
|
808
|
+
case "$icontains": {
|
|
809
|
+
assertRenderableText(op, field, val);
|
|
810
|
+
const patternRef = asciiLowerSqlExpr(bind(params, likePattern("contains", val)));
|
|
811
|
+
return `${asciiLowerSqlExpr(col)} LIKE ${patternRef} ESCAPE ${bind(params, LIKE_ESCAPE_CHAR)}`;
|
|
812
|
+
}
|
|
635
813
|
// [#5298] NULL-safe: `NOT LIKE` is UNKNOWN for a NULL column, and "does not
|
|
636
814
|
// contain" is true of a value that is not there.
|
|
637
815
|
case "$notContains":
|
|
816
|
+
assertRenderableText(op, field, val);
|
|
638
817
|
return nullSafeNegative(col, `${col} NOT LIKE ${bindLike(params, likePattern("contains", val))}`);
|
|
639
818
|
case "$startsWith":
|
|
819
|
+
assertRenderableText(op, field, val);
|
|
640
820
|
return `${col} LIKE ${bindLike(params, likePattern("starts", val))}`;
|
|
641
821
|
case "$endsWith":
|
|
822
|
+
assertRenderableText(op, field, val);
|
|
642
823
|
return `${col} LIKE ${bindLike(params, likePattern("ends", val))}`;
|
|
824
|
+
// [#6387] `val` is a boolean here — {@link assertBooleanFlagComparands}
|
|
825
|
+
// refused anything else at {@link compileField}, before this emitter runs.
|
|
826
|
+
// So `=== true` is an exhaustive TWO-WAY choice over the declared domain,
|
|
827
|
+
// not the "anything truthy is IS NULL" rule it used to be. That old rule is
|
|
828
|
+
// what put the STRING `"false"` on the side opposite the `false` it was
|
|
829
|
+
// written to mean; the identity spelling cannot, and it is the spelling
|
|
830
|
+
// {@link nullValueSatisfiesOperator} now mirrors (#5146 / #5298).
|
|
643
831
|
case "$null":
|
|
644
|
-
return val ? `${col} IS NULL` : `${col} IS NOT NULL`;
|
|
832
|
+
return val === true ? `${col} IS NULL` : `${col} IS NOT NULL`;
|
|
645
833
|
case "$exists":
|
|
646
|
-
return val ? `${col} IS NOT NULL` : `${col} IS NULL`;
|
|
834
|
+
return val === true ? `${col} IS NOT NULL` : `${col} IS NULL`;
|
|
647
835
|
default:
|
|
648
836
|
throw readScopeCompileError(`[read-scope-sql] unsupported operator "${op}" on "${field}" (fail-closed).`);
|
|
649
837
|
}
|
|
@@ -656,11 +844,22 @@ function nullValueSatisfiesOperator2(op, value) {
|
|
|
656
844
|
// Mirror image: `$ne: null` compiles to `IS NOT NULL`, which a NULL fails.
|
|
657
845
|
case "$ne":
|
|
658
846
|
return value !== null;
|
|
659
|
-
//
|
|
847
|
+
// [#6387] Identity, matching this file's emitter (see the note above).
|
|
848
|
+
// `assertBooleanFlagComparands` refuses anything but `true` / `false` before
|
|
849
|
+
// this table is consulted, so each arm is an exhaustive TWO-WAY choice over
|
|
850
|
+
// the declared domain — and the strict spelling is chosen over the lenient
|
|
851
|
+
// one it replaces for the reason #5347 gave: `Boolean(value)` and
|
|
852
|
+
// `value === true` are equivalent only while the gate upstream holds, and
|
|
853
|
+
// the lenient spelling would quietly resume answering for shapes nobody
|
|
854
|
+
// ruled on if that gate were ever moved. A NULL column satisfies `$null`
|
|
855
|
+
// exactly when the author asked for null…
|
|
660
856
|
case "$null":
|
|
661
|
-
return
|
|
857
|
+
return value === true;
|
|
858
|
+
// …and satisfies `$exists` exactly when the author asked for "no value".
|
|
859
|
+
// `$null: true` and `$exists: false` are the same question, so these two
|
|
860
|
+
// arms are correctly each other's MIRROR, not each other's copy (#5369).
|
|
662
861
|
case "$exists":
|
|
663
|
-
return
|
|
862
|
+
return value === false;
|
|
664
863
|
// Negative-polarity set / substring tests hold vacuously for an absent value.
|
|
665
864
|
case "$nin":
|
|
666
865
|
return true;
|
|
@@ -1166,13 +1365,19 @@ var NativeSQLStrategy = class {
|
|
|
1166
1365
|
contains: "LIKE",
|
|
1167
1366
|
notContains: "NOT LIKE",
|
|
1168
1367
|
startsWith: "LIKE",
|
|
1169
|
-
endsWith: "LIKE"
|
|
1368
|
+
endsWith: "LIKE",
|
|
1369
|
+
// [#6520] `$icontains` — `LIKE` like its neighbours; what separates it is
|
|
1370
|
+
// the ASCII fold applied below, not the keyword.
|
|
1371
|
+
icontains: "LIKE"
|
|
1170
1372
|
};
|
|
1171
1373
|
const likeShape = {
|
|
1172
1374
|
contains: "contains",
|
|
1173
1375
|
notContains: "contains",
|
|
1174
1376
|
startsWith: "starts",
|
|
1175
|
-
endsWith: "ends"
|
|
1377
|
+
endsWith: "ends",
|
|
1378
|
+
// [#6520] Same wildcard placement as `contains`; the case fold is what
|
|
1379
|
+
// differs, and it is applied to both sides of the comparison below.
|
|
1380
|
+
icontains: "contains"
|
|
1176
1381
|
};
|
|
1177
1382
|
if (operator === "set") return `${rawCol} IS NOT NULL`;
|
|
1178
1383
|
if (operator === "notSet") return `${rawCol} IS NULL`;
|
|
@@ -1191,6 +1396,9 @@ var NativeSQLStrategy = class {
|
|
|
1191
1396
|
params.push(likePattern(shape, values[0]));
|
|
1192
1397
|
const patternRef = `$${params.length}`;
|
|
1193
1398
|
params.push(LIKE_ESCAPE_CHAR);
|
|
1399
|
+
if (operator === "icontains") {
|
|
1400
|
+
return `${asciiLowerSqlExpr(rawCol)} ${sqlOp} ${asciiLowerSqlExpr(patternRef)} ESCAPE $${params.length}`;
|
|
1401
|
+
}
|
|
1194
1402
|
return `${rawCol} ${sqlOp} ${patternRef} ESCAPE $${params.length}`;
|
|
1195
1403
|
}
|
|
1196
1404
|
if (operator === "lte") {
|
|
@@ -1294,7 +1502,12 @@ var LIKE_SQL_OPS = {
|
|
|
1294
1502
|
contains: { sql: "LIKE", shape: "contains" },
|
|
1295
1503
|
notContains: { sql: "NOT LIKE", shape: "contains" },
|
|
1296
1504
|
startsWith: { sql: "LIKE", shape: "starts" },
|
|
1297
|
-
endsWith: { sql: "LIKE", shape: "ends" }
|
|
1505
|
+
endsWith: { sql: "LIKE", shape: "ends" },
|
|
1506
|
+
// [#6520] `$icontains`: the same escaped pattern and bound `ESCAPE` as its
|
|
1507
|
+
// four case-EXACT neighbours, with `fold` adding the ASCII-only case fold to
|
|
1508
|
+
// both sides of the comparison. The flag is on this row alone — the family
|
|
1509
|
+
// above it is case-sensitive by ruling (#4706 Q2 = A).
|
|
1510
|
+
icontains: { sql: "LIKE", shape: "contains", fold: true }
|
|
1298
1511
|
};
|
|
1299
1512
|
var ObjectQLStrategy = class {
|
|
1300
1513
|
constructor() {
|
|
@@ -1735,7 +1948,9 @@ var ObjectQLStrategy = class {
|
|
|
1735
1948
|
params.push(likePattern(like.shape, values[0]));
|
|
1736
1949
|
const patternRef = `$${params.length}`;
|
|
1737
1950
|
params.push(LIKE_ESCAPE_CHAR);
|
|
1738
|
-
|
|
1951
|
+
const lhs = like.fold ? asciiLowerSqlExpr(col) : col;
|
|
1952
|
+
const rhs = like.fold ? asciiLowerSqlExpr(patternRef) : patternRef;
|
|
1953
|
+
return `${lhs} ${like.sql} ${rhs} ESCAPE $${params.length}`;
|
|
1739
1954
|
}
|
|
1740
1955
|
const op = SCALAR_SQL_OPS[operator];
|
|
1741
1956
|
if (!op) {
|
|
@@ -2021,6 +2236,14 @@ var ObjectQLStrategy = class {
|
|
|
2021
2236
|
* string — `String(…)`, the same normalisation `like-pattern.ts` applies at the
|
|
2022
2237
|
* two SQL emitters and `driver-sql`'s `applyLike` applies at the driver, so one
|
|
2023
2238
|
* `$contains` means one thing on every face (#5567's invariant).
|
|
2239
|
+
*
|
|
2240
|
+
* [#5234] Those four `String(…)` calls now only ever see a value that renders
|
|
2241
|
+
* faithfully: `fieldLeaves` refuses an object comparand on this family before a
|
|
2242
|
+
* leaf exists. That ordering is load-bearing rather than incidental — this arm
|
|
2243
|
+
* is a PRODUCER for the engine, so stringifying an object here would have
|
|
2244
|
+
* laundered it into `'[object Object]'` and handed a driver a perfectly
|
|
2245
|
+
* well-typed string. A strict driver downstream could never have seen the shape
|
|
2246
|
+
* it was strict about, which is why the guard sits at the door and not here.
|
|
2024
2247
|
*/
|
|
2025
2248
|
convertFilter(operator, values) {
|
|
2026
2249
|
if (operator === "set") return { $ne: null };
|
|
@@ -2132,7 +2355,8 @@ var ObjectQLStrategy = class {
|
|
|
2132
2355
|
|
|
2133
2356
|
// src/dataset-compiler.ts
|
|
2134
2357
|
var import_data2 = require("@objectstack/spec/data");
|
|
2135
|
-
var
|
|
2358
|
+
var import_ui = require("@objectstack/spec/ui");
|
|
2359
|
+
var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set();
|
|
2136
2360
|
var SUPPORTED_AGGREGATES = import_data2.AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
|
|
2137
2361
|
function aggregateToMetricType(m) {
|
|
2138
2362
|
if (!m.aggregate) {
|
|
@@ -2167,6 +2391,7 @@ function fieldRelationshipPath(field) {
|
|
|
2167
2391
|
}
|
|
2168
2392
|
var MAX_JOIN_HOPS = 3;
|
|
2169
2393
|
var joinAlias = (path) => path.replace(/\./g, "__");
|
|
2394
|
+
var REGISTRY_LOCALE = void 0;
|
|
2170
2395
|
function compileDataset(dataset, resolver, options) {
|
|
2171
2396
|
const include = dataset.include ?? [];
|
|
2172
2397
|
const declaredDatasource = (objectName) => {
|
|
@@ -2238,7 +2463,11 @@ function compileDataset(dataset, resolver, options) {
|
|
|
2238
2463
|
assertDeclared(d.field, "dimension", d.name);
|
|
2239
2464
|
const dim = {
|
|
2240
2465
|
name: d.name,
|
|
2241
|
-
|
|
2466
|
+
// [#6761] An inline locale map is a label, not a missing one. Before this,
|
|
2467
|
+
// the `typeof === 'string'` test dropped the map and substituted the
|
|
2468
|
+
// machine name, which `/analytics/meta` then published as a display title
|
|
2469
|
+
// (`title: 'owner'` for a dimension labelled `{ en: 'Owner', … }`).
|
|
2470
|
+
label: (0, import_ui.resolveI18nLabel)(d.label, REGISTRY_LOCALE) ?? d.name,
|
|
2242
2471
|
type: dimensionType(d),
|
|
2243
2472
|
sql: d.field
|
|
2244
2473
|
};
|
|
@@ -2258,7 +2487,8 @@ function compileDataset(dataset, resolver, options) {
|
|
|
2258
2487
|
if (m.field) assertDeclared(m.field, "measure", m.name);
|
|
2259
2488
|
const metric = {
|
|
2260
2489
|
name: m.name,
|
|
2261
|
-
|
|
2490
|
+
// [#6761] Same as the dimension label above — see {@link REGISTRY_LOCALE}.
|
|
2491
|
+
label: (0, import_ui.resolveI18nLabel)(m.label, REGISTRY_LOCALE) ?? m.name,
|
|
2262
2492
|
type: aggregateToMetricType(m),
|
|
2263
2493
|
// `count` with no field aggregates over rows (*).
|
|
2264
2494
|
sql: m.field ?? "*"
|
|
@@ -2269,7 +2499,10 @@ function compileDataset(dataset, resolver, options) {
|
|
|
2269
2499
|
}
|
|
2270
2500
|
const cube = {
|
|
2271
2501
|
name: dataset.name,
|
|
2272
|
-
|
|
2502
|
+
// [#6761] The cube's own display title, same rule. `Cube.title` is optional
|
|
2503
|
+
// in the schema, but an absent dataset label already produced the machine
|
|
2504
|
+
// name here and that is not what this card changes — only the map case moves.
|
|
2505
|
+
title: (0, import_ui.resolveI18nLabel)(dataset.label, REGISTRY_LOCALE) ?? dataset.name,
|
|
2273
2506
|
sql: dataset.object,
|
|
2274
2507
|
measures,
|
|
2275
2508
|
dimensions,
|
|
@@ -2478,6 +2711,62 @@ function shiftRange(range, kind) {
|
|
|
2478
2711
|
const prevStartMs = prevEndMs - (lengthDays - 1) * DAY_MS;
|
|
2479
2712
|
return [toISODate(prevStartMs), toISODate(prevEndMs)];
|
|
2480
2713
|
}
|
|
2714
|
+
function isoWeekKeyOfUtcMs(ms) {
|
|
2715
|
+
const target = new Date(ms);
|
|
2716
|
+
const dayNum = (target.getUTCDay() + 6) % 7;
|
|
2717
|
+
target.setUTCDate(target.getUTCDate() - dayNum + 3);
|
|
2718
|
+
const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
|
|
2719
|
+
const weekNo = 1 + Math.round(
|
|
2720
|
+
((target.getTime() - firstThursday.getTime()) / DAY_MS - 3 + (firstThursday.getUTCDay() + 6) % 7) / 7
|
|
2721
|
+
);
|
|
2722
|
+
return `${target.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`;
|
|
2723
|
+
}
|
|
2724
|
+
function bucketOrdinalOfDay(ymd, granularity) {
|
|
2725
|
+
const ms = parseUTC(ymd);
|
|
2726
|
+
const d = new Date(ms);
|
|
2727
|
+
const y = d.getUTCFullYear();
|
|
2728
|
+
const m = d.getUTCMonth();
|
|
2729
|
+
switch (granularity) {
|
|
2730
|
+
case "year":
|
|
2731
|
+
return y;
|
|
2732
|
+
case "quarter":
|
|
2733
|
+
return y * 4 + Math.floor(m / 3);
|
|
2734
|
+
case "month":
|
|
2735
|
+
return y * 12 + m;
|
|
2736
|
+
// 1970-01-01 was a Thursday, so shifting by 3 days puts the Monday boundary
|
|
2737
|
+
// on a multiple of 7 and the ordinal advances exactly at each ISO week start.
|
|
2738
|
+
case "week":
|
|
2739
|
+
return Math.floor((ms + 3 * DAY_MS) / (7 * DAY_MS));
|
|
2740
|
+
case "day":
|
|
2741
|
+
default:
|
|
2742
|
+
return Math.floor(ms / DAY_MS);
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
function bucketKeyAtOrdinal(ordinal, granularity) {
|
|
2746
|
+
switch (granularity) {
|
|
2747
|
+
case "year":
|
|
2748
|
+
return String(ordinal);
|
|
2749
|
+
case "quarter":
|
|
2750
|
+
return `${Math.floor(ordinal / 4)}-Q${ordinal % 4 + 1}`;
|
|
2751
|
+
case "month":
|
|
2752
|
+
return `${Math.floor(ordinal / 12)}-${String(ordinal % 12 + 1).padStart(2, "0")}`;
|
|
2753
|
+
case "week":
|
|
2754
|
+
return isoWeekKeyOfUtcMs(ordinal * 7 * DAY_MS - 3 * DAY_MS);
|
|
2755
|
+
case "day":
|
|
2756
|
+
default:
|
|
2757
|
+
return toISODate(ordinal * DAY_MS);
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
function alignedCompareBucketKey(key, granularity, kind, currentRange, shiftedRange) {
|
|
2761
|
+
if (typeof key !== "string" || key.length === 0) return null;
|
|
2762
|
+
const span = (0, import_core3.bucketKeyToCalendarRange)(key, granularity);
|
|
2763
|
+
if (!span) return null;
|
|
2764
|
+
const targetOrdinal = kind === "previousYear" ? bucketOrdinalOfDay(shiftYear(span.start, 1), granularity) : bucketOrdinalOfDay(span.start, granularity) + (bucketOrdinalOfDay(currentRange[0], granularity) - bucketOrdinalOfDay(shiftedRange[0], granularity));
|
|
2765
|
+
const first = bucketOrdinalOfDay(currentRange[0], granularity);
|
|
2766
|
+
const last = bucketOrdinalOfDay(currentRange[1], granularity);
|
|
2767
|
+
if (targetOrdinal < first || targetOrdinal > last) return null;
|
|
2768
|
+
return bucketKeyAtOrdinal(targetOrdinal, granularity);
|
|
2769
|
+
}
|
|
2481
2770
|
var DatasetExecutor = class {
|
|
2482
2771
|
/**
|
|
2483
2772
|
* @param service - The analytics service the executor issues its queries to.
|
|
@@ -2664,6 +2953,24 @@ var DatasetExecutor = class {
|
|
|
2664
2953
|
timeDimensionsOf(compiled, dimensions) {
|
|
2665
2954
|
return dimensions.filter((d) => compiled.cube.dimensions[d]?.type === "time");
|
|
2666
2955
|
}
|
|
2956
|
+
/**
|
|
2957
|
+
* The EFFECTIVE bucket size one dimension is grouped at for this selection,
|
|
2958
|
+
* or `undefined` when it is not a date dimension or nothing states a size (in
|
|
2959
|
+
* which case the runtime groups the raw column).
|
|
2960
|
+
*
|
|
2961
|
+
* One definition, two readers, deliberately: {@link buildQuery} uses it to
|
|
2962
|
+
* decide the `GROUP BY`, and {@link runCompare} uses it to realign the
|
|
2963
|
+
* comparison pass's bucket keys (#6007). Those two MUST agree — realigning
|
|
2964
|
+
* `month` keys a query grouped by `quarter` would move every comparison value
|
|
2965
|
+
* onto a bucket that does not exist — and the way to make them agree is to
|
|
2966
|
+
* have one of them, not two that look alike.
|
|
2967
|
+
*/
|
|
2968
|
+
granularityOf(compiled, selection, name) {
|
|
2969
|
+
const cd = compiled.cube.dimensions[name];
|
|
2970
|
+
if (cd?.type !== "time") return void 0;
|
|
2971
|
+
const datasetDefault = cd.granularities?.length === 1 ? String(cd.granularities[0]) : void 0;
|
|
2972
|
+
return resolveDimensionGranularity(selection, name, datasetDefault);
|
|
2973
|
+
}
|
|
2667
2974
|
buildQuery(compiled, opts) {
|
|
2668
2975
|
const q = {
|
|
2669
2976
|
cube: compiled.cube.name,
|
|
@@ -2677,12 +2984,7 @@ var DatasetExecutor = class {
|
|
|
2677
2984
|
const selTimeDims = opts.selection.timeDimensions ?? [];
|
|
2678
2985
|
const selDims = new Set(selTimeDims.map((t) => t.dimension));
|
|
2679
2986
|
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
|
-
};
|
|
2987
|
+
const granularityFor = (name) => this.granularityOf(compiled, opts.selection, name);
|
|
2686
2988
|
const bucketsUnstatedEntry = (dimension) => groupedDims.has(dimension) || opts.selection.dateGranularity != null;
|
|
2687
2989
|
const resolvedTimeDims = selTimeDims.map((t) => {
|
|
2688
2990
|
if (t.granularity) return t;
|
|
@@ -2717,9 +3019,14 @@ var DatasetExecutor = class {
|
|
|
2717
3019
|
{ ...selection, timeDimensions: shiftedTd },
|
|
2718
3020
|
{ measures, dimensions, baseFilter, context }
|
|
2719
3021
|
);
|
|
3022
|
+
const granularity = dimensions.includes(dimension) ? this.granularityOf(compiled, selection, dimension) : void 0;
|
|
2720
3023
|
return sub.rows.map((row) => {
|
|
2721
3024
|
const out = {};
|
|
2722
3025
|
for (const dim of dimensions) out[dim] = row[dim];
|
|
3026
|
+
if (granularity) {
|
|
3027
|
+
const aligned = alignedCompareBucketKey(row[dimension], granularity, cmp.kind, range, shifted);
|
|
3028
|
+
if (aligned != null) out[dimension] = aligned;
|
|
3029
|
+
}
|
|
2723
3030
|
for (const m of measures) out[`${m}__compare`] = row[m];
|
|
2724
3031
|
return out;
|
|
2725
3032
|
});
|
|
@@ -3096,8 +3403,12 @@ function hasDeclaredErrorEnvelope(err) {
|
|
|
3096
3403
|
const e = err;
|
|
3097
3404
|
return typeof e?.status === "number" && typeof e?.code === "string" && e.code.length > 0;
|
|
3098
3405
|
}
|
|
3406
|
+
function isMissingColumnOfRelation(message) {
|
|
3407
|
+
return (0, import_types.matchMissingColumnOfRelation)(message) !== void 0;
|
|
3408
|
+
}
|
|
3099
3409
|
function isMissingSourceError(err) {
|
|
3100
3410
|
const raw = String(err?.message ?? err ?? "");
|
|
3411
|
+
if (isMissingColumnOfRelation(raw)) return false;
|
|
3101
3412
|
const msg = raw.toLowerCase();
|
|
3102
3413
|
return msg.includes("no such table") || // sqlite / libsql
|
|
3103
3414
|
/relation\s+[`"']?[A-Za-z0-9_$.]+[`"']?\s+does not exist/i.test(raw) || // postgres
|
|
@@ -3107,6 +3418,7 @@ function isMissingSourceError(err) {
|
|
|
3107
3418
|
}
|
|
3108
3419
|
function missingSourceRelation(err) {
|
|
3109
3420
|
const msg = String(err?.message ?? err ?? "");
|
|
3421
|
+
if (isMissingColumnOfRelation(msg)) return void 0;
|
|
3110
3422
|
const patterns = [
|
|
3111
3423
|
/no such table:\s*[`"'[]?([A-Za-z0-9_$.]+)/i,
|
|
3112
3424
|
// sqlite / libsql
|
|
@@ -3351,6 +3663,7 @@ var AnalyticsService = class {
|
|
|
3351
3663
|
return previewResult;
|
|
3352
3664
|
}
|
|
3353
3665
|
}
|
|
3666
|
+
const requestLocale = context?.locale;
|
|
3354
3667
|
const provider = this.readScopeProvider;
|
|
3355
3668
|
const resolveScope = provider ? (targetObject) => provider(targetObject, context) : void 0;
|
|
3356
3669
|
const labelDeps = this.labelResolver ? withLabelFetchCache(this.labelResolver) : void 0;
|
|
@@ -3460,7 +3773,10 @@ var AnalyticsService = class {
|
|
|
3460
3773
|
for (const f of result.fields) {
|
|
3461
3774
|
const m = measureByName.get(f.name) ?? measureByName.get(f.name.replace(/__compare$/, ""));
|
|
3462
3775
|
if (!m) continue;
|
|
3463
|
-
if (f.label == null
|
|
3776
|
+
if (f.label == null) {
|
|
3777
|
+
const label = (0, import_ui2.resolveI18nLabel)(m.label, requestLocale);
|
|
3778
|
+
if (label !== void 0) f.label = label;
|
|
3779
|
+
}
|
|
3464
3780
|
if (f.format == null && m.format) f.format = m.format;
|
|
3465
3781
|
const fc = f;
|
|
3466
3782
|
const mc = m;
|
|
@@ -3489,7 +3805,9 @@ var AnalyticsService = class {
|
|
|
3489
3805
|
for (const f of result.fields) {
|
|
3490
3806
|
if (f.label != null) continue;
|
|
3491
3807
|
const d = dimByName.get(f.name) ?? dimByField.get(f.name);
|
|
3492
|
-
if (d
|
|
3808
|
+
if (!d) continue;
|
|
3809
|
+
const label = (0, import_ui2.resolveI18nLabel)(d.label, requestLocale);
|
|
3810
|
+
if (label !== void 0) f.label = label;
|
|
3493
3811
|
}
|
|
3494
3812
|
}
|
|
3495
3813
|
return result;
|
|
@@ -3569,10 +3887,10 @@ var AnalyticsService = class {
|
|
|
3569
3887
|
else this.logger.warn(message);
|
|
3570
3888
|
return;
|
|
3571
3889
|
}
|
|
3572
|
-
const stripPrefix = (m) => m.includes(".") ? m.split(".").slice(1).join(".") : m;
|
|
3573
3890
|
const extraMeasures = {};
|
|
3574
3891
|
for (const m of query.measures || []) {
|
|
3575
|
-
|
|
3892
|
+
if (cube.measures[m] || extraMeasures[m]) continue;
|
|
3893
|
+
const key = mintableMeasureKey(m, name);
|
|
3576
3894
|
if (cube.measures[key] || extraMeasures[key]) continue;
|
|
3577
3895
|
extraMeasures[key] = inferMeasure(key);
|
|
3578
3896
|
}
|
|
@@ -3621,6 +3939,13 @@ var AnalyticsService = class {
|
|
|
3621
3939
|
* the data path's `resolveQueryFields`: they are engine-assigned rather than
|
|
3622
3940
|
* declared, and a gate stricter than the engine it guards would reject
|
|
3623
3941
|
* queries that used to work.
|
|
3942
|
+
*
|
|
3943
|
+
* [#5918] Its `stripPrefix` below is deliberately NOT narrowed the way the two
|
|
3944
|
+
* MINTS were. This is a RESOLVER — it mirrors `lookupMember`'s tiers to answer
|
|
3945
|
+
* "which Metric will the strategy read", and that tier order did not change.
|
|
3946
|
+
* What changed is what can reach it: a dotted measure is now either a
|
|
3947
|
+
* `<cube>.` qualifier or a key the cube itself declares, because every other
|
|
3948
|
+
* dotted spelling is refused at the mint before this gate runs.
|
|
3624
3949
|
*/
|
|
3625
3950
|
assertMeasureFields(query, cube, declaredMeasures) {
|
|
3626
3951
|
const probe = this.getObjectFieldNames;
|
|
@@ -3909,7 +4234,7 @@ var AnalyticsService = class {
|
|
|
3909
4234
|
};
|
|
3910
4235
|
measures.count = { name: "count", label: "Count", type: "count", sql: "*" };
|
|
3911
4236
|
for (const m of query.measures || []) {
|
|
3912
|
-
const key = m
|
|
4237
|
+
const key = mintableMeasureKey(m, cubeName);
|
|
3913
4238
|
if (measures[key]) continue;
|
|
3914
4239
|
const inferred = inferMeasure(key);
|
|
3915
4240
|
measures[key] = inferred;
|
|
@@ -3968,6 +4293,15 @@ var AnalyticsService = class {
|
|
|
3968
4293
|
);
|
|
3969
4294
|
}
|
|
3970
4295
|
};
|
|
4296
|
+
function mintableMeasureKey(member, cubeName) {
|
|
4297
|
+
const dot = member.indexOf(".");
|
|
4298
|
+
if (dot < 0) return member;
|
|
4299
|
+
if (member.slice(0, dot) === cubeName) return member.slice(dot + 1);
|
|
4300
|
+
throw invalidMemberError(
|
|
4301
|
+
`[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.`,
|
|
4302
|
+
{ member, param: "measures", cube: cubeName }
|
|
4303
|
+
);
|
|
4304
|
+
}
|
|
3971
4305
|
function inferMeasure(key) {
|
|
3972
4306
|
if (key === "count") {
|
|
3973
4307
|
return { name: "count", label: "Count", type: "count", sql: "*" };
|
|
@@ -4266,7 +4600,17 @@ var AnalyticsServicePlugin = class {
|
|
|
4266
4600
|
// datasource the query was routed to. Undefined ⇒ the object rides the
|
|
4267
4601
|
// default datasource (or the engine cannot answer), and the diagnostic
|
|
4268
4602
|
// says so rather than inventing a name.
|
|
4269
|
-
|
|
4603
|
+
//
|
|
4604
|
+
// [#5288] Asked of the ENGINE's resolver, not of the object's declaration.
|
|
4605
|
+
// `getObject(name).datasource` is the declared value — step 1 of the five
|
|
4606
|
+
// `getDriver` routes by — so an object placed by a `datasourceMapping`
|
|
4607
|
+
// rule, by the ADR-0057 §3.6 lifecycle split, or by its package's
|
|
4608
|
+
// `defaultDatasource` answered `'default'`, and the diagnostic named a
|
|
4609
|
+
// database the rows are not in. Recomputing those rules here instead would
|
|
4610
|
+
// be the second implementation `resolveMappedDatasource` (#4462) exists to
|
|
4611
|
+
// prevent: it drifts by one step, silently, and the drift only surfaces as
|
|
4612
|
+
// an error message pointing at the wrong database.
|
|
4613
|
+
getObjectDatasource: (objectName) => dataEngine()?.resolveEffectiveDatasource?.(objectName),
|
|
4270
4614
|
// ADR-0062 D6 — a federated object carries an `external` block (ADR-0015).
|
|
4271
4615
|
// Reported so NativeSQLStrategy declines it (its hand-compiled FROM would
|
|
4272
4616
|
// hit the wrong physical table) and the driver-correct ObjectQL path runs.
|