@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/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 { createLogger, getEnv, bucketKeyToCalendarRange as bucketKeyToCalendarRange2, zonedDateStartToUtcMs } from "@objectstack/core";
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 schema.
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
- * - `number` fields `sum`, `avg`, `min`, `max` measures
51
- * - `boolean` fields → `count` measure (count where true)
52
- * - All non-computed fields → dimensions
53
- * - `date`/`datetime` fields time dimensions with standard granularities
54
- * - A default `count` measure is always added
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 compileSub(node, qAlias) {
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 bindLike(params, pattern) {
762
- return `${bind(params, pattern)} ESCAPE ${bind(params, LIKE_ESCAPE_CHAR)}`;
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) return "1 = 1";
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 bindLike}.
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 `likePattern` sees it — see {@link assertRenderableText}.
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 `${col} LIKE ${bindLike(params, likePattern("contains", val))}`;
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
- const patternRef = asciiLowerSqlExpr(bind(params, likePattern("contains", val)));
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 nullSafeNegative(col, `${col} NOT LIKE ${bindLike(params, likePattern("contains", val))}`);
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 `${col} LIKE ${bindLike(params, likePattern("starts", val))}`;
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 `${col} LIKE ${bindLike(params, likePattern("ends", val))}`;
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
- params.push(likePattern(shape, values[0]));
1638
- const patternRef = `$${params.length}`;
1639
- params.push(LIKE_ESCAPE_CHAR);
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 `${rawCol} ${sqlOp} ${patternRef} ESCAPE $${params.length}`;
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
- contains: { sql: "LIKE", shape: "contains" },
1745
- notContains: { sql: "NOT LIKE", shape: "contains" },
1746
- startsWith: { sql: "LIKE", shape: "starts" },
1747
- endsWith: { sql: "LIKE", shape: "ends" },
1748
- // [#6520] `$icontains`: the same escaped pattern and bound `ESCAPE` as its
1749
- // four case-EXACT neighbours, with `fold` adding the ASCII-only case fold to
1750
- // both sides of the comparison. The flag is on this row alone the family
1751
- // above it is case-sensitive by ruling (#4706 Q2 = A).
1752
- icontains: { sql: "LIKE", shape: "contains", fold: true }
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
- aggregations.push({ field, method, alias: measure });
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 aggSql = method === "count" ? "COUNT(*)" : method === "count_distinct" ? `COUNT(DISTINCT ${field})` : `${method.toUpperCase()}(${field})`;
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
- const echoedDatasetFilter = ctx.getDatasetScope?.(query.cube)?.filter;
1928
- if (echoedDatasetFilter) {
2186
+ if (datasetScope?.filter) {
1929
2187
  const scopeSql = this.renderFilterNodeSql(
1930
- normalizeAnalyticsFilterTree({ where: echoedDatasetFilter }),
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
- * ## Two producers, one inventory (#10861)
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
- * Dataset leaves are inserted FIRST so a member named by BOTH producers keeps
2045
- * the caller's provenance (last write wins on a duplicate key): if it is in
2046
- * the request too, the request is the actionable place to fix it.
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 datasetFilter = ctx.getDatasetScope?.(query.cube)?.filter;
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
- ...datasetFilter ? leaves(normalizeAnalyticsFilterTree({ where: datasetFilter }), "dataset-filter") : [],
2062
- ...leaves(normalizeAnalyticsFilterTree(query), "where")
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 MULTI-HOP dimension (`a.b.c`), or a
2078
- * non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
2079
- * cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
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 five refusals below are `invalidMemberError` — `INVALID_FIELD` /
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 is the exception that proves the rule and is written to
2098
- * it: it can only fire where a dataset DOES exist, and it is the one refusal
2099
- * here whose member no request key named — so it carries `cube` and no
2100
- * `param`, and says in its own words which document to go and edit. It stays
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 its neighbour — this engine cannot join this
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
- * two wire shapes for one capability limit.
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
- params.push(likePattern(like.shape, values[0]));
2297
- const patternRef = `$${params.length}`;
2298
- params.push(LIKE_ESCAPE_CHAR);
2299
- const lhs = like.fold ? asciiLowerSqlExpr(col) : col;
2300
- const rhs = like.fold ? asciiLowerSqlExpr(patternRef) : patternRef;
2301
- return `${lhs} ${like.sql} ${rhs} ESCAPE $${params.length}`;
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: candidate.type === "count_distinct" ? "count_distinct" : candidate.type
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 aggregate(rows, metricType, field) {
3702
- if (metricType === "count" || field === "*") {
3703
- if (metricType === "countDistinct") {
3704
- return new Set(rows.map((r) => r[field]).filter((v) => v != null)).size;
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
- return rows.length;
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
- case "countDistinct":
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 nums.length ? Math.min(...nums) : 0;
4156
+ return extremumOf(rows, field, "min");
3718
4157
  case "max":
3719
- return nums.length ? Math.max(...nums) : 0;
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
- ...dimensions.map((d) => ({ name: d, type: "string" })),
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
- if (!this.readScopeProvider) return { ...this.baseCtx, context };
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(query, context) {
4000
- if (!query.cube) {
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 = (selection.dimensions ?? []).map((name) => dataset.dimensions?.find((d) => d.name === name)).filter((d) => !!d);
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 = [...selectedDims];
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(query, context) {
4259
- if (!query.cube) {
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: a.method,
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.