@objectstack/service-analytics 17.2.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 +899 -0
- package/dist/index.cjs +746 -133
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +275 -34
- package/dist/index.d.ts +275 -34
- package/dist/index.js +739 -115
- package/dist/index.js.map +1 -1
- package/package.json +16 -10
package/dist/index.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
// src/analytics-service.ts
|
|
2
2
|
import { percentScaleOf } from "@objectstack/spec/data";
|
|
3
3
|
import { resolveI18nLabel as resolveI18nLabel2 } from "@objectstack/spec/ui";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
createLogger,
|
|
6
|
+
getEnv,
|
|
7
|
+
bucketKeyToCalendarRange as bucketKeyToCalendarRange2,
|
|
8
|
+
zonedDateStartToUtcMs,
|
|
9
|
+
filterTokenContextFrom as filterTokenContextFrom2,
|
|
10
|
+
resolveFilterTokens as resolveFilterTokens2
|
|
11
|
+
} from "@objectstack/core";
|
|
5
12
|
import { matchMissingColumnOfRelation } from "@objectstack/types";
|
|
6
13
|
|
|
7
14
|
// src/cube-registry.ts
|
|
@@ -44,14 +51,36 @@ var CubeRegistry = class {
|
|
|
44
51
|
this.cubes.clear();
|
|
45
52
|
}
|
|
46
53
|
/**
|
|
47
|
-
* 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`.
|
|
56
|
+
*
|
|
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.
|
|
48
65
|
*
|
|
49
|
-
* Heuristic rules
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* -
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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.
|
|
55
84
|
*
|
|
56
85
|
* @param objectName - The snake_case object name (used as table/cube name)
|
|
57
86
|
* @param fields - Array of field descriptors `{ name, type, label? }`
|
|
@@ -119,6 +148,38 @@ var CubeRegistry = class {
|
|
|
119
148
|
}
|
|
120
149
|
};
|
|
121
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
|
+
|
|
122
183
|
// src/strategies/filter-normalizer.ts
|
|
123
184
|
import { isFilterAST, parseFilterAST, VALID_AST_OPERATORS } from "@objectstack/spec/data";
|
|
124
185
|
import { StandardErrorCode } from "@objectstack/spec/api";
|
|
@@ -662,6 +723,86 @@ function asciiLowerSqlExpr(expr) {
|
|
|
662
723
|
return `translate(${expr}, '${ASCII_UPPER_LETTERS}', '${ASCII_LOWER_LETTERS}')`;
|
|
663
724
|
}
|
|
664
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
|
+
|
|
665
806
|
// src/read-scope-sql.ts
|
|
666
807
|
var IDENT = /^[a-z_][a-z0-9_]*$/i;
|
|
667
808
|
var READ_SCOPE_COMPILE_FAILED = "READ_SCOPE_COMPILE_FAILED";
|
|
@@ -672,6 +813,7 @@ function readScopeCompileError(message) {
|
|
|
672
813
|
return err;
|
|
673
814
|
}
|
|
674
815
|
var FALSE_CLAUSE = "1 = 0";
|
|
816
|
+
var TRUE_CLAUSE = "1 = 1";
|
|
675
817
|
function isFilterNode(v) {
|
|
676
818
|
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
677
819
|
}
|
|
@@ -681,18 +823,65 @@ function quoteIdent(name, kind) {
|
|
|
681
823
|
}
|
|
682
824
|
return `"${name}"`;
|
|
683
825
|
}
|
|
684
|
-
function compileScopedFilterToSql(filter, alias) {
|
|
826
|
+
function compileScopedFilterToSql(filter, alias, options = {}) {
|
|
685
827
|
const quotedAlias = quoteIdent(alias, "alias");
|
|
686
828
|
const params = [];
|
|
687
|
-
const sql = compileNode(filter, quotedAlias, params);
|
|
829
|
+
const sql = compileNode(filter, quotedAlias, params, options);
|
|
688
830
|
return { sql, params };
|
|
689
831
|
}
|
|
690
|
-
function
|
|
832
|
+
function emptyMembershipFinding(spec, negated, path) {
|
|
833
|
+
if (Array.isArray(spec)) {
|
|
834
|
+
return spec.length === 0 && negated ? { path: `${path}: []`, kind: "negatedIn" } : null;
|
|
835
|
+
}
|
|
836
|
+
if (spec === null || typeof spec !== "object") return null;
|
|
837
|
+
const rec = spec;
|
|
838
|
+
if (Array.isArray(rec.$nin) && rec.$nin.length === 0) return { path: `${path}.$nin`, kind: "nin" };
|
|
839
|
+
if (Array.isArray(rec.$in) && rec.$in.length === 0 && negated) {
|
|
840
|
+
return { path: `${path}.$in`, kind: "negatedIn" };
|
|
841
|
+
}
|
|
842
|
+
return null;
|
|
843
|
+
}
|
|
844
|
+
function findEmptyMembership(node, negated, path) {
|
|
845
|
+
if (node === null || typeof node !== "object" || Array.isArray(node)) return null;
|
|
846
|
+
const rec = node;
|
|
847
|
+
const bare = emptyMembershipFinding(rec, negated, path.length > 0 ? path : "<root>");
|
|
848
|
+
if (bare) return bare;
|
|
849
|
+
for (const [key, value] of Object.entries(rec)) {
|
|
850
|
+
const here = path.length > 0 ? `${path}.${key}` : key;
|
|
851
|
+
if (key === "$not") {
|
|
852
|
+
const found = findEmptyMembership(value, !negated, here);
|
|
853
|
+
if (found) return found;
|
|
854
|
+
} else if (key === "$and" || key === "$or") {
|
|
855
|
+
if (!Array.isArray(value)) continue;
|
|
856
|
+
for (let i = 0; i < value.length; i++) {
|
|
857
|
+
const found = findEmptyMembership(value[i], negated, `${here}[${i}]`);
|
|
858
|
+
if (found) return found;
|
|
859
|
+
}
|
|
860
|
+
} else if (!key.startsWith("$")) {
|
|
861
|
+
const found = emptyMembershipFinding(value, negated, here);
|
|
862
|
+
if (found) return found;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
867
|
+
function assertReadScopeCannotVacate(scope, objectName) {
|
|
868
|
+
const found = findEmptyMembership(scope, false, "");
|
|
869
|
+
if (found === null) return;
|
|
870
|
+
if (found.kind === "nin") {
|
|
871
|
+
throw readScopeCompileError(
|
|
872
|
+
`[read-scope-sql] read scope for "${objectName}" has an empty $nin at ${found.path} \u2014 an empty exclusion excludes nothing, so the engine lowers that clause to constant TRUE and the scope does not bind as written. Refused at every polarity, matching this module's own $nin arm (fail-closed).`
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
throw readScopeCompileError(
|
|
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).`
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
function compileSub(node, qAlias, opts) {
|
|
691
880
|
const params = [];
|
|
692
|
-
const sql = compileNode(node, qAlias, params);
|
|
881
|
+
const sql = compileNode(node, qAlias, params, opts);
|
|
693
882
|
return { sql, params };
|
|
694
883
|
}
|
|
695
|
-
function compileNode(node, qAlias, params) {
|
|
884
|
+
function compileNode(node, qAlias, params, opts) {
|
|
696
885
|
if (!isFilterNode(node)) {
|
|
697
886
|
throw readScopeCompileError("[read-scope-sql] read scope must be a filter object (fail-closed).");
|
|
698
887
|
}
|
|
@@ -706,7 +895,7 @@ function compileNode(node, qAlias, params) {
|
|
|
706
895
|
if (key === "$or") clauses.push(FALSE_CLAUSE);
|
|
707
896
|
continue;
|
|
708
897
|
}
|
|
709
|
-
const compiled = value.map((child) => compileSub(child, qAlias));
|
|
898
|
+
const compiled = value.map((child) => compileSub(child, qAlias, opts));
|
|
710
899
|
if (key === "$or" && compiled.some((c) => c.sql.length === 0)) continue;
|
|
711
900
|
const kept = compiled.filter((c) => c.sql.length > 0);
|
|
712
901
|
if (kept.length === 0) continue;
|
|
@@ -715,7 +904,7 @@ function compileNode(node, qAlias, params) {
|
|
|
715
904
|
clauses.push(`(${kept.map((c) => c.sql).join(joiner)})`);
|
|
716
905
|
} else if (key === "$not") {
|
|
717
906
|
const operand = isFilterNode(value) ? nullSafeNegationOperand2(value) : value;
|
|
718
|
-
const inner = compileSub(operand, qAlias);
|
|
907
|
+
const inner = compileSub(operand, qAlias, opts);
|
|
719
908
|
if (inner.sql.length === 0) {
|
|
720
909
|
clauses.push(FALSE_CLAUSE);
|
|
721
910
|
} else {
|
|
@@ -725,12 +914,12 @@ function compileNode(node, qAlias, params) {
|
|
|
725
914
|
} else if (key.startsWith("$")) {
|
|
726
915
|
throw readScopeCompileError(`[read-scope-sql] unsupported top-level operator "${key}" (fail-closed).`);
|
|
727
916
|
} else {
|
|
728
|
-
clauses.push(compileField(key, value, qAlias, params));
|
|
917
|
+
clauses.push(compileField(key, value, qAlias, params, opts));
|
|
729
918
|
}
|
|
730
919
|
}
|
|
731
920
|
return clauses.join(" AND ");
|
|
732
921
|
}
|
|
733
|
-
function compileField(field, value, qAlias, params) {
|
|
922
|
+
function compileField(field, value, qAlias, params, opts) {
|
|
734
923
|
const col = `${qAlias}.${quoteIdent(field, "field")}`;
|
|
735
924
|
assertDefinedComparands2(field, value);
|
|
736
925
|
assertBooleanFlagComparands(field, value);
|
|
@@ -750,7 +939,7 @@ function compileField(field, value, qAlias, params) {
|
|
|
750
939
|
}
|
|
751
940
|
const parts = [];
|
|
752
941
|
for (const op of keys) {
|
|
753
|
-
parts.push(compileOperator(col, op, ops[op], field, params));
|
|
942
|
+
parts.push(compileOperator(col, op, ops[op], field, params, opts));
|
|
754
943
|
}
|
|
755
944
|
return parts.length === 1 ? parts[0] : `(${parts.join(" AND ")})`;
|
|
756
945
|
}
|
|
@@ -758,8 +947,20 @@ function bind(params, v) {
|
|
|
758
947
|
params.push(v);
|
|
759
948
|
return "?";
|
|
760
949
|
}
|
|
761
|
-
function
|
|
762
|
-
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;
|
|
763
964
|
}
|
|
764
965
|
function nullSafeNegative(col, test) {
|
|
765
966
|
return `(${col} IS NULL OR ${test})`;
|
|
@@ -824,7 +1025,7 @@ function assertNoFieldReferenceComparand2(field, spec) {
|
|
|
824
1025
|
});
|
|
825
1026
|
}
|
|
826
1027
|
}
|
|
827
|
-
function compileOperator(col, op, val, field, params) {
|
|
1028
|
+
function compileOperator(col, op, val, field, params, opts) {
|
|
828
1029
|
switch (op) {
|
|
829
1030
|
case "$eq":
|
|
830
1031
|
return val === null ? `${col} IS NULL` : `${col} = ${bind(params, val)}`;
|
|
@@ -848,7 +1049,7 @@ function compileOperator(col, op, val, field, params) {
|
|
|
848
1049
|
}
|
|
849
1050
|
case "$nin": {
|
|
850
1051
|
if (!Array.isArray(val)) throw readScopeCompileError(`[read-scope-sql] $nin for "${field}" needs an array (fail-closed).`);
|
|
851
|
-
if (val.length === 0)
|
|
1052
|
+
if (val.length === 0) throw readScopeCompileError(`[read-scope-sql] $nin for "${field}" is empty \u2014 an empty exclusion excludes nothing and would compile the read scope to constant TRUE (fail-closed).`);
|
|
852
1053
|
assertCompilableMembers(op, field, val);
|
|
853
1054
|
return nullSafeNegative(col, `${col} NOT IN (${val.map((v) => bind(params, v)).join(", ")})`);
|
|
854
1055
|
}
|
|
@@ -858,12 +1059,19 @@ function compileOperator(col, op, val, field, params) {
|
|
|
858
1059
|
return `${col} BETWEEN ${bind(params, val[0])} AND ${bind(params, val[1])}`;
|
|
859
1060
|
}
|
|
860
1061
|
// [#5567] The comparand is a LITERAL, so it is escaped and the escape
|
|
861
|
-
// character is bound with it. See {@link
|
|
1062
|
+
// character is bound with it. See {@link textMatch}.
|
|
862
1063
|
// [#5234] …and it must be a value `String()` can render, which is asserted
|
|
863
|
-
// 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).
|
|
864
1072
|
case "$contains":
|
|
865
1073
|
assertRenderableText(op, field, val);
|
|
866
|
-
return
|
|
1074
|
+
return textOverNonTextColumn(op, field, opts) ?? textMatch(col, "contains", val, false, params, opts);
|
|
867
1075
|
/**
|
|
868
1076
|
* [#6520] `$icontains` on the READ-SCOPE lowering — the one compiler in this
|
|
869
1077
|
* package where a wrong answer is an ADR-0021 scope over-reach rather than a
|
|
@@ -879,23 +1087,39 @@ function compileOperator(col, op, val, field, params) {
|
|
|
879
1087
|
* the rows already lower-case — and on a read scope that is a row set the
|
|
880
1088
|
* policy author never wrote, in the narrowing direction here but in the
|
|
881
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.
|
|
882
1108
|
*/
|
|
883
|
-
case "$icontains":
|
|
1109
|
+
case "$icontains":
|
|
884
1110
|
assertRenderableText(op, field, val);
|
|
885
|
-
|
|
886
|
-
return `${asciiLowerSqlExpr(col)} LIKE ${patternRef} ESCAPE ${bind(params, LIKE_ESCAPE_CHAR)}`;
|
|
887
|
-
}
|
|
1111
|
+
return textOverNonTextColumn(op, field, opts) ?? textMatch(col, "contains", val, false, params, opts, true);
|
|
888
1112
|
// [#5298] NULL-safe: `NOT LIKE` is UNKNOWN for a NULL column, and "does not
|
|
889
1113
|
// contain" is true of a value that is not there.
|
|
890
1114
|
case "$notContains":
|
|
891
1115
|
assertRenderableText(op, field, val);
|
|
892
|
-
return
|
|
1116
|
+
return textOverNonTextColumn(op, field, opts) ?? nullSafeNegative(col, textMatch(col, "contains", val, true, params, opts));
|
|
893
1117
|
case "$startsWith":
|
|
894
1118
|
assertRenderableText(op, field, val);
|
|
895
|
-
return
|
|
1119
|
+
return textOverNonTextColumn(op, field, opts) ?? textMatch(col, "starts", val, false, params, opts);
|
|
896
1120
|
case "$endsWith":
|
|
897
1121
|
assertRenderableText(op, field, val);
|
|
898
|
-
return
|
|
1122
|
+
return textOverNonTextColumn(op, field, opts) ?? textMatch(col, "ends", val, false, params, opts);
|
|
899
1123
|
// [#6387] `val` is a boolean here — {@link assertBooleanFlagComparands}
|
|
900
1124
|
// refused anything else at {@link compileField}, before this emitter runs.
|
|
901
1125
|
// So `=== true` is an exhaustive TWO-WAY choice over the declared domain,
|
|
@@ -1326,7 +1550,11 @@ var NativeSQLStrategy = class {
|
|
|
1326
1550
|
if (typeof ctx.getReadScope !== "function") return;
|
|
1327
1551
|
const filter = ctx.getReadScope(objectName);
|
|
1328
1552
|
if (filter === void 0 || filter === null) return;
|
|
1329
|
-
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
|
+
});
|
|
1557
|
+
assertReadScopeCannotVacate(filter, objectName);
|
|
1330
1558
|
if (!sql) return;
|
|
1331
1559
|
let i = 0;
|
|
1332
1560
|
const rendered = sql.replace(/\?/g, () => {
|
|
@@ -1603,12 +1831,16 @@ var NativeSQLStrategy = class {
|
|
|
1603
1831
|
gte: ">=",
|
|
1604
1832
|
lt: "<",
|
|
1605
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.
|
|
1606
1840
|
contains: "LIKE",
|
|
1607
1841
|
notContains: "NOT LIKE",
|
|
1608
1842
|
startsWith: "LIKE",
|
|
1609
1843
|
endsWith: "LIKE",
|
|
1610
|
-
// [#6520] `$icontains` — `LIKE` like its neighbours; what separates it is
|
|
1611
|
-
// the ASCII fold applied below, not the keyword.
|
|
1612
1844
|
icontains: "LIKE"
|
|
1613
1845
|
};
|
|
1614
1846
|
const likeShape = {
|
|
@@ -1634,13 +1866,22 @@ var NativeSQLStrategy = class {
|
|
|
1634
1866
|
if (!sqlOp || !values || values.length === 0) return null;
|
|
1635
1867
|
const shape = likeShape[operator];
|
|
1636
1868
|
if (shape) {
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
if (operator === "icontains") {
|
|
1641
|
-
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;
|
|
1642
1872
|
}
|
|
1643
|
-
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
|
+
});
|
|
1644
1885
|
}
|
|
1645
1886
|
if (operator === "lte") {
|
|
1646
1887
|
const nextDay = nextUtcCalendarDay(values[0]);
|
|
@@ -1741,15 +1982,25 @@ var SCALAR_SQL_OPS = {
|
|
|
1741
1982
|
lte: "<="
|
|
1742
1983
|
};
|
|
1743
1984
|
var LIKE_SQL_OPS = {
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
//
|
|
1749
|
-
//
|
|
1750
|
-
//
|
|
1751
|
-
//
|
|
1752
|
-
|
|
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 }
|
|
1753
2004
|
};
|
|
1754
2005
|
var ObjectQLStrategy = class {
|
|
1755
2006
|
constructor() {
|
|
@@ -1780,11 +2031,16 @@ var ObjectQLStrategy = class {
|
|
|
1780
2031
|
for (const [dim, gran] of granByDim) {
|
|
1781
2032
|
groupBy.push({ field: this.resolveFieldName(cube, dim, "dimension"), dateGranularity: gran });
|
|
1782
2033
|
}
|
|
2034
|
+
const datasetScope = ctx.getDatasetScope?.(query.cube);
|
|
1783
2035
|
const aggregations = [];
|
|
1784
2036
|
if (query.measures && query.measures.length > 0) {
|
|
1785
2037
|
for (const measure of query.measures) {
|
|
1786
2038
|
const { field, method } = this.resolveMeasureAggregation(cube, measure);
|
|
1787
|
-
|
|
2039
|
+
const measureFilter = datasetScope?.measureFilters?.[measure];
|
|
2040
|
+
const filterCondition = measureFilter ? this.filterNodeToCondition(normalizeAnalyticsFilterTree({ where: measureFilter }), cube) : null;
|
|
2041
|
+
aggregations.push(
|
|
2042
|
+
filterCondition ? { field, method, alias: measure, filter: filterCondition } : { field, method, alias: measure }
|
|
2043
|
+
);
|
|
1788
2044
|
}
|
|
1789
2045
|
}
|
|
1790
2046
|
const filter = {};
|
|
@@ -1794,7 +2050,6 @@ var ObjectQLStrategy = class {
|
|
|
1794
2050
|
const extra = this.mergeFilterOperand(filter, field, bounds);
|
|
1795
2051
|
if (extra) conjuncts.push(extra);
|
|
1796
2052
|
}
|
|
1797
|
-
const datasetScope = ctx.getDatasetScope?.(query.cube);
|
|
1798
2053
|
if (datasetScope?.filter) {
|
|
1799
2054
|
const scopeCondition = this.filterNodeToCondition(
|
|
1800
2055
|
normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
|
|
@@ -1883,6 +2138,7 @@ var ObjectQLStrategy = class {
|
|
|
1883
2138
|
}
|
|
1884
2139
|
const tableName = this.extractObjectName(cube);
|
|
1885
2140
|
const plan = this.planCrossObject(cube, query, this.filterMemberView(cube, query, ctx));
|
|
2141
|
+
const datasetScope = ctx.getDatasetScope?.(query.cube);
|
|
1886
2142
|
const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
|
|
1887
2143
|
const joinClauses = [];
|
|
1888
2144
|
const dimExpr = (dim) => {
|
|
@@ -1913,7 +2169,9 @@ var ObjectQLStrategy = class {
|
|
|
1913
2169
|
if (query.measures) {
|
|
1914
2170
|
for (const m of query.measures) {
|
|
1915
2171
|
const { field, method } = this.resolveMeasureAggregation(cube, m);
|
|
1916
|
-
const
|
|
2172
|
+
const measureFilter = datasetScope?.measureFilters?.[m];
|
|
2173
|
+
const predicate = measureFilter ? this.renderFilterNodeSql(normalizeAnalyticsFilterTree({ where: measureFilter }), cube, params, ctx) : null;
|
|
2174
|
+
const aggSql = predicate ? this.conditionalAggregateSql(method, field, predicate) : method === "count" ? "COUNT(*)" : method === "count_distinct" ? `COUNT(DISTINCT ${field})` : `${method.toUpperCase()}(${field})`;
|
|
1917
2175
|
selectParts.push(`${aggSql} AS "${m}"`);
|
|
1918
2176
|
}
|
|
1919
2177
|
}
|
|
@@ -1921,15 +2179,16 @@ var ObjectQLStrategy = class {
|
|
|
1921
2179
|
const filterClause = this.renderFilterNodeSql(
|
|
1922
2180
|
normalizeAnalyticsFilterTree(query),
|
|
1923
2181
|
cube,
|
|
1924
|
-
params
|
|
2182
|
+
params,
|
|
2183
|
+
ctx
|
|
1925
2184
|
);
|
|
1926
2185
|
if (filterClause) whereParts.push(filterClause);
|
|
1927
|
-
|
|
1928
|
-
if (echoedDatasetFilter) {
|
|
2186
|
+
if (datasetScope?.filter) {
|
|
1929
2187
|
const scopeSql = this.renderFilterNodeSql(
|
|
1930
|
-
normalizeAnalyticsFilterTree({ where:
|
|
2188
|
+
normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
|
|
1931
2189
|
cube,
|
|
1932
|
-
params
|
|
2190
|
+
params,
|
|
2191
|
+
ctx
|
|
1933
2192
|
);
|
|
1934
2193
|
if (scopeSql) whereParts.push(scopeSql);
|
|
1935
2194
|
}
|
|
@@ -1942,7 +2201,11 @@ var ObjectQLStrategy = class {
|
|
|
1942
2201
|
}
|
|
1943
2202
|
const scope = ctx.getReadScope?.(tableName);
|
|
1944
2203
|
if (scope != null) {
|
|
1945
|
-
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
|
+
});
|
|
2208
|
+
assertReadScopeCannotVacate(scope, tableName);
|
|
1946
2209
|
if (scopeSql) {
|
|
1947
2210
|
let i = 0;
|
|
1948
2211
|
const rendered = scopeSql.replace(/\?/g, () => {
|
|
@@ -1989,6 +2252,7 @@ var ObjectQLStrategy = class {
|
|
|
1989
2252
|
if (typeof ctx.getReadScope !== "function") return userFilter;
|
|
1990
2253
|
const scope = ctx.getReadScope(objectName);
|
|
1991
2254
|
if (scope === void 0 || scope === null) return userFilter;
|
|
2255
|
+
assertReadScopeCannotVacate(scope, objectName);
|
|
1992
2256
|
const scopeFilter = markFilterSubtreeProvenance(scope, "policy");
|
|
1993
2257
|
if (!userFilter) return scopeFilter;
|
|
1994
2258
|
return { $and: [userFilter, scopeFilter] };
|
|
@@ -2017,7 +2281,7 @@ var ObjectQLStrategy = class {
|
|
|
2017
2281
|
* the members inside were unreadable from the outside and the envelope check
|
|
2018
2282
|
* could not reject what it could not see.
|
|
2019
2283
|
*
|
|
2020
|
-
* ##
|
|
2284
|
+
* ## Three producers, one inventory (#10861, #11461)
|
|
2021
2285
|
*
|
|
2022
2286
|
* The caller's `where` is not the only thing that reaches `engine.aggregate`
|
|
2023
2287
|
* as a predicate. Since PR #10758 the compiled dataset's own definition-level
|
|
@@ -2032,6 +2296,36 @@ var ObjectQLStrategy = class {
|
|
|
2032
2296
|
* which driver will serve the dataset and would refuse a dataset that is
|
|
2033
2297
|
* perfectly legal on a native-SQL deployment.
|
|
2034
2298
|
*
|
|
2299
|
+
* [#11461] #10413 phase 2 then added a THIRD producer with the same reach and
|
|
2300
|
+
* none of the coverage: a compiled measure's own `filter`, lowered onto that
|
|
2301
|
+
* measure's `aggregations[].filter` entry (#10576). This view enumerated two
|
|
2302
|
+
* origins, so the third was invisible to the envelope check and the arm of
|
|
2303
|
+
* `planCrossObject` that inspects `query.measures` reads only each measure's
|
|
2304
|
+
* resolved FIELD, never its filter. Measured on the unfixed tree, one fixture,
|
|
2305
|
+
* both doors:
|
|
2306
|
+
*
|
|
2307
|
+
* ```
|
|
2308
|
+
* BEFORE execute() ACCEPTED -> aggregations: [{field:"*",method:"count",
|
|
2309
|
+
* alias:"west_count",
|
|
2310
|
+
* filter:{"account.region":"West"}}]
|
|
2311
|
+
* -> rows [{stage:"won",total_count:3,west_count:0}]
|
|
2312
|
+
* (the truthful west_count is 2; total_count
|
|
2313
|
+
* is right, so the wrong number arrived in
|
|
2314
|
+
* the same response shape as the right one)
|
|
2315
|
+
* generateSql() ACCEPTED -> COUNT(CASE WHEN account.region = $1 THEN 1 END)
|
|
2316
|
+
* over a FROM with no join in it at all
|
|
2317
|
+
* AFTER both doors REFUSED INVALID_FIELD / 400, engine never reached
|
|
2318
|
+
* ```
|
|
2319
|
+
*
|
|
2320
|
+
* The same maintainer ruling covers it — same hazard, same physical verdict,
|
|
2321
|
+
* one more producer — so it folds in HERE for the #10861 reason and not into
|
|
2322
|
+
* `dataset-compiler.ts`, which still cannot see which driver will serve the
|
|
2323
|
+
* dataset. Only the REQUESTED measures are folded: both doors' aggregation
|
|
2324
|
+
* loops read `measureFilters[m]` for `m of query.measures` and nothing else,
|
|
2325
|
+
* so a filter declared on a measure this query never asks for reaches no
|
|
2326
|
+
* engine, and refusing on it would reject a query for a member that was never
|
|
2327
|
+
* going to be evaluated.
|
|
2328
|
+
*
|
|
2035
2329
|
* Structure is discarded on purpose — a member is cross-object or it is not,
|
|
2036
2330
|
* and which branch of a disjunction it sits in cannot make
|
|
2037
2331
|
* `engine.aggregate` able to join it. PROVENANCE is not discarded, because it
|
|
@@ -2041,9 +2335,12 @@ var ObjectQLStrategy = class {
|
|
|
2041
2335
|
* `planCrossObject`. The value slot carries that and nothing else; it never
|
|
2042
2336
|
* reaches a driver.
|
|
2043
2337
|
*
|
|
2044
|
-
*
|
|
2045
|
-
*
|
|
2046
|
-
*
|
|
2338
|
+
* Insertion order is measure-filter, then dataset-filter, then `where`, and
|
|
2339
|
+
* last write wins on a duplicate key. Two things follow, in that order of
|
|
2340
|
+
* importance. A member named by the request too keeps the CALLER's provenance,
|
|
2341
|
+
* because if it is in the request that is the actionable place to fix it. And
|
|
2342
|
+
* every shape that was refused before #11461 keeps the exact message it had:
|
|
2343
|
+
* the new origin can only ever win a key no older producer names.
|
|
2047
2344
|
*
|
|
2048
2345
|
* Time-dimension WINDOWS are deliberately absent (they live in
|
|
2049
2346
|
* `dateRangeBounds`, not in `where`). They need no arm here: a cross-object
|
|
@@ -2053,13 +2350,21 @@ var ObjectQLStrategy = class {
|
|
|
2053
2350
|
* diagnostic and the reason that loop runs first.
|
|
2054
2351
|
*/
|
|
2055
2352
|
filterMemberView(cube, query, ctx) {
|
|
2056
|
-
const
|
|
2353
|
+
const datasetScope = ctx.getDatasetScope?.(query.cube);
|
|
2057
2354
|
const leaves = (node, origin) => collectFilterLeaves(node).map(
|
|
2058
2355
|
(f) => [this.resolveFieldName(cube, f.member, "any"), origin]
|
|
2059
2356
|
);
|
|
2357
|
+
const measureLeaves = (query.measures ?? []).flatMap((m) => {
|
|
2358
|
+
const measureFilter = datasetScope?.measureFilters?.[m];
|
|
2359
|
+
return measureFilter ? leaves(
|
|
2360
|
+
normalizeAnalyticsFilterTree({ where: measureFilter }),
|
|
2361
|
+
{ kind: "measure-filter", measure: m }
|
|
2362
|
+
) : [];
|
|
2363
|
+
});
|
|
2060
2364
|
return Object.fromEntries([
|
|
2061
|
-
...
|
|
2062
|
-
...leaves(normalizeAnalyticsFilterTree(
|
|
2365
|
+
...measureLeaves,
|
|
2366
|
+
...datasetScope?.filter ? leaves(normalizeAnalyticsFilterTree({ where: datasetScope.filter }), { kind: "dataset-filter" }) : [],
|
|
2367
|
+
...leaves(normalizeAnalyticsFilterTree(query), { kind: "where" })
|
|
2063
2368
|
]);
|
|
2064
2369
|
}
|
|
2065
2370
|
/**
|
|
@@ -2074,18 +2379,20 @@ var ObjectQLStrategy = class {
|
|
|
2074
2379
|
* THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
|
|
2075
2380
|
* (needs a real join to evaluate), a cross-object leaf in the DATASET's own
|
|
2076
2381
|
* definition-level `filter` (#10861 — same join it does not have, arriving
|
|
2077
|
-
* from the producer PR #10758 added), a
|
|
2078
|
-
*
|
|
2079
|
-
*
|
|
2382
|
+
* from the producer PR #10758 added), a cross-object leaf in ONE MEASURE's own
|
|
2383
|
+
* `filter` (#11461 — the same join again, arriving from the producer #10413
|
|
2384
|
+
* phase 2 added), a MULTI-HOP dimension (`a.b.c`), or a non-recombinable
|
|
2385
|
+
* measure (`avg`/`count_distinct`, whose sub-bucket values cannot be merged).
|
|
2386
|
+
* A loud error beats the silent mis-bucket #3654 kills.
|
|
2080
2387
|
* `generateSql()` calls this too, so the preview accepts/rejects the same set
|
|
2081
2388
|
* — and since #10759 both callers derive `filter` from the one
|
|
2082
2389
|
* {@link filterMemberView}, so that sentence is enforced by construction
|
|
2083
2390
|
* instead of restated at two call sites.
|
|
2084
2391
|
*
|
|
2085
|
-
* [#5716] All
|
|
2392
|
+
* [#5716] All six refusals below are `invalidMemberError` — `INVALID_FIELD` /
|
|
2086
2393
|
* 400, naming the member — and the four that predate #10861 keep their
|
|
2087
2394
|
* MESSAGES unchanged (they are good diagnostics, and #5923's tests read
|
|
2088
|
-
* them). Each is decided by two facts and nothing else: a member that will
|
|
2395
|
+
* them); so does #10861's own, which #11461 left untouched beside it. Each is decided by two facts and nothing else: a member that will
|
|
2089
2396
|
* reach the engine's predicate, and whether that member resolves across a
|
|
2090
2397
|
* join. Neither is an internal invariant — a cube where the member exists and
|
|
2091
2398
|
* a driver that could serve it are both perfectly ordinary, which is exactly
|
|
@@ -2094,14 +2401,15 @@ var ObjectQLStrategy = class {
|
|
|
2094
2401
|
* because the fix is always to change or drop ONE named member, and because
|
|
2095
2402
|
* four of them fire on `/analytics/query` where no dataset exists.
|
|
2096
2403
|
*
|
|
2097
|
-
* [#10861] The fifth
|
|
2098
|
-
* it:
|
|
2099
|
-
* here whose member no request key named — so
|
|
2100
|
-
* `param`, and says in its own words which document to
|
|
2404
|
+
* [#10861, #11461] The fifth and sixth are the exceptions that prove the rule
|
|
2405
|
+
* and are written to it: they can only fire where a dataset DOES exist, and
|
|
2406
|
+
* they are the two refusals here whose member no request key named — so each
|
|
2407
|
+
* carries `cube` and no `param`, and says in its own words which document to
|
|
2408
|
+
* go and edit, the sixth naming the MEASURE inside it as well. Both stay
|
|
2101
2409
|
* `INVALID_FIELD` rather than becoming `DATASET_INVALID` because the verdict
|
|
2102
|
-
* is the same physical one as
|
|
2410
|
+
* is the same physical one as their neighbours — this engine cannot join this
|
|
2103
2411
|
* member — and splitting the code by PROVENANCE would make a caller branch on
|
|
2104
|
-
*
|
|
2412
|
+
* three wire shapes for one capability limit.
|
|
2105
2413
|
*
|
|
2106
2414
|
* Detection is on RESOLVED field names, so a dotted dimension the cube
|
|
2107
2415
|
* flattens to a real column is treated as base, not cross-object.
|
|
@@ -2123,7 +2431,7 @@ var ObjectQLStrategy = class {
|
|
|
2123
2431
|
member: m,
|
|
2124
2432
|
field: this.resolveMeasureAggregation(cube, m).field
|
|
2125
2433
|
})),
|
|
2126
|
-
...Object.entries(filter).filter(([, origin]) => origin === "where").map(([f]) => ({ where: "filter", member: f, field: f }))
|
|
2434
|
+
...Object.entries(filter).filter(([, origin]) => origin.kind === "where").map(([f]) => ({ where: "filter", member: f, field: f }))
|
|
2127
2435
|
].filter((r) => this.isCrossObjectField(cube, r.field, baseObject));
|
|
2128
2436
|
if (nonDim.length > 0) {
|
|
2129
2437
|
throw invalidMemberError(
|
|
@@ -2137,13 +2445,23 @@ var ObjectQLStrategy = class {
|
|
|
2137
2445
|
}
|
|
2138
2446
|
);
|
|
2139
2447
|
}
|
|
2140
|
-
const scopeCross = Object.entries(filter).filter(([field, origin]) => origin === "dataset-filter" && this.isCrossObjectField(cube, field, baseObject)).map(([field]) => field);
|
|
2448
|
+
const scopeCross = Object.entries(filter).filter(([field, origin]) => origin.kind === "dataset-filter" && this.isCrossObjectField(cube, field, baseObject)).map(([field]) => field);
|
|
2141
2449
|
if (scopeCross.length > 0) {
|
|
2142
2450
|
throw invalidMemberError(
|
|
2143
2451
|
`[Analytics] ObjectQLStrategy cannot evaluate the cross-object filter ("${scopeCross[0]}") that dataset "${cube.name}" declares at its definition level \u2014 the engine cannot join in an aggregate, so this predicate matches nothing and the answer would be neither the scoped number nor the unscoped one. Nothing in the request names it: remove the cross-object leaf from the dataset's own \`filter\`, or serve this dataset on a native-SQL driver, where the same definition is valid.`,
|
|
2144
2452
|
{ member: scopeCross[0], cube: cube.name }
|
|
2145
2453
|
);
|
|
2146
2454
|
}
|
|
2455
|
+
const measureCross = Object.entries(filter).flatMap(
|
|
2456
|
+
([field, origin]) => origin.kind === "measure-filter" && this.isCrossObjectField(cube, field, baseObject) ? [{ field, measure: origin.measure }] : []
|
|
2457
|
+
);
|
|
2458
|
+
if (measureCross.length > 0) {
|
|
2459
|
+
const { field, measure } = measureCross[0];
|
|
2460
|
+
throw invalidMemberError(
|
|
2461
|
+
`[Analytics] ObjectQLStrategy cannot evaluate the cross-object filter ("${field}") that dataset "${cube.name}" declares on its measure "${measure}" \u2014 the engine cannot join in an aggregate, so this measure would be counted over a predicate that matches nothing and would answer 0 rather than the scoped number. Remove the cross-object leaf from that measure's own \`filter\`, or serve this dataset on a native-SQL driver, where the same definition is valid.`,
|
|
2462
|
+
{ member: field, cube: cube.name }
|
|
2463
|
+
);
|
|
2464
|
+
}
|
|
2147
2465
|
const crossDims = [];
|
|
2148
2466
|
for (const dim of query.dimensions ?? []) {
|
|
2149
2467
|
const field = this.resolveFieldName(cube, dim, "dimension");
|
|
@@ -2247,6 +2565,7 @@ var ObjectQLStrategy = class {
|
|
|
2247
2565
|
if (fkValues.length === 0 || typeof ctx.executeAggregate !== "function") return map;
|
|
2248
2566
|
const idFilter = { id: { $in: fkValues } };
|
|
2249
2567
|
const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
|
|
2568
|
+
if (scope != null) assertReadScopeCannotVacate(scope, refObject);
|
|
2250
2569
|
if (scope != null) markFilterSubtreeProvenance(scope, "policy");
|
|
2251
2570
|
const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
|
|
2252
2571
|
const rows = await ctx.executeAggregate(refObject, {
|
|
@@ -2260,6 +2579,45 @@ var ObjectQLStrategy = class {
|
|
|
2260
2579
|
}
|
|
2261
2580
|
return map;
|
|
2262
2581
|
}
|
|
2582
|
+
/**
|
|
2583
|
+
* A measure's aggregate, restricted to the rows its own `filter` admits
|
|
2584
|
+
* (#10413 phase 2) — the same six functions `generateSql`'s unconditional
|
|
2585
|
+
* branch renders, wrapped in a `CASE WHEN`.
|
|
2586
|
+
*
|
|
2587
|
+
* Spelled `CASE WHEN` rather than SQL-standard `FILTER (WHERE …)`, mirroring
|
|
2588
|
+
* `NativeSQLStrategy.CONDITIONAL_AGGREGATE_SQL`: this string is DOCUMENTATION
|
|
2589
|
+
* of an execution that really goes through `engine.aggregate`'s per-driver
|
|
2590
|
+
* `aggregations[].filter` lowering (#10576), not a statement this class runs
|
|
2591
|
+
* itself, so there is no reason to pick a dialect-restricted spelling over
|
|
2592
|
+
* the portable one the SQL-executing sibling already settled on.
|
|
2593
|
+
*
|
|
2594
|
+
* `count` over `*` counts a constant (`COUNT(CASE WHEN p THEN 1 END)`, since
|
|
2595
|
+
* `COUNT(CASE WHEN p THEN * END)` is not valid SQL); over a real column it
|
|
2596
|
+
* counts that column's non-null values among the admitted rows.
|
|
2597
|
+
*/
|
|
2598
|
+
conditionalAggregateSql(method, col, pred) {
|
|
2599
|
+
const target = col === "*" ? "1" : col;
|
|
2600
|
+
switch (method) {
|
|
2601
|
+
case "count":
|
|
2602
|
+
return `COUNT(CASE WHEN ${pred} THEN ${target} END)`;
|
|
2603
|
+
case "count_distinct":
|
|
2604
|
+
return `COUNT(DISTINCT CASE WHEN ${pred} THEN ${col} END)`;
|
|
2605
|
+
case "sum":
|
|
2606
|
+
return `SUM(CASE WHEN ${pred} THEN ${col} END)`;
|
|
2607
|
+
case "avg":
|
|
2608
|
+
return `AVG(CASE WHEN ${pred} THEN ${col} END)`;
|
|
2609
|
+
case "min":
|
|
2610
|
+
return `MIN(CASE WHEN ${pred} THEN ${col} END)`;
|
|
2611
|
+
case "max":
|
|
2612
|
+
return `MAX(CASE WHEN ${pred} THEN ${col} END)`;
|
|
2613
|
+
// Closed vocabulary, same posture as `resolveMeasureAggregation`'s
|
|
2614
|
+
// callers: `method` comes only from that function, whose own aggTypes
|
|
2615
|
+
// list is exactly these six, so this default is unreachable rather than
|
|
2616
|
+
// a silent fallback for a method this table forgot.
|
|
2617
|
+
default:
|
|
2618
|
+
return `${method.toUpperCase()}(CASE WHEN ${pred} THEN ${col} END)`;
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2263
2621
|
/**
|
|
2264
2622
|
* Render one normalized filter as a display SQL predicate for `generateSql`.
|
|
2265
2623
|
*
|
|
@@ -2280,7 +2638,7 @@ var ObjectQLStrategy = class {
|
|
|
2280
2638
|
* not render that operator": #5333 was exactly that conflation, and an
|
|
2281
2639
|
* unrenderable operator now THROWS (see the exit below).
|
|
2282
2640
|
*/
|
|
2283
|
-
buildFilterClauseSql(col, operator, values, params) {
|
|
2641
|
+
buildFilterClauseSql(col, operator, values, params, target, ctx) {
|
|
2284
2642
|
if (operator === "set") return `${col} IS NOT NULL`;
|
|
2285
2643
|
if (operator === "notSet") return `${col} IS NULL`;
|
|
2286
2644
|
if (!values || values.length === 0) return null;
|
|
@@ -2293,12 +2651,22 @@ var ObjectQLStrategy = class {
|
|
|
2293
2651
|
}
|
|
2294
2652
|
const like = LIKE_SQL_OPS[operator];
|
|
2295
2653
|
if (like) {
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
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
|
+
});
|
|
2302
2670
|
}
|
|
2303
2671
|
const op = SCALAR_SQL_OPS[operator];
|
|
2304
2672
|
if (!op) {
|
|
@@ -2337,6 +2705,29 @@ var ObjectQLStrategy = class {
|
|
|
2337
2705
|
}
|
|
2338
2706
|
return void 0;
|
|
2339
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
|
+
}
|
|
2340
2731
|
resolveFieldName(cube, member, kind) {
|
|
2341
2732
|
if (kind === "dimension" || kind === "any") {
|
|
2342
2733
|
const dim = this.lookupMember(cube, member, "dimension");
|
|
@@ -2351,8 +2742,20 @@ var ObjectQLStrategy = class {
|
|
|
2351
2742
|
resolveMeasureAggregation(cube, measureName) {
|
|
2352
2743
|
const direct = this.lookupMember(cube, measureName, "measure");
|
|
2353
2744
|
if (direct) {
|
|
2745
|
+
if (EXPRESSION_METRIC_TYPES.has(direct.type)) {
|
|
2746
|
+
throw invalidMemberError(
|
|
2747
|
+
`[Analytics] ObjectQLStrategy cannot evaluate the custom-SQL measure ("${measureName}") \u2014 its type "${direct.type}" declares a raw SQL expression, which the engine aggregate AST cannot carry; served anyway it would answer null for every bucket under the measure's own name. Use an aggregate measure (count/sum/avg/min/max/count_distinct), or run on a native-SQL driver.`,
|
|
2748
|
+
{ member: measureName, param: "measures", cube: cube.name }
|
|
2749
|
+
);
|
|
2750
|
+
}
|
|
2354
2751
|
return {
|
|
2355
2752
|
field: direct.sql.replace(/^\$/, ""),
|
|
2753
|
+
// The assertion, not a parse: for a CubeSchema-legal cube the type
|
|
2754
|
+
// partition above leaves exactly the six `AggregationFunction` values.
|
|
2755
|
+
// An enum-INVALID type (host drift, the comment above) still flows
|
|
2756
|
+
// through unchecked ON PURPOSE — adding a method allowlist here would
|
|
2757
|
+
// re-blame the caller with a 400 for OUR bug, so the cast keeps the
|
|
2758
|
+
// compile-time contract (#12776) without changing that posture.
|
|
2356
2759
|
method: direct.type === "count_distinct" ? "count_distinct" : direct.type
|
|
2357
2760
|
};
|
|
2358
2761
|
}
|
|
@@ -2366,7 +2769,7 @@ var ObjectQLStrategy = class {
|
|
|
2366
2769
|
if (candidate && candidate.type === type) {
|
|
2367
2770
|
return {
|
|
2368
2771
|
field: candidate.sql.replace(/^\$/, ""),
|
|
2369
|
-
method:
|
|
2772
|
+
method: type
|
|
2370
2773
|
};
|
|
2371
2774
|
}
|
|
2372
2775
|
}
|
|
@@ -2464,7 +2867,7 @@ var ObjectQLStrategy = class {
|
|
|
2464
2867
|
* exactly — including the invariant that a `null` return leaves `params`
|
|
2465
2868
|
* untouched, so no comparand is left with no placeholder to consume it.
|
|
2466
2869
|
*/
|
|
2467
|
-
renderFilterNodeSql(node, cube, params) {
|
|
2870
|
+
renderFilterNodeSql(node, cube, params, ctx) {
|
|
2468
2871
|
if (!node) return null;
|
|
2469
2872
|
if (node.kind === "const") {
|
|
2470
2873
|
return node.value ? SQL_CONST_TRUE : SQL_CONST_FALSE;
|
|
@@ -2474,17 +2877,23 @@ var ObjectQLStrategy = class {
|
|
|
2474
2877
|
this.resolveFieldName(cube, node.member, "any"),
|
|
2475
2878
|
node.operator,
|
|
2476
2879
|
node.values,
|
|
2477
|
-
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
|
|
2478
2887
|
);
|
|
2479
2888
|
}
|
|
2480
2889
|
if (node.kind === "not") {
|
|
2481
|
-
const inner = this.renderFilterNodeSql(node.child, cube, params);
|
|
2890
|
+
const inner = this.renderFilterNodeSql(node.child, cube, params, ctx);
|
|
2482
2891
|
return inner ? `NOT (${inner})` : SQL_CONST_FALSE;
|
|
2483
2892
|
}
|
|
2484
2893
|
const paramBase = params.length;
|
|
2485
2894
|
const parts = [];
|
|
2486
2895
|
for (const child of node.children) {
|
|
2487
|
-
const clause = this.renderFilterNodeSql(child, cube, params);
|
|
2896
|
+
const clause = this.renderFilterNodeSql(child, cube, params, ctx);
|
|
2488
2897
|
if (clause === null) {
|
|
2489
2898
|
if (node.kind !== "or") continue;
|
|
2490
2899
|
params.length = paramBase;
|
|
@@ -3698,25 +4107,55 @@ function bucketDate(value, granularity, timezone) {
|
|
|
3698
4107
|
return `${y}-${m}-${day}`;
|
|
3699
4108
|
}
|
|
3700
4109
|
}
|
|
3701
|
-
function
|
|
3702
|
-
if (
|
|
3703
|
-
|
|
3704
|
-
|
|
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;
|
|
3705
4134
|
}
|
|
3706
|
-
|
|
4135
|
+
const c = compareOperands(v, winner);
|
|
4136
|
+
if (kind === "min" ? c < 0 : c > 0) winner = v;
|
|
3707
4137
|
}
|
|
4138
|
+
return seen ? winner : null;
|
|
4139
|
+
}
|
|
4140
|
+
function aggregate(rows, metricType, field) {
|
|
4141
|
+
if (metricType === "count" || field === "*") return rows.length;
|
|
3708
4142
|
const nums = rows.map((r) => Number(r[field])).filter((n) => Number.isFinite(n));
|
|
3709
4143
|
switch (metricType) {
|
|
3710
|
-
|
|
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":
|
|
3711
4150
|
return new Set(rows.map((r) => r[field]).filter((v) => v != null)).size;
|
|
3712
4151
|
case "sum":
|
|
3713
4152
|
return nums.reduce((a, b) => a + b, 0);
|
|
3714
4153
|
case "avg":
|
|
3715
4154
|
return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : 0;
|
|
3716
4155
|
case "min":
|
|
3717
|
-
return
|
|
4156
|
+
return extremumOf(rows, field, "min");
|
|
3718
4157
|
case "max":
|
|
3719
|
-
return
|
|
4158
|
+
return extremumOf(rows, field, "max");
|
|
3720
4159
|
default:
|
|
3721
4160
|
return nums.length ? nums.reduce((a, b) => a + b, 0) : rows.length;
|
|
3722
4161
|
}
|
|
@@ -3777,7 +4216,19 @@ function evaluateAnalyticsQueryOverRows(query, cube, rows) {
|
|
|
3777
4216
|
return {
|
|
3778
4217
|
rows: limited,
|
|
3779
4218
|
fields: [
|
|
3780
|
-
|
|
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).
|
|
3781
4232
|
...query.measures.map((m) => ({ name: m, type: "number" }))
|
|
3782
4233
|
]
|
|
3783
4234
|
};
|
|
@@ -3910,7 +4361,17 @@ var AnalyticsService = class {
|
|
|
3910
4361
|
},
|
|
3911
4362
|
coerceTemporalFilterValue: config.coerceTemporalFilterValue,
|
|
3912
4363
|
coerceTemporalFilterColumn: config.coerceTemporalFilterColumn,
|
|
3913
|
-
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)
|
|
3914
4375
|
};
|
|
3915
4376
|
const builtIn = [
|
|
3916
4377
|
new NativeSQLStrategy(),
|
|
@@ -3930,15 +4391,59 @@ var AnalyticsService = class {
|
|
|
3930
4391
|
* current request's ExecutionContext (ADR-0021 D-C). The strategy then sees a
|
|
3931
4392
|
* `getReadScope(objectName)` that already knows the active tenant.
|
|
3932
4393
|
*/
|
|
3933
|
-
async callCtx(query, context) {
|
|
3934
|
-
|
|
4394
|
+
async callCtx(query, context, tokenCtx) {
|
|
4395
|
+
const getDatasetScope = this.resolvedDatasetScopeGetter(tokenCtx);
|
|
4396
|
+
if (!this.readScopeProvider) return { ...this.baseCtx, context, getDatasetScope };
|
|
3935
4397
|
const scopes = await this.resolveReadScopes(query, context);
|
|
3936
4398
|
return {
|
|
3937
4399
|
...this.baseCtx,
|
|
3938
4400
|
context,
|
|
4401
|
+
getDatasetScope,
|
|
3939
4402
|
getReadScope: (objectName) => scopes.get(objectName) ?? null
|
|
3940
4403
|
};
|
|
3941
4404
|
}
|
|
4405
|
+
/**
|
|
4406
|
+
* [#12230] Copy-on-write expansion of filter placeholders across everything
|
|
4407
|
+
* a DIRECT analytics query compares on: `where` and each time dimension's
|
|
4408
|
+
* `dateRange` — the same positions `DatasetExecutor.resolveSelectionTokens`
|
|
4409
|
+
* covers for the dashboard door, minus the dataset-only channels it alone
|
|
4410
|
+
* carries (measure filters ride the dataset-scope getter below).
|
|
4411
|
+
*
|
|
4412
|
+
* The input is never mutated: a query object can be caller-owned metadata
|
|
4413
|
+
* (a saved report definition, a flow node's config) reused across requests,
|
|
4414
|
+
* and resolving in place would bake one request's user id into every later
|
|
4415
|
+
* render. Returns the SAME object when nothing resolved.
|
|
4416
|
+
*/
|
|
4417
|
+
resolveQueryTokens(query, tokenCtx) {
|
|
4418
|
+
const where = resolveFilterTokens2(query.where, tokenCtx);
|
|
4419
|
+
const timeDimensions = query.timeDimensions?.map((td) => {
|
|
4420
|
+
if (td.dateRange == null) return td;
|
|
4421
|
+
const dateRange = resolveFilterTokens2(td.dateRange, tokenCtx);
|
|
4422
|
+
return dateRange === td.dateRange ? td : { ...td, dateRange };
|
|
4423
|
+
});
|
|
4424
|
+
const tdChanged = timeDimensions !== void 0 && timeDimensions.some((td, i) => td !== query.timeDimensions[i]);
|
|
4425
|
+
if (where === query.where && !tdChanged) return query;
|
|
4426
|
+
const out = { ...query };
|
|
4427
|
+
if (where !== query.where) out.where = where;
|
|
4428
|
+
if (tdChanged) out.timeDimensions = timeDimensions;
|
|
4429
|
+
return out;
|
|
4430
|
+
}
|
|
4431
|
+
/**
|
|
4432
|
+
* [#12230] A per-request `getDatasetScope` whose answers have their filter
|
|
4433
|
+
* placeholders resolved against THIS caller. See `callCtx` for why the
|
|
4434
|
+
* registry's copy cannot be handed out raw. Token-free scopes pass through
|
|
4435
|
+
* by reference — `resolveFilterTokens` returns its input unchanged when the
|
|
4436
|
+
* tree holds no placeholder, so the common case allocates nothing.
|
|
4437
|
+
*/
|
|
4438
|
+
resolvedDatasetScopeGetter(tokenCtx) {
|
|
4439
|
+
return (cubeName) => {
|
|
4440
|
+
const scope = this.baseCtx.getDatasetScope?.(cubeName);
|
|
4441
|
+
if (!scope) return scope;
|
|
4442
|
+
const filter = resolveFilterTokens2(scope.filter, tokenCtx);
|
|
4443
|
+
const measureFilters = resolveFilterTokens2(scope.measureFilters, tokenCtx);
|
|
4444
|
+
return filter === scope.filter && measureFilters === scope.measureFilters ? scope : { filter, measureFilters };
|
|
4445
|
+
};
|
|
4446
|
+
}
|
|
3942
4447
|
/**
|
|
3943
4448
|
* Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
|
|
3944
4449
|
* AND every joined object of the query's cube, keyed by object name. This is
|
|
@@ -3996,12 +4501,14 @@ var AnalyticsService = class {
|
|
|
3996
4501
|
* aggregate bridge) instead of failing — or worse, fabricating empty rows.
|
|
3997
4502
|
* Any other error propagates untouched.
|
|
3998
4503
|
*/
|
|
3999
|
-
async query(
|
|
4000
|
-
if (!
|
|
4504
|
+
async query(queryInput, context) {
|
|
4505
|
+
if (!queryInput.cube) {
|
|
4001
4506
|
throw new Error("Cube name is required in analytics query");
|
|
4002
4507
|
}
|
|
4508
|
+
const tokenCtx = filterTokenContextFrom2(context, /* @__PURE__ */ new Date());
|
|
4509
|
+
const query = this.resolveQueryTokens(queryInput, tokenCtx);
|
|
4003
4510
|
this.ensureCube(query);
|
|
4004
|
-
const ctx = await this.callCtx(query, context);
|
|
4511
|
+
const ctx = await this.callCtx(query, context, tokenCtx);
|
|
4005
4512
|
let skip;
|
|
4006
4513
|
for (; ; ) {
|
|
4007
4514
|
const strategy = this.resolveStrategy(query, ctx, skip);
|
|
@@ -4080,10 +4587,10 @@ var AnalyticsService = class {
|
|
|
4080
4587
|
query: async (q) => evaluateAnalyticsQueryOverRows(q, compiled.cube, seedRows)
|
|
4081
4588
|
};
|
|
4082
4589
|
const previewResult = await new DatasetExecutor(previewService).execute(compiled, selection, context);
|
|
4590
|
+
this.enrichResultColumns(previewResult, dataset, selection, context);
|
|
4083
4591
|
return previewResult;
|
|
4084
4592
|
}
|
|
4085
4593
|
}
|
|
4086
|
-
const requestLocale = context?.locale;
|
|
4087
4594
|
const provider = this.readScopeProvider;
|
|
4088
4595
|
const resolveScope = provider ? (targetObject) => provider(targetObject, context) : void 0;
|
|
4089
4596
|
const labelDeps = this.labelResolver ? withLabelFetchCache(this.labelResolver) : void 0;
|
|
@@ -4119,7 +4626,7 @@ var AnalyticsService = class {
|
|
|
4119
4626
|
}
|
|
4120
4627
|
throw err;
|
|
4121
4628
|
}
|
|
4122
|
-
const selectedDims =
|
|
4629
|
+
const selectedDims = this.selectedDimensions(dataset, selection);
|
|
4123
4630
|
const drillDims = selectedDims.filter((d) => !!d.field && d.type !== "date");
|
|
4124
4631
|
if (drillDims.length && result.rows.length) {
|
|
4125
4632
|
result.object = dataset.object;
|
|
@@ -4188,6 +4695,48 @@ var AnalyticsService = class {
|
|
|
4188
4695
|
}
|
|
4189
4696
|
}
|
|
4190
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;
|
|
4191
4740
|
if (result.fields?.length && dataset.measures?.length) {
|
|
4192
4741
|
const measureByName = new Map(dataset.measures.map((m) => [m.name, m]));
|
|
4193
4742
|
for (const f of result.fields) {
|
|
@@ -4197,6 +4746,7 @@ var AnalyticsService = class {
|
|
|
4197
4746
|
const label = resolveI18nLabel2(m.label, requestLocale);
|
|
4198
4747
|
if (label !== void 0) f.label = label;
|
|
4199
4748
|
}
|
|
4749
|
+
if (f.builtinAggregate == null && m.label == null && m.aggregate) f.builtinAggregate = m.aggregate;
|
|
4200
4750
|
if (f.format == null && m.format) f.format = m.format;
|
|
4201
4751
|
const fc = f;
|
|
4202
4752
|
const mc = m;
|
|
@@ -4211,9 +4761,11 @@ var AnalyticsService = class {
|
|
|
4211
4761
|
if (f.percentScale == null) {
|
|
4212
4762
|
f.percentScale = m.derived?.op === "ratio" ? "fraction" : percentScaleOf(meta);
|
|
4213
4763
|
}
|
|
4764
|
+
const resultType = measureResultType(m.aggregate, meta?.type);
|
|
4765
|
+
if (resultType) f.type = resultType;
|
|
4214
4766
|
}
|
|
4215
4767
|
}
|
|
4216
|
-
const describableDims = [...
|
|
4768
|
+
const describableDims = [...this.selectedDimensions(dataset, selection)];
|
|
4217
4769
|
for (const t of selection.timeDimensions ?? []) {
|
|
4218
4770
|
if (describableDims.some((d2) => d2.name === t.dimension)) continue;
|
|
4219
4771
|
const d = dataset.dimensions?.find((x) => x.name === t.dimension);
|
|
@@ -4230,7 +4782,6 @@ var AnalyticsService = class {
|
|
|
4230
4782
|
if (label !== void 0) f.label = label;
|
|
4231
4783
|
}
|
|
4232
4784
|
}
|
|
4233
|
-
return result;
|
|
4234
4785
|
}
|
|
4235
4786
|
/**
|
|
4236
4787
|
* Get cube metadata for discovery.
|
|
@@ -4255,12 +4806,14 @@ var AnalyticsService = class {
|
|
|
4255
4806
|
/**
|
|
4256
4807
|
* Generate SQL for a query without executing it (dry-run).
|
|
4257
4808
|
*/
|
|
4258
|
-
async generateSql(
|
|
4259
|
-
if (!
|
|
4809
|
+
async generateSql(queryInput, context) {
|
|
4810
|
+
if (!queryInput.cube) {
|
|
4260
4811
|
throw new Error("Cube name is required for SQL generation");
|
|
4261
4812
|
}
|
|
4813
|
+
const tokenCtx = filterTokenContextFrom2(context, /* @__PURE__ */ new Date());
|
|
4814
|
+
const query = this.resolveQueryTokens(queryInput, tokenCtx);
|
|
4262
4815
|
this.ensureCube(query);
|
|
4263
|
-
const ctx = await this.callCtx(query, context);
|
|
4816
|
+
const ctx = await this.callCtx(query, context, tokenCtx);
|
|
4264
4817
|
const strategy = this.resolveStrategy(query, ctx);
|
|
4265
4818
|
this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" \u2192 ${strategy.name}`);
|
|
4266
4819
|
return strategy.generateSql(query, ctx);
|
|
@@ -4774,6 +5327,16 @@ var FallbackDelegateStrategy = class {
|
|
|
4774
5327
|
};
|
|
4775
5328
|
|
|
4776
5329
|
// src/plugin.ts
|
|
5330
|
+
import { AggregationFunction as AggregationFunction2 } from "@objectstack/spec/data";
|
|
5331
|
+
function parseEngineAggregateFunction(method, alias) {
|
|
5332
|
+
const parsed = AggregationFunction2.safeParse(method);
|
|
5333
|
+
if (!parsed.success) {
|
|
5334
|
+
throw new Error(
|
|
5335
|
+
`[Analytics] The aggregate bridge cannot forward the aggregation "${alias}": "${method}" is not one of the engine's aggregate functions (${AggregationFunction2.options.join(", ")}). A custom-SQL measure is refused earlier, with a caller-facing diagnostic, by ObjectQLStrategy; reaching this point means the analytics layer produced a method the engine contract does not declare.`
|
|
5336
|
+
);
|
|
5337
|
+
}
|
|
5338
|
+
return parsed.data;
|
|
5339
|
+
}
|
|
4777
5340
|
var AnalyticsServicePlugin = class {
|
|
4778
5341
|
constructor(options = {}) {
|
|
4779
5342
|
this.name = "com.objectstack.service-analytics";
|
|
@@ -4831,10 +5394,58 @@ var AnalyticsServicePlugin = class {
|
|
|
4831
5394
|
const rows = await engine.aggregate(objectName, {
|
|
4832
5395
|
where: filter,
|
|
4833
5396
|
groupBy,
|
|
5397
|
+
// [#10413 phase 2 / #10576] `a.filter` is the per-aggregation
|
|
5398
|
+
// predicate `ObjectQLStrategy` lowers a measure's own scoped
|
|
5399
|
+
// `filter` into. This map already renames `method` → `function`
|
|
5400
|
+
// for the engine's own vocabulary; dropping `filter` here — as this
|
|
5401
|
+
// bridge did before this line existed — would have made the
|
|
5402
|
+
// strategy's lowering a NO-OP on every real deployment that boots
|
|
5403
|
+
// through this auto-bridge (the default path: `new
|
|
5404
|
+
// AnalyticsServicePlugin({ cubes })` with no custom
|
|
5405
|
+
// `executeAggregate`), passing every unit test that stubs
|
|
5406
|
+
// `executeAggregate` directly while silently dropping the filter in
|
|
5407
|
+
// production — the exact declared-≠-enforced shape Prime Directive
|
|
5408
|
+
// #10 calls out. Omitted (not `filter: undefined`) when the
|
|
5409
|
+
// aggregation carries none, matching the engine's own
|
|
5410
|
+
// vacuous-filter convention.
|
|
4834
5411
|
aggregations: aggregations?.map((a) => ({
|
|
4835
|
-
function
|
|
5412
|
+
// [#11833] `function` is the engine contract's SIX-value
|
|
5413
|
+
// `AggregationFunction`. This bridge's own input declared
|
|
5414
|
+
// `method: string` when that history was written
|
|
5415
|
+
// (`StrategyContext.executeAggregate`, spec
|
|
5416
|
+
// `contracts/analytics-service.ts`), so the two ends of this
|
|
5417
|
+
// rename spoke different vocabularies: narrowing the engine side
|
|
5418
|
+
// to the contract turned the forward into a compile error — the
|
|
5419
|
+
// correct signal, and the one the deleted structural type hid by
|
|
5420
|
+
// declaring `function: string` on both sides.
|
|
5421
|
+
//
|
|
5422
|
+
// Since #12776 (contract) and #12940 (this plugin's own config
|
|
5423
|
+
// mirror above), BOTH ends declare the enum, so the rename is
|
|
5424
|
+
// enum-to-enum and the parse below is defence in depth behind a
|
|
5425
|
+
// compile-time check rather than the only check — see
|
|
5426
|
+
// `parseEngineAggregateFunction` for why erased types still leave
|
|
5427
|
+
// it load-bearing.
|
|
5428
|
+
//
|
|
5429
|
+
// It was closed by PARSING with the spec enum itself rather than
|
|
5430
|
+
// by widening back to `string` (what hid it) or casting past it
|
|
5431
|
+
// (which keeps the hole and adds a lie). `AggregationFunction` is
|
|
5432
|
+
// the same schema `AggregationNodeSchema.function` is built from,
|
|
5433
|
+
// so there is one vocabulary, and its own error map already
|
|
5434
|
+
// carries the `array_agg`/`string_agg` retirement prescriptions.
|
|
5435
|
+
//
|
|
5436
|
+
// TIERING, deliberately: the reachable producer of a non-aggregate
|
|
5437
|
+
// method — a custom-SQL measure (`AggregationMetricType`
|
|
5438
|
+
// `number`/`string`/`boolean`) — is already refused upstream with a
|
|
5439
|
+
// caller-blaming 400 by `ObjectQLStrategy.resolveMeasureAggregation`
|
|
5440
|
+
// (#12209). Anything still arriving here is host drift, which that
|
|
5441
|
+
// refusal's docblock assigns to the undeclared-500 tier — so this
|
|
5442
|
+
// throws rather than re-blaming the caller, and it answers loudly
|
|
5443
|
+
// instead of letting the engine answer `null` per bucket under the
|
|
5444
|
+
// author's own measure name (the #4157 class).
|
|
5445
|
+
function: parseEngineAggregateFunction(a.method, a.alias),
|
|
4836
5446
|
field: a.field,
|
|
4837
|
-
alias: a.alias
|
|
5447
|
+
alias: a.alias,
|
|
5448
|
+
...a.filter ? { filter: a.filter } : {}
|
|
4838
5449
|
})),
|
|
4839
5450
|
// ADR-0053 Phase 2: thread the reference tz so date buckets resolve on
|
|
4840
5451
|
// that zone's calendar days (engine buckets in-memory when non-UTC).
|
|
@@ -4936,6 +5547,7 @@ var AnalyticsServicePlugin = class {
|
|
|
4936
5547
|
const map = /* @__PURE__ */ new Map();
|
|
4937
5548
|
const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
|
|
4938
5549
|
if (!displayField || !executeAggregate || ids.length === 0) return map;
|
|
5550
|
+
if (scope) assertReadScopeCannotVacate(scope, targetObject);
|
|
4939
5551
|
const CHUNK = 500;
|
|
4940
5552
|
for (let i = 0; i < ids.length; i += CHUNK) {
|
|
4941
5553
|
const idFilter = { id: { $in: ids.slice(i, i + CHUNK) } };
|
|
@@ -5003,6 +5615,16 @@ var AnalyticsServicePlugin = class {
|
|
|
5003
5615
|
}
|
|
5004
5616
|
return columnSql;
|
|
5005
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
|
+
};
|
|
5006
5628
|
const config = {
|
|
5007
5629
|
cubes: this.options.cubes,
|
|
5008
5630
|
logger: ctx.logger,
|
|
@@ -5044,6 +5666,8 @@ var AnalyticsServicePlugin = class {
|
|
|
5044
5666
|
// prevent: it drifts by one step, silently, and the drift only surfaces as
|
|
5045
5667
|
// an error message pointing at the wrong database.
|
|
5046
5668
|
getObjectDatasource: (objectName) => dataEngine()?.resolveEffectiveDatasource?.(objectName),
|
|
5669
|
+
// [#15684] The executing driver's own dialect — see `sqlDialect` above.
|
|
5670
|
+
sqlDialect,
|
|
5047
5671
|
// ADR-0062 D6 — a federated object carries an `external` block (ADR-0015).
|
|
5048
5672
|
// Reported so NativeSQLStrategy declines it (its hand-compiled FROM would
|
|
5049
5673
|
// hit the wrong physical table) and the driver-correct ObjectQL path runs.
|