@objectstack/service-analytics 17.3.0 → 17.4.0

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