@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.cjs CHANGED
@@ -41,7 +41,7 @@ __export(index_exports, {
41
41
  module.exports = __toCommonJS(index_exports);
42
42
 
43
43
  // src/analytics-service.ts
44
- var import_data6 = require("@objectstack/spec/data");
44
+ var import_data8 = require("@objectstack/spec/data");
45
45
  var import_ui2 = require("@objectstack/spec/ui");
46
46
  var import_core6 = require("@objectstack/core");
47
47
  var import_types = require("@objectstack/types");
@@ -86,14 +86,36 @@ var CubeRegistry = class {
86
86
  this.cubes.clear();
87
87
  }
88
88
  /**
89
- * Auto-generate a cube definition from an object schema.
89
+ * Auto-generate a cube definition from an object's FIELD SCHEMA, and register
90
+ * it under `objectName`.
91
+ *
92
+ * ⚠️ Nothing in this repository calls this — the only in-tree caller is a unit
93
+ * test, and every cube the platform registers itself comes from one of the
94
+ * three sources named on the class above (#15019). That is not the same thing
95
+ * as unreachable: `CubeRegistry` is exported from the package entry and
96
+ * `AnalyticsService.cubeRegistry` is public, so a consumer of
97
+ * `@objectstack/service-analytics` can call it, and what it mints does reach
98
+ * the wire — `getMeta()` serves the labels below as `CubeMeta` titles. Whether
99
+ * this published method is removed or wired up as a real cube source is #15019.
90
100
  *
91
- * Heuristic rules:
92
- * - `number` fields `sum`, `avg`, `min`, `max` measures
93
- * - `boolean` fields → `count` measure (count where true)
94
- * - All non-computed fields → dimensions
95
- * - `date`/`datetime` fields time dimensions with standard granularities
96
- * - A default `count` measure is always added
101
+ * Heuristic rules, measured by driving the built package (the list this
102
+ * replaces claimed three behaviours the code does not have — `min`/`max`
103
+ * measures, a `count` measure for booleans, and a computed-field exclusion):
104
+ * - `number` / `currency` / `percent` fields → one `sum` and one `avg` measure
105
+ * each, labelled with the field's label plus ` (Sum)` / ` (Avg)`. No `min`
106
+ * or `max` measure is minted.
107
+ * - EVERY field becomes a dimension; there is no computed-field exclusion (the
108
+ * `fields` parameter carries no flag one could exclude on).
109
+ * - `boolean` fields become a `boolean` DIMENSION and nothing else — no count
110
+ * measure is minted for them.
111
+ * - `date` / `datetime` fields → `time` dimensions granulated
112
+ * day/week/month/quarter/year.
113
+ * - A default `count` measure labelled `Count` is always added.
114
+ *
115
+ * Those three defaults (`Count`, and the two composites) are English literals
116
+ * with no i18n hook; #14492's ruling listed the `Count` one as a site to carry
117
+ * the `builtinAggregate` discriminator, and it was left alone because no
118
+ * in-repo path reaches it.
97
119
  *
98
120
  * @param objectName - The snake_case object name (used as table/cube name)
99
121
  * @param fields - Array of field descriptors `{ name, type, label? }`
@@ -161,19 +183,47 @@ var CubeRegistry = class {
161
183
  }
162
184
  };
163
185
 
186
+ // src/measure-result-type.ts
187
+ var import_data = require("@objectstack/spec/data");
188
+ var MEASURE_RESULT_TYPE_TEMPORAL = "time";
189
+ var MEASURE_RESULT_TYPE_STRING = "string";
190
+ var TEMPORAL_SOURCE_FIELD_TYPES = /* @__PURE__ */ new Set([
191
+ "date",
192
+ "datetime",
193
+ "time"
194
+ ]);
195
+ var STRING_SOURCE_FIELD_TYPES = /* @__PURE__ */ new Set([
196
+ // Plain strings: text/textarea/email/url/phone/password/secret, the rich
197
+ // bodies (markdown/html/richtext/code), and color/signature/qrcode.
198
+ ...import_data.STRING_VALUE_TYPES,
199
+ // One declared option code — select/radio.
200
+ ...import_data.SINGLE_OPTION_TYPES,
201
+ // The referenced row's id — lookup/master_detail/tree/user.
202
+ ...import_data.REFERENCE_VALUE_TYPES,
203
+ // The rendered record number, zero-padded under the default `{0000}`.
204
+ "autonumber"
205
+ ]);
206
+ function measureResultType(aggregate2, sourceFieldType) {
207
+ if (aggregate2 !== "min" && aggregate2 !== "max") return void 0;
208
+ if (sourceFieldType === void 0) return void 0;
209
+ if (TEMPORAL_SOURCE_FIELD_TYPES.has(sourceFieldType)) return MEASURE_RESULT_TYPE_TEMPORAL;
210
+ if (STRING_SOURCE_FIELD_TYPES.has(sourceFieldType)) return MEASURE_RESULT_TYPE_STRING;
211
+ return void 0;
212
+ }
213
+
164
214
  // src/strategies/filter-normalizer.ts
165
- var import_data2 = require("@objectstack/spec/data");
215
+ var import_data3 = require("@objectstack/spec/data");
166
216
  var import_api = require("@objectstack/spec/api");
167
217
 
168
218
  // src/comparand-shape.ts
169
219
  var import_core = require("@objectstack/core");
170
- var import_data = require("@objectstack/spec/data");
220
+ var import_data2 = require("@objectstack/spec/data");
171
221
  function isBindableComparand(value) {
172
222
  if (value === void 0) return true;
173
- return (0, import_data.isAcceptedFilterComparand)(value) || ArrayBuffer.isView(value);
223
+ return (0, import_data2.isAcceptedFilterComparand)(value) || ArrayBuffer.isView(value);
174
224
  }
175
225
  function isRenderableTextComparand(value) {
176
- return value === void 0 || (0, import_data.isAcceptedFilterComparand)(value);
226
+ return value === void 0 || (0, import_data2.isAcceptedFilterComparand)(value);
177
227
  }
178
228
  function isFieldReference(value) {
179
229
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
@@ -271,7 +321,7 @@ function shapePreview(value) {
271
321
  }
272
322
  }
273
323
  function unrenderableTextComparandMessage(op, field, value) {
274
- return `"${op}" on "${field}" matches against the TEXT of a pattern, but its comparand is ${Array.isArray(value) ? "an array" : "an object"} (${shapePreview(value)}). filter.zod.ts declares it a string (StringOperatorSchema); ${import_data.ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} is accepted. Refusing rather than stringifying it: String({}) is "[object Object]", so the pattern that ran would be one nobody wrote \u2014 and a row storing that literal text matches it.`;
324
+ return `"${op}" on "${field}" matches against the TEXT of a pattern, but its comparand is ${Array.isArray(value) ? "an array" : "an object"} (${shapePreview(value)}). filter.zod.ts declares it a string (StringOperatorSchema); ${import_data2.ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} is accepted. Refusing rather than stringifying it: String({}) is "[object Object]", so the pattern that ran would be one nobody wrote \u2014 and a row storing that literal text matches it.`;
275
325
  }
276
326
  function fieldReferenceComparandMessage(op, field, ref, position) {
277
327
  return `"${op}" on "${field}"${position ? ` (${position})` : ""} compares against the field reference { "$field": "${ref}" }, which this compiler does not lower into a column-to-column comparison. Refusing rather than binding it: the reference object used to become the BOUND VALUE of the comparison, so the emitted predicate compared "${field}" against the reference itself \u2014 a value no row can hold \u2014 and a read scope built from it answered the wrong row set with nothing to read. \u26A0\uFE0F This is NOT the platform declining the rule. @objectstack/spec declares this shape (FieldReferenceSchema), @objectstack/formula resolves it per record in memory, driver-sql / driver-sqlite-wasm compile it to a same-table column comparison for the six scalar operators since #5222, and since the 2026-08-12 ruling on #7598 the analytics native-SQL strategy DECLINES such a query so it routes to the ObjectQL engine path and runs there \u2014 the driver enforcing declared-only enumeration, the tenant-isolation ban and the comparison class with metadata it owns. What refuses here is this SQL lowering, whose only remaining caller is the /analytics/sql display echo; it has no faithful rendering of the predicate the engine path actually runs, and half-rendering one would describe a query that returns different rows. Run the query itself (/analytics/query) to get its rows (#7598).`;
@@ -280,7 +330,7 @@ function fieldReferenceBetweenBoundMessage(op, field, ref, index) {
280
330
  return `"${op}" on "${field}" has the field reference { "$field": "${ref}" } at index ${index} of its [min, max] bounds. A range BOUND may not be a field reference on any backend: driver-sql and driver-sqlite-wasm refuse both endpoints (#5222), @objectstack/formula does not resolve a reference inside a list either \u2014 it orders the bounds against the raw reference object, which no value compares meaningfully to \u2014 and @objectstack/spec no longer declares the position at all (#7596 removed FieldReferenceSchema from the $between endpoint union, ADR-0049 declared = enforced). Refusing rather than lowering it: this compiler splits $between into its two bounds, so the reference would arrive at the driver under a "$gte" / "$lte" the author never wrote \u2014 a position the SQL drivers DO compile \u2014 and the range would quietly succeed here while the identical filter is refused everywhere else. Use a literal bound, or spell the comparison you meant as a scalar one ({ "${field}": { "$gte": { "$field": "${ref}" } } }), which IS served \u2014 on the ObjectQL engine path, where the driver enforces the #5222 rulings (#7598).`;
281
331
  }
282
332
  function unbindableListMemberMessage(op, field, value, index) {
283
- return `"${op}" on "${field}" has a value at index ${index} of its list that cannot be bound as a SQL parameter: ${shapePreview(value)}. Every member of an $in/$nin/$between list is a comparand in its own right \u2014 use ${import_data.ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} (or a binary value). Refusing rather than binding it: the member can equal no stored value, so the list silently loses that entry (and a $nin loses the exclusion the caller wrote).`;
333
+ return `"${op}" on "${field}" has a value at index ${index} of its list that cannot be bound as a SQL parameter: ${shapePreview(value)}. Every member of an $in/$nin/$between list is a comparand in its own right \u2014 use ${import_data2.ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} (or a binary value). Refusing rather than binding it: the member can equal no stored value, so the list silently loses that entry (and a $nin loses the exclusion the caller wrote).`;
284
334
  }
285
335
 
286
336
  // src/strategies/filter-normalizer.ts
@@ -628,7 +678,7 @@ function nullSafeNegationOperand(node) {
628
678
  }
629
679
  function filterArrayNotLowerableError(where) {
630
680
  return invalidFilterError(
631
- `[analytics] received a 'where' array that is not a filter: ${JSON.stringify(where)}. A filter array is a comparison [field, operator, value], a logical node ["and"|"or", ...conditions], or a list of those \u2014 it is INPUT-ONLY sugar (spec 'FilterArray'), lowered to a FilterCondition by @objectstack/spec parseFilterAST() at every door, this one included (#5158/#5334). This value cannot be lowered, and an unapplied filter would have charted the UNFILTERED dataset. Recognised operators: ${[...import_data2.VALID_AST_OPERATORS].sort().join(", ")}. Infix joins ([condA, "or", condB]) are NOT one of the shapes \u2014 write the prefix form ["or", condA, condB].`
681
+ `[analytics] received a 'where' array that is not a filter: ${JSON.stringify(where)}. A filter array is a comparison [field, operator, value], a logical node ["and"|"or", ...conditions], or a list of those \u2014 it is INPUT-ONLY sugar (spec 'FilterArray'), lowered to a FilterCondition by @objectstack/spec parseFilterAST() at every door, this one included (#5158/#5334). This value cannot be lowered, and an unapplied filter would have charted the UNFILTERED dataset. Recognised operators: ${[...import_data3.VALID_AST_OPERATORS].sort().join(", ")}. Infix joins ([condA, "or", condB]) are NOT one of the shapes \u2014 write the prefix form ["or", condA, condB].`
632
682
  );
633
683
  }
634
684
  function lowerAnalyticsWhere(query) {
@@ -637,8 +687,8 @@ function lowerAnalyticsWhere(query) {
637
687
  if (!where || typeof where !== "object") return null;
638
688
  if (Array.isArray(where)) {
639
689
  if (where.length === 0) return null;
640
- if (!(0, import_data2.isFilterAST)(where)) throw filterArrayNotLowerableError(where);
641
- const condition = (0, import_data2.parseFilterAST)(where);
690
+ if (!(0, import_data3.isFilterAST)(where)) throw filterArrayNotLowerableError(where);
691
+ const condition = (0, import_data3.parseFilterAST)(where);
642
692
  if (!condition || typeof condition !== "object" || Array.isArray(condition)) {
643
693
  throw invalidFilterError(
644
694
  `[analytics] filter array ${JSON.stringify(where)} passed isFilterAST() but parseFilterAST() lowered it to ${JSON.stringify(condition)}. Refusing rather than charting the dataset unfiltered (#5158/#5334).`
@@ -699,6 +749,86 @@ function asciiLowerSqlExpr(expr) {
699
749
  return `translate(${expr}, '${ASCII_UPPER_LETTERS}', '${ASCII_LOWER_LETTERS}')`;
700
750
  }
701
751
 
752
+ // src/text-match-sql.ts
753
+ var KNOWN_DIALECTS = /* @__PURE__ */ new Set(["sqlite", "postgres", "mysql"]);
754
+ function normalizeSqlDialect(name) {
755
+ return typeof name === "string" && KNOWN_DIALECTS.has(name) ? name : "unknown";
756
+ }
757
+ function sqlDialectFor(ctx, objectName) {
758
+ const hook = ctx.sqlDialect;
759
+ if (typeof hook !== "function") return "unknown";
760
+ return normalizeSqlDialect(hook.call(ctx, objectName));
761
+ }
762
+ function escapeGlobPattern(value) {
763
+ return String(value).replace(/[*?[]/g, "[$&]");
764
+ }
765
+ function wrapShape(escaped, shape, wildcard) {
766
+ if (shape === "starts") return `${escaped}${wildcard}`;
767
+ if (shape === "ends") return `${wildcard}${escaped}`;
768
+ return `${wildcard}${escaped}${wildcard}`;
769
+ }
770
+ function globPattern(shape, value) {
771
+ return wrapShape(escapeGlobPattern(value), shape, "*");
772
+ }
773
+ function mysqlAsciiLowerBinarySql(expr) {
774
+ return asciiLowerReplaceSql(`CAST(${expr} AS BINARY)`);
775
+ }
776
+ function asciiLowerReplaceSql(expr) {
777
+ let out = expr;
778
+ for (let i = 0; i < ASCII_UPPER_LETTERS.length; i++) {
779
+ out = `REPLACE(${out}, '${ASCII_UPPER_LETTERS[i]}', '${ASCII_LOWER_LETTERS[i]}')`;
780
+ }
781
+ return out;
782
+ }
783
+ function textMatchPredicateSql(req) {
784
+ const { dialect, column, shape, value, bind: bind2 } = req;
785
+ const negate = req.negate === true;
786
+ const fold = req.fold === true;
787
+ if (dialect === "sqlite") {
788
+ const lower = (expr) => fold ? `lower(${expr})` : expr;
789
+ return `${lower(column)} ${negate ? "NOT GLOB" : "GLOB"} ${lower(bind2(globPattern(shape, value)))}`;
790
+ }
791
+ const keyword = negate ? "NOT LIKE" : "LIKE";
792
+ if (dialect === "mysql") {
793
+ const binary = (expr) => fold ? mysqlAsciiLowerBinarySql(expr) : `CAST(${expr} AS BINARY)`;
794
+ return `${binary(column)} ${keyword} ${binary(bind2(likePattern(shape, value)))} ESCAPE ${bind2(LIKE_ESCAPE_CHAR)}`;
795
+ }
796
+ const asciiLower = dialect === "postgres" ? asciiLowerSqlExpr : asciiLowerReplaceSql;
797
+ const folded = (expr) => fold ? asciiLower(expr) : expr;
798
+ return `${folded(column)} ${keyword} ${folded(bind2(likePattern(shape, value)))} ESCAPE ${bind2(LIKE_ESCAPE_CHAR)}`;
799
+ }
800
+
801
+ // src/non-text-column.ts
802
+ var import_data4 = require("@objectstack/spec/data");
803
+ function textOperatorPolarity(op) {
804
+ switch (op) {
805
+ case "$contains":
806
+ case "$startsWith":
807
+ case "$endsWith":
808
+ case "$icontains":
809
+ case "$like":
810
+ case "$ilike":
811
+ case "contains":
812
+ case "startsWith":
813
+ case "endsWith":
814
+ case "icontains":
815
+ return "positive";
816
+ case "$notContains":
817
+ case "notContains":
818
+ return "negative";
819
+ default:
820
+ return null;
821
+ }
822
+ }
823
+ function isNonTextDeclaredType(type) {
824
+ return typeof type === "string" && import_data4.NON_TEXT_STORED_VALUE_TYPES.has(type);
825
+ }
826
+ function nonTextColumnResolver(ctx, objectName) {
827
+ const declared = ctx.declaredFieldType;
828
+ if (typeof declared !== "function") return void 0;
829
+ return (field) => isNonTextDeclaredType(declared.call(ctx, objectName, field));
830
+ }
831
+
702
832
  // src/read-scope-sql.ts
703
833
  var IDENT = /^[a-z_][a-z0-9_]*$/i;
704
834
  var READ_SCOPE_COMPILE_FAILED = "READ_SCOPE_COMPILE_FAILED";
@@ -709,6 +839,7 @@ function readScopeCompileError(message) {
709
839
  return err;
710
840
  }
711
841
  var FALSE_CLAUSE = "1 = 0";
842
+ var TRUE_CLAUSE = "1 = 1";
712
843
  function isFilterNode(v) {
713
844
  return v !== null && typeof v === "object" && !Array.isArray(v);
714
845
  }
@@ -718,10 +849,10 @@ function quoteIdent(name, kind) {
718
849
  }
719
850
  return `"${name}"`;
720
851
  }
721
- function compileScopedFilterToSql(filter, alias) {
852
+ function compileScopedFilterToSql(filter, alias, options = {}) {
722
853
  const quotedAlias = quoteIdent(alias, "alias");
723
854
  const params = [];
724
- const sql = compileNode(filter, quotedAlias, params);
855
+ const sql = compileNode(filter, quotedAlias, params, options);
725
856
  return { sql, params };
726
857
  }
727
858
  function emptyMembershipFinding(spec, negated, path) {
@@ -771,12 +902,12 @@ function assertReadScopeCannotVacate(scope, objectName) {
771
902
  `[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).`
772
903
  );
773
904
  }
774
- function compileSub(node, qAlias) {
905
+ function compileSub(node, qAlias, opts) {
775
906
  const params = [];
776
- const sql = compileNode(node, qAlias, params);
907
+ const sql = compileNode(node, qAlias, params, opts);
777
908
  return { sql, params };
778
909
  }
779
- function compileNode(node, qAlias, params) {
910
+ function compileNode(node, qAlias, params, opts) {
780
911
  if (!isFilterNode(node)) {
781
912
  throw readScopeCompileError("[read-scope-sql] read scope must be a filter object (fail-closed).");
782
913
  }
@@ -790,7 +921,7 @@ function compileNode(node, qAlias, params) {
790
921
  if (key === "$or") clauses.push(FALSE_CLAUSE);
791
922
  continue;
792
923
  }
793
- const compiled = value.map((child) => compileSub(child, qAlias));
924
+ const compiled = value.map((child) => compileSub(child, qAlias, opts));
794
925
  if (key === "$or" && compiled.some((c) => c.sql.length === 0)) continue;
795
926
  const kept = compiled.filter((c) => c.sql.length > 0);
796
927
  if (kept.length === 0) continue;
@@ -799,7 +930,7 @@ function compileNode(node, qAlias, params) {
799
930
  clauses.push(`(${kept.map((c) => c.sql).join(joiner)})`);
800
931
  } else if (key === "$not") {
801
932
  const operand = isFilterNode(value) ? nullSafeNegationOperand2(value) : value;
802
- const inner = compileSub(operand, qAlias);
933
+ const inner = compileSub(operand, qAlias, opts);
803
934
  if (inner.sql.length === 0) {
804
935
  clauses.push(FALSE_CLAUSE);
805
936
  } else {
@@ -809,12 +940,12 @@ function compileNode(node, qAlias, params) {
809
940
  } else if (key.startsWith("$")) {
810
941
  throw readScopeCompileError(`[read-scope-sql] unsupported top-level operator "${key}" (fail-closed).`);
811
942
  } else {
812
- clauses.push(compileField(key, value, qAlias, params));
943
+ clauses.push(compileField(key, value, qAlias, params, opts));
813
944
  }
814
945
  }
815
946
  return clauses.join(" AND ");
816
947
  }
817
- function compileField(field, value, qAlias, params) {
948
+ function compileField(field, value, qAlias, params, opts) {
818
949
  const col = `${qAlias}.${quoteIdent(field, "field")}`;
819
950
  assertDefinedComparands2(field, value);
820
951
  assertBooleanFlagComparands(field, value);
@@ -834,7 +965,7 @@ function compileField(field, value, qAlias, params) {
834
965
  }
835
966
  const parts = [];
836
967
  for (const op of keys) {
837
- parts.push(compileOperator(col, op, ops[op], field, params));
968
+ parts.push(compileOperator(col, op, ops[op], field, params, opts));
838
969
  }
839
970
  return parts.length === 1 ? parts[0] : `(${parts.join(" AND ")})`;
840
971
  }
@@ -842,8 +973,20 @@ function bind(params, v) {
842
973
  params.push(v);
843
974
  return "?";
844
975
  }
845
- function bindLike(params, pattern) {
846
- return `${bind(params, pattern)} ESCAPE ${bind(params, LIKE_ESCAPE_CHAR)}`;
976
+ function textMatch(col, shape, val, negate, params, opts, fold = false) {
977
+ return textMatchPredicateSql({
978
+ dialect: normalizeSqlDialect(opts.dialect),
979
+ column: col,
980
+ shape,
981
+ value: val,
982
+ negate,
983
+ fold,
984
+ bind: (v) => bind(params, v)
985
+ });
986
+ }
987
+ function textOverNonTextColumn(op, field, opts) {
988
+ if (!opts.nonTextColumn || !opts.nonTextColumn(field)) return null;
989
+ return textOperatorPolarity(op) === "negative" ? TRUE_CLAUSE : FALSE_CLAUSE;
847
990
  }
848
991
  function nullSafeNegative(col, test) {
849
992
  return `(${col} IS NULL OR ${test})`;
@@ -908,7 +1051,7 @@ function assertNoFieldReferenceComparand2(field, spec) {
908
1051
  });
909
1052
  }
910
1053
  }
911
- function compileOperator(col, op, val, field, params) {
1054
+ function compileOperator(col, op, val, field, params, opts) {
912
1055
  switch (op) {
913
1056
  case "$eq":
914
1057
  return val === null ? `${col} IS NULL` : `${col} = ${bind(params, val)}`;
@@ -942,12 +1085,19 @@ function compileOperator(col, op, val, field, params) {
942
1085
  return `${col} BETWEEN ${bind(params, val[0])} AND ${bind(params, val[1])}`;
943
1086
  }
944
1087
  // [#5567] The comparand is a LITERAL, so it is escaped and the escape
945
- // character is bound with it. See {@link bindLike}.
1088
+ // character is bound with it. See {@link textMatch}.
946
1089
  // [#5234] …and it must be a value `String()` can render, which is asserted
947
- // BEFORE `likePattern` sees it — see {@link assertRenderableText}.
1090
+ // BEFORE a pattern is built from it — see {@link assertRenderableText}.
1091
+ // [#14079] Every text arm asks {@link textOverNonTextColumn} AFTER its
1092
+ // comparand gate and BEFORE it binds: a comparand the contract refuses is
1093
+ // still refused, and a column whose stored value is never text gets the
1094
+ // contract's constant instead of a match over a number.
1095
+ // [#15684] …and the four case-EXACT arms take their construct from the
1096
+ // DIALECT ({@link textMatch}): a plain `LIKE` folds ASCII case on SQLite,
1097
+ // so this scope ADMITTED rows the policy excludes — over-reach (#3948).
948
1098
  case "$contains":
949
1099
  assertRenderableText(op, field, val);
950
- return `${col} LIKE ${bindLike(params, likePattern("contains", val))}`;
1100
+ return textOverNonTextColumn(op, field, opts) ?? textMatch(col, "contains", val, false, params, opts);
951
1101
  /**
952
1102
  * [#6520] `$icontains` on the READ-SCOPE lowering — the one compiler in this
953
1103
  * package where a wrong answer is an ADR-0021 scope over-reach rather than a
@@ -963,23 +1113,39 @@ function compileOperator(col, op, val, field, params) {
963
1113
  * the rows already lower-case — and on a read scope that is a row set the
964
1114
  * policy author never wrote, in the narrowing direction here but in the
965
1115
  * WIDENING direction under a `$not`.
1116
+ *
1117
+ * [#15780] …and WHICH fold is the DIALECT's answer, exactly as the keyword
1118
+ * is for the case-exact arms. This line used to spell its own binds and
1119
+ * emit `translate()` unconditionally, on the reasoning that a
1120
+ * case-INSENSITIVE operator never wants the per-dialect case-EXACT
1121
+ * construct. The first half of that was right and the second half hid the
1122
+ * defect: `translate()` is Postgres/Oracle, so on a SQLite datasource this
1123
+ * read scope compiled to a statement the engine could not PARSE — an RLS
1124
+ * policy that cannot be evaluated at all. It goes through
1125
+ * {@link textMatch} now with `fold` set, which keeps `translate()` on
1126
+ * Postgres, emits `lower(col) GLOB lower(?)` on SQLite, the nested-
1127
+ * `REPLACE` binary fold on MySQL and — [#16028] — the same `REPLACE` chain
1128
+ * without the cast on the `unknown` residue, because a datasource whose
1129
+ * dialect nothing answered can BE SQLite and `translate()` failed to parse
1130
+ * there just as loudly through this compiler as through the other two. The
1131
+ * `ESCAPE`
1132
+ * binding is still never folded — the construct table owns that, and the
1133
+ * SQLite arm has no `ESCAPE` clause to bind at all.
966
1134
  */
967
- case "$icontains": {
1135
+ case "$icontains":
968
1136
  assertRenderableText(op, field, val);
969
- const patternRef = asciiLowerSqlExpr(bind(params, likePattern("contains", val)));
970
- return `${asciiLowerSqlExpr(col)} LIKE ${patternRef} ESCAPE ${bind(params, LIKE_ESCAPE_CHAR)}`;
971
- }
1137
+ return textOverNonTextColumn(op, field, opts) ?? textMatch(col, "contains", val, false, params, opts, true);
972
1138
  // [#5298] NULL-safe: `NOT LIKE` is UNKNOWN for a NULL column, and "does not
973
1139
  // contain" is true of a value that is not there.
974
1140
  case "$notContains":
975
1141
  assertRenderableText(op, field, val);
976
- return nullSafeNegative(col, `${col} NOT LIKE ${bindLike(params, likePattern("contains", val))}`);
1142
+ return textOverNonTextColumn(op, field, opts) ?? nullSafeNegative(col, textMatch(col, "contains", val, true, params, opts));
977
1143
  case "$startsWith":
978
1144
  assertRenderableText(op, field, val);
979
- return `${col} LIKE ${bindLike(params, likePattern("starts", val))}`;
1145
+ return textOverNonTextColumn(op, field, opts) ?? textMatch(col, "starts", val, false, params, opts);
980
1146
  case "$endsWith":
981
1147
  assertRenderableText(op, field, val);
982
- return `${col} LIKE ${bindLike(params, likePattern("ends", val))}`;
1148
+ return textOverNonTextColumn(op, field, opts) ?? textMatch(col, "ends", val, false, params, opts);
983
1149
  // [#6387] `val` is a boolean here — {@link assertBooleanFlagComparands}
984
1150
  // refused anything else at {@link compileField}, before this emitter runs.
985
1151
  // So `=== true` is an exhaustive TWO-WAY choice over the declared domain,
@@ -1410,7 +1576,10 @@ var NativeSQLStrategy = class {
1410
1576
  if (typeof ctx.getReadScope !== "function") return;
1411
1577
  const filter = ctx.getReadScope(objectName);
1412
1578
  if (filter === void 0 || filter === null) return;
1413
- const { sql, params: scopeParams } = compileScopedFilterToSql(filter, alias);
1579
+ const { sql, params: scopeParams } = compileScopedFilterToSql(filter, alias, {
1580
+ nonTextColumn: nonTextColumnResolver(ctx, objectName),
1581
+ dialect: sqlDialectFor(ctx, objectName)
1582
+ });
1414
1583
  assertReadScopeCannotVacate(filter, objectName);
1415
1584
  if (!sql) return;
1416
1585
  let i = 0;
@@ -1688,12 +1857,16 @@ var NativeSQLStrategy = class {
1688
1857
  gte: ">=",
1689
1858
  lt: "<",
1690
1859
  lte: "<=",
1860
+ // [#15684 / #15780] For every text operator these entries are the
1861
+ // OPERATOR GATE, not the emitted keyword: `text-match-sql.ts` picks
1862
+ // `LIKE` or `GLOB` per dialect below. ⛔ Reading these five as the
1863
+ // emitted SQL is exactly the mistake #15780 was — `$icontains` was the
1864
+ // last row still emitting the keyword written here, together with a
1865
+ // `translate()` fold SQLite cannot parse.
1691
1866
  contains: "LIKE",
1692
1867
  notContains: "NOT LIKE",
1693
1868
  startsWith: "LIKE",
1694
1869
  endsWith: "LIKE",
1695
- // [#6520] `$icontains` — `LIKE` like its neighbours; what separates it is
1696
- // the ASCII fold applied below, not the keyword.
1697
1870
  icontains: "LIKE"
1698
1871
  };
1699
1872
  const likeShape = {
@@ -1719,13 +1892,22 @@ var NativeSQLStrategy = class {
1719
1892
  if (!sqlOp || !values || values.length === 0) return null;
1720
1893
  const shape = likeShape[operator];
1721
1894
  if (shape) {
1722
- params.push(likePattern(shape, values[0]));
1723
- const patternRef = `$${params.length}`;
1724
- params.push(LIKE_ESCAPE_CHAR);
1725
- if (operator === "icontains") {
1726
- return `${asciiLowerSqlExpr(rawCol)} ${sqlOp} ${asciiLowerSqlExpr(patternRef)} ESCAPE $${params.length}`;
1895
+ const polarity = textOperatorPolarity(operator);
1896
+ if (polarity && nonTextColumnResolver(ctx, target.object)?.(target.field)) {
1897
+ return polarity === "negative" ? SQL_CONST_TRUE : SQL_CONST_FALSE;
1727
1898
  }
1728
- return `${rawCol} ${sqlOp} ${patternRef} ESCAPE $${params.length}`;
1899
+ return textMatchPredicateSql({
1900
+ dialect: sqlDialectFor(ctx, target.object),
1901
+ column: rawCol,
1902
+ shape,
1903
+ value: values[0],
1904
+ negate: operator === "notContains",
1905
+ fold: operator === "icontains",
1906
+ bind: (v) => {
1907
+ params.push(v);
1908
+ return `$${params.length}`;
1909
+ }
1910
+ });
1729
1911
  }
1730
1912
  if (operator === "lte") {
1731
1913
  const nextDay = (0, import_core2.nextUtcCalendarDay)(values[0]);
@@ -1758,7 +1940,7 @@ var NativeSQLStrategy = class {
1758
1940
  };
1759
1941
 
1760
1942
  // src/strategies/objectql-strategy.ts
1761
- var import_data3 = require("@objectstack/spec/data");
1943
+ var import_data5 = require("@objectstack/spec/data");
1762
1944
  var import_core3 = require("@objectstack/core");
1763
1945
 
1764
1946
  // src/strategies/cross-object-rebucket.ts
@@ -1826,15 +2008,25 @@ var SCALAR_SQL_OPS = {
1826
2008
  lte: "<="
1827
2009
  };
1828
2010
  var LIKE_SQL_OPS = {
1829
- contains: { sql: "LIKE", shape: "contains" },
1830
- notContains: { sql: "NOT LIKE", shape: "contains" },
1831
- startsWith: { sql: "LIKE", shape: "starts" },
1832
- endsWith: { sql: "LIKE", shape: "ends" },
1833
- // [#6520] `$icontains`: the same escaped pattern and bound `ESCAPE` as its
1834
- // four case-EXACT neighbours, with `fold` adding the ASCII-only case fold to
1835
- // both sides of the comparison. The flag is on this row alone the family
1836
- // above it is case-sensitive by ruling (#4706 Q2 = A).
1837
- icontains: { sql: "LIKE", shape: "contains", fold: true }
2011
+ // [#15684 / #15780] This table carries NO keyword, deliberately. Every row's
2012
+ // construct comes from the DIALECT (`text-match-sql.ts`): `GLOB` on SQLite,
2013
+ // `LIKE` over `CAST(… AS BINARY)` on MySQL, plain `LIKE` on Postgres, since a
2014
+ // plain `LIKE` is case-exact on Postgres alone. #15684 kept a `sql` field
2015
+ // here for the FOLDING row, which was the last row still emitting a keyword
2016
+ // written locally together with the `translate()` fold that could not parse
2017
+ // on SQLite. #15780 moved that row onto the table too, so the field had no
2018
+ // reader left, and a dead field named `sql` sitting beside a compiler is an
2019
+ // invitation to read it as the emitted keyword.
2020
+ //
2021
+ // What survives here is only what the construct table cannot derive from the
2022
+ // operator name: which POLARITY the row is (`negate`) and whether it FOLDS
2023
+ // (`fold`), each spelled once. `fold` is on `icontains` ALONE — the four
2024
+ // above it are case-SENSITIVE by ruling (#4706 Q2 = A).
2025
+ contains: { shape: "contains" },
2026
+ notContains: { shape: "contains", negate: true },
2027
+ startsWith: { shape: "starts" },
2028
+ endsWith: { shape: "ends" },
2029
+ icontains: { shape: "contains", fold: true }
1838
2030
  };
1839
2031
  var ObjectQLStrategy = class {
1840
2032
  constructor() {
@@ -2004,7 +2196,7 @@ var ObjectQLStrategy = class {
2004
2196
  for (const m of query.measures) {
2005
2197
  const { field, method } = this.resolveMeasureAggregation(cube, m);
2006
2198
  const measureFilter = datasetScope?.measureFilters?.[m];
2007
- const predicate = measureFilter ? this.renderFilterNodeSql(normalizeAnalyticsFilterTree({ where: measureFilter }), cube, params) : null;
2199
+ const predicate = measureFilter ? this.renderFilterNodeSql(normalizeAnalyticsFilterTree({ where: measureFilter }), cube, params, ctx) : null;
2008
2200
  const aggSql = predicate ? this.conditionalAggregateSql(method, field, predicate) : method === "count" ? "COUNT(*)" : method === "count_distinct" ? `COUNT(DISTINCT ${field})` : `${method.toUpperCase()}(${field})`;
2009
2201
  selectParts.push(`${aggSql} AS "${m}"`);
2010
2202
  }
@@ -2013,14 +2205,16 @@ var ObjectQLStrategy = class {
2013
2205
  const filterClause = this.renderFilterNodeSql(
2014
2206
  normalizeAnalyticsFilterTree(query),
2015
2207
  cube,
2016
- params
2208
+ params,
2209
+ ctx
2017
2210
  );
2018
2211
  if (filterClause) whereParts.push(filterClause);
2019
2212
  if (datasetScope?.filter) {
2020
2213
  const scopeSql = this.renderFilterNodeSql(
2021
2214
  normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
2022
2215
  cube,
2023
- params
2216
+ params,
2217
+ ctx
2024
2218
  );
2025
2219
  if (scopeSql) whereParts.push(scopeSql);
2026
2220
  }
@@ -2033,7 +2227,10 @@ var ObjectQLStrategy = class {
2033
2227
  }
2034
2228
  const scope = ctx.getReadScope?.(tableName);
2035
2229
  if (scope != null) {
2036
- const { sql: scopeSql, params: scopeParams } = compileScopedFilterToSql(scope, tableName);
2230
+ const { sql: scopeSql, params: scopeParams } = compileScopedFilterToSql(scope, tableName, {
2231
+ nonTextColumn: nonTextColumnResolver(ctx, tableName),
2232
+ dialect: sqlDialectFor(ctx, tableName)
2233
+ });
2037
2234
  assertReadScopeCannotVacate(scope, tableName);
2038
2235
  if (scopeSql) {
2039
2236
  let i = 0;
@@ -2077,12 +2274,12 @@ var ObjectQLStrategy = class {
2077
2274
  * predicate. `$and` makes that structurally impossible.
2078
2275
  */
2079
2276
  withReadScope(objectName, filter, ctx) {
2080
- const userFilter = Object.keys(filter).length > 0 ? (0, import_data3.markFilterSubtreeProvenance)(filter, "author") : void 0;
2277
+ const userFilter = Object.keys(filter).length > 0 ? (0, import_data5.markFilterSubtreeProvenance)(filter, "author") : void 0;
2081
2278
  if (typeof ctx.getReadScope !== "function") return userFilter;
2082
2279
  const scope = ctx.getReadScope(objectName);
2083
2280
  if (scope === void 0 || scope === null) return userFilter;
2084
2281
  assertReadScopeCannotVacate(scope, objectName);
2085
- const scopeFilter = (0, import_data3.markFilterSubtreeProvenance)(scope, "policy");
2282
+ const scopeFilter = (0, import_data5.markFilterSubtreeProvenance)(scope, "policy");
2086
2283
  if (!userFilter) return scopeFilter;
2087
2284
  return { $and: [userFilter, scopeFilter] };
2088
2285
  }
@@ -2395,7 +2592,7 @@ var ObjectQLStrategy = class {
2395
2592
  const idFilter = { id: { $in: fkValues } };
2396
2593
  const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
2397
2594
  if (scope != null) assertReadScopeCannotVacate(scope, refObject);
2398
- if (scope != null) (0, import_data3.markFilterSubtreeProvenance)(scope, "policy");
2595
+ if (scope != null) (0, import_data5.markFilterSubtreeProvenance)(scope, "policy");
2399
2596
  const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
2400
2597
  const rows = await ctx.executeAggregate(refObject, {
2401
2598
  groupBy: ["id", attr],
@@ -2467,7 +2664,7 @@ var ObjectQLStrategy = class {
2467
2664
  * not render that operator": #5333 was exactly that conflation, and an
2468
2665
  * unrenderable operator now THROWS (see the exit below).
2469
2666
  */
2470
- buildFilterClauseSql(col, operator, values, params) {
2667
+ buildFilterClauseSql(col, operator, values, params, target, ctx) {
2471
2668
  if (operator === "set") return `${col} IS NOT NULL`;
2472
2669
  if (operator === "notSet") return `${col} IS NULL`;
2473
2670
  if (!values || values.length === 0) return null;
@@ -2480,12 +2677,22 @@ var ObjectQLStrategy = class {
2480
2677
  }
2481
2678
  const like = LIKE_SQL_OPS[operator];
2482
2679
  if (like) {
2483
- params.push(likePattern(like.shape, values[0]));
2484
- const patternRef = `$${params.length}`;
2485
- params.push(LIKE_ESCAPE_CHAR);
2486
- const lhs = like.fold ? asciiLowerSqlExpr(col) : col;
2487
- const rhs = like.fold ? asciiLowerSqlExpr(patternRef) : patternRef;
2488
- return `${lhs} ${like.sql} ${rhs} ESCAPE $${params.length}`;
2680
+ const polarity = textOperatorPolarity(operator);
2681
+ if (polarity && target && ctx && nonTextColumnResolver(ctx, target.object)?.(target.field)) {
2682
+ return polarity === "negative" ? SQL_CONST_TRUE : SQL_CONST_FALSE;
2683
+ }
2684
+ return textMatchPredicateSql({
2685
+ dialect: target && ctx ? sqlDialectFor(ctx, target.object) : "unknown",
2686
+ column: col,
2687
+ shape: like.shape,
2688
+ value: values[0],
2689
+ negate: like.negate === true,
2690
+ fold: like.fold === true,
2691
+ bind: (v) => {
2692
+ params.push(v);
2693
+ return `$${params.length}`;
2694
+ }
2695
+ });
2489
2696
  }
2490
2697
  const op = SCALAR_SQL_OPS[operator];
2491
2698
  if (!op) {
@@ -2524,6 +2731,29 @@ var ObjectQLStrategy = class {
2524
2731
  }
2525
2732
  return void 0;
2526
2733
  }
2734
+ /**
2735
+ * [#14079] The (object, field) a filter member binds against — the echo's
2736
+ * copy of `NativeSQLStrategy.resolveStorageTarget`, kept beside the
2737
+ * `LIKE_SQL_OPS` table for the same reason that table is a copy: this file
2738
+ * renders a description of the statement THAT compiler produces, and the
2739
+ * declared-type test both apply is keyed by object and field. A dotted
2740
+ * `sql` is a relationship path (ADR-0071): every segment but the last is a
2741
+ * hop whose join alias is the dot-to-`__` spelling the dataset compiler keys
2742
+ * `cube.joins` by, the last is the column.
2743
+ */
2744
+ resolveStorageTarget(cube, member, baseObject) {
2745
+ const dim = this.lookupMember(cube, member, "dimension");
2746
+ const measure = dim ? void 0 : this.lookupMember(cube, member, "measure");
2747
+ const rawSql = dim?.sql ?? measure?.sql ?? (member.includes(".") ? member.split(".").slice(1).join(".") : member);
2748
+ if (rawSql.includes(".")) {
2749
+ const segments = rawSql.split(".");
2750
+ const field = segments[segments.length - 1];
2751
+ const relPath = segments.slice(0, -1).join(".");
2752
+ const object = cube.joins?.[relPath.replace(/\./g, "__")]?.name ?? relPath;
2753
+ return { object, field };
2754
+ }
2755
+ return { object: baseObject, field: rawSql.replace(/^\$/, "") };
2756
+ }
2527
2757
  resolveFieldName(cube, member, kind) {
2528
2758
  if (kind === "dimension" || kind === "any") {
2529
2759
  const dim = this.lookupMember(cube, member, "dimension");
@@ -2663,7 +2893,7 @@ var ObjectQLStrategy = class {
2663
2893
  * exactly — including the invariant that a `null` return leaves `params`
2664
2894
  * untouched, so no comparand is left with no placeholder to consume it.
2665
2895
  */
2666
- renderFilterNodeSql(node, cube, params) {
2896
+ renderFilterNodeSql(node, cube, params, ctx) {
2667
2897
  if (!node) return null;
2668
2898
  if (node.kind === "const") {
2669
2899
  return node.value ? SQL_CONST_TRUE : SQL_CONST_FALSE;
@@ -2673,17 +2903,23 @@ var ObjectQLStrategy = class {
2673
2903
  this.resolveFieldName(cube, node.member, "any"),
2674
2904
  node.operator,
2675
2905
  node.values,
2676
- params
2906
+ params,
2907
+ // [#14079] The (object, field) this member binds against, resolved the
2908
+ // way `NativeSQLStrategy.resolveStorageTarget` resolves it, so the echo
2909
+ // asks the declared-type hook the same question the executed statement
2910
+ // asked and prints the same constant for a non-text column.
2911
+ this.resolveStorageTarget(cube, node.member, this.extractObjectName(cube)),
2912
+ ctx
2677
2913
  );
2678
2914
  }
2679
2915
  if (node.kind === "not") {
2680
- const inner = this.renderFilterNodeSql(node.child, cube, params);
2916
+ const inner = this.renderFilterNodeSql(node.child, cube, params, ctx);
2681
2917
  return inner ? `NOT (${inner})` : SQL_CONST_FALSE;
2682
2918
  }
2683
2919
  const paramBase = params.length;
2684
2920
  const parts = [];
2685
2921
  for (const child of node.children) {
2686
- const clause = this.renderFilterNodeSql(child, cube, params);
2922
+ const clause = this.renderFilterNodeSql(child, cube, params, ctx);
2687
2923
  if (clause === null) {
2688
2924
  if (node.kind !== "or") continue;
2689
2925
  params.length = paramBase;
@@ -2938,10 +3174,10 @@ var ObjectQLStrategy = class {
2938
3174
  };
2939
3175
 
2940
3176
  // src/dataset-compiler.ts
2941
- var import_data4 = require("@objectstack/spec/data");
3177
+ var import_data6 = require("@objectstack/spec/data");
2942
3178
  var import_ui = require("@objectstack/spec/ui");
2943
3179
  var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set();
2944
- var SUPPORTED_AGGREGATES = import_data4.AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
3180
+ var SUPPORTED_AGGREGATES = import_data6.AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
2945
3181
  function aggregateToMetricType(m) {
2946
3182
  if (!m.aggregate) {
2947
3183
  throw new Error(`[dataset-compiler] non-derived measure "${m.name}" has no aggregate`);
@@ -3103,7 +3339,7 @@ function compileDataset(dataset, resolver, options) {
3103
3339
  }
3104
3340
 
3105
3341
  // src/dataset-executor.ts
3106
- var import_data5 = require("@objectstack/spec/data");
3342
+ var import_data7 = require("@objectstack/spec/data");
3107
3343
  var import_core4 = require("@objectstack/core");
3108
3344
  function resolveSelectionTokens(compiled, selection, context) {
3109
3345
  const tokenCtx = (0, import_core4.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
@@ -3143,7 +3379,7 @@ function evaluateDerivedMeasures(rows, derived) {
3143
3379
  }
3144
3380
  function fillEmptyGroups(rows, columnAggregates) {
3145
3381
  for (const [column, aggregate2] of Object.entries(columnAggregates)) {
3146
- const empty = (0, import_data5.emptyGroupValueFor)(aggregate2);
3382
+ const empty = (0, import_data7.emptyGroupValueFor)(aggregate2);
3147
3383
  if (empty === void 0) continue;
3148
3384
  for (const row of rows) if (row[column] == null) row[column] = empty;
3149
3385
  }
@@ -3897,25 +4133,55 @@ function bucketDate(value, granularity, timezone) {
3897
4133
  return `${y}-${m}-${day}`;
3898
4134
  }
3899
4135
  }
3900
- function aggregate(rows, metricType, field) {
3901
- if (metricType === "count" || field === "*") {
3902
- if (metricType === "countDistinct") {
3903
- return new Set(rows.map((r) => r[field]).filter((v) => v != null)).size;
4136
+ function numericOperand(v) {
4137
+ if (typeof v === "number") return Number.isFinite(v) ? v : null;
4138
+ if (typeof v === "string" && v.trim() !== "") {
4139
+ const n = Number(v);
4140
+ return Number.isFinite(n) ? n : null;
4141
+ }
4142
+ return null;
4143
+ }
4144
+ function compareOperands(a, b) {
4145
+ const an = numericOperand(a);
4146
+ const bn = numericOperand(b);
4147
+ if (an !== null && bn !== null) return an - bn;
4148
+ return compare(a, b);
4149
+ }
4150
+ function extremumOf(rows, field, kind) {
4151
+ let winner;
4152
+ let seen = false;
4153
+ for (const r of rows) {
4154
+ const v = r[field];
4155
+ if (v == null) continue;
4156
+ if (!seen) {
4157
+ winner = v;
4158
+ seen = true;
4159
+ continue;
3904
4160
  }
3905
- return rows.length;
4161
+ const c = compareOperands(v, winner);
4162
+ if (kind === "min" ? c < 0 : c > 0) winner = v;
3906
4163
  }
4164
+ return seen ? winner : null;
4165
+ }
4166
+ function aggregate(rows, metricType, field) {
4167
+ if (metricType === "count" || field === "*") return rows.length;
3907
4168
  const nums = rows.map((r) => Number(r[field])).filter((n) => Number.isFinite(n));
3908
4169
  switch (metricType) {
3909
- case "countDistinct":
4170
+ // The spec's spelling (`AggregationFunction`), which is what the compiler
4171
+ // copies through. It used to be spelled `countDistinct` here — a word no
4172
+ // producer mints — so the arm was UNREACHABLE and the measure fell to the
4173
+ // numeric `default` below, answering a sum of coerced values (or a row
4174
+ // count) under the author's `count_distinct` name.
4175
+ case "count_distinct":
3910
4176
  return new Set(rows.map((r) => r[field]).filter((v) => v != null)).size;
3911
4177
  case "sum":
3912
4178
  return nums.reduce((a, b) => a + b, 0);
3913
4179
  case "avg":
3914
4180
  return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : 0;
3915
4181
  case "min":
3916
- return nums.length ? Math.min(...nums) : 0;
4182
+ return extremumOf(rows, field, "min");
3917
4183
  case "max":
3918
- return nums.length ? Math.max(...nums) : 0;
4184
+ return extremumOf(rows, field, "max");
3919
4185
  default:
3920
4186
  return nums.length ? nums.reduce((a, b) => a + b, 0) : rows.length;
3921
4187
  }
@@ -3976,7 +4242,19 @@ function evaluateAnalyticsQueryOverRows(query, cube, rows) {
3976
4242
  return {
3977
4243
  rows: limited,
3978
4244
  fields: [
3979
- ...dimensions.map((d) => ({ name: d, type: "string" })),
4245
+ // A dimension column is described by the CUBE dimension's own type the
4246
+ // same expression `NativeSQLStrategy.buildFieldMeta` and its ObjectQL
4247
+ // sibling use (`d?.type || 'string'`), so a `date` dataset dimension is
4248
+ // `'time'` here exactly as it is on the live path. Minting `'string'` for
4249
+ // every dimension made the same column two different things depending
4250
+ // only on whether a pending seed draft existed (#16203 (b)).
4251
+ ...dimensions.map((d) => ({ name: d, type: String(cube.dimensions?.[d]?.type || "string") })),
4252
+ // ⛔ A MEASURE column keeps the `'number'` every producer in the platform
4253
+ // mints for it, live faces included. Correcting it is one rule owned by
4254
+ // `measureResultType` (#15768/#16101) and applied at the ADR-0021
4255
+ // descriptor pass; a second copy of it here would be two implementations
4256
+ // free to drift, over a question this producer cannot answer anyway (it
4257
+ // has the cube, not the source object's declared field types).
3980
4258
  ...query.measures.map((m) => ({ name: m, type: "number" }))
3981
4259
  ]
3982
4260
  };
@@ -4109,7 +4387,17 @@ var AnalyticsService = class {
4109
4387
  },
4110
4388
  coerceTemporalFilterValue: config.coerceTemporalFilterValue,
4111
4389
  coerceTemporalFilterColumn: config.coerceTemporalFilterColumn,
4112
- isExternalObject: config.isExternalObject
4390
+ isExternalObject: config.isExternalObject,
4391
+ // [#14079] The declared field type, read off the same `sourceFieldMeta`
4392
+ // hook the display chains use — so the three SQL compilers can give a
4393
+ // text operator over a numeric or boolean column the contract's answer
4394
+ // at compile time. A host that wired no hook answers `undefined`, and
4395
+ // the compilers keep the behaviour they had.
4396
+ declaredFieldType: (object, field) => config.sourceFieldMeta?.(object, field)?.type,
4397
+ // [#15684] The dialect that will run the compiled statement, so the
4398
+ // case-EXACT text family picks a construct that IS case-exact there.
4399
+ // Same tiering as the hook above: `undefined` keeps today's `LIKE`.
4400
+ sqlDialect: (object) => config.sqlDialect?.(object)
4113
4401
  };
4114
4402
  const builtIn = [
4115
4403
  new NativeSQLStrategy(),
@@ -4325,10 +4613,10 @@ var AnalyticsService = class {
4325
4613
  query: async (q) => evaluateAnalyticsQueryOverRows(q, compiled.cube, seedRows)
4326
4614
  };
4327
4615
  const previewResult = await new DatasetExecutor(previewService).execute(compiled, selection, context);
4616
+ this.enrichResultColumns(previewResult, dataset, selection, context);
4328
4617
  return previewResult;
4329
4618
  }
4330
4619
  }
4331
- const requestLocale = context?.locale;
4332
4620
  const provider = this.readScopeProvider;
4333
4621
  const resolveScope = provider ? (targetObject) => provider(targetObject, context) : void 0;
4334
4622
  const labelDeps = this.labelResolver ? withLabelFetchCache(this.labelResolver) : void 0;
@@ -4364,7 +4652,7 @@ var AnalyticsService = class {
4364
4652
  }
4365
4653
  throw err;
4366
4654
  }
4367
- const selectedDims = (selection.dimensions ?? []).map((name) => dataset.dimensions?.find((d) => d.name === name)).filter((d) => !!d);
4655
+ const selectedDims = this.selectedDimensions(dataset, selection);
4368
4656
  const drillDims = selectedDims.filter((d) => !!d.field && d.type !== "date");
4369
4657
  if (drillDims.length && result.rows.length) {
4370
4658
  result.object = dataset.object;
@@ -4433,6 +4721,48 @@ var AnalyticsService = class {
4433
4721
  }
4434
4722
  }
4435
4723
  }
4724
+ this.enrichResultColumns(result, dataset, selection, context);
4725
+ return result;
4726
+ }
4727
+ /**
4728
+ * The dataset dimensions this selection GROUPED THE GRID BY, resolved against
4729
+ * the dataset definition. Shared by drill metadata, row-value label
4730
+ * resolution and — through {@link enrichResultColumns} — the dimension column
4731
+ * headers, so all three answer "which dimensions" the same way.
4732
+ */
4733
+ selectedDimensions(dataset, selection) {
4734
+ return (selection.dimensions ?? []).map((name) => dataset.dimensions?.find((d) => d.name === name)).filter((d) => !!d);
4735
+ }
4736
+ /**
4737
+ * ADR-0021 — describe the result's COLUMNS from the dataset's own authored
4738
+ * definition: a measure's `label` / `format` / `currency` / `percentScale` /
4739
+ * `builtinAggregate` and the `type` its aggregate really returns, then a
4740
+ * dimension column's header `label`.
4741
+ *
4742
+ * **Every key here is read off the DATASET** (the authored measure or
4743
+ * dimension) **and `sourceFieldMeta`** (the source object's declared field
4744
+ * metadata). Not one is read off `result.rows`. That is what makes this one
4745
+ * seam serve both paths that produce a dataset response — the live engine
4746
+ * query and the ADR-0037 P3 draft-data preview — and it is why #16097 was a
4747
+ * defect rather than a deliberate omission: the preview branch returns ~250
4748
+ * lines before this ran, so a response over drafted seed rows carried none of
4749
+ * these keys and a renderer fell back to humanizing the raw measure name and
4750
+ * guessing a percent scale from magnitude — the exact failures #5537,
4751
+ * objectui#3136 and #14492 each closed on the live path.
4752
+ *
4753
+ * Extracted rather than copied onto the second path, for the reason the
4754
+ * `type` correction below already gives for living here at all: this is ONE
4755
+ * rule holding both halves of the question, and a per-path copy would be two
4756
+ * implementations of it, free to drift.
4757
+ *
4758
+ * ⛔ Not here, and deliberately: dimension VALUE label resolution
4759
+ * ({@link resolveDimensionLabels}), which rewrites the grouped value in each
4760
+ * ROW. That reads the rows, it is the one enrichment a seed-draft row set can
4761
+ * make unnecessary, and the preview path skips it on purpose — see the note
4762
+ * at that early return.
4763
+ */
4764
+ enrichResultColumns(result, dataset, selection, context) {
4765
+ const requestLocale = context?.locale;
4436
4766
  if (result.fields?.length && dataset.measures?.length) {
4437
4767
  const measureByName = new Map(dataset.measures.map((m) => [m.name, m]));
4438
4768
  for (const f of result.fields) {
@@ -4455,11 +4785,13 @@ var AnalyticsService = class {
4455
4785
  }
4456
4786
  }
4457
4787
  if (f.percentScale == null) {
4458
- f.percentScale = m.derived?.op === "ratio" ? "fraction" : (0, import_data6.percentScaleOf)(meta);
4788
+ f.percentScale = m.derived?.op === "ratio" ? "fraction" : (0, import_data8.percentScaleOf)(meta);
4459
4789
  }
4790
+ const resultType = measureResultType(m.aggregate, meta?.type);
4791
+ if (resultType) f.type = resultType;
4460
4792
  }
4461
4793
  }
4462
- const describableDims = [...selectedDims];
4794
+ const describableDims = [...this.selectedDimensions(dataset, selection)];
4463
4795
  for (const t of selection.timeDimensions ?? []) {
4464
4796
  if (describableDims.some((d2) => d2.name === t.dimension)) continue;
4465
4797
  const d = dataset.dimensions?.find((x) => x.name === t.dimension);
@@ -4476,7 +4808,6 @@ var AnalyticsService = class {
4476
4808
  if (label !== void 0) f.label = label;
4477
4809
  }
4478
4810
  }
4479
- return result;
4480
4811
  }
4481
4812
  /**
4482
4813
  * Get cube metadata for discovery.
@@ -5022,12 +5353,12 @@ var FallbackDelegateStrategy = class {
5022
5353
  };
5023
5354
 
5024
5355
  // src/plugin.ts
5025
- var import_data7 = require("@objectstack/spec/data");
5356
+ var import_data9 = require("@objectstack/spec/data");
5026
5357
  function parseEngineAggregateFunction(method, alias) {
5027
- const parsed = import_data7.AggregationFunction.safeParse(method);
5358
+ const parsed = import_data9.AggregationFunction.safeParse(method);
5028
5359
  if (!parsed.success) {
5029
5360
  throw new Error(
5030
- `[Analytics] The aggregate bridge cannot forward the aggregation "${alias}": "${method}" is not one of the engine's aggregate functions (${import_data7.AggregationFunction.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.`
5361
+ `[Analytics] The aggregate bridge cannot forward the aggregation "${alias}": "${method}" is not one of the engine's aggregate functions (${import_data9.AggregationFunction.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.`
5031
5362
  );
5032
5363
  }
5033
5364
  return parsed.data;
@@ -5310,6 +5641,16 @@ var AnalyticsServicePlugin = class {
5310
5641
  }
5311
5642
  return columnSql;
5312
5643
  };
5644
+ const sqlDialect = (objectName) => {
5645
+ try {
5646
+ const svc = ctx.getService("data");
5647
+ const driver = svc?.getDriverForObject?.(objectName);
5648
+ const named = driver?.dialectName;
5649
+ return typeof named === "string" ? named : void 0;
5650
+ } catch {
5651
+ return void 0;
5652
+ }
5653
+ };
5313
5654
  const config = {
5314
5655
  cubes: this.options.cubes,
5315
5656
  logger: ctx.logger,
@@ -5351,6 +5692,8 @@ var AnalyticsServicePlugin = class {
5351
5692
  // prevent: it drifts by one step, silently, and the drift only surfaces as
5352
5693
  // an error message pointing at the wrong database.
5353
5694
  getObjectDatasource: (objectName) => dataEngine()?.resolveEffectiveDatasource?.(objectName),
5695
+ // [#15684] The executing driver's own dialect — see `sqlDialect` above.
5696
+ sqlDialect,
5354
5697
  // ADR-0062 D6 — a federated object carries an `external` block (ADR-0015).
5355
5698
  // Reported so NativeSQLStrategy declines it (its hand-compiled FROM would
5356
5699
  // hit the wrong physical table) and the driver-correct ObjectQL path runs.