@objectstack/service-analytics 17.3.0 → 17.4.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 +298 -0
- package/dist/index.cjs +442 -99
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +145 -14
- package/dist/index.d.ts +145 -14
- package/dist/index.js +424 -77
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
package/dist/index.js
CHANGED
|
@@ -51,14 +51,36 @@ var CubeRegistry = class {
|
|
|
51
51
|
this.cubes.clear();
|
|
52
52
|
}
|
|
53
53
|
/**
|
|
54
|
-
* Auto-generate a cube definition from an object
|
|
54
|
+
* Auto-generate a cube definition from an object's FIELD SCHEMA, and register
|
|
55
|
+
* it under `objectName`.
|
|
55
56
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* -
|
|
57
|
+
* ⚠️ Nothing in this repository calls this — the only in-tree caller is a unit
|
|
58
|
+
* test, and every cube the platform registers itself comes from one of the
|
|
59
|
+
* three sources named on the class above (#15019). That is not the same thing
|
|
60
|
+
* as unreachable: `CubeRegistry` is exported from the package entry and
|
|
61
|
+
* `AnalyticsService.cubeRegistry` is public, so a consumer of
|
|
62
|
+
* `@objectstack/service-analytics` can call it, and what it mints does reach
|
|
63
|
+
* the wire — `getMeta()` serves the labels below as `CubeMeta` titles. Whether
|
|
64
|
+
* this published method is removed or wired up as a real cube source is #15019.
|
|
65
|
+
*
|
|
66
|
+
* Heuristic rules, measured by driving the built package (the list this
|
|
67
|
+
* replaces claimed three behaviours the code does not have — `min`/`max`
|
|
68
|
+
* measures, a `count` measure for booleans, and a computed-field exclusion):
|
|
69
|
+
* - `number` / `currency` / `percent` fields → one `sum` and one `avg` measure
|
|
70
|
+
* each, labelled with the field's label plus ` (Sum)` / ` (Avg)`. No `min`
|
|
71
|
+
* or `max` measure is minted.
|
|
72
|
+
* - EVERY field becomes a dimension; there is no computed-field exclusion (the
|
|
73
|
+
* `fields` parameter carries no flag one could exclude on).
|
|
74
|
+
* - `boolean` fields become a `boolean` DIMENSION and nothing else — no count
|
|
75
|
+
* measure is minted for them.
|
|
76
|
+
* - `date` / `datetime` fields → `time` dimensions granulated
|
|
77
|
+
* day/week/month/quarter/year.
|
|
78
|
+
* - A default `count` measure labelled `Count` is always added.
|
|
79
|
+
*
|
|
80
|
+
* Those three defaults (`Count`, and the two composites) are English literals
|
|
81
|
+
* with no i18n hook; #14492's ruling listed the `Count` one as a site to carry
|
|
82
|
+
* the `builtinAggregate` discriminator, and it was left alone because no
|
|
83
|
+
* in-repo path reaches it.
|
|
62
84
|
*
|
|
63
85
|
* @param objectName - The snake_case object name (used as table/cube name)
|
|
64
86
|
* @param fields - Array of field descriptors `{ name, type, label? }`
|
|
@@ -126,6 +148,38 @@ var CubeRegistry = class {
|
|
|
126
148
|
}
|
|
127
149
|
};
|
|
128
150
|
|
|
151
|
+
// src/measure-result-type.ts
|
|
152
|
+
import {
|
|
153
|
+
REFERENCE_VALUE_TYPES,
|
|
154
|
+
SINGLE_OPTION_TYPES,
|
|
155
|
+
STRING_VALUE_TYPES
|
|
156
|
+
} from "@objectstack/spec/data";
|
|
157
|
+
var MEASURE_RESULT_TYPE_TEMPORAL = "time";
|
|
158
|
+
var MEASURE_RESULT_TYPE_STRING = "string";
|
|
159
|
+
var TEMPORAL_SOURCE_FIELD_TYPES = /* @__PURE__ */ new Set([
|
|
160
|
+
"date",
|
|
161
|
+
"datetime",
|
|
162
|
+
"time"
|
|
163
|
+
]);
|
|
164
|
+
var STRING_SOURCE_FIELD_TYPES = /* @__PURE__ */ new Set([
|
|
165
|
+
// Plain strings: text/textarea/email/url/phone/password/secret, the rich
|
|
166
|
+
// bodies (markdown/html/richtext/code), and color/signature/qrcode.
|
|
167
|
+
...STRING_VALUE_TYPES,
|
|
168
|
+
// One declared option code — select/radio.
|
|
169
|
+
...SINGLE_OPTION_TYPES,
|
|
170
|
+
// The referenced row's id — lookup/master_detail/tree/user.
|
|
171
|
+
...REFERENCE_VALUE_TYPES,
|
|
172
|
+
// The rendered record number, zero-padded under the default `{0000}`.
|
|
173
|
+
"autonumber"
|
|
174
|
+
]);
|
|
175
|
+
function measureResultType(aggregate2, sourceFieldType) {
|
|
176
|
+
if (aggregate2 !== "min" && aggregate2 !== "max") return void 0;
|
|
177
|
+
if (sourceFieldType === void 0) return void 0;
|
|
178
|
+
if (TEMPORAL_SOURCE_FIELD_TYPES.has(sourceFieldType)) return MEASURE_RESULT_TYPE_TEMPORAL;
|
|
179
|
+
if (STRING_SOURCE_FIELD_TYPES.has(sourceFieldType)) return MEASURE_RESULT_TYPE_STRING;
|
|
180
|
+
return void 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
129
183
|
// src/strategies/filter-normalizer.ts
|
|
130
184
|
import { isFilterAST, parseFilterAST, VALID_AST_OPERATORS } from "@objectstack/spec/data";
|
|
131
185
|
import { StandardErrorCode } from "@objectstack/spec/api";
|
|
@@ -669,6 +723,86 @@ function asciiLowerSqlExpr(expr) {
|
|
|
669
723
|
return `translate(${expr}, '${ASCII_UPPER_LETTERS}', '${ASCII_LOWER_LETTERS}')`;
|
|
670
724
|
}
|
|
671
725
|
|
|
726
|
+
// src/text-match-sql.ts
|
|
727
|
+
var KNOWN_DIALECTS = /* @__PURE__ */ new Set(["sqlite", "postgres", "mysql"]);
|
|
728
|
+
function normalizeSqlDialect(name) {
|
|
729
|
+
return typeof name === "string" && KNOWN_DIALECTS.has(name) ? name : "unknown";
|
|
730
|
+
}
|
|
731
|
+
function sqlDialectFor(ctx, objectName) {
|
|
732
|
+
const hook = ctx.sqlDialect;
|
|
733
|
+
if (typeof hook !== "function") return "unknown";
|
|
734
|
+
return normalizeSqlDialect(hook.call(ctx, objectName));
|
|
735
|
+
}
|
|
736
|
+
function escapeGlobPattern(value) {
|
|
737
|
+
return String(value).replace(/[*?[]/g, "[$&]");
|
|
738
|
+
}
|
|
739
|
+
function wrapShape(escaped, shape, wildcard) {
|
|
740
|
+
if (shape === "starts") return `${escaped}${wildcard}`;
|
|
741
|
+
if (shape === "ends") return `${wildcard}${escaped}`;
|
|
742
|
+
return `${wildcard}${escaped}${wildcard}`;
|
|
743
|
+
}
|
|
744
|
+
function globPattern(shape, value) {
|
|
745
|
+
return wrapShape(escapeGlobPattern(value), shape, "*");
|
|
746
|
+
}
|
|
747
|
+
function mysqlAsciiLowerBinarySql(expr) {
|
|
748
|
+
return asciiLowerReplaceSql(`CAST(${expr} AS BINARY)`);
|
|
749
|
+
}
|
|
750
|
+
function asciiLowerReplaceSql(expr) {
|
|
751
|
+
let out = expr;
|
|
752
|
+
for (let i = 0; i < ASCII_UPPER_LETTERS.length; i++) {
|
|
753
|
+
out = `REPLACE(${out}, '${ASCII_UPPER_LETTERS[i]}', '${ASCII_LOWER_LETTERS[i]}')`;
|
|
754
|
+
}
|
|
755
|
+
return out;
|
|
756
|
+
}
|
|
757
|
+
function textMatchPredicateSql(req) {
|
|
758
|
+
const { dialect, column, shape, value, bind: bind2 } = req;
|
|
759
|
+
const negate = req.negate === true;
|
|
760
|
+
const fold = req.fold === true;
|
|
761
|
+
if (dialect === "sqlite") {
|
|
762
|
+
const lower = (expr) => fold ? `lower(${expr})` : expr;
|
|
763
|
+
return `${lower(column)} ${negate ? "NOT GLOB" : "GLOB"} ${lower(bind2(globPattern(shape, value)))}`;
|
|
764
|
+
}
|
|
765
|
+
const keyword = negate ? "NOT LIKE" : "LIKE";
|
|
766
|
+
if (dialect === "mysql") {
|
|
767
|
+
const binary = (expr) => fold ? mysqlAsciiLowerBinarySql(expr) : `CAST(${expr} AS BINARY)`;
|
|
768
|
+
return `${binary(column)} ${keyword} ${binary(bind2(likePattern(shape, value)))} ESCAPE ${bind2(LIKE_ESCAPE_CHAR)}`;
|
|
769
|
+
}
|
|
770
|
+
const asciiLower = dialect === "postgres" ? asciiLowerSqlExpr : asciiLowerReplaceSql;
|
|
771
|
+
const folded = (expr) => fold ? asciiLower(expr) : expr;
|
|
772
|
+
return `${folded(column)} ${keyword} ${folded(bind2(likePattern(shape, value)))} ESCAPE ${bind2(LIKE_ESCAPE_CHAR)}`;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// src/non-text-column.ts
|
|
776
|
+
import { NON_TEXT_STORED_VALUE_TYPES } from "@objectstack/spec/data";
|
|
777
|
+
function textOperatorPolarity(op) {
|
|
778
|
+
switch (op) {
|
|
779
|
+
case "$contains":
|
|
780
|
+
case "$startsWith":
|
|
781
|
+
case "$endsWith":
|
|
782
|
+
case "$icontains":
|
|
783
|
+
case "$like":
|
|
784
|
+
case "$ilike":
|
|
785
|
+
case "contains":
|
|
786
|
+
case "startsWith":
|
|
787
|
+
case "endsWith":
|
|
788
|
+
case "icontains":
|
|
789
|
+
return "positive";
|
|
790
|
+
case "$notContains":
|
|
791
|
+
case "notContains":
|
|
792
|
+
return "negative";
|
|
793
|
+
default:
|
|
794
|
+
return null;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
function isNonTextDeclaredType(type) {
|
|
798
|
+
return typeof type === "string" && NON_TEXT_STORED_VALUE_TYPES.has(type);
|
|
799
|
+
}
|
|
800
|
+
function nonTextColumnResolver(ctx, objectName) {
|
|
801
|
+
const declared = ctx.declaredFieldType;
|
|
802
|
+
if (typeof declared !== "function") return void 0;
|
|
803
|
+
return (field) => isNonTextDeclaredType(declared.call(ctx, objectName, field));
|
|
804
|
+
}
|
|
805
|
+
|
|
672
806
|
// src/read-scope-sql.ts
|
|
673
807
|
var IDENT = /^[a-z_][a-z0-9_]*$/i;
|
|
674
808
|
var READ_SCOPE_COMPILE_FAILED = "READ_SCOPE_COMPILE_FAILED";
|
|
@@ -679,6 +813,7 @@ function readScopeCompileError(message) {
|
|
|
679
813
|
return err;
|
|
680
814
|
}
|
|
681
815
|
var FALSE_CLAUSE = "1 = 0";
|
|
816
|
+
var TRUE_CLAUSE = "1 = 1";
|
|
682
817
|
function isFilterNode(v) {
|
|
683
818
|
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
684
819
|
}
|
|
@@ -688,10 +823,10 @@ function quoteIdent(name, kind) {
|
|
|
688
823
|
}
|
|
689
824
|
return `"${name}"`;
|
|
690
825
|
}
|
|
691
|
-
function compileScopedFilterToSql(filter, alias) {
|
|
826
|
+
function compileScopedFilterToSql(filter, alias, options = {}) {
|
|
692
827
|
const quotedAlias = quoteIdent(alias, "alias");
|
|
693
828
|
const params = [];
|
|
694
|
-
const sql = compileNode(filter, quotedAlias, params);
|
|
829
|
+
const sql = compileNode(filter, quotedAlias, params, options);
|
|
695
830
|
return { sql, params };
|
|
696
831
|
}
|
|
697
832
|
function emptyMembershipFinding(spec, negated, path) {
|
|
@@ -741,12 +876,12 @@ function assertReadScopeCannotVacate(scope, objectName) {
|
|
|
741
876
|
`[read-scope-sql] read scope for "${objectName}" has an empty $in under negation at ${found.path} \u2014 an empty membership matches nothing, so its negation matches every row and the read scope admits the whole table (fail-closed).`
|
|
742
877
|
);
|
|
743
878
|
}
|
|
744
|
-
function compileSub(node, qAlias) {
|
|
879
|
+
function compileSub(node, qAlias, opts) {
|
|
745
880
|
const params = [];
|
|
746
|
-
const sql = compileNode(node, qAlias, params);
|
|
881
|
+
const sql = compileNode(node, qAlias, params, opts);
|
|
747
882
|
return { sql, params };
|
|
748
883
|
}
|
|
749
|
-
function compileNode(node, qAlias, params) {
|
|
884
|
+
function compileNode(node, qAlias, params, opts) {
|
|
750
885
|
if (!isFilterNode(node)) {
|
|
751
886
|
throw readScopeCompileError("[read-scope-sql] read scope must be a filter object (fail-closed).");
|
|
752
887
|
}
|
|
@@ -760,7 +895,7 @@ function compileNode(node, qAlias, params) {
|
|
|
760
895
|
if (key === "$or") clauses.push(FALSE_CLAUSE);
|
|
761
896
|
continue;
|
|
762
897
|
}
|
|
763
|
-
const compiled = value.map((child) => compileSub(child, qAlias));
|
|
898
|
+
const compiled = value.map((child) => compileSub(child, qAlias, opts));
|
|
764
899
|
if (key === "$or" && compiled.some((c) => c.sql.length === 0)) continue;
|
|
765
900
|
const kept = compiled.filter((c) => c.sql.length > 0);
|
|
766
901
|
if (kept.length === 0) continue;
|
|
@@ -769,7 +904,7 @@ function compileNode(node, qAlias, params) {
|
|
|
769
904
|
clauses.push(`(${kept.map((c) => c.sql).join(joiner)})`);
|
|
770
905
|
} else if (key === "$not") {
|
|
771
906
|
const operand = isFilterNode(value) ? nullSafeNegationOperand2(value) : value;
|
|
772
|
-
const inner = compileSub(operand, qAlias);
|
|
907
|
+
const inner = compileSub(operand, qAlias, opts);
|
|
773
908
|
if (inner.sql.length === 0) {
|
|
774
909
|
clauses.push(FALSE_CLAUSE);
|
|
775
910
|
} else {
|
|
@@ -779,12 +914,12 @@ function compileNode(node, qAlias, params) {
|
|
|
779
914
|
} else if (key.startsWith("$")) {
|
|
780
915
|
throw readScopeCompileError(`[read-scope-sql] unsupported top-level operator "${key}" (fail-closed).`);
|
|
781
916
|
} else {
|
|
782
|
-
clauses.push(compileField(key, value, qAlias, params));
|
|
917
|
+
clauses.push(compileField(key, value, qAlias, params, opts));
|
|
783
918
|
}
|
|
784
919
|
}
|
|
785
920
|
return clauses.join(" AND ");
|
|
786
921
|
}
|
|
787
|
-
function compileField(field, value, qAlias, params) {
|
|
922
|
+
function compileField(field, value, qAlias, params, opts) {
|
|
788
923
|
const col = `${qAlias}.${quoteIdent(field, "field")}`;
|
|
789
924
|
assertDefinedComparands2(field, value);
|
|
790
925
|
assertBooleanFlagComparands(field, value);
|
|
@@ -804,7 +939,7 @@ function compileField(field, value, qAlias, params) {
|
|
|
804
939
|
}
|
|
805
940
|
const parts = [];
|
|
806
941
|
for (const op of keys) {
|
|
807
|
-
parts.push(compileOperator(col, op, ops[op], field, params));
|
|
942
|
+
parts.push(compileOperator(col, op, ops[op], field, params, opts));
|
|
808
943
|
}
|
|
809
944
|
return parts.length === 1 ? parts[0] : `(${parts.join(" AND ")})`;
|
|
810
945
|
}
|
|
@@ -812,8 +947,20 @@ function bind(params, v) {
|
|
|
812
947
|
params.push(v);
|
|
813
948
|
return "?";
|
|
814
949
|
}
|
|
815
|
-
function
|
|
816
|
-
return
|
|
950
|
+
function textMatch(col, shape, val, negate, params, opts, fold = false) {
|
|
951
|
+
return textMatchPredicateSql({
|
|
952
|
+
dialect: normalizeSqlDialect(opts.dialect),
|
|
953
|
+
column: col,
|
|
954
|
+
shape,
|
|
955
|
+
value: val,
|
|
956
|
+
negate,
|
|
957
|
+
fold,
|
|
958
|
+
bind: (v) => bind(params, v)
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
function textOverNonTextColumn(op, field, opts) {
|
|
962
|
+
if (!opts.nonTextColumn || !opts.nonTextColumn(field)) return null;
|
|
963
|
+
return textOperatorPolarity(op) === "negative" ? TRUE_CLAUSE : FALSE_CLAUSE;
|
|
817
964
|
}
|
|
818
965
|
function nullSafeNegative(col, test) {
|
|
819
966
|
return `(${col} IS NULL OR ${test})`;
|
|
@@ -878,7 +1025,7 @@ function assertNoFieldReferenceComparand2(field, spec) {
|
|
|
878
1025
|
});
|
|
879
1026
|
}
|
|
880
1027
|
}
|
|
881
|
-
function compileOperator(col, op, val, field, params) {
|
|
1028
|
+
function compileOperator(col, op, val, field, params, opts) {
|
|
882
1029
|
switch (op) {
|
|
883
1030
|
case "$eq":
|
|
884
1031
|
return val === null ? `${col} IS NULL` : `${col} = ${bind(params, val)}`;
|
|
@@ -912,12 +1059,19 @@ function compileOperator(col, op, val, field, params) {
|
|
|
912
1059
|
return `${col} BETWEEN ${bind(params, val[0])} AND ${bind(params, val[1])}`;
|
|
913
1060
|
}
|
|
914
1061
|
// [#5567] The comparand is a LITERAL, so it is escaped and the escape
|
|
915
|
-
// character is bound with it. See {@link
|
|
1062
|
+
// character is bound with it. See {@link textMatch}.
|
|
916
1063
|
// [#5234] …and it must be a value `String()` can render, which is asserted
|
|
917
|
-
// BEFORE
|
|
1064
|
+
// BEFORE a pattern is built from it — see {@link assertRenderableText}.
|
|
1065
|
+
// [#14079] Every text arm asks {@link textOverNonTextColumn} AFTER its
|
|
1066
|
+
// comparand gate and BEFORE it binds: a comparand the contract refuses is
|
|
1067
|
+
// still refused, and a column whose stored value is never text gets the
|
|
1068
|
+
// contract's constant instead of a match over a number.
|
|
1069
|
+
// [#15684] …and the four case-EXACT arms take their construct from the
|
|
1070
|
+
// DIALECT ({@link textMatch}): a plain `LIKE` folds ASCII case on SQLite,
|
|
1071
|
+
// so this scope ADMITTED rows the policy excludes — over-reach (#3948).
|
|
918
1072
|
case "$contains":
|
|
919
1073
|
assertRenderableText(op, field, val);
|
|
920
|
-
return
|
|
1074
|
+
return textOverNonTextColumn(op, field, opts) ?? textMatch(col, "contains", val, false, params, opts);
|
|
921
1075
|
/**
|
|
922
1076
|
* [#6520] `$icontains` on the READ-SCOPE lowering — the one compiler in this
|
|
923
1077
|
* package where a wrong answer is an ADR-0021 scope over-reach rather than a
|
|
@@ -933,23 +1087,39 @@ function compileOperator(col, op, val, field, params) {
|
|
|
933
1087
|
* the rows already lower-case — and on a read scope that is a row set the
|
|
934
1088
|
* policy author never wrote, in the narrowing direction here but in the
|
|
935
1089
|
* WIDENING direction under a `$not`.
|
|
1090
|
+
*
|
|
1091
|
+
* [#15780] …and WHICH fold is the DIALECT's answer, exactly as the keyword
|
|
1092
|
+
* is for the case-exact arms. This line used to spell its own binds and
|
|
1093
|
+
* emit `translate()` unconditionally, on the reasoning that a
|
|
1094
|
+
* case-INSENSITIVE operator never wants the per-dialect case-EXACT
|
|
1095
|
+
* construct. The first half of that was right and the second half hid the
|
|
1096
|
+
* defect: `translate()` is Postgres/Oracle, so on a SQLite datasource this
|
|
1097
|
+
* read scope compiled to a statement the engine could not PARSE — an RLS
|
|
1098
|
+
* policy that cannot be evaluated at all. It goes through
|
|
1099
|
+
* {@link textMatch} now with `fold` set, which keeps `translate()` on
|
|
1100
|
+
* Postgres, emits `lower(col) GLOB lower(?)` on SQLite, the nested-
|
|
1101
|
+
* `REPLACE` binary fold on MySQL and — [#16028] — the same `REPLACE` chain
|
|
1102
|
+
* without the cast on the `unknown` residue, because a datasource whose
|
|
1103
|
+
* dialect nothing answered can BE SQLite and `translate()` failed to parse
|
|
1104
|
+
* there just as loudly through this compiler as through the other two. The
|
|
1105
|
+
* `ESCAPE`
|
|
1106
|
+
* binding is still never folded — the construct table owns that, and the
|
|
1107
|
+
* SQLite arm has no `ESCAPE` clause to bind at all.
|
|
936
1108
|
*/
|
|
937
|
-
case "$icontains":
|
|
1109
|
+
case "$icontains":
|
|
938
1110
|
assertRenderableText(op, field, val);
|
|
939
|
-
|
|
940
|
-
return `${asciiLowerSqlExpr(col)} LIKE ${patternRef} ESCAPE ${bind(params, LIKE_ESCAPE_CHAR)}`;
|
|
941
|
-
}
|
|
1111
|
+
return textOverNonTextColumn(op, field, opts) ?? textMatch(col, "contains", val, false, params, opts, true);
|
|
942
1112
|
// [#5298] NULL-safe: `NOT LIKE` is UNKNOWN for a NULL column, and "does not
|
|
943
1113
|
// contain" is true of a value that is not there.
|
|
944
1114
|
case "$notContains":
|
|
945
1115
|
assertRenderableText(op, field, val);
|
|
946
|
-
return
|
|
1116
|
+
return textOverNonTextColumn(op, field, opts) ?? nullSafeNegative(col, textMatch(col, "contains", val, true, params, opts));
|
|
947
1117
|
case "$startsWith":
|
|
948
1118
|
assertRenderableText(op, field, val);
|
|
949
|
-
return
|
|
1119
|
+
return textOverNonTextColumn(op, field, opts) ?? textMatch(col, "starts", val, false, params, opts);
|
|
950
1120
|
case "$endsWith":
|
|
951
1121
|
assertRenderableText(op, field, val);
|
|
952
|
-
return
|
|
1122
|
+
return textOverNonTextColumn(op, field, opts) ?? textMatch(col, "ends", val, false, params, opts);
|
|
953
1123
|
// [#6387] `val` is a boolean here — {@link assertBooleanFlagComparands}
|
|
954
1124
|
// refused anything else at {@link compileField}, before this emitter runs.
|
|
955
1125
|
// So `=== true` is an exhaustive TWO-WAY choice over the declared domain,
|
|
@@ -1380,7 +1550,10 @@ var NativeSQLStrategy = class {
|
|
|
1380
1550
|
if (typeof ctx.getReadScope !== "function") return;
|
|
1381
1551
|
const filter = ctx.getReadScope(objectName);
|
|
1382
1552
|
if (filter === void 0 || filter === null) return;
|
|
1383
|
-
const { sql, params: scopeParams } = compileScopedFilterToSql(filter, alias
|
|
1553
|
+
const { sql, params: scopeParams } = compileScopedFilterToSql(filter, alias, {
|
|
1554
|
+
nonTextColumn: nonTextColumnResolver(ctx, objectName),
|
|
1555
|
+
dialect: sqlDialectFor(ctx, objectName)
|
|
1556
|
+
});
|
|
1384
1557
|
assertReadScopeCannotVacate(filter, objectName);
|
|
1385
1558
|
if (!sql) return;
|
|
1386
1559
|
let i = 0;
|
|
@@ -1658,12 +1831,16 @@ var NativeSQLStrategy = class {
|
|
|
1658
1831
|
gte: ">=",
|
|
1659
1832
|
lt: "<",
|
|
1660
1833
|
lte: "<=",
|
|
1834
|
+
// [#15684 / #15780] For every text operator these entries are the
|
|
1835
|
+
// OPERATOR GATE, not the emitted keyword: `text-match-sql.ts` picks
|
|
1836
|
+
// `LIKE` or `GLOB` per dialect below. ⛔ Reading these five as the
|
|
1837
|
+
// emitted SQL is exactly the mistake #15780 was — `$icontains` was the
|
|
1838
|
+
// last row still emitting the keyword written here, together with a
|
|
1839
|
+
// `translate()` fold SQLite cannot parse.
|
|
1661
1840
|
contains: "LIKE",
|
|
1662
1841
|
notContains: "NOT LIKE",
|
|
1663
1842
|
startsWith: "LIKE",
|
|
1664
1843
|
endsWith: "LIKE",
|
|
1665
|
-
// [#6520] `$icontains` — `LIKE` like its neighbours; what separates it is
|
|
1666
|
-
// the ASCII fold applied below, not the keyword.
|
|
1667
1844
|
icontains: "LIKE"
|
|
1668
1845
|
};
|
|
1669
1846
|
const likeShape = {
|
|
@@ -1689,13 +1866,22 @@ var NativeSQLStrategy = class {
|
|
|
1689
1866
|
if (!sqlOp || !values || values.length === 0) return null;
|
|
1690
1867
|
const shape = likeShape[operator];
|
|
1691
1868
|
if (shape) {
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
if (operator === "icontains") {
|
|
1696
|
-
return `${asciiLowerSqlExpr(rawCol)} ${sqlOp} ${asciiLowerSqlExpr(patternRef)} ESCAPE $${params.length}`;
|
|
1869
|
+
const polarity = textOperatorPolarity(operator);
|
|
1870
|
+
if (polarity && nonTextColumnResolver(ctx, target.object)?.(target.field)) {
|
|
1871
|
+
return polarity === "negative" ? SQL_CONST_TRUE : SQL_CONST_FALSE;
|
|
1697
1872
|
}
|
|
1698
|
-
return
|
|
1873
|
+
return textMatchPredicateSql({
|
|
1874
|
+
dialect: sqlDialectFor(ctx, target.object),
|
|
1875
|
+
column: rawCol,
|
|
1876
|
+
shape,
|
|
1877
|
+
value: values[0],
|
|
1878
|
+
negate: operator === "notContains",
|
|
1879
|
+
fold: operator === "icontains",
|
|
1880
|
+
bind: (v) => {
|
|
1881
|
+
params.push(v);
|
|
1882
|
+
return `$${params.length}`;
|
|
1883
|
+
}
|
|
1884
|
+
});
|
|
1699
1885
|
}
|
|
1700
1886
|
if (operator === "lte") {
|
|
1701
1887
|
const nextDay = nextUtcCalendarDay(values[0]);
|
|
@@ -1796,15 +1982,25 @@ var SCALAR_SQL_OPS = {
|
|
|
1796
1982
|
lte: "<="
|
|
1797
1983
|
};
|
|
1798
1984
|
var LIKE_SQL_OPS = {
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
//
|
|
1804
|
-
//
|
|
1805
|
-
//
|
|
1806
|
-
//
|
|
1807
|
-
|
|
1985
|
+
// [#15684 / #15780] This table carries NO keyword, deliberately. Every row's
|
|
1986
|
+
// construct comes from the DIALECT (`text-match-sql.ts`): `GLOB` on SQLite,
|
|
1987
|
+
// `LIKE` over `CAST(… AS BINARY)` on MySQL, plain `LIKE` on Postgres, since a
|
|
1988
|
+
// plain `LIKE` is case-exact on Postgres alone. #15684 kept a `sql` field
|
|
1989
|
+
// here for the FOLDING row, which was the last row still emitting a keyword
|
|
1990
|
+
// written locally — together with the `translate()` fold that could not parse
|
|
1991
|
+
// on SQLite. #15780 moved that row onto the table too, so the field had no
|
|
1992
|
+
// reader left, and a dead field named `sql` sitting beside a compiler is an
|
|
1993
|
+
// invitation to read it as the emitted keyword.
|
|
1994
|
+
//
|
|
1995
|
+
// What survives here is only what the construct table cannot derive from the
|
|
1996
|
+
// operator name: which POLARITY the row is (`negate`) and whether it FOLDS
|
|
1997
|
+
// (`fold`), each spelled once. `fold` is on `icontains` ALONE — the four
|
|
1998
|
+
// above it are case-SENSITIVE by ruling (#4706 Q2 = A).
|
|
1999
|
+
contains: { shape: "contains" },
|
|
2000
|
+
notContains: { shape: "contains", negate: true },
|
|
2001
|
+
startsWith: { shape: "starts" },
|
|
2002
|
+
endsWith: { shape: "ends" },
|
|
2003
|
+
icontains: { shape: "contains", fold: true }
|
|
1808
2004
|
};
|
|
1809
2005
|
var ObjectQLStrategy = class {
|
|
1810
2006
|
constructor() {
|
|
@@ -1974,7 +2170,7 @@ var ObjectQLStrategy = class {
|
|
|
1974
2170
|
for (const m of query.measures) {
|
|
1975
2171
|
const { field, method } = this.resolveMeasureAggregation(cube, m);
|
|
1976
2172
|
const measureFilter = datasetScope?.measureFilters?.[m];
|
|
1977
|
-
const predicate = measureFilter ? this.renderFilterNodeSql(normalizeAnalyticsFilterTree({ where: measureFilter }), cube, params) : null;
|
|
2173
|
+
const predicate = measureFilter ? this.renderFilterNodeSql(normalizeAnalyticsFilterTree({ where: measureFilter }), cube, params, ctx) : null;
|
|
1978
2174
|
const aggSql = predicate ? this.conditionalAggregateSql(method, field, predicate) : method === "count" ? "COUNT(*)" : method === "count_distinct" ? `COUNT(DISTINCT ${field})` : `${method.toUpperCase()}(${field})`;
|
|
1979
2175
|
selectParts.push(`${aggSql} AS "${m}"`);
|
|
1980
2176
|
}
|
|
@@ -1983,14 +2179,16 @@ var ObjectQLStrategy = class {
|
|
|
1983
2179
|
const filterClause = this.renderFilterNodeSql(
|
|
1984
2180
|
normalizeAnalyticsFilterTree(query),
|
|
1985
2181
|
cube,
|
|
1986
|
-
params
|
|
2182
|
+
params,
|
|
2183
|
+
ctx
|
|
1987
2184
|
);
|
|
1988
2185
|
if (filterClause) whereParts.push(filterClause);
|
|
1989
2186
|
if (datasetScope?.filter) {
|
|
1990
2187
|
const scopeSql = this.renderFilterNodeSql(
|
|
1991
2188
|
normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
|
|
1992
2189
|
cube,
|
|
1993
|
-
params
|
|
2190
|
+
params,
|
|
2191
|
+
ctx
|
|
1994
2192
|
);
|
|
1995
2193
|
if (scopeSql) whereParts.push(scopeSql);
|
|
1996
2194
|
}
|
|
@@ -2003,7 +2201,10 @@ var ObjectQLStrategy = class {
|
|
|
2003
2201
|
}
|
|
2004
2202
|
const scope = ctx.getReadScope?.(tableName);
|
|
2005
2203
|
if (scope != null) {
|
|
2006
|
-
const { sql: scopeSql, params: scopeParams } = compileScopedFilterToSql(scope, tableName
|
|
2204
|
+
const { sql: scopeSql, params: scopeParams } = compileScopedFilterToSql(scope, tableName, {
|
|
2205
|
+
nonTextColumn: nonTextColumnResolver(ctx, tableName),
|
|
2206
|
+
dialect: sqlDialectFor(ctx, tableName)
|
|
2207
|
+
});
|
|
2007
2208
|
assertReadScopeCannotVacate(scope, tableName);
|
|
2008
2209
|
if (scopeSql) {
|
|
2009
2210
|
let i = 0;
|
|
@@ -2437,7 +2638,7 @@ var ObjectQLStrategy = class {
|
|
|
2437
2638
|
* not render that operator": #5333 was exactly that conflation, and an
|
|
2438
2639
|
* unrenderable operator now THROWS (see the exit below).
|
|
2439
2640
|
*/
|
|
2440
|
-
buildFilterClauseSql(col, operator, values, params) {
|
|
2641
|
+
buildFilterClauseSql(col, operator, values, params, target, ctx) {
|
|
2441
2642
|
if (operator === "set") return `${col} IS NOT NULL`;
|
|
2442
2643
|
if (operator === "notSet") return `${col} IS NULL`;
|
|
2443
2644
|
if (!values || values.length === 0) return null;
|
|
@@ -2450,12 +2651,22 @@ var ObjectQLStrategy = class {
|
|
|
2450
2651
|
}
|
|
2451
2652
|
const like = LIKE_SQL_OPS[operator];
|
|
2452
2653
|
if (like) {
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2654
|
+
const polarity = textOperatorPolarity(operator);
|
|
2655
|
+
if (polarity && target && ctx && nonTextColumnResolver(ctx, target.object)?.(target.field)) {
|
|
2656
|
+
return polarity === "negative" ? SQL_CONST_TRUE : SQL_CONST_FALSE;
|
|
2657
|
+
}
|
|
2658
|
+
return textMatchPredicateSql({
|
|
2659
|
+
dialect: target && ctx ? sqlDialectFor(ctx, target.object) : "unknown",
|
|
2660
|
+
column: col,
|
|
2661
|
+
shape: like.shape,
|
|
2662
|
+
value: values[0],
|
|
2663
|
+
negate: like.negate === true,
|
|
2664
|
+
fold: like.fold === true,
|
|
2665
|
+
bind: (v) => {
|
|
2666
|
+
params.push(v);
|
|
2667
|
+
return `$${params.length}`;
|
|
2668
|
+
}
|
|
2669
|
+
});
|
|
2459
2670
|
}
|
|
2460
2671
|
const op = SCALAR_SQL_OPS[operator];
|
|
2461
2672
|
if (!op) {
|
|
@@ -2494,6 +2705,29 @@ var ObjectQLStrategy = class {
|
|
|
2494
2705
|
}
|
|
2495
2706
|
return void 0;
|
|
2496
2707
|
}
|
|
2708
|
+
/**
|
|
2709
|
+
* [#14079] The (object, field) a filter member binds against — the echo's
|
|
2710
|
+
* copy of `NativeSQLStrategy.resolveStorageTarget`, kept beside the
|
|
2711
|
+
* `LIKE_SQL_OPS` table for the same reason that table is a copy: this file
|
|
2712
|
+
* renders a description of the statement THAT compiler produces, and the
|
|
2713
|
+
* declared-type test both apply is keyed by object and field. A dotted
|
|
2714
|
+
* `sql` is a relationship path (ADR-0071): every segment but the last is a
|
|
2715
|
+
* hop whose join alias is the dot-to-`__` spelling the dataset compiler keys
|
|
2716
|
+
* `cube.joins` by, the last is the column.
|
|
2717
|
+
*/
|
|
2718
|
+
resolveStorageTarget(cube, member, baseObject) {
|
|
2719
|
+
const dim = this.lookupMember(cube, member, "dimension");
|
|
2720
|
+
const measure = dim ? void 0 : this.lookupMember(cube, member, "measure");
|
|
2721
|
+
const rawSql = dim?.sql ?? measure?.sql ?? (member.includes(".") ? member.split(".").slice(1).join(".") : member);
|
|
2722
|
+
if (rawSql.includes(".")) {
|
|
2723
|
+
const segments = rawSql.split(".");
|
|
2724
|
+
const field = segments[segments.length - 1];
|
|
2725
|
+
const relPath = segments.slice(0, -1).join(".");
|
|
2726
|
+
const object = cube.joins?.[relPath.replace(/\./g, "__")]?.name ?? relPath;
|
|
2727
|
+
return { object, field };
|
|
2728
|
+
}
|
|
2729
|
+
return { object: baseObject, field: rawSql.replace(/^\$/, "") };
|
|
2730
|
+
}
|
|
2497
2731
|
resolveFieldName(cube, member, kind) {
|
|
2498
2732
|
if (kind === "dimension" || kind === "any") {
|
|
2499
2733
|
const dim = this.lookupMember(cube, member, "dimension");
|
|
@@ -2633,7 +2867,7 @@ var ObjectQLStrategy = class {
|
|
|
2633
2867
|
* exactly — including the invariant that a `null` return leaves `params`
|
|
2634
2868
|
* untouched, so no comparand is left with no placeholder to consume it.
|
|
2635
2869
|
*/
|
|
2636
|
-
renderFilterNodeSql(node, cube, params) {
|
|
2870
|
+
renderFilterNodeSql(node, cube, params, ctx) {
|
|
2637
2871
|
if (!node) return null;
|
|
2638
2872
|
if (node.kind === "const") {
|
|
2639
2873
|
return node.value ? SQL_CONST_TRUE : SQL_CONST_FALSE;
|
|
@@ -2643,17 +2877,23 @@ var ObjectQLStrategy = class {
|
|
|
2643
2877
|
this.resolveFieldName(cube, node.member, "any"),
|
|
2644
2878
|
node.operator,
|
|
2645
2879
|
node.values,
|
|
2646
|
-
params
|
|
2880
|
+
params,
|
|
2881
|
+
// [#14079] The (object, field) this member binds against, resolved the
|
|
2882
|
+
// way `NativeSQLStrategy.resolveStorageTarget` resolves it, so the echo
|
|
2883
|
+
// asks the declared-type hook the same question the executed statement
|
|
2884
|
+
// asked and prints the same constant for a non-text column.
|
|
2885
|
+
this.resolveStorageTarget(cube, node.member, this.extractObjectName(cube)),
|
|
2886
|
+
ctx
|
|
2647
2887
|
);
|
|
2648
2888
|
}
|
|
2649
2889
|
if (node.kind === "not") {
|
|
2650
|
-
const inner = this.renderFilterNodeSql(node.child, cube, params);
|
|
2890
|
+
const inner = this.renderFilterNodeSql(node.child, cube, params, ctx);
|
|
2651
2891
|
return inner ? `NOT (${inner})` : SQL_CONST_FALSE;
|
|
2652
2892
|
}
|
|
2653
2893
|
const paramBase = params.length;
|
|
2654
2894
|
const parts = [];
|
|
2655
2895
|
for (const child of node.children) {
|
|
2656
|
-
const clause = this.renderFilterNodeSql(child, cube, params);
|
|
2896
|
+
const clause = this.renderFilterNodeSql(child, cube, params, ctx);
|
|
2657
2897
|
if (clause === null) {
|
|
2658
2898
|
if (node.kind !== "or") continue;
|
|
2659
2899
|
params.length = paramBase;
|
|
@@ -3867,25 +4107,55 @@ function bucketDate(value, granularity, timezone) {
|
|
|
3867
4107
|
return `${y}-${m}-${day}`;
|
|
3868
4108
|
}
|
|
3869
4109
|
}
|
|
3870
|
-
function
|
|
3871
|
-
if (
|
|
3872
|
-
|
|
3873
|
-
|
|
4110
|
+
function numericOperand(v) {
|
|
4111
|
+
if (typeof v === "number") return Number.isFinite(v) ? v : null;
|
|
4112
|
+
if (typeof v === "string" && v.trim() !== "") {
|
|
4113
|
+
const n = Number(v);
|
|
4114
|
+
return Number.isFinite(n) ? n : null;
|
|
4115
|
+
}
|
|
4116
|
+
return null;
|
|
4117
|
+
}
|
|
4118
|
+
function compareOperands(a, b) {
|
|
4119
|
+
const an = numericOperand(a);
|
|
4120
|
+
const bn = numericOperand(b);
|
|
4121
|
+
if (an !== null && bn !== null) return an - bn;
|
|
4122
|
+
return compare(a, b);
|
|
4123
|
+
}
|
|
4124
|
+
function extremumOf(rows, field, kind) {
|
|
4125
|
+
let winner;
|
|
4126
|
+
let seen = false;
|
|
4127
|
+
for (const r of rows) {
|
|
4128
|
+
const v = r[field];
|
|
4129
|
+
if (v == null) continue;
|
|
4130
|
+
if (!seen) {
|
|
4131
|
+
winner = v;
|
|
4132
|
+
seen = true;
|
|
4133
|
+
continue;
|
|
3874
4134
|
}
|
|
3875
|
-
|
|
4135
|
+
const c = compareOperands(v, winner);
|
|
4136
|
+
if (kind === "min" ? c < 0 : c > 0) winner = v;
|
|
3876
4137
|
}
|
|
4138
|
+
return seen ? winner : null;
|
|
4139
|
+
}
|
|
4140
|
+
function aggregate(rows, metricType, field) {
|
|
4141
|
+
if (metricType === "count" || field === "*") return rows.length;
|
|
3877
4142
|
const nums = rows.map((r) => Number(r[field])).filter((n) => Number.isFinite(n));
|
|
3878
4143
|
switch (metricType) {
|
|
3879
|
-
|
|
4144
|
+
// The spec's spelling (`AggregationFunction`), which is what the compiler
|
|
4145
|
+
// copies through. It used to be spelled `countDistinct` here — a word no
|
|
4146
|
+
// producer mints — so the arm was UNREACHABLE and the measure fell to the
|
|
4147
|
+
// numeric `default` below, answering a sum of coerced values (or a row
|
|
4148
|
+
// count) under the author's `count_distinct` name.
|
|
4149
|
+
case "count_distinct":
|
|
3880
4150
|
return new Set(rows.map((r) => r[field]).filter((v) => v != null)).size;
|
|
3881
4151
|
case "sum":
|
|
3882
4152
|
return nums.reduce((a, b) => a + b, 0);
|
|
3883
4153
|
case "avg":
|
|
3884
4154
|
return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : 0;
|
|
3885
4155
|
case "min":
|
|
3886
|
-
return
|
|
4156
|
+
return extremumOf(rows, field, "min");
|
|
3887
4157
|
case "max":
|
|
3888
|
-
return
|
|
4158
|
+
return extremumOf(rows, field, "max");
|
|
3889
4159
|
default:
|
|
3890
4160
|
return nums.length ? nums.reduce((a, b) => a + b, 0) : rows.length;
|
|
3891
4161
|
}
|
|
@@ -3946,7 +4216,19 @@ function evaluateAnalyticsQueryOverRows(query, cube, rows) {
|
|
|
3946
4216
|
return {
|
|
3947
4217
|
rows: limited,
|
|
3948
4218
|
fields: [
|
|
3949
|
-
|
|
4219
|
+
// A dimension column is described by the CUBE dimension's own type — the
|
|
4220
|
+
// same expression `NativeSQLStrategy.buildFieldMeta` and its ObjectQL
|
|
4221
|
+
// sibling use (`d?.type || 'string'`), so a `date` dataset dimension is
|
|
4222
|
+
// `'time'` here exactly as it is on the live path. Minting `'string'` for
|
|
4223
|
+
// every dimension made the same column two different things depending
|
|
4224
|
+
// only on whether a pending seed draft existed (#16203 (b)).
|
|
4225
|
+
...dimensions.map((d) => ({ name: d, type: String(cube.dimensions?.[d]?.type || "string") })),
|
|
4226
|
+
// ⛔ A MEASURE column keeps the `'number'` every producer in the platform
|
|
4227
|
+
// mints for it, live faces included. Correcting it is one rule owned by
|
|
4228
|
+
// `measureResultType` (#15768/#16101) and applied at the ADR-0021
|
|
4229
|
+
// descriptor pass; a second copy of it here would be two implementations
|
|
4230
|
+
// free to drift, over a question this producer cannot answer anyway (it
|
|
4231
|
+
// has the cube, not the source object's declared field types).
|
|
3950
4232
|
...query.measures.map((m) => ({ name: m, type: "number" }))
|
|
3951
4233
|
]
|
|
3952
4234
|
};
|
|
@@ -4079,7 +4361,17 @@ var AnalyticsService = class {
|
|
|
4079
4361
|
},
|
|
4080
4362
|
coerceTemporalFilterValue: config.coerceTemporalFilterValue,
|
|
4081
4363
|
coerceTemporalFilterColumn: config.coerceTemporalFilterColumn,
|
|
4082
|
-
isExternalObject: config.isExternalObject
|
|
4364
|
+
isExternalObject: config.isExternalObject,
|
|
4365
|
+
// [#14079] The declared field type, read off the same `sourceFieldMeta`
|
|
4366
|
+
// hook the display chains use — so the three SQL compilers can give a
|
|
4367
|
+
// text operator over a numeric or boolean column the contract's answer
|
|
4368
|
+
// at compile time. A host that wired no hook answers `undefined`, and
|
|
4369
|
+
// the compilers keep the behaviour they had.
|
|
4370
|
+
declaredFieldType: (object, field) => config.sourceFieldMeta?.(object, field)?.type,
|
|
4371
|
+
// [#15684] The dialect that will run the compiled statement, so the
|
|
4372
|
+
// case-EXACT text family picks a construct that IS case-exact there.
|
|
4373
|
+
// Same tiering as the hook above: `undefined` keeps today's `LIKE`.
|
|
4374
|
+
sqlDialect: (object) => config.sqlDialect?.(object)
|
|
4083
4375
|
};
|
|
4084
4376
|
const builtIn = [
|
|
4085
4377
|
new NativeSQLStrategy(),
|
|
@@ -4295,10 +4587,10 @@ var AnalyticsService = class {
|
|
|
4295
4587
|
query: async (q) => evaluateAnalyticsQueryOverRows(q, compiled.cube, seedRows)
|
|
4296
4588
|
};
|
|
4297
4589
|
const previewResult = await new DatasetExecutor(previewService).execute(compiled, selection, context);
|
|
4590
|
+
this.enrichResultColumns(previewResult, dataset, selection, context);
|
|
4298
4591
|
return previewResult;
|
|
4299
4592
|
}
|
|
4300
4593
|
}
|
|
4301
|
-
const requestLocale = context?.locale;
|
|
4302
4594
|
const provider = this.readScopeProvider;
|
|
4303
4595
|
const resolveScope = provider ? (targetObject) => provider(targetObject, context) : void 0;
|
|
4304
4596
|
const labelDeps = this.labelResolver ? withLabelFetchCache(this.labelResolver) : void 0;
|
|
@@ -4334,7 +4626,7 @@ var AnalyticsService = class {
|
|
|
4334
4626
|
}
|
|
4335
4627
|
throw err;
|
|
4336
4628
|
}
|
|
4337
|
-
const selectedDims =
|
|
4629
|
+
const selectedDims = this.selectedDimensions(dataset, selection);
|
|
4338
4630
|
const drillDims = selectedDims.filter((d) => !!d.field && d.type !== "date");
|
|
4339
4631
|
if (drillDims.length && result.rows.length) {
|
|
4340
4632
|
result.object = dataset.object;
|
|
@@ -4403,6 +4695,48 @@ var AnalyticsService = class {
|
|
|
4403
4695
|
}
|
|
4404
4696
|
}
|
|
4405
4697
|
}
|
|
4698
|
+
this.enrichResultColumns(result, dataset, selection, context);
|
|
4699
|
+
return result;
|
|
4700
|
+
}
|
|
4701
|
+
/**
|
|
4702
|
+
* The dataset dimensions this selection GROUPED THE GRID BY, resolved against
|
|
4703
|
+
* the dataset definition. Shared by drill metadata, row-value label
|
|
4704
|
+
* resolution and — through {@link enrichResultColumns} — the dimension column
|
|
4705
|
+
* headers, so all three answer "which dimensions" the same way.
|
|
4706
|
+
*/
|
|
4707
|
+
selectedDimensions(dataset, selection) {
|
|
4708
|
+
return (selection.dimensions ?? []).map((name) => dataset.dimensions?.find((d) => d.name === name)).filter((d) => !!d);
|
|
4709
|
+
}
|
|
4710
|
+
/**
|
|
4711
|
+
* ADR-0021 — describe the result's COLUMNS from the dataset's own authored
|
|
4712
|
+
* definition: a measure's `label` / `format` / `currency` / `percentScale` /
|
|
4713
|
+
* `builtinAggregate` and the `type` its aggregate really returns, then a
|
|
4714
|
+
* dimension column's header `label`.
|
|
4715
|
+
*
|
|
4716
|
+
* **Every key here is read off the DATASET** (the authored measure or
|
|
4717
|
+
* dimension) **and `sourceFieldMeta`** (the source object's declared field
|
|
4718
|
+
* metadata). Not one is read off `result.rows`. That is what makes this one
|
|
4719
|
+
* seam serve both paths that produce a dataset response — the live engine
|
|
4720
|
+
* query and the ADR-0037 P3 draft-data preview — and it is why #16097 was a
|
|
4721
|
+
* defect rather than a deliberate omission: the preview branch returns ~250
|
|
4722
|
+
* lines before this ran, so a response over drafted seed rows carried none of
|
|
4723
|
+
* these keys and a renderer fell back to humanizing the raw measure name and
|
|
4724
|
+
* guessing a percent scale from magnitude — the exact failures #5537,
|
|
4725
|
+
* objectui#3136 and #14492 each closed on the live path.
|
|
4726
|
+
*
|
|
4727
|
+
* Extracted rather than copied onto the second path, for the reason the
|
|
4728
|
+
* `type` correction below already gives for living here at all: this is ONE
|
|
4729
|
+
* rule holding both halves of the question, and a per-path copy would be two
|
|
4730
|
+
* implementations of it, free to drift.
|
|
4731
|
+
*
|
|
4732
|
+
* ⛔ Not here, and deliberately: dimension VALUE label resolution
|
|
4733
|
+
* ({@link resolveDimensionLabels}), which rewrites the grouped value in each
|
|
4734
|
+
* ROW. That reads the rows, it is the one enrichment a seed-draft row set can
|
|
4735
|
+
* make unnecessary, and the preview path skips it on purpose — see the note
|
|
4736
|
+
* at that early return.
|
|
4737
|
+
*/
|
|
4738
|
+
enrichResultColumns(result, dataset, selection, context) {
|
|
4739
|
+
const requestLocale = context?.locale;
|
|
4406
4740
|
if (result.fields?.length && dataset.measures?.length) {
|
|
4407
4741
|
const measureByName = new Map(dataset.measures.map((m) => [m.name, m]));
|
|
4408
4742
|
for (const f of result.fields) {
|
|
@@ -4427,9 +4761,11 @@ var AnalyticsService = class {
|
|
|
4427
4761
|
if (f.percentScale == null) {
|
|
4428
4762
|
f.percentScale = m.derived?.op === "ratio" ? "fraction" : percentScaleOf(meta);
|
|
4429
4763
|
}
|
|
4764
|
+
const resultType = measureResultType(m.aggregate, meta?.type);
|
|
4765
|
+
if (resultType) f.type = resultType;
|
|
4430
4766
|
}
|
|
4431
4767
|
}
|
|
4432
|
-
const describableDims = [...
|
|
4768
|
+
const describableDims = [...this.selectedDimensions(dataset, selection)];
|
|
4433
4769
|
for (const t of selection.timeDimensions ?? []) {
|
|
4434
4770
|
if (describableDims.some((d2) => d2.name === t.dimension)) continue;
|
|
4435
4771
|
const d = dataset.dimensions?.find((x) => x.name === t.dimension);
|
|
@@ -4446,7 +4782,6 @@ var AnalyticsService = class {
|
|
|
4446
4782
|
if (label !== void 0) f.label = label;
|
|
4447
4783
|
}
|
|
4448
4784
|
}
|
|
4449
|
-
return result;
|
|
4450
4785
|
}
|
|
4451
4786
|
/**
|
|
4452
4787
|
* Get cube metadata for discovery.
|
|
@@ -5280,6 +5615,16 @@ var AnalyticsServicePlugin = class {
|
|
|
5280
5615
|
}
|
|
5281
5616
|
return columnSql;
|
|
5282
5617
|
};
|
|
5618
|
+
const sqlDialect = (objectName) => {
|
|
5619
|
+
try {
|
|
5620
|
+
const svc = ctx.getService("data");
|
|
5621
|
+
const driver = svc?.getDriverForObject?.(objectName);
|
|
5622
|
+
const named = driver?.dialectName;
|
|
5623
|
+
return typeof named === "string" ? named : void 0;
|
|
5624
|
+
} catch {
|
|
5625
|
+
return void 0;
|
|
5626
|
+
}
|
|
5627
|
+
};
|
|
5283
5628
|
const config = {
|
|
5284
5629
|
cubes: this.options.cubes,
|
|
5285
5630
|
logger: ctx.logger,
|
|
@@ -5321,6 +5666,8 @@ var AnalyticsServicePlugin = class {
|
|
|
5321
5666
|
// prevent: it drifts by one step, silently, and the drift only surfaces as
|
|
5322
5667
|
// an error message pointing at the wrong database.
|
|
5323
5668
|
getObjectDatasource: (objectName) => dataEngine()?.resolveEffectiveDatasource?.(objectName),
|
|
5669
|
+
// [#15684] The executing driver's own dialect — see `sqlDialect` above.
|
|
5670
|
+
sqlDialect,
|
|
5324
5671
|
// ADR-0062 D6 — a federated object carries an `external` block (ADR-0015).
|
|
5325
5672
|
// Reported so NativeSQLStrategy declines it (its hand-compiled FROM would
|
|
5326
5673
|
// hit the wrong physical table) and the driver-correct ObjectQL path runs.
|