@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.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// src/analytics-service.ts
|
|
2
2
|
import { percentScaleOf } from "@objectstack/spec/data";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveI18nLabel as resolveI18nLabel2 } from "@objectstack/spec/ui";
|
|
4
|
+
import { createLogger, bucketKeyToCalendarRange as bucketKeyToCalendarRange2, zonedDateStartToUtcMs } from "@objectstack/core";
|
|
5
|
+
import { matchMissingColumnOfRelation } from "@objectstack/types";
|
|
4
6
|
|
|
5
7
|
// src/cube-registry.ts
|
|
6
8
|
var CubeRegistry = class {
|
|
@@ -120,6 +122,43 @@ var CubeRegistry = class {
|
|
|
120
122
|
// src/strategies/filter-normalizer.ts
|
|
121
123
|
import { isFilterAST, parseFilterAST, VALID_AST_OPERATORS } from "@objectstack/spec/data";
|
|
122
124
|
import { StandardErrorCode } from "@objectstack/spec/api";
|
|
125
|
+
|
|
126
|
+
// src/comparand-shape.ts
|
|
127
|
+
function isBindableComparand(value) {
|
|
128
|
+
if (value === null || value === void 0) return true;
|
|
129
|
+
const kind = typeof value;
|
|
130
|
+
if (kind === "string" || kind === "number" || kind === "bigint" || kind === "boolean") return true;
|
|
131
|
+
return value instanceof Date || ArrayBuffer.isView(value);
|
|
132
|
+
}
|
|
133
|
+
function isRenderableTextComparand(value) {
|
|
134
|
+
if (value === null || value === void 0) return true;
|
|
135
|
+
const kind = typeof value;
|
|
136
|
+
if (kind === "string" || kind === "number" || kind === "bigint" || kind === "boolean") return true;
|
|
137
|
+
return value instanceof Date;
|
|
138
|
+
}
|
|
139
|
+
var TEXT_PATTERN_OPERATORS = /* @__PURE__ */ new Set([
|
|
140
|
+
"$contains",
|
|
141
|
+
"$notContains",
|
|
142
|
+
"$startsWith",
|
|
143
|
+
"$endsWith"
|
|
144
|
+
]);
|
|
145
|
+
function shapePreview(value) {
|
|
146
|
+
try {
|
|
147
|
+
const json = JSON.stringify(value);
|
|
148
|
+
if (typeof json !== "string") return typeof value;
|
|
149
|
+
return json.length > 80 ? `${json.slice(0, 77)}...` : json;
|
|
150
|
+
} catch {
|
|
151
|
+
return typeof value;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function unrenderableTextComparandMessage(op, field, value) {
|
|
155
|
+
return `"${op}" on "${field}" matches against the TEXT of a pattern, but its comparand is ${Array.isArray(value) ? "an array" : "an object"} (${shapePreview(value)}). filter.zod.ts declares it a string (StringOperatorSchema); a string, number, boolean, null or Date is accepted. Refusing rather than stringifying it: String({}) is "[object Object]", so the pattern that ran would be one nobody wrote \u2014 and a row storing that literal text matches it.`;
|
|
156
|
+
}
|
|
157
|
+
function unbindableListMemberMessage(op, field, value, index) {
|
|
158
|
+
return `"${op}" on "${field}" has a value at index ${index} of its list that cannot be bound as a SQL parameter: ${shapePreview(value)}. Every member of an $in/$nin/$between list is a comparand in its own right \u2014 use a string, number, boolean, null, Date or binary value. Refusing rather than binding it: the member can equal no stored value, so the list silently loses that entry (and a $nin loses the exclusion the caller wrote).`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// src/strategies/filter-normalizer.ts
|
|
123
162
|
function invalidFilterError(message) {
|
|
124
163
|
const err = new Error(message);
|
|
125
164
|
err.code = StandardErrorCode.enum.INVALID_FILTER;
|
|
@@ -138,7 +177,11 @@ var MONGO_TO_CUBE_OP = {
|
|
|
138
177
|
$contains: "contains",
|
|
139
178
|
$notContains: "notContains",
|
|
140
179
|
$startsWith: "startsWith",
|
|
141
|
-
$endsWith: "endsWith"
|
|
180
|
+
$endsWith: "endsWith",
|
|
181
|
+
// [#6520] The case-INSENSITIVE twin, ASCII fold only. A separate cube operator
|
|
182
|
+
// rather than a flag on `contains`, because the two compile to different SQL
|
|
183
|
+
// and one name would make the renderers guess which was meant.
|
|
184
|
+
$icontains: "icontains"
|
|
142
185
|
};
|
|
143
186
|
function comparand(v) {
|
|
144
187
|
return v === void 0 ? null : v;
|
|
@@ -161,7 +204,64 @@ function andOf(children) {
|
|
|
161
204
|
if (children.length === 1) return children[0];
|
|
162
205
|
return { kind: "and", children };
|
|
163
206
|
}
|
|
207
|
+
function assertCompilableComparand(opKey, field, value) {
|
|
208
|
+
if (TEXT_PATTERN_OPERATORS.has(opKey)) {
|
|
209
|
+
if (!isRenderableTextComparand(value)) {
|
|
210
|
+
throw invalidFilterError(`[analytics] ${unrenderableTextComparandMessage(opKey, field, value)}`);
|
|
211
|
+
}
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if ((opKey === "$in" || opKey === "$nin") && Array.isArray(value)) {
|
|
215
|
+
value.forEach((member, index) => {
|
|
216
|
+
if (!isBindableComparand(member)) {
|
|
217
|
+
throw invalidFilterError(`[analytics] ${unbindableListMemberMessage(opKey, field, member, index)}`);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function undefinedComparandError(field, path) {
|
|
223
|
+
return invalidFilterError(
|
|
224
|
+
`[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).`
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
function assertDefinedComparands(field, spec) {
|
|
228
|
+
const root = `"${field}"`;
|
|
229
|
+
if (spec === void 0) throw undefinedComparandError(field, root);
|
|
230
|
+
if (Array.isArray(spec)) {
|
|
231
|
+
spec.forEach((member, index) => {
|
|
232
|
+
if (member === void 0) throw undefinedComparandError(field, `${root}[${index}]`);
|
|
233
|
+
});
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (!isFilterObject(spec)) return;
|
|
237
|
+
for (const [op, opValue] of Object.entries(spec)) {
|
|
238
|
+
if (!op.startsWith("$") || op === "$null" || op === "$exists") continue;
|
|
239
|
+
const opPath = `${root}.${op}`;
|
|
240
|
+
if (opValue === void 0) throw undefinedComparandError(field, opPath);
|
|
241
|
+
if (!Array.isArray(opValue)) continue;
|
|
242
|
+
opValue.forEach((member, index) => {
|
|
243
|
+
if (member === void 0) throw undefinedComparandError(field, `${opPath}[${index}]`);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function mixedFieldWrapperError(field, opKeys, nonOpKeys) {
|
|
248
|
+
const offending = nonOpKeys.map((k) => `"${k}"`).join(", ");
|
|
249
|
+
const rewrites = nonOpKeys.map((k) => `"${k}" \u2192 "$${k}"`).join(", ");
|
|
250
|
+
const example = nonOpKeys[0];
|
|
251
|
+
return invalidFilterError(
|
|
252
|
+
`[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).`
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
function assertUnmixedFieldWrapper(field, wrapper) {
|
|
256
|
+
const keys = Object.keys(wrapper);
|
|
257
|
+
const opKeys = keys.filter((k) => k.startsWith("$"));
|
|
258
|
+
if (opKeys.length === 0) return;
|
|
259
|
+
const nonOpKeys = keys.filter((k) => !k.startsWith("$"));
|
|
260
|
+
if (nonOpKeys.length === 0) return;
|
|
261
|
+
throw mixedFieldWrapperError(field, opKeys, nonOpKeys);
|
|
262
|
+
}
|
|
164
263
|
function fieldLeaves(key, raw) {
|
|
264
|
+
assertDefinedComparands(key, raw);
|
|
165
265
|
const out = [];
|
|
166
266
|
const leaf = (operator, values) => {
|
|
167
267
|
out.push({ kind: "leaf", member: key, operator, values });
|
|
@@ -177,6 +277,7 @@ function fieldLeaves(key, raw) {
|
|
|
177
277
|
`[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.`
|
|
178
278
|
);
|
|
179
279
|
}
|
|
280
|
+
assertUnmixedFieldWrapper(key, wrapper);
|
|
180
281
|
const opKeys = Object.keys(wrapper).filter((k) => k.startsWith("$"));
|
|
181
282
|
if (opKeys.length > 0) {
|
|
182
283
|
for (const opKey of opKeys) {
|
|
@@ -211,6 +312,7 @@ function fieldLeaves(key, raw) {
|
|
|
211
312
|
);
|
|
212
313
|
}
|
|
213
314
|
const v = wrapper[opKey];
|
|
315
|
+
assertCompilableComparand(opKey, key, v);
|
|
214
316
|
const values = Array.isArray(v) ? v.map(comparand) : [comparand(v)];
|
|
215
317
|
if (nullValueSatisfiesOperator(opKey, v) && !operatorIsNullTotal(opKey, v)) {
|
|
216
318
|
out.push({
|
|
@@ -240,7 +342,6 @@ function fieldLeaves(key, raw) {
|
|
|
240
342
|
function buildNode(cond) {
|
|
241
343
|
const children = [];
|
|
242
344
|
for (const [key, raw] of Object.entries(cond)) {
|
|
243
|
-
if (raw === void 0) continue;
|
|
244
345
|
if (key === "$and" || key === "$or") {
|
|
245
346
|
if (!Array.isArray(raw)) {
|
|
246
347
|
throw invalidFilterError(
|
|
@@ -456,6 +557,11 @@ function likePattern(shape, value) {
|
|
|
456
557
|
const escaped = escapeLikePattern(value);
|
|
457
558
|
return shape === "starts" ? `${escaped}%` : shape === "ends" ? `%${escaped}` : `%${escaped}%`;
|
|
458
559
|
}
|
|
560
|
+
var ASCII_UPPER_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
561
|
+
var ASCII_LOWER_LETTERS = "abcdefghijklmnopqrstuvwxyz";
|
|
562
|
+
function asciiLowerSqlExpr(expr) {
|
|
563
|
+
return `translate(${expr}, '${ASCII_UPPER_LETTERS}', '${ASCII_LOWER_LETTERS}')`;
|
|
564
|
+
}
|
|
459
565
|
|
|
460
566
|
// src/read-scope-sql.ts
|
|
461
567
|
var IDENT = /^[a-z_][a-z0-9_]*$/i;
|
|
@@ -527,6 +633,8 @@ function compileNode(node, qAlias, params) {
|
|
|
527
633
|
}
|
|
528
634
|
function compileField(field, value, qAlias, params) {
|
|
529
635
|
const col = `${qAlias}.${quoteIdent(field, "field")}`;
|
|
636
|
+
assertDefinedComparands2(field, value);
|
|
637
|
+
assertBooleanFlagComparands(field, value);
|
|
530
638
|
if (value === null) return `${col} IS NULL`;
|
|
531
639
|
if (typeof value !== "object" || value instanceof Date) {
|
|
532
640
|
params.push(value);
|
|
@@ -556,6 +664,49 @@ function bindLike(params, pattern) {
|
|
|
556
664
|
function nullSafeNegative(col, test) {
|
|
557
665
|
return `(${col} IS NULL OR ${test})`;
|
|
558
666
|
}
|
|
667
|
+
function assertCompilableMembers(op, field, members) {
|
|
668
|
+
members.forEach((member, index) => {
|
|
669
|
+
if (!isBindableComparand(member)) {
|
|
670
|
+
throw readScopeCompileError(`[read-scope-sql] ${unbindableListMemberMessage(op, field, member, index)}`);
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
function assertRenderableText(op, field, val) {
|
|
675
|
+
if (isRenderableTextComparand(val)) return;
|
|
676
|
+
throw readScopeCompileError(`[read-scope-sql] ${unrenderableTextComparandMessage(op, field, val)}`);
|
|
677
|
+
}
|
|
678
|
+
function undefinedComparandError2(field, path) {
|
|
679
|
+
return readScopeCompileError(
|
|
680
|
+
`[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).`
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
function assertDefinedComparands2(field, spec) {
|
|
684
|
+
const root = `"${field}"`;
|
|
685
|
+
if (spec === void 0) throw undefinedComparandError2(field, root);
|
|
686
|
+
if (!isFilterNode(spec)) return;
|
|
687
|
+
for (const [op, opValue] of Object.entries(spec)) {
|
|
688
|
+
if (!op.startsWith("$") || op === "$null" || op === "$exists") continue;
|
|
689
|
+
const opPath = `${root}.${op}`;
|
|
690
|
+
if (opValue === void 0) throw undefinedComparandError2(field, opPath);
|
|
691
|
+
if (!Array.isArray(opValue)) continue;
|
|
692
|
+
opValue.forEach((member, index) => {
|
|
693
|
+
if (member === void 0) throw undefinedComparandError2(field, `${opPath}[${index}]`);
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
function nonBooleanFlagComparandError(op, field, path) {
|
|
698
|
+
return readScopeCompileError(
|
|
699
|
+
`[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).`
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
function assertBooleanFlagComparands(field, spec) {
|
|
703
|
+
if (!isFilterNode(spec)) return;
|
|
704
|
+
for (const op of ["$null", "$exists"]) {
|
|
705
|
+
if (!Object.prototype.hasOwnProperty.call(spec, op)) continue;
|
|
706
|
+
if (typeof spec[op] === "boolean") continue;
|
|
707
|
+
throw nonBooleanFlagComparandError(op, field, `"${field}".${op}`);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
559
710
|
function compileOperator(col, op, val, field, params) {
|
|
560
711
|
switch (op) {
|
|
561
712
|
case "$eq":
|
|
@@ -575,33 +726,70 @@ function compileOperator(col, op, val, field, params) {
|
|
|
575
726
|
case "$in": {
|
|
576
727
|
if (!Array.isArray(val)) throw readScopeCompileError(`[read-scope-sql] $in for "${field}" needs an array (fail-closed).`);
|
|
577
728
|
if (val.length === 0) return FALSE_CLAUSE;
|
|
729
|
+
assertCompilableMembers(op, field, val);
|
|
578
730
|
return `${col} IN (${val.map((v) => bind(params, v)).join(", ")})`;
|
|
579
731
|
}
|
|
580
732
|
case "$nin": {
|
|
581
733
|
if (!Array.isArray(val)) throw readScopeCompileError(`[read-scope-sql] $nin for "${field}" needs an array (fail-closed).`);
|
|
582
734
|
if (val.length === 0) return "1 = 1";
|
|
735
|
+
assertCompilableMembers(op, field, val);
|
|
583
736
|
return nullSafeNegative(col, `${col} NOT IN (${val.map((v) => bind(params, v)).join(", ")})`);
|
|
584
737
|
}
|
|
585
738
|
case "$between": {
|
|
586
739
|
if (!Array.isArray(val) || val.length !== 2) throw readScopeCompileError(`[read-scope-sql] $between for "${field}" needs [min,max] (fail-closed).`);
|
|
740
|
+
assertCompilableMembers(op, field, val);
|
|
587
741
|
return `${col} BETWEEN ${bind(params, val[0])} AND ${bind(params, val[1])}`;
|
|
588
742
|
}
|
|
589
743
|
// [#5567] The comparand is a LITERAL, so it is escaped and the escape
|
|
590
744
|
// character is bound with it. See {@link bindLike}.
|
|
745
|
+
// [#5234] …and it must be a value `String()` can render, which is asserted
|
|
746
|
+
// BEFORE `likePattern` sees it — see {@link assertRenderableText}.
|
|
591
747
|
case "$contains":
|
|
748
|
+
assertRenderableText(op, field, val);
|
|
592
749
|
return `${col} LIKE ${bindLike(params, likePattern("contains", val))}`;
|
|
750
|
+
/**
|
|
751
|
+
* [#6520] `$icontains` on the READ-SCOPE lowering — the one compiler in this
|
|
752
|
+
* package where a wrong answer is an ADR-0021 scope over-reach rather than a
|
|
753
|
+
* loose chart filter, which is why the fold is the spec's ruled one and not
|
|
754
|
+
* `LOWER()`.
|
|
755
|
+
*
|
|
756
|
+
* `assertRenderableText` first, exactly as its case-exact twin above: the
|
|
757
|
+
* comparand has to be something `String()` renders faithfully before a
|
|
758
|
+
* pattern is built from it (#5234).
|
|
759
|
+
*
|
|
760
|
+
* The fold wraps BOTH the column and the bound pattern. Folding one side
|
|
761
|
+
* only would compare a folded needle against a raw column — matching just
|
|
762
|
+
* the rows already lower-case — and on a read scope that is a row set the
|
|
763
|
+
* policy author never wrote, in the narrowing direction here but in the
|
|
764
|
+
* WIDENING direction under a `$not`.
|
|
765
|
+
*/
|
|
766
|
+
case "$icontains": {
|
|
767
|
+
assertRenderableText(op, field, val);
|
|
768
|
+
const patternRef = asciiLowerSqlExpr(bind(params, likePattern("contains", val)));
|
|
769
|
+
return `${asciiLowerSqlExpr(col)} LIKE ${patternRef} ESCAPE ${bind(params, LIKE_ESCAPE_CHAR)}`;
|
|
770
|
+
}
|
|
593
771
|
// [#5298] NULL-safe: `NOT LIKE` is UNKNOWN for a NULL column, and "does not
|
|
594
772
|
// contain" is true of a value that is not there.
|
|
595
773
|
case "$notContains":
|
|
774
|
+
assertRenderableText(op, field, val);
|
|
596
775
|
return nullSafeNegative(col, `${col} NOT LIKE ${bindLike(params, likePattern("contains", val))}`);
|
|
597
776
|
case "$startsWith":
|
|
777
|
+
assertRenderableText(op, field, val);
|
|
598
778
|
return `${col} LIKE ${bindLike(params, likePattern("starts", val))}`;
|
|
599
779
|
case "$endsWith":
|
|
780
|
+
assertRenderableText(op, field, val);
|
|
600
781
|
return `${col} LIKE ${bindLike(params, likePattern("ends", val))}`;
|
|
782
|
+
// [#6387] `val` is a boolean here — {@link assertBooleanFlagComparands}
|
|
783
|
+
// refused anything else at {@link compileField}, before this emitter runs.
|
|
784
|
+
// So `=== true` is an exhaustive TWO-WAY choice over the declared domain,
|
|
785
|
+
// not the "anything truthy is IS NULL" rule it used to be. That old rule is
|
|
786
|
+
// what put the STRING `"false"` on the side opposite the `false` it was
|
|
787
|
+
// written to mean; the identity spelling cannot, and it is the spelling
|
|
788
|
+
// {@link nullValueSatisfiesOperator} now mirrors (#5146 / #5298).
|
|
601
789
|
case "$null":
|
|
602
|
-
return val ? `${col} IS NULL` : `${col} IS NOT NULL`;
|
|
790
|
+
return val === true ? `${col} IS NULL` : `${col} IS NOT NULL`;
|
|
603
791
|
case "$exists":
|
|
604
|
-
return val ? `${col} IS NOT NULL` : `${col} IS NULL`;
|
|
792
|
+
return val === true ? `${col} IS NOT NULL` : `${col} IS NULL`;
|
|
605
793
|
default:
|
|
606
794
|
throw readScopeCompileError(`[read-scope-sql] unsupported operator "${op}" on "${field}" (fail-closed).`);
|
|
607
795
|
}
|
|
@@ -614,11 +802,22 @@ function nullValueSatisfiesOperator2(op, value) {
|
|
|
614
802
|
// Mirror image: `$ne: null` compiles to `IS NOT NULL`, which a NULL fails.
|
|
615
803
|
case "$ne":
|
|
616
804
|
return value !== null;
|
|
617
|
-
//
|
|
805
|
+
// [#6387] Identity, matching this file's emitter (see the note above).
|
|
806
|
+
// `assertBooleanFlagComparands` refuses anything but `true` / `false` before
|
|
807
|
+
// this table is consulted, so each arm is an exhaustive TWO-WAY choice over
|
|
808
|
+
// the declared domain — and the strict spelling is chosen over the lenient
|
|
809
|
+
// one it replaces for the reason #5347 gave: `Boolean(value)` and
|
|
810
|
+
// `value === true` are equivalent only while the gate upstream holds, and
|
|
811
|
+
// the lenient spelling would quietly resume answering for shapes nobody
|
|
812
|
+
// ruled on if that gate were ever moved. A NULL column satisfies `$null`
|
|
813
|
+
// exactly when the author asked for null…
|
|
618
814
|
case "$null":
|
|
619
|
-
return
|
|
815
|
+
return value === true;
|
|
816
|
+
// …and satisfies `$exists` exactly when the author asked for "no value".
|
|
817
|
+
// `$null: true` and `$exists: false` are the same question, so these two
|
|
818
|
+
// arms are correctly each other's MIRROR, not each other's copy (#5369).
|
|
620
819
|
case "$exists":
|
|
621
|
-
return
|
|
820
|
+
return value === false;
|
|
622
821
|
// Negative-polarity set / substring tests hold vacuously for an absent value.
|
|
623
822
|
case "$nin":
|
|
624
823
|
return true;
|
|
@@ -1124,13 +1323,19 @@ var NativeSQLStrategy = class {
|
|
|
1124
1323
|
contains: "LIKE",
|
|
1125
1324
|
notContains: "NOT LIKE",
|
|
1126
1325
|
startsWith: "LIKE",
|
|
1127
|
-
endsWith: "LIKE"
|
|
1326
|
+
endsWith: "LIKE",
|
|
1327
|
+
// [#6520] `$icontains` — `LIKE` like its neighbours; what separates it is
|
|
1328
|
+
// the ASCII fold applied below, not the keyword.
|
|
1329
|
+
icontains: "LIKE"
|
|
1128
1330
|
};
|
|
1129
1331
|
const likeShape = {
|
|
1130
1332
|
contains: "contains",
|
|
1131
1333
|
notContains: "contains",
|
|
1132
1334
|
startsWith: "starts",
|
|
1133
|
-
endsWith: "ends"
|
|
1335
|
+
endsWith: "ends",
|
|
1336
|
+
// [#6520] Same wildcard placement as `contains`; the case fold is what
|
|
1337
|
+
// differs, and it is applied to both sides of the comparison below.
|
|
1338
|
+
icontains: "contains"
|
|
1134
1339
|
};
|
|
1135
1340
|
if (operator === "set") return `${rawCol} IS NOT NULL`;
|
|
1136
1341
|
if (operator === "notSet") return `${rawCol} IS NULL`;
|
|
@@ -1149,6 +1354,9 @@ var NativeSQLStrategy = class {
|
|
|
1149
1354
|
params.push(likePattern(shape, values[0]));
|
|
1150
1355
|
const patternRef = `$${params.length}`;
|
|
1151
1356
|
params.push(LIKE_ESCAPE_CHAR);
|
|
1357
|
+
if (operator === "icontains") {
|
|
1358
|
+
return `${asciiLowerSqlExpr(rawCol)} ${sqlOp} ${asciiLowerSqlExpr(patternRef)} ESCAPE $${params.length}`;
|
|
1359
|
+
}
|
|
1152
1360
|
return `${rawCol} ${sqlOp} ${patternRef} ESCAPE $${params.length}`;
|
|
1153
1361
|
}
|
|
1154
1362
|
if (operator === "lte") {
|
|
@@ -1252,7 +1460,12 @@ var LIKE_SQL_OPS = {
|
|
|
1252
1460
|
contains: { sql: "LIKE", shape: "contains" },
|
|
1253
1461
|
notContains: { sql: "NOT LIKE", shape: "contains" },
|
|
1254
1462
|
startsWith: { sql: "LIKE", shape: "starts" },
|
|
1255
|
-
endsWith: { sql: "LIKE", shape: "ends" }
|
|
1463
|
+
endsWith: { sql: "LIKE", shape: "ends" },
|
|
1464
|
+
// [#6520] `$icontains`: the same escaped pattern and bound `ESCAPE` as its
|
|
1465
|
+
// four case-EXACT neighbours, with `fold` adding the ASCII-only case fold to
|
|
1466
|
+
// both sides of the comparison. The flag is on this row alone — the family
|
|
1467
|
+
// above it is case-sensitive by ruling (#4706 Q2 = A).
|
|
1468
|
+
icontains: { sql: "LIKE", shape: "contains", fold: true }
|
|
1256
1469
|
};
|
|
1257
1470
|
var ObjectQLStrategy = class {
|
|
1258
1471
|
constructor() {
|
|
@@ -1693,7 +1906,9 @@ var ObjectQLStrategy = class {
|
|
|
1693
1906
|
params.push(likePattern(like.shape, values[0]));
|
|
1694
1907
|
const patternRef = `$${params.length}`;
|
|
1695
1908
|
params.push(LIKE_ESCAPE_CHAR);
|
|
1696
|
-
|
|
1909
|
+
const lhs = like.fold ? asciiLowerSqlExpr(col) : col;
|
|
1910
|
+
const rhs = like.fold ? asciiLowerSqlExpr(patternRef) : patternRef;
|
|
1911
|
+
return `${lhs} ${like.sql} ${rhs} ESCAPE $${params.length}`;
|
|
1697
1912
|
}
|
|
1698
1913
|
const op = SCALAR_SQL_OPS[operator];
|
|
1699
1914
|
if (!op) {
|
|
@@ -1979,6 +2194,14 @@ var ObjectQLStrategy = class {
|
|
|
1979
2194
|
* string — `String(…)`, the same normalisation `like-pattern.ts` applies at the
|
|
1980
2195
|
* two SQL emitters and `driver-sql`'s `applyLike` applies at the driver, so one
|
|
1981
2196
|
* `$contains` means one thing on every face (#5567's invariant).
|
|
2197
|
+
*
|
|
2198
|
+
* [#5234] Those four `String(…)` calls now only ever see a value that renders
|
|
2199
|
+
* faithfully: `fieldLeaves` refuses an object comparand on this family before a
|
|
2200
|
+
* leaf exists. That ordering is load-bearing rather than incidental — this arm
|
|
2201
|
+
* is a PRODUCER for the engine, so stringifying an object here would have
|
|
2202
|
+
* laundered it into `'[object Object]'` and handed a driver a perfectly
|
|
2203
|
+
* well-typed string. A strict driver downstream could never have seen the shape
|
|
2204
|
+
* it was strict about, which is why the guard sits at the door and not here.
|
|
1982
2205
|
*/
|
|
1983
2206
|
convertFilter(operator, values) {
|
|
1984
2207
|
if (operator === "set") return { $ne: null };
|
|
@@ -2090,7 +2313,8 @@ var ObjectQLStrategy = class {
|
|
|
2090
2313
|
|
|
2091
2314
|
// src/dataset-compiler.ts
|
|
2092
2315
|
import { AggregationFunction } from "@objectstack/spec/data";
|
|
2093
|
-
|
|
2316
|
+
import { resolveI18nLabel } from "@objectstack/spec/ui";
|
|
2317
|
+
var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set();
|
|
2094
2318
|
var SUPPORTED_AGGREGATES = AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
|
|
2095
2319
|
function aggregateToMetricType(m) {
|
|
2096
2320
|
if (!m.aggregate) {
|
|
@@ -2125,6 +2349,7 @@ function fieldRelationshipPath(field) {
|
|
|
2125
2349
|
}
|
|
2126
2350
|
var MAX_JOIN_HOPS = 3;
|
|
2127
2351
|
var joinAlias = (path) => path.replace(/\./g, "__");
|
|
2352
|
+
var REGISTRY_LOCALE = void 0;
|
|
2128
2353
|
function compileDataset(dataset, resolver, options) {
|
|
2129
2354
|
const include = dataset.include ?? [];
|
|
2130
2355
|
const declaredDatasource = (objectName) => {
|
|
@@ -2196,7 +2421,11 @@ function compileDataset(dataset, resolver, options) {
|
|
|
2196
2421
|
assertDeclared(d.field, "dimension", d.name);
|
|
2197
2422
|
const dim = {
|
|
2198
2423
|
name: d.name,
|
|
2199
|
-
|
|
2424
|
+
// [#6761] An inline locale map is a label, not a missing one. Before this,
|
|
2425
|
+
// the `typeof === 'string'` test dropped the map and substituted the
|
|
2426
|
+
// machine name, which `/analytics/meta` then published as a display title
|
|
2427
|
+
// (`title: 'owner'` for a dimension labelled `{ en: 'Owner', … }`).
|
|
2428
|
+
label: resolveI18nLabel(d.label, REGISTRY_LOCALE) ?? d.name,
|
|
2200
2429
|
type: dimensionType(d),
|
|
2201
2430
|
sql: d.field
|
|
2202
2431
|
};
|
|
@@ -2216,7 +2445,8 @@ function compileDataset(dataset, resolver, options) {
|
|
|
2216
2445
|
if (m.field) assertDeclared(m.field, "measure", m.name);
|
|
2217
2446
|
const metric = {
|
|
2218
2447
|
name: m.name,
|
|
2219
|
-
|
|
2448
|
+
// [#6761] Same as the dimension label above — see {@link REGISTRY_LOCALE}.
|
|
2449
|
+
label: resolveI18nLabel(m.label, REGISTRY_LOCALE) ?? m.name,
|
|
2220
2450
|
type: aggregateToMetricType(m),
|
|
2221
2451
|
// `count` with no field aggregates over rows (*).
|
|
2222
2452
|
sql: m.field ?? "*"
|
|
@@ -2227,7 +2457,10 @@ function compileDataset(dataset, resolver, options) {
|
|
|
2227
2457
|
}
|
|
2228
2458
|
const cube = {
|
|
2229
2459
|
name: dataset.name,
|
|
2230
|
-
|
|
2460
|
+
// [#6761] The cube's own display title, same rule. `Cube.title` is optional
|
|
2461
|
+
// in the schema, but an absent dataset label already produced the machine
|
|
2462
|
+
// name here and that is not what this card changes — only the map case moves.
|
|
2463
|
+
title: resolveI18nLabel(dataset.label, REGISTRY_LOCALE) ?? dataset.name,
|
|
2231
2464
|
sql: dataset.object,
|
|
2232
2465
|
measures,
|
|
2233
2466
|
dimensions,
|
|
@@ -2245,7 +2478,7 @@ function compileDataset(dataset, resolver, options) {
|
|
|
2245
2478
|
|
|
2246
2479
|
// src/dataset-executor.ts
|
|
2247
2480
|
import { emptyGroupValueFor } from "@objectstack/spec/data";
|
|
2248
|
-
import { filterTokenContextFrom, resolveFilterTokens } from "@objectstack/core";
|
|
2481
|
+
import { bucketKeyToCalendarRange, filterTokenContextFrom, resolveFilterTokens } from "@objectstack/core";
|
|
2249
2482
|
function resolveSelectionTokens(compiled, selection, context) {
|
|
2250
2483
|
const tokenCtx = filterTokenContextFrom(context, /* @__PURE__ */ new Date());
|
|
2251
2484
|
const resolve = (v) => resolveFilterTokens(v, tokenCtx);
|
|
@@ -2436,6 +2669,62 @@ function shiftRange(range, kind) {
|
|
|
2436
2669
|
const prevStartMs = prevEndMs - (lengthDays - 1) * DAY_MS;
|
|
2437
2670
|
return [toISODate(prevStartMs), toISODate(prevEndMs)];
|
|
2438
2671
|
}
|
|
2672
|
+
function isoWeekKeyOfUtcMs(ms) {
|
|
2673
|
+
const target = new Date(ms);
|
|
2674
|
+
const dayNum = (target.getUTCDay() + 6) % 7;
|
|
2675
|
+
target.setUTCDate(target.getUTCDate() - dayNum + 3);
|
|
2676
|
+
const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
|
|
2677
|
+
const weekNo = 1 + Math.round(
|
|
2678
|
+
((target.getTime() - firstThursday.getTime()) / DAY_MS - 3 + (firstThursday.getUTCDay() + 6) % 7) / 7
|
|
2679
|
+
);
|
|
2680
|
+
return `${target.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`;
|
|
2681
|
+
}
|
|
2682
|
+
function bucketOrdinalOfDay(ymd, granularity) {
|
|
2683
|
+
const ms = parseUTC(ymd);
|
|
2684
|
+
const d = new Date(ms);
|
|
2685
|
+
const y = d.getUTCFullYear();
|
|
2686
|
+
const m = d.getUTCMonth();
|
|
2687
|
+
switch (granularity) {
|
|
2688
|
+
case "year":
|
|
2689
|
+
return y;
|
|
2690
|
+
case "quarter":
|
|
2691
|
+
return y * 4 + Math.floor(m / 3);
|
|
2692
|
+
case "month":
|
|
2693
|
+
return y * 12 + m;
|
|
2694
|
+
// 1970-01-01 was a Thursday, so shifting by 3 days puts the Monday boundary
|
|
2695
|
+
// on a multiple of 7 and the ordinal advances exactly at each ISO week start.
|
|
2696
|
+
case "week":
|
|
2697
|
+
return Math.floor((ms + 3 * DAY_MS) / (7 * DAY_MS));
|
|
2698
|
+
case "day":
|
|
2699
|
+
default:
|
|
2700
|
+
return Math.floor(ms / DAY_MS);
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
function bucketKeyAtOrdinal(ordinal, granularity) {
|
|
2704
|
+
switch (granularity) {
|
|
2705
|
+
case "year":
|
|
2706
|
+
return String(ordinal);
|
|
2707
|
+
case "quarter":
|
|
2708
|
+
return `${Math.floor(ordinal / 4)}-Q${ordinal % 4 + 1}`;
|
|
2709
|
+
case "month":
|
|
2710
|
+
return `${Math.floor(ordinal / 12)}-${String(ordinal % 12 + 1).padStart(2, "0")}`;
|
|
2711
|
+
case "week":
|
|
2712
|
+
return isoWeekKeyOfUtcMs(ordinal * 7 * DAY_MS - 3 * DAY_MS);
|
|
2713
|
+
case "day":
|
|
2714
|
+
default:
|
|
2715
|
+
return toISODate(ordinal * DAY_MS);
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
function alignedCompareBucketKey(key, granularity, kind, currentRange, shiftedRange) {
|
|
2719
|
+
if (typeof key !== "string" || key.length === 0) return null;
|
|
2720
|
+
const span = bucketKeyToCalendarRange(key, granularity);
|
|
2721
|
+
if (!span) return null;
|
|
2722
|
+
const targetOrdinal = kind === "previousYear" ? bucketOrdinalOfDay(shiftYear(span.start, 1), granularity) : bucketOrdinalOfDay(span.start, granularity) + (bucketOrdinalOfDay(currentRange[0], granularity) - bucketOrdinalOfDay(shiftedRange[0], granularity));
|
|
2723
|
+
const first = bucketOrdinalOfDay(currentRange[0], granularity);
|
|
2724
|
+
const last = bucketOrdinalOfDay(currentRange[1], granularity);
|
|
2725
|
+
if (targetOrdinal < first || targetOrdinal > last) return null;
|
|
2726
|
+
return bucketKeyAtOrdinal(targetOrdinal, granularity);
|
|
2727
|
+
}
|
|
2439
2728
|
var DatasetExecutor = class {
|
|
2440
2729
|
/**
|
|
2441
2730
|
* @param service - The analytics service the executor issues its queries to.
|
|
@@ -2622,6 +2911,24 @@ var DatasetExecutor = class {
|
|
|
2622
2911
|
timeDimensionsOf(compiled, dimensions) {
|
|
2623
2912
|
return dimensions.filter((d) => compiled.cube.dimensions[d]?.type === "time");
|
|
2624
2913
|
}
|
|
2914
|
+
/**
|
|
2915
|
+
* The EFFECTIVE bucket size one dimension is grouped at for this selection,
|
|
2916
|
+
* or `undefined` when it is not a date dimension or nothing states a size (in
|
|
2917
|
+
* which case the runtime groups the raw column).
|
|
2918
|
+
*
|
|
2919
|
+
* One definition, two readers, deliberately: {@link buildQuery} uses it to
|
|
2920
|
+
* decide the `GROUP BY`, and {@link runCompare} uses it to realign the
|
|
2921
|
+
* comparison pass's bucket keys (#6007). Those two MUST agree — realigning
|
|
2922
|
+
* `month` keys a query grouped by `quarter` would move every comparison value
|
|
2923
|
+
* onto a bucket that does not exist — and the way to make them agree is to
|
|
2924
|
+
* have one of them, not two that look alike.
|
|
2925
|
+
*/
|
|
2926
|
+
granularityOf(compiled, selection, name) {
|
|
2927
|
+
const cd = compiled.cube.dimensions[name];
|
|
2928
|
+
if (cd?.type !== "time") return void 0;
|
|
2929
|
+
const datasetDefault = cd.granularities?.length === 1 ? String(cd.granularities[0]) : void 0;
|
|
2930
|
+
return resolveDimensionGranularity(selection, name, datasetDefault);
|
|
2931
|
+
}
|
|
2625
2932
|
buildQuery(compiled, opts) {
|
|
2626
2933
|
const q = {
|
|
2627
2934
|
cube: compiled.cube.name,
|
|
@@ -2635,12 +2942,7 @@ var DatasetExecutor = class {
|
|
|
2635
2942
|
const selTimeDims = opts.selection.timeDimensions ?? [];
|
|
2636
2943
|
const selDims = new Set(selTimeDims.map((t) => t.dimension));
|
|
2637
2944
|
const groupedDims = new Set(opts.dimensions);
|
|
2638
|
-
const granularityFor = (name) =>
|
|
2639
|
-
const cd = compiled.cube.dimensions[name];
|
|
2640
|
-
if (cd?.type !== "time") return void 0;
|
|
2641
|
-
const datasetDefault = cd.granularities?.length === 1 ? String(cd.granularities[0]) : void 0;
|
|
2642
|
-
return resolveDimensionGranularity(opts.selection, name, datasetDefault);
|
|
2643
|
-
};
|
|
2945
|
+
const granularityFor = (name) => this.granularityOf(compiled, opts.selection, name);
|
|
2644
2946
|
const bucketsUnstatedEntry = (dimension) => groupedDims.has(dimension) || opts.selection.dateGranularity != null;
|
|
2645
2947
|
const resolvedTimeDims = selTimeDims.map((t) => {
|
|
2646
2948
|
if (t.granularity) return t;
|
|
@@ -2675,9 +2977,14 @@ var DatasetExecutor = class {
|
|
|
2675
2977
|
{ ...selection, timeDimensions: shiftedTd },
|
|
2676
2978
|
{ measures, dimensions, baseFilter, context }
|
|
2677
2979
|
);
|
|
2980
|
+
const granularity = dimensions.includes(dimension) ? this.granularityOf(compiled, selection, dimension) : void 0;
|
|
2678
2981
|
return sub.rows.map((row) => {
|
|
2679
2982
|
const out = {};
|
|
2680
2983
|
for (const dim of dimensions) out[dim] = row[dim];
|
|
2984
|
+
if (granularity) {
|
|
2985
|
+
const aligned = alignedCompareBucketKey(row[dimension], granularity, cmp.kind, range, shifted);
|
|
2986
|
+
if (aligned != null) out[dimension] = aligned;
|
|
2987
|
+
}
|
|
2681
2988
|
for (const m of measures) out[`${m}__compare`] = row[m];
|
|
2682
2989
|
return out;
|
|
2683
2990
|
});
|
|
@@ -3054,8 +3361,12 @@ function hasDeclaredErrorEnvelope(err) {
|
|
|
3054
3361
|
const e = err;
|
|
3055
3362
|
return typeof e?.status === "number" && typeof e?.code === "string" && e.code.length > 0;
|
|
3056
3363
|
}
|
|
3364
|
+
function isMissingColumnOfRelation(message) {
|
|
3365
|
+
return matchMissingColumnOfRelation(message) !== void 0;
|
|
3366
|
+
}
|
|
3057
3367
|
function isMissingSourceError(err) {
|
|
3058
3368
|
const raw = String(err?.message ?? err ?? "");
|
|
3369
|
+
if (isMissingColumnOfRelation(raw)) return false;
|
|
3059
3370
|
const msg = raw.toLowerCase();
|
|
3060
3371
|
return msg.includes("no such table") || // sqlite / libsql
|
|
3061
3372
|
/relation\s+[`"']?[A-Za-z0-9_$.]+[`"']?\s+does not exist/i.test(raw) || // postgres
|
|
@@ -3065,6 +3376,7 @@ function isMissingSourceError(err) {
|
|
|
3065
3376
|
}
|
|
3066
3377
|
function missingSourceRelation(err) {
|
|
3067
3378
|
const msg = String(err?.message ?? err ?? "");
|
|
3379
|
+
if (isMissingColumnOfRelation(msg)) return void 0;
|
|
3068
3380
|
const patterns = [
|
|
3069
3381
|
/no such table:\s*[`"'[]?([A-Za-z0-9_$.]+)/i,
|
|
3070
3382
|
// sqlite / libsql
|
|
@@ -3309,6 +3621,7 @@ var AnalyticsService = class {
|
|
|
3309
3621
|
return previewResult;
|
|
3310
3622
|
}
|
|
3311
3623
|
}
|
|
3624
|
+
const requestLocale = context?.locale;
|
|
3312
3625
|
const provider = this.readScopeProvider;
|
|
3313
3626
|
const resolveScope = provider ? (targetObject) => provider(targetObject, context) : void 0;
|
|
3314
3627
|
const labelDeps = this.labelResolver ? withLabelFetchCache(this.labelResolver) : void 0;
|
|
@@ -3383,7 +3696,7 @@ var AnalyticsService = class {
|
|
|
3383
3696
|
result.drillRanges = result.rows.map((row) => {
|
|
3384
3697
|
const ranges = {};
|
|
3385
3698
|
for (const { d, granularity, instant } of rangeDims) {
|
|
3386
|
-
const cal =
|
|
3699
|
+
const cal = bucketKeyToCalendarRange2(row[d.name], granularity);
|
|
3387
3700
|
if (cal) {
|
|
3388
3701
|
ranges[d.name] = { field: d.field, gte: bound(cal.start, instant), lt: bound(cal.end, instant) };
|
|
3389
3702
|
}
|
|
@@ -3418,7 +3731,10 @@ var AnalyticsService = class {
|
|
|
3418
3731
|
for (const f of result.fields) {
|
|
3419
3732
|
const m = measureByName.get(f.name) ?? measureByName.get(f.name.replace(/__compare$/, ""));
|
|
3420
3733
|
if (!m) continue;
|
|
3421
|
-
if (f.label == null
|
|
3734
|
+
if (f.label == null) {
|
|
3735
|
+
const label = resolveI18nLabel2(m.label, requestLocale);
|
|
3736
|
+
if (label !== void 0) f.label = label;
|
|
3737
|
+
}
|
|
3422
3738
|
if (f.format == null && m.format) f.format = m.format;
|
|
3423
3739
|
const fc = f;
|
|
3424
3740
|
const mc = m;
|
|
@@ -3447,7 +3763,9 @@ var AnalyticsService = class {
|
|
|
3447
3763
|
for (const f of result.fields) {
|
|
3448
3764
|
if (f.label != null) continue;
|
|
3449
3765
|
const d = dimByName.get(f.name) ?? dimByField.get(f.name);
|
|
3450
|
-
if (d
|
|
3766
|
+
if (!d) continue;
|
|
3767
|
+
const label = resolveI18nLabel2(d.label, requestLocale);
|
|
3768
|
+
if (label !== void 0) f.label = label;
|
|
3451
3769
|
}
|
|
3452
3770
|
}
|
|
3453
3771
|
return result;
|
|
@@ -3527,10 +3845,10 @@ var AnalyticsService = class {
|
|
|
3527
3845
|
else this.logger.warn(message);
|
|
3528
3846
|
return;
|
|
3529
3847
|
}
|
|
3530
|
-
const stripPrefix = (m) => m.includes(".") ? m.split(".").slice(1).join(".") : m;
|
|
3531
3848
|
const extraMeasures = {};
|
|
3532
3849
|
for (const m of query.measures || []) {
|
|
3533
|
-
|
|
3850
|
+
if (cube.measures[m] || extraMeasures[m]) continue;
|
|
3851
|
+
const key = mintableMeasureKey(m, name);
|
|
3534
3852
|
if (cube.measures[key] || extraMeasures[key]) continue;
|
|
3535
3853
|
extraMeasures[key] = inferMeasure(key);
|
|
3536
3854
|
}
|
|
@@ -3579,6 +3897,13 @@ var AnalyticsService = class {
|
|
|
3579
3897
|
* the data path's `resolveQueryFields`: they are engine-assigned rather than
|
|
3580
3898
|
* declared, and a gate stricter than the engine it guards would reject
|
|
3581
3899
|
* queries that used to work.
|
|
3900
|
+
*
|
|
3901
|
+
* [#5918] Its `stripPrefix` below is deliberately NOT narrowed the way the two
|
|
3902
|
+
* MINTS were. This is a RESOLVER — it mirrors `lookupMember`'s tiers to answer
|
|
3903
|
+
* "which Metric will the strategy read", and that tier order did not change.
|
|
3904
|
+
* What changed is what can reach it: a dotted measure is now either a
|
|
3905
|
+
* `<cube>.` qualifier or a key the cube itself declares, because every other
|
|
3906
|
+
* dotted spelling is refused at the mint before this gate runs.
|
|
3582
3907
|
*/
|
|
3583
3908
|
assertMeasureFields(query, cube, declaredMeasures) {
|
|
3584
3909
|
const probe = this.getObjectFieldNames;
|
|
@@ -3867,7 +4192,7 @@ var AnalyticsService = class {
|
|
|
3867
4192
|
};
|
|
3868
4193
|
measures.count = { name: "count", label: "Count", type: "count", sql: "*" };
|
|
3869
4194
|
for (const m of query.measures || []) {
|
|
3870
|
-
const key = m
|
|
4195
|
+
const key = mintableMeasureKey(m, cubeName);
|
|
3871
4196
|
if (measures[key]) continue;
|
|
3872
4197
|
const inferred = inferMeasure(key);
|
|
3873
4198
|
measures[key] = inferred;
|
|
@@ -3926,6 +4251,15 @@ var AnalyticsService = class {
|
|
|
3926
4251
|
);
|
|
3927
4252
|
}
|
|
3928
4253
|
};
|
|
4254
|
+
function mintableMeasureKey(member, cubeName) {
|
|
4255
|
+
const dot = member.indexOf(".");
|
|
4256
|
+
if (dot < 0) return member;
|
|
4257
|
+
if (member.slice(0, dot) === cubeName) return member.slice(dot + 1);
|
|
4258
|
+
throw invalidMemberError(
|
|
4259
|
+
`[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.`,
|
|
4260
|
+
{ member, param: "measures", cube: cubeName }
|
|
4261
|
+
);
|
|
4262
|
+
}
|
|
3929
4263
|
function inferMeasure(key) {
|
|
3930
4264
|
if (key === "count") {
|
|
3931
4265
|
return { name: "count", label: "Count", type: "count", sql: "*" };
|
|
@@ -4224,7 +4558,17 @@ var AnalyticsServicePlugin = class {
|
|
|
4224
4558
|
// datasource the query was routed to. Undefined ⇒ the object rides the
|
|
4225
4559
|
// default datasource (or the engine cannot answer), and the diagnostic
|
|
4226
4560
|
// says so rather than inventing a name.
|
|
4227
|
-
|
|
4561
|
+
//
|
|
4562
|
+
// [#5288] Asked of the ENGINE's resolver, not of the object's declaration.
|
|
4563
|
+
// `getObject(name).datasource` is the declared value — step 1 of the five
|
|
4564
|
+
// `getDriver` routes by — so an object placed by a `datasourceMapping`
|
|
4565
|
+
// rule, by the ADR-0057 §3.6 lifecycle split, or by its package's
|
|
4566
|
+
// `defaultDatasource` answered `'default'`, and the diagnostic named a
|
|
4567
|
+
// database the rows are not in. Recomputing those rules here instead would
|
|
4568
|
+
// be the second implementation `resolveMappedDatasource` (#4462) exists to
|
|
4569
|
+
// prevent: it drifts by one step, silently, and the drift only surfaces as
|
|
4570
|
+
// an error message pointing at the wrong database.
|
|
4571
|
+
getObjectDatasource: (objectName) => dataEngine()?.resolveEffectiveDatasource?.(objectName),
|
|
4228
4572
|
// ADR-0062 D6 — a federated object carries an `external` block (ADR-0015).
|
|
4229
4573
|
// Reported so NativeSQLStrategy declines it (its hand-compiled FROM would
|
|
4230
4574
|
// hit the wrong physical table) and the driver-correct ObjectQL path runs.
|