@objectstack/service-analytics 17.2.0 → 17.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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.
100
+ *
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.
90
114
  *
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
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,18 +849,65 @@ 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
- function compileSub(node, qAlias) {
858
+ function emptyMembershipFinding(spec, negated, path) {
859
+ if (Array.isArray(spec)) {
860
+ return spec.length === 0 && negated ? { path: `${path}: []`, kind: "negatedIn" } : null;
861
+ }
862
+ if (spec === null || typeof spec !== "object") return null;
863
+ const rec = spec;
864
+ if (Array.isArray(rec.$nin) && rec.$nin.length === 0) return { path: `${path}.$nin`, kind: "nin" };
865
+ if (Array.isArray(rec.$in) && rec.$in.length === 0 && negated) {
866
+ return { path: `${path}.$in`, kind: "negatedIn" };
867
+ }
868
+ return null;
869
+ }
870
+ function findEmptyMembership(node, negated, path) {
871
+ if (node === null || typeof node !== "object" || Array.isArray(node)) return null;
872
+ const rec = node;
873
+ const bare = emptyMembershipFinding(rec, negated, path.length > 0 ? path : "<root>");
874
+ if (bare) return bare;
875
+ for (const [key, value] of Object.entries(rec)) {
876
+ const here = path.length > 0 ? `${path}.${key}` : key;
877
+ if (key === "$not") {
878
+ const found = findEmptyMembership(value, !negated, here);
879
+ if (found) return found;
880
+ } else if (key === "$and" || key === "$or") {
881
+ if (!Array.isArray(value)) continue;
882
+ for (let i = 0; i < value.length; i++) {
883
+ const found = findEmptyMembership(value[i], negated, `${here}[${i}]`);
884
+ if (found) return found;
885
+ }
886
+ } else if (!key.startsWith("$")) {
887
+ const found = emptyMembershipFinding(value, negated, here);
888
+ if (found) return found;
889
+ }
890
+ }
891
+ return null;
892
+ }
893
+ function assertReadScopeCannotVacate(scope, objectName) {
894
+ const found = findEmptyMembership(scope, false, "");
895
+ if (found === null) return;
896
+ if (found.kind === "nin") {
897
+ throw readScopeCompileError(
898
+ `[read-scope-sql] read scope for "${objectName}" has an empty $nin at ${found.path} \u2014 an empty exclusion excludes nothing, so the engine lowers that clause to constant TRUE and the scope does not bind as written. Refused at every polarity, matching this module's own $nin arm (fail-closed).`
899
+ );
900
+ }
901
+ throw readScopeCompileError(
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).`
903
+ );
904
+ }
905
+ function compileSub(node, qAlias, opts) {
728
906
  const params = [];
729
- const sql = compileNode(node, qAlias, params);
907
+ const sql = compileNode(node, qAlias, params, opts);
730
908
  return { sql, params };
731
909
  }
732
- function compileNode(node, qAlias, params) {
910
+ function compileNode(node, qAlias, params, opts) {
733
911
  if (!isFilterNode(node)) {
734
912
  throw readScopeCompileError("[read-scope-sql] read scope must be a filter object (fail-closed).");
735
913
  }
@@ -743,7 +921,7 @@ function compileNode(node, qAlias, params) {
743
921
  if (key === "$or") clauses.push(FALSE_CLAUSE);
744
922
  continue;
745
923
  }
746
- const compiled = value.map((child) => compileSub(child, qAlias));
924
+ const compiled = value.map((child) => compileSub(child, qAlias, opts));
747
925
  if (key === "$or" && compiled.some((c) => c.sql.length === 0)) continue;
748
926
  const kept = compiled.filter((c) => c.sql.length > 0);
749
927
  if (kept.length === 0) continue;
@@ -752,7 +930,7 @@ function compileNode(node, qAlias, params) {
752
930
  clauses.push(`(${kept.map((c) => c.sql).join(joiner)})`);
753
931
  } else if (key === "$not") {
754
932
  const operand = isFilterNode(value) ? nullSafeNegationOperand2(value) : value;
755
- const inner = compileSub(operand, qAlias);
933
+ const inner = compileSub(operand, qAlias, opts);
756
934
  if (inner.sql.length === 0) {
757
935
  clauses.push(FALSE_CLAUSE);
758
936
  } else {
@@ -762,12 +940,12 @@ function compileNode(node, qAlias, params) {
762
940
  } else if (key.startsWith("$")) {
763
941
  throw readScopeCompileError(`[read-scope-sql] unsupported top-level operator "${key}" (fail-closed).`);
764
942
  } else {
765
- clauses.push(compileField(key, value, qAlias, params));
943
+ clauses.push(compileField(key, value, qAlias, params, opts));
766
944
  }
767
945
  }
768
946
  return clauses.join(" AND ");
769
947
  }
770
- function compileField(field, value, qAlias, params) {
948
+ function compileField(field, value, qAlias, params, opts) {
771
949
  const col = `${qAlias}.${quoteIdent(field, "field")}`;
772
950
  assertDefinedComparands2(field, value);
773
951
  assertBooleanFlagComparands(field, value);
@@ -787,7 +965,7 @@ function compileField(field, value, qAlias, params) {
787
965
  }
788
966
  const parts = [];
789
967
  for (const op of keys) {
790
- parts.push(compileOperator(col, op, ops[op], field, params));
968
+ parts.push(compileOperator(col, op, ops[op], field, params, opts));
791
969
  }
792
970
  return parts.length === 1 ? parts[0] : `(${parts.join(" AND ")})`;
793
971
  }
@@ -795,8 +973,20 @@ function bind(params, v) {
795
973
  params.push(v);
796
974
  return "?";
797
975
  }
798
- function bindLike(params, pattern) {
799
- 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;
800
990
  }
801
991
  function nullSafeNegative(col, test) {
802
992
  return `(${col} IS NULL OR ${test})`;
@@ -861,7 +1051,7 @@ function assertNoFieldReferenceComparand2(field, spec) {
861
1051
  });
862
1052
  }
863
1053
  }
864
- function compileOperator(col, op, val, field, params) {
1054
+ function compileOperator(col, op, val, field, params, opts) {
865
1055
  switch (op) {
866
1056
  case "$eq":
867
1057
  return val === null ? `${col} IS NULL` : `${col} = ${bind(params, val)}`;
@@ -885,7 +1075,7 @@ function compileOperator(col, op, val, field, params) {
885
1075
  }
886
1076
  case "$nin": {
887
1077
  if (!Array.isArray(val)) throw readScopeCompileError(`[read-scope-sql] $nin for "${field}" needs an array (fail-closed).`);
888
- if (val.length === 0) return "1 = 1";
1078
+ if (val.length === 0) throw readScopeCompileError(`[read-scope-sql] $nin for "${field}" is empty \u2014 an empty exclusion excludes nothing and would compile the read scope to constant TRUE (fail-closed).`);
889
1079
  assertCompilableMembers(op, field, val);
890
1080
  return nullSafeNegative(col, `${col} NOT IN (${val.map((v) => bind(params, v)).join(", ")})`);
891
1081
  }
@@ -895,12 +1085,19 @@ function compileOperator(col, op, val, field, params) {
895
1085
  return `${col} BETWEEN ${bind(params, val[0])} AND ${bind(params, val[1])}`;
896
1086
  }
897
1087
  // [#5567] The comparand is a LITERAL, so it is escaped and the escape
898
- // character is bound with it. See {@link bindLike}.
1088
+ // character is bound with it. See {@link textMatch}.
899
1089
  // [#5234] …and it must be a value `String()` can render, which is asserted
900
- // 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).
901
1098
  case "$contains":
902
1099
  assertRenderableText(op, field, val);
903
- return `${col} LIKE ${bindLike(params, likePattern("contains", val))}`;
1100
+ return textOverNonTextColumn(op, field, opts) ?? textMatch(col, "contains", val, false, params, opts);
904
1101
  /**
905
1102
  * [#6520] `$icontains` on the READ-SCOPE lowering — the one compiler in this
906
1103
  * package where a wrong answer is an ADR-0021 scope over-reach rather than a
@@ -916,23 +1113,39 @@ function compileOperator(col, op, val, field, params) {
916
1113
  * the rows already lower-case — and on a read scope that is a row set the
917
1114
  * policy author never wrote, in the narrowing direction here but in the
918
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.
919
1134
  */
920
- case "$icontains": {
1135
+ case "$icontains":
921
1136
  assertRenderableText(op, field, val);
922
- const patternRef = asciiLowerSqlExpr(bind(params, likePattern("contains", val)));
923
- return `${asciiLowerSqlExpr(col)} LIKE ${patternRef} ESCAPE ${bind(params, LIKE_ESCAPE_CHAR)}`;
924
- }
1137
+ return textOverNonTextColumn(op, field, opts) ?? textMatch(col, "contains", val, false, params, opts, true);
925
1138
  // [#5298] NULL-safe: `NOT LIKE` is UNKNOWN for a NULL column, and "does not
926
1139
  // contain" is true of a value that is not there.
927
1140
  case "$notContains":
928
1141
  assertRenderableText(op, field, val);
929
- 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));
930
1143
  case "$startsWith":
931
1144
  assertRenderableText(op, field, val);
932
- return `${col} LIKE ${bindLike(params, likePattern("starts", val))}`;
1145
+ return textOverNonTextColumn(op, field, opts) ?? textMatch(col, "starts", val, false, params, opts);
933
1146
  case "$endsWith":
934
1147
  assertRenderableText(op, field, val);
935
- return `${col} LIKE ${bindLike(params, likePattern("ends", val))}`;
1148
+ return textOverNonTextColumn(op, field, opts) ?? textMatch(col, "ends", val, false, params, opts);
936
1149
  // [#6387] `val` is a boolean here — {@link assertBooleanFlagComparands}
937
1150
  // refused anything else at {@link compileField}, before this emitter runs.
938
1151
  // So `=== true` is an exhaustive TWO-WAY choice over the declared domain,
@@ -1363,7 +1576,11 @@ var NativeSQLStrategy = class {
1363
1576
  if (typeof ctx.getReadScope !== "function") return;
1364
1577
  const filter = ctx.getReadScope(objectName);
1365
1578
  if (filter === void 0 || filter === null) return;
1366
- 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
+ });
1583
+ assertReadScopeCannotVacate(filter, objectName);
1367
1584
  if (!sql) return;
1368
1585
  let i = 0;
1369
1586
  const rendered = sql.replace(/\?/g, () => {
@@ -1640,12 +1857,16 @@ var NativeSQLStrategy = class {
1640
1857
  gte: ">=",
1641
1858
  lt: "<",
1642
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.
1643
1866
  contains: "LIKE",
1644
1867
  notContains: "NOT LIKE",
1645
1868
  startsWith: "LIKE",
1646
1869
  endsWith: "LIKE",
1647
- // [#6520] `$icontains` — `LIKE` like its neighbours; what separates it is
1648
- // the ASCII fold applied below, not the keyword.
1649
1870
  icontains: "LIKE"
1650
1871
  };
1651
1872
  const likeShape = {
@@ -1671,13 +1892,22 @@ var NativeSQLStrategy = class {
1671
1892
  if (!sqlOp || !values || values.length === 0) return null;
1672
1893
  const shape = likeShape[operator];
1673
1894
  if (shape) {
1674
- params.push(likePattern(shape, values[0]));
1675
- const patternRef = `$${params.length}`;
1676
- params.push(LIKE_ESCAPE_CHAR);
1677
- if (operator === "icontains") {
1678
- 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;
1679
1898
  }
1680
- 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
+ });
1681
1911
  }
1682
1912
  if (operator === "lte") {
1683
1913
  const nextDay = (0, import_core2.nextUtcCalendarDay)(values[0]);
@@ -1710,7 +1940,7 @@ var NativeSQLStrategy = class {
1710
1940
  };
1711
1941
 
1712
1942
  // src/strategies/objectql-strategy.ts
1713
- var import_data3 = require("@objectstack/spec/data");
1943
+ var import_data5 = require("@objectstack/spec/data");
1714
1944
  var import_core3 = require("@objectstack/core");
1715
1945
 
1716
1946
  // src/strategies/cross-object-rebucket.ts
@@ -1778,15 +2008,25 @@ var SCALAR_SQL_OPS = {
1778
2008
  lte: "<="
1779
2009
  };
1780
2010
  var LIKE_SQL_OPS = {
1781
- contains: { sql: "LIKE", shape: "contains" },
1782
- notContains: { sql: "NOT LIKE", shape: "contains" },
1783
- startsWith: { sql: "LIKE", shape: "starts" },
1784
- endsWith: { sql: "LIKE", shape: "ends" },
1785
- // [#6520] `$icontains`: the same escaped pattern and bound `ESCAPE` as its
1786
- // four case-EXACT neighbours, with `fold` adding the ASCII-only case fold to
1787
- // both sides of the comparison. The flag is on this row alone the family
1788
- // above it is case-sensitive by ruling (#4706 Q2 = A).
1789
- 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 }
1790
2030
  };
1791
2031
  var ObjectQLStrategy = class {
1792
2032
  constructor() {
@@ -1817,11 +2057,16 @@ var ObjectQLStrategy = class {
1817
2057
  for (const [dim, gran] of granByDim) {
1818
2058
  groupBy.push({ field: this.resolveFieldName(cube, dim, "dimension"), dateGranularity: gran });
1819
2059
  }
2060
+ const datasetScope = ctx.getDatasetScope?.(query.cube);
1820
2061
  const aggregations = [];
1821
2062
  if (query.measures && query.measures.length > 0) {
1822
2063
  for (const measure of query.measures) {
1823
2064
  const { field, method } = this.resolveMeasureAggregation(cube, measure);
1824
- aggregations.push({ field, method, alias: measure });
2065
+ const measureFilter = datasetScope?.measureFilters?.[measure];
2066
+ const filterCondition = measureFilter ? this.filterNodeToCondition(normalizeAnalyticsFilterTree({ where: measureFilter }), cube) : null;
2067
+ aggregations.push(
2068
+ filterCondition ? { field, method, alias: measure, filter: filterCondition } : { field, method, alias: measure }
2069
+ );
1825
2070
  }
1826
2071
  }
1827
2072
  const filter = {};
@@ -1831,7 +2076,6 @@ var ObjectQLStrategy = class {
1831
2076
  const extra = this.mergeFilterOperand(filter, field, bounds);
1832
2077
  if (extra) conjuncts.push(extra);
1833
2078
  }
1834
- const datasetScope = ctx.getDatasetScope?.(query.cube);
1835
2079
  if (datasetScope?.filter) {
1836
2080
  const scopeCondition = this.filterNodeToCondition(
1837
2081
  normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
@@ -1920,6 +2164,7 @@ var ObjectQLStrategy = class {
1920
2164
  }
1921
2165
  const tableName = this.extractObjectName(cube);
1922
2166
  const plan = this.planCrossObject(cube, query, this.filterMemberView(cube, query, ctx));
2167
+ const datasetScope = ctx.getDatasetScope?.(query.cube);
1923
2168
  const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
1924
2169
  const joinClauses = [];
1925
2170
  const dimExpr = (dim) => {
@@ -1950,7 +2195,9 @@ var ObjectQLStrategy = class {
1950
2195
  if (query.measures) {
1951
2196
  for (const m of query.measures) {
1952
2197
  const { field, method } = this.resolveMeasureAggregation(cube, m);
1953
- const aggSql = method === "count" ? "COUNT(*)" : method === "count_distinct" ? `COUNT(DISTINCT ${field})` : `${method.toUpperCase()}(${field})`;
2198
+ const measureFilter = datasetScope?.measureFilters?.[m];
2199
+ const predicate = measureFilter ? this.renderFilterNodeSql(normalizeAnalyticsFilterTree({ where: measureFilter }), cube, params, ctx) : null;
2200
+ const aggSql = predicate ? this.conditionalAggregateSql(method, field, predicate) : method === "count" ? "COUNT(*)" : method === "count_distinct" ? `COUNT(DISTINCT ${field})` : `${method.toUpperCase()}(${field})`;
1954
2201
  selectParts.push(`${aggSql} AS "${m}"`);
1955
2202
  }
1956
2203
  }
@@ -1958,15 +2205,16 @@ var ObjectQLStrategy = class {
1958
2205
  const filterClause = this.renderFilterNodeSql(
1959
2206
  normalizeAnalyticsFilterTree(query),
1960
2207
  cube,
1961
- params
2208
+ params,
2209
+ ctx
1962
2210
  );
1963
2211
  if (filterClause) whereParts.push(filterClause);
1964
- const echoedDatasetFilter = ctx.getDatasetScope?.(query.cube)?.filter;
1965
- if (echoedDatasetFilter) {
2212
+ if (datasetScope?.filter) {
1966
2213
  const scopeSql = this.renderFilterNodeSql(
1967
- normalizeAnalyticsFilterTree({ where: echoedDatasetFilter }),
2214
+ normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
1968
2215
  cube,
1969
- params
2216
+ params,
2217
+ ctx
1970
2218
  );
1971
2219
  if (scopeSql) whereParts.push(scopeSql);
1972
2220
  }
@@ -1979,7 +2227,11 @@ var ObjectQLStrategy = class {
1979
2227
  }
1980
2228
  const scope = ctx.getReadScope?.(tableName);
1981
2229
  if (scope != null) {
1982
- 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
+ });
2234
+ assertReadScopeCannotVacate(scope, tableName);
1983
2235
  if (scopeSql) {
1984
2236
  let i = 0;
1985
2237
  const rendered = scopeSql.replace(/\?/g, () => {
@@ -2022,11 +2274,12 @@ var ObjectQLStrategy = class {
2022
2274
  * predicate. `$and` makes that structurally impossible.
2023
2275
  */
2024
2276
  withReadScope(objectName, filter, ctx) {
2025
- 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;
2026
2278
  if (typeof ctx.getReadScope !== "function") return userFilter;
2027
2279
  const scope = ctx.getReadScope(objectName);
2028
2280
  if (scope === void 0 || scope === null) return userFilter;
2029
- const scopeFilter = (0, import_data3.markFilterSubtreeProvenance)(scope, "policy");
2281
+ assertReadScopeCannotVacate(scope, objectName);
2282
+ const scopeFilter = (0, import_data5.markFilterSubtreeProvenance)(scope, "policy");
2030
2283
  if (!userFilter) return scopeFilter;
2031
2284
  return { $and: [userFilter, scopeFilter] };
2032
2285
  }
@@ -2054,7 +2307,7 @@ var ObjectQLStrategy = class {
2054
2307
  * the members inside were unreadable from the outside and the envelope check
2055
2308
  * could not reject what it could not see.
2056
2309
  *
2057
- * ## Two producers, one inventory (#10861)
2310
+ * ## Three producers, one inventory (#10861, #11461)
2058
2311
  *
2059
2312
  * The caller's `where` is not the only thing that reaches `engine.aggregate`
2060
2313
  * as a predicate. Since PR #10758 the compiled dataset's own definition-level
@@ -2069,6 +2322,36 @@ var ObjectQLStrategy = class {
2069
2322
  * which driver will serve the dataset and would refuse a dataset that is
2070
2323
  * perfectly legal on a native-SQL deployment.
2071
2324
  *
2325
+ * [#11461] #10413 phase 2 then added a THIRD producer with the same reach and
2326
+ * none of the coverage: a compiled measure's own `filter`, lowered onto that
2327
+ * measure's `aggregations[].filter` entry (#10576). This view enumerated two
2328
+ * origins, so the third was invisible to the envelope check and the arm of
2329
+ * `planCrossObject` that inspects `query.measures` reads only each measure's
2330
+ * resolved FIELD, never its filter. Measured on the unfixed tree, one fixture,
2331
+ * both doors:
2332
+ *
2333
+ * ```
2334
+ * BEFORE execute() ACCEPTED -> aggregations: [{field:"*",method:"count",
2335
+ * alias:"west_count",
2336
+ * filter:{"account.region":"West"}}]
2337
+ * -> rows [{stage:"won",total_count:3,west_count:0}]
2338
+ * (the truthful west_count is 2; total_count
2339
+ * is right, so the wrong number arrived in
2340
+ * the same response shape as the right one)
2341
+ * generateSql() ACCEPTED -> COUNT(CASE WHEN account.region = $1 THEN 1 END)
2342
+ * over a FROM with no join in it at all
2343
+ * AFTER both doors REFUSED INVALID_FIELD / 400, engine never reached
2344
+ * ```
2345
+ *
2346
+ * The same maintainer ruling covers it — same hazard, same physical verdict,
2347
+ * one more producer — so it folds in HERE for the #10861 reason and not into
2348
+ * `dataset-compiler.ts`, which still cannot see which driver will serve the
2349
+ * dataset. Only the REQUESTED measures are folded: both doors' aggregation
2350
+ * loops read `measureFilters[m]` for `m of query.measures` and nothing else,
2351
+ * so a filter declared on a measure this query never asks for reaches no
2352
+ * engine, and refusing on it would reject a query for a member that was never
2353
+ * going to be evaluated.
2354
+ *
2072
2355
  * Structure is discarded on purpose — a member is cross-object or it is not,
2073
2356
  * and which branch of a disjunction it sits in cannot make
2074
2357
  * `engine.aggregate` able to join it. PROVENANCE is not discarded, because it
@@ -2078,9 +2361,12 @@ var ObjectQLStrategy = class {
2078
2361
  * `planCrossObject`. The value slot carries that and nothing else; it never
2079
2362
  * reaches a driver.
2080
2363
  *
2081
- * Dataset leaves are inserted FIRST so a member named by BOTH producers keeps
2082
- * the caller's provenance (last write wins on a duplicate key): if it is in
2083
- * the request too, the request is the actionable place to fix it.
2364
+ * Insertion order is measure-filter, then dataset-filter, then `where`, and
2365
+ * last write wins on a duplicate key. Two things follow, in that order of
2366
+ * importance. A member named by the request too keeps the CALLER's provenance,
2367
+ * because if it is in the request that is the actionable place to fix it. And
2368
+ * every shape that was refused before #11461 keeps the exact message it had:
2369
+ * the new origin can only ever win a key no older producer names.
2084
2370
  *
2085
2371
  * Time-dimension WINDOWS are deliberately absent (they live in
2086
2372
  * `dateRangeBounds`, not in `where`). They need no arm here: a cross-object
@@ -2090,13 +2376,21 @@ var ObjectQLStrategy = class {
2090
2376
  * diagnostic and the reason that loop runs first.
2091
2377
  */
2092
2378
  filterMemberView(cube, query, ctx) {
2093
- const datasetFilter = ctx.getDatasetScope?.(query.cube)?.filter;
2379
+ const datasetScope = ctx.getDatasetScope?.(query.cube);
2094
2380
  const leaves = (node, origin) => collectFilterLeaves(node).map(
2095
2381
  (f) => [this.resolveFieldName(cube, f.member, "any"), origin]
2096
2382
  );
2383
+ const measureLeaves = (query.measures ?? []).flatMap((m) => {
2384
+ const measureFilter = datasetScope?.measureFilters?.[m];
2385
+ return measureFilter ? leaves(
2386
+ normalizeAnalyticsFilterTree({ where: measureFilter }),
2387
+ { kind: "measure-filter", measure: m }
2388
+ ) : [];
2389
+ });
2097
2390
  return Object.fromEntries([
2098
- ...datasetFilter ? leaves(normalizeAnalyticsFilterTree({ where: datasetFilter }), "dataset-filter") : [],
2099
- ...leaves(normalizeAnalyticsFilterTree(query), "where")
2391
+ ...measureLeaves,
2392
+ ...datasetScope?.filter ? leaves(normalizeAnalyticsFilterTree({ where: datasetScope.filter }), { kind: "dataset-filter" }) : [],
2393
+ ...leaves(normalizeAnalyticsFilterTree(query), { kind: "where" })
2100
2394
  ]);
2101
2395
  }
2102
2396
  /**
@@ -2111,18 +2405,20 @@ var ObjectQLStrategy = class {
2111
2405
  * THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
2112
2406
  * (needs a real join to evaluate), a cross-object leaf in the DATASET's own
2113
2407
  * definition-level `filter` (#10861 — same join it does not have, arriving
2114
- * from the producer PR #10758 added), a MULTI-HOP dimension (`a.b.c`), or a
2115
- * non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
2116
- * cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
2408
+ * from the producer PR #10758 added), a cross-object leaf in ONE MEASURE's own
2409
+ * `filter` (#11461 the same join again, arriving from the producer #10413
2410
+ * phase 2 added), a MULTI-HOP dimension (`a.b.c`), or a non-recombinable
2411
+ * measure (`avg`/`count_distinct`, whose sub-bucket values cannot be merged).
2412
+ * A loud error beats the silent mis-bucket #3654 kills.
2117
2413
  * `generateSql()` calls this too, so the preview accepts/rejects the same set
2118
2414
  * — and since #10759 both callers derive `filter` from the one
2119
2415
  * {@link filterMemberView}, so that sentence is enforced by construction
2120
2416
  * instead of restated at two call sites.
2121
2417
  *
2122
- * [#5716] All five refusals below are `invalidMemberError` — `INVALID_FIELD` /
2418
+ * [#5716] All six refusals below are `invalidMemberError` — `INVALID_FIELD` /
2123
2419
  * 400, naming the member — and the four that predate #10861 keep their
2124
2420
  * MESSAGES unchanged (they are good diagnostics, and #5923's tests read
2125
- * them). Each is decided by two facts and nothing else: a member that will
2421
+ * them); so does #10861's own, which #11461 left untouched beside it. Each is decided by two facts and nothing else: a member that will
2126
2422
  * reach the engine's predicate, and whether that member resolves across a
2127
2423
  * join. Neither is an internal invariant — a cube where the member exists and
2128
2424
  * a driver that could serve it are both perfectly ordinary, which is exactly
@@ -2131,14 +2427,15 @@ var ObjectQLStrategy = class {
2131
2427
  * because the fix is always to change or drop ONE named member, and because
2132
2428
  * four of them fire on `/analytics/query` where no dataset exists.
2133
2429
  *
2134
- * [#10861] The fifth is the exception that proves the rule and is written to
2135
- * it: it can only fire where a dataset DOES exist, and it is the one refusal
2136
- * here whose member no request key named — so it carries `cube` and no
2137
- * `param`, and says in its own words which document to go and edit. It stays
2430
+ * [#10861, #11461] The fifth and sixth are the exceptions that prove the rule
2431
+ * and are written to it: they can only fire where a dataset DOES exist, and
2432
+ * they are the two refusals here whose member no request key named — so each
2433
+ * carries `cube` and no `param`, and says in its own words which document to
2434
+ * go and edit, the sixth naming the MEASURE inside it as well. Both stay
2138
2435
  * `INVALID_FIELD` rather than becoming `DATASET_INVALID` because the verdict
2139
- * is the same physical one as its neighbour — this engine cannot join this
2436
+ * is the same physical one as their neighbours — this engine cannot join this
2140
2437
  * member — and splitting the code by PROVENANCE would make a caller branch on
2141
- * two wire shapes for one capability limit.
2438
+ * three wire shapes for one capability limit.
2142
2439
  *
2143
2440
  * Detection is on RESOLVED field names, so a dotted dimension the cube
2144
2441
  * flattens to a real column is treated as base, not cross-object.
@@ -2160,7 +2457,7 @@ var ObjectQLStrategy = class {
2160
2457
  member: m,
2161
2458
  field: this.resolveMeasureAggregation(cube, m).field
2162
2459
  })),
2163
- ...Object.entries(filter).filter(([, origin]) => origin === "where").map(([f]) => ({ where: "filter", member: f, field: f }))
2460
+ ...Object.entries(filter).filter(([, origin]) => origin.kind === "where").map(([f]) => ({ where: "filter", member: f, field: f }))
2164
2461
  ].filter((r) => this.isCrossObjectField(cube, r.field, baseObject));
2165
2462
  if (nonDim.length > 0) {
2166
2463
  throw invalidMemberError(
@@ -2174,13 +2471,23 @@ var ObjectQLStrategy = class {
2174
2471
  }
2175
2472
  );
2176
2473
  }
2177
- const scopeCross = Object.entries(filter).filter(([field, origin]) => origin === "dataset-filter" && this.isCrossObjectField(cube, field, baseObject)).map(([field]) => field);
2474
+ const scopeCross = Object.entries(filter).filter(([field, origin]) => origin.kind === "dataset-filter" && this.isCrossObjectField(cube, field, baseObject)).map(([field]) => field);
2178
2475
  if (scopeCross.length > 0) {
2179
2476
  throw invalidMemberError(
2180
2477
  `[Analytics] ObjectQLStrategy cannot evaluate the cross-object filter ("${scopeCross[0]}") that dataset "${cube.name}" declares at its definition level \u2014 the engine cannot join in an aggregate, so this predicate matches nothing and the answer would be neither the scoped number nor the unscoped one. Nothing in the request names it: remove the cross-object leaf from the dataset's own \`filter\`, or serve this dataset on a native-SQL driver, where the same definition is valid.`,
2181
2478
  { member: scopeCross[0], cube: cube.name }
2182
2479
  );
2183
2480
  }
2481
+ const measureCross = Object.entries(filter).flatMap(
2482
+ ([field, origin]) => origin.kind === "measure-filter" && this.isCrossObjectField(cube, field, baseObject) ? [{ field, measure: origin.measure }] : []
2483
+ );
2484
+ if (measureCross.length > 0) {
2485
+ const { field, measure } = measureCross[0];
2486
+ throw invalidMemberError(
2487
+ `[Analytics] ObjectQLStrategy cannot evaluate the cross-object filter ("${field}") that dataset "${cube.name}" declares on its measure "${measure}" \u2014 the engine cannot join in an aggregate, so this measure would be counted over a predicate that matches nothing and would answer 0 rather than the scoped number. Remove the cross-object leaf from that measure's own \`filter\`, or serve this dataset on a native-SQL driver, where the same definition is valid.`,
2488
+ { member: field, cube: cube.name }
2489
+ );
2490
+ }
2184
2491
  const crossDims = [];
2185
2492
  for (const dim of query.dimensions ?? []) {
2186
2493
  const field = this.resolveFieldName(cube, dim, "dimension");
@@ -2284,7 +2591,8 @@ var ObjectQLStrategy = class {
2284
2591
  if (fkValues.length === 0 || typeof ctx.executeAggregate !== "function") return map;
2285
2592
  const idFilter = { id: { $in: fkValues } };
2286
2593
  const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
2287
- if (scope != null) (0, import_data3.markFilterSubtreeProvenance)(scope, "policy");
2594
+ if (scope != null) assertReadScopeCannotVacate(scope, refObject);
2595
+ if (scope != null) (0, import_data5.markFilterSubtreeProvenance)(scope, "policy");
2288
2596
  const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
2289
2597
  const rows = await ctx.executeAggregate(refObject, {
2290
2598
  groupBy: ["id", attr],
@@ -2297,6 +2605,45 @@ var ObjectQLStrategy = class {
2297
2605
  }
2298
2606
  return map;
2299
2607
  }
2608
+ /**
2609
+ * A measure's aggregate, restricted to the rows its own `filter` admits
2610
+ * (#10413 phase 2) — the same six functions `generateSql`'s unconditional
2611
+ * branch renders, wrapped in a `CASE WHEN`.
2612
+ *
2613
+ * Spelled `CASE WHEN` rather than SQL-standard `FILTER (WHERE …)`, mirroring
2614
+ * `NativeSQLStrategy.CONDITIONAL_AGGREGATE_SQL`: this string is DOCUMENTATION
2615
+ * of an execution that really goes through `engine.aggregate`'s per-driver
2616
+ * `aggregations[].filter` lowering (#10576), not a statement this class runs
2617
+ * itself, so there is no reason to pick a dialect-restricted spelling over
2618
+ * the portable one the SQL-executing sibling already settled on.
2619
+ *
2620
+ * `count` over `*` counts a constant (`COUNT(CASE WHEN p THEN 1 END)`, since
2621
+ * `COUNT(CASE WHEN p THEN * END)` is not valid SQL); over a real column it
2622
+ * counts that column's non-null values among the admitted rows.
2623
+ */
2624
+ conditionalAggregateSql(method, col, pred) {
2625
+ const target = col === "*" ? "1" : col;
2626
+ switch (method) {
2627
+ case "count":
2628
+ return `COUNT(CASE WHEN ${pred} THEN ${target} END)`;
2629
+ case "count_distinct":
2630
+ return `COUNT(DISTINCT CASE WHEN ${pred} THEN ${col} END)`;
2631
+ case "sum":
2632
+ return `SUM(CASE WHEN ${pred} THEN ${col} END)`;
2633
+ case "avg":
2634
+ return `AVG(CASE WHEN ${pred} THEN ${col} END)`;
2635
+ case "min":
2636
+ return `MIN(CASE WHEN ${pred} THEN ${col} END)`;
2637
+ case "max":
2638
+ return `MAX(CASE WHEN ${pred} THEN ${col} END)`;
2639
+ // Closed vocabulary, same posture as `resolveMeasureAggregation`'s
2640
+ // callers: `method` comes only from that function, whose own aggTypes
2641
+ // list is exactly these six, so this default is unreachable rather than
2642
+ // a silent fallback for a method this table forgot.
2643
+ default:
2644
+ return `${method.toUpperCase()}(CASE WHEN ${pred} THEN ${col} END)`;
2645
+ }
2646
+ }
2300
2647
  /**
2301
2648
  * Render one normalized filter as a display SQL predicate for `generateSql`.
2302
2649
  *
@@ -2317,7 +2664,7 @@ var ObjectQLStrategy = class {
2317
2664
  * not render that operator": #5333 was exactly that conflation, and an
2318
2665
  * unrenderable operator now THROWS (see the exit below).
2319
2666
  */
2320
- buildFilterClauseSql(col, operator, values, params) {
2667
+ buildFilterClauseSql(col, operator, values, params, target, ctx) {
2321
2668
  if (operator === "set") return `${col} IS NOT NULL`;
2322
2669
  if (operator === "notSet") return `${col} IS NULL`;
2323
2670
  if (!values || values.length === 0) return null;
@@ -2330,12 +2677,22 @@ var ObjectQLStrategy = class {
2330
2677
  }
2331
2678
  const like = LIKE_SQL_OPS[operator];
2332
2679
  if (like) {
2333
- params.push(likePattern(like.shape, values[0]));
2334
- const patternRef = `$${params.length}`;
2335
- params.push(LIKE_ESCAPE_CHAR);
2336
- const lhs = like.fold ? asciiLowerSqlExpr(col) : col;
2337
- const rhs = like.fold ? asciiLowerSqlExpr(patternRef) : patternRef;
2338
- 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
+ });
2339
2696
  }
2340
2697
  const op = SCALAR_SQL_OPS[operator];
2341
2698
  if (!op) {
@@ -2374,6 +2731,29 @@ var ObjectQLStrategy = class {
2374
2731
  }
2375
2732
  return void 0;
2376
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
+ }
2377
2757
  resolveFieldName(cube, member, kind) {
2378
2758
  if (kind === "dimension" || kind === "any") {
2379
2759
  const dim = this.lookupMember(cube, member, "dimension");
@@ -2388,8 +2768,20 @@ var ObjectQLStrategy = class {
2388
2768
  resolveMeasureAggregation(cube, measureName) {
2389
2769
  const direct = this.lookupMember(cube, measureName, "measure");
2390
2770
  if (direct) {
2771
+ if (EXPRESSION_METRIC_TYPES.has(direct.type)) {
2772
+ throw invalidMemberError(
2773
+ `[Analytics] ObjectQLStrategy cannot evaluate the custom-SQL measure ("${measureName}") \u2014 its type "${direct.type}" declares a raw SQL expression, which the engine aggregate AST cannot carry; served anyway it would answer null for every bucket under the measure's own name. Use an aggregate measure (count/sum/avg/min/max/count_distinct), or run on a native-SQL driver.`,
2774
+ { member: measureName, param: "measures", cube: cube.name }
2775
+ );
2776
+ }
2391
2777
  return {
2392
2778
  field: direct.sql.replace(/^\$/, ""),
2779
+ // The assertion, not a parse: for a CubeSchema-legal cube the type
2780
+ // partition above leaves exactly the six `AggregationFunction` values.
2781
+ // An enum-INVALID type (host drift, the comment above) still flows
2782
+ // through unchecked ON PURPOSE — adding a method allowlist here would
2783
+ // re-blame the caller with a 400 for OUR bug, so the cast keeps the
2784
+ // compile-time contract (#12776) without changing that posture.
2393
2785
  method: direct.type === "count_distinct" ? "count_distinct" : direct.type
2394
2786
  };
2395
2787
  }
@@ -2403,7 +2795,7 @@ var ObjectQLStrategy = class {
2403
2795
  if (candidate && candidate.type === type) {
2404
2796
  return {
2405
2797
  field: candidate.sql.replace(/^\$/, ""),
2406
- method: candidate.type === "count_distinct" ? "count_distinct" : candidate.type
2798
+ method: type
2407
2799
  };
2408
2800
  }
2409
2801
  }
@@ -2501,7 +2893,7 @@ var ObjectQLStrategy = class {
2501
2893
  * exactly — including the invariant that a `null` return leaves `params`
2502
2894
  * untouched, so no comparand is left with no placeholder to consume it.
2503
2895
  */
2504
- renderFilterNodeSql(node, cube, params) {
2896
+ renderFilterNodeSql(node, cube, params, ctx) {
2505
2897
  if (!node) return null;
2506
2898
  if (node.kind === "const") {
2507
2899
  return node.value ? SQL_CONST_TRUE : SQL_CONST_FALSE;
@@ -2511,17 +2903,23 @@ var ObjectQLStrategy = class {
2511
2903
  this.resolveFieldName(cube, node.member, "any"),
2512
2904
  node.operator,
2513
2905
  node.values,
2514
- 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
2515
2913
  );
2516
2914
  }
2517
2915
  if (node.kind === "not") {
2518
- const inner = this.renderFilterNodeSql(node.child, cube, params);
2916
+ const inner = this.renderFilterNodeSql(node.child, cube, params, ctx);
2519
2917
  return inner ? `NOT (${inner})` : SQL_CONST_FALSE;
2520
2918
  }
2521
2919
  const paramBase = params.length;
2522
2920
  const parts = [];
2523
2921
  for (const child of node.children) {
2524
- const clause = this.renderFilterNodeSql(child, cube, params);
2922
+ const clause = this.renderFilterNodeSql(child, cube, params, ctx);
2525
2923
  if (clause === null) {
2526
2924
  if (node.kind !== "or") continue;
2527
2925
  params.length = paramBase;
@@ -2776,10 +3174,10 @@ var ObjectQLStrategy = class {
2776
3174
  };
2777
3175
 
2778
3176
  // src/dataset-compiler.ts
2779
- var import_data4 = require("@objectstack/spec/data");
3177
+ var import_data6 = require("@objectstack/spec/data");
2780
3178
  var import_ui = require("@objectstack/spec/ui");
2781
3179
  var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set();
2782
- 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));
2783
3181
  function aggregateToMetricType(m) {
2784
3182
  if (!m.aggregate) {
2785
3183
  throw new Error(`[dataset-compiler] non-derived measure "${m.name}" has no aggregate`);
@@ -2941,7 +3339,7 @@ function compileDataset(dataset, resolver, options) {
2941
3339
  }
2942
3340
 
2943
3341
  // src/dataset-executor.ts
2944
- var import_data5 = require("@objectstack/spec/data");
3342
+ var import_data7 = require("@objectstack/spec/data");
2945
3343
  var import_core4 = require("@objectstack/core");
2946
3344
  function resolveSelectionTokens(compiled, selection, context) {
2947
3345
  const tokenCtx = (0, import_core4.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
@@ -2981,7 +3379,7 @@ function evaluateDerivedMeasures(rows, derived) {
2981
3379
  }
2982
3380
  function fillEmptyGroups(rows, columnAggregates) {
2983
3381
  for (const [column, aggregate2] of Object.entries(columnAggregates)) {
2984
- const empty = (0, import_data5.emptyGroupValueFor)(aggregate2);
3382
+ const empty = (0, import_data7.emptyGroupValueFor)(aggregate2);
2985
3383
  if (empty === void 0) continue;
2986
3384
  for (const row of rows) if (row[column] == null) row[column] = empty;
2987
3385
  }
@@ -3735,25 +4133,55 @@ function bucketDate(value, granularity, timezone) {
3735
4133
  return `${y}-${m}-${day}`;
3736
4134
  }
3737
4135
  }
3738
- function aggregate(rows, metricType, field) {
3739
- if (metricType === "count" || field === "*") {
3740
- if (metricType === "countDistinct") {
3741
- 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;
3742
4160
  }
3743
- return rows.length;
4161
+ const c = compareOperands(v, winner);
4162
+ if (kind === "min" ? c < 0 : c > 0) winner = v;
3744
4163
  }
4164
+ return seen ? winner : null;
4165
+ }
4166
+ function aggregate(rows, metricType, field) {
4167
+ if (metricType === "count" || field === "*") return rows.length;
3745
4168
  const nums = rows.map((r) => Number(r[field])).filter((n) => Number.isFinite(n));
3746
4169
  switch (metricType) {
3747
- 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":
3748
4176
  return new Set(rows.map((r) => r[field]).filter((v) => v != null)).size;
3749
4177
  case "sum":
3750
4178
  return nums.reduce((a, b) => a + b, 0);
3751
4179
  case "avg":
3752
4180
  return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : 0;
3753
4181
  case "min":
3754
- return nums.length ? Math.min(...nums) : 0;
4182
+ return extremumOf(rows, field, "min");
3755
4183
  case "max":
3756
- return nums.length ? Math.max(...nums) : 0;
4184
+ return extremumOf(rows, field, "max");
3757
4185
  default:
3758
4186
  return nums.length ? nums.reduce((a, b) => a + b, 0) : rows.length;
3759
4187
  }
@@ -3814,7 +4242,19 @@ function evaluateAnalyticsQueryOverRows(query, cube, rows) {
3814
4242
  return {
3815
4243
  rows: limited,
3816
4244
  fields: [
3817
- ...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).
3818
4258
  ...query.measures.map((m) => ({ name: m, type: "number" }))
3819
4259
  ]
3820
4260
  };
@@ -3947,7 +4387,17 @@ var AnalyticsService = class {
3947
4387
  },
3948
4388
  coerceTemporalFilterValue: config.coerceTemporalFilterValue,
3949
4389
  coerceTemporalFilterColumn: config.coerceTemporalFilterColumn,
3950
- 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)
3951
4401
  };
3952
4402
  const builtIn = [
3953
4403
  new NativeSQLStrategy(),
@@ -3967,15 +4417,59 @@ var AnalyticsService = class {
3967
4417
  * current request's ExecutionContext (ADR-0021 D-C). The strategy then sees a
3968
4418
  * `getReadScope(objectName)` that already knows the active tenant.
3969
4419
  */
3970
- async callCtx(query, context) {
3971
- if (!this.readScopeProvider) return { ...this.baseCtx, context };
4420
+ async callCtx(query, context, tokenCtx) {
4421
+ const getDatasetScope = this.resolvedDatasetScopeGetter(tokenCtx);
4422
+ if (!this.readScopeProvider) return { ...this.baseCtx, context, getDatasetScope };
3972
4423
  const scopes = await this.resolveReadScopes(query, context);
3973
4424
  return {
3974
4425
  ...this.baseCtx,
3975
4426
  context,
4427
+ getDatasetScope,
3976
4428
  getReadScope: (objectName) => scopes.get(objectName) ?? null
3977
4429
  };
3978
4430
  }
4431
+ /**
4432
+ * [#12230] Copy-on-write expansion of filter placeholders across everything
4433
+ * a DIRECT analytics query compares on: `where` and each time dimension's
4434
+ * `dateRange` — the same positions `DatasetExecutor.resolveSelectionTokens`
4435
+ * covers for the dashboard door, minus the dataset-only channels it alone
4436
+ * carries (measure filters ride the dataset-scope getter below).
4437
+ *
4438
+ * The input is never mutated: a query object can be caller-owned metadata
4439
+ * (a saved report definition, a flow node's config) reused across requests,
4440
+ * and resolving in place would bake one request's user id into every later
4441
+ * render. Returns the SAME object when nothing resolved.
4442
+ */
4443
+ resolveQueryTokens(query, tokenCtx) {
4444
+ const where = (0, import_core6.resolveFilterTokens)(query.where, tokenCtx);
4445
+ const timeDimensions = query.timeDimensions?.map((td) => {
4446
+ if (td.dateRange == null) return td;
4447
+ const dateRange = (0, import_core6.resolveFilterTokens)(td.dateRange, tokenCtx);
4448
+ return dateRange === td.dateRange ? td : { ...td, dateRange };
4449
+ });
4450
+ const tdChanged = timeDimensions !== void 0 && timeDimensions.some((td, i) => td !== query.timeDimensions[i]);
4451
+ if (where === query.where && !tdChanged) return query;
4452
+ const out = { ...query };
4453
+ if (where !== query.where) out.where = where;
4454
+ if (tdChanged) out.timeDimensions = timeDimensions;
4455
+ return out;
4456
+ }
4457
+ /**
4458
+ * [#12230] A per-request `getDatasetScope` whose answers have their filter
4459
+ * placeholders resolved against THIS caller. See `callCtx` for why the
4460
+ * registry's copy cannot be handed out raw. Token-free scopes pass through
4461
+ * by reference — `resolveFilterTokens` returns its input unchanged when the
4462
+ * tree holds no placeholder, so the common case allocates nothing.
4463
+ */
4464
+ resolvedDatasetScopeGetter(tokenCtx) {
4465
+ return (cubeName) => {
4466
+ const scope = this.baseCtx.getDatasetScope?.(cubeName);
4467
+ if (!scope) return scope;
4468
+ const filter = (0, import_core6.resolveFilterTokens)(scope.filter, tokenCtx);
4469
+ const measureFilters = (0, import_core6.resolveFilterTokens)(scope.measureFilters, tokenCtx);
4470
+ return filter === scope.filter && measureFilters === scope.measureFilters ? scope : { filter, measureFilters };
4471
+ };
4472
+ }
3979
4473
  /**
3980
4474
  * Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
3981
4475
  * AND every joined object of the query's cube, keyed by object name. This is
@@ -4033,12 +4527,14 @@ var AnalyticsService = class {
4033
4527
  * aggregate bridge) instead of failing — or worse, fabricating empty rows.
4034
4528
  * Any other error propagates untouched.
4035
4529
  */
4036
- async query(query, context) {
4037
- if (!query.cube) {
4530
+ async query(queryInput, context) {
4531
+ if (!queryInput.cube) {
4038
4532
  throw new Error("Cube name is required in analytics query");
4039
4533
  }
4534
+ const tokenCtx = (0, import_core6.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
4535
+ const query = this.resolveQueryTokens(queryInput, tokenCtx);
4040
4536
  this.ensureCube(query);
4041
- const ctx = await this.callCtx(query, context);
4537
+ const ctx = await this.callCtx(query, context, tokenCtx);
4042
4538
  let skip;
4043
4539
  for (; ; ) {
4044
4540
  const strategy = this.resolveStrategy(query, ctx, skip);
@@ -4117,10 +4613,10 @@ var AnalyticsService = class {
4117
4613
  query: async (q) => evaluateAnalyticsQueryOverRows(q, compiled.cube, seedRows)
4118
4614
  };
4119
4615
  const previewResult = await new DatasetExecutor(previewService).execute(compiled, selection, context);
4616
+ this.enrichResultColumns(previewResult, dataset, selection, context);
4120
4617
  return previewResult;
4121
4618
  }
4122
4619
  }
4123
- const requestLocale = context?.locale;
4124
4620
  const provider = this.readScopeProvider;
4125
4621
  const resolveScope = provider ? (targetObject) => provider(targetObject, context) : void 0;
4126
4622
  const labelDeps = this.labelResolver ? withLabelFetchCache(this.labelResolver) : void 0;
@@ -4156,7 +4652,7 @@ var AnalyticsService = class {
4156
4652
  }
4157
4653
  throw err;
4158
4654
  }
4159
- const selectedDims = (selection.dimensions ?? []).map((name) => dataset.dimensions?.find((d) => d.name === name)).filter((d) => !!d);
4655
+ const selectedDims = this.selectedDimensions(dataset, selection);
4160
4656
  const drillDims = selectedDims.filter((d) => !!d.field && d.type !== "date");
4161
4657
  if (drillDims.length && result.rows.length) {
4162
4658
  result.object = dataset.object;
@@ -4225,6 +4721,48 @@ var AnalyticsService = class {
4225
4721
  }
4226
4722
  }
4227
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;
4228
4766
  if (result.fields?.length && dataset.measures?.length) {
4229
4767
  const measureByName = new Map(dataset.measures.map((m) => [m.name, m]));
4230
4768
  for (const f of result.fields) {
@@ -4234,6 +4772,7 @@ var AnalyticsService = class {
4234
4772
  const label = (0, import_ui2.resolveI18nLabel)(m.label, requestLocale);
4235
4773
  if (label !== void 0) f.label = label;
4236
4774
  }
4775
+ if (f.builtinAggregate == null && m.label == null && m.aggregate) f.builtinAggregate = m.aggregate;
4237
4776
  if (f.format == null && m.format) f.format = m.format;
4238
4777
  const fc = f;
4239
4778
  const mc = m;
@@ -4246,11 +4785,13 @@ var AnalyticsService = class {
4246
4785
  }
4247
4786
  }
4248
4787
  if (f.percentScale == null) {
4249
- 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);
4250
4789
  }
4790
+ const resultType = measureResultType(m.aggregate, meta?.type);
4791
+ if (resultType) f.type = resultType;
4251
4792
  }
4252
4793
  }
4253
- const describableDims = [...selectedDims];
4794
+ const describableDims = [...this.selectedDimensions(dataset, selection)];
4254
4795
  for (const t of selection.timeDimensions ?? []) {
4255
4796
  if (describableDims.some((d2) => d2.name === t.dimension)) continue;
4256
4797
  const d = dataset.dimensions?.find((x) => x.name === t.dimension);
@@ -4267,7 +4808,6 @@ var AnalyticsService = class {
4267
4808
  if (label !== void 0) f.label = label;
4268
4809
  }
4269
4810
  }
4270
- return result;
4271
4811
  }
4272
4812
  /**
4273
4813
  * Get cube metadata for discovery.
@@ -4292,12 +4832,14 @@ var AnalyticsService = class {
4292
4832
  /**
4293
4833
  * Generate SQL for a query without executing it (dry-run).
4294
4834
  */
4295
- async generateSql(query, context) {
4296
- if (!query.cube) {
4835
+ async generateSql(queryInput, context) {
4836
+ if (!queryInput.cube) {
4297
4837
  throw new Error("Cube name is required for SQL generation");
4298
4838
  }
4839
+ const tokenCtx = (0, import_core6.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
4840
+ const query = this.resolveQueryTokens(queryInput, tokenCtx);
4299
4841
  this.ensureCube(query);
4300
- const ctx = await this.callCtx(query, context);
4842
+ const ctx = await this.callCtx(query, context, tokenCtx);
4301
4843
  const strategy = this.resolveStrategy(query, ctx);
4302
4844
  this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" \u2192 ${strategy.name}`);
4303
4845
  return strategy.generateSql(query, ctx);
@@ -4811,6 +5353,16 @@ var FallbackDelegateStrategy = class {
4811
5353
  };
4812
5354
 
4813
5355
  // src/plugin.ts
5356
+ var import_data9 = require("@objectstack/spec/data");
5357
+ function parseEngineAggregateFunction(method, alias) {
5358
+ const parsed = import_data9.AggregationFunction.safeParse(method);
5359
+ if (!parsed.success) {
5360
+ throw new Error(
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.`
5362
+ );
5363
+ }
5364
+ return parsed.data;
5365
+ }
4814
5366
  var AnalyticsServicePlugin = class {
4815
5367
  constructor(options = {}) {
4816
5368
  this.name = "com.objectstack.service-analytics";
@@ -4868,10 +5420,58 @@ var AnalyticsServicePlugin = class {
4868
5420
  const rows = await engine.aggregate(objectName, {
4869
5421
  where: filter,
4870
5422
  groupBy,
5423
+ // [#10413 phase 2 / #10576] `a.filter` is the per-aggregation
5424
+ // predicate `ObjectQLStrategy` lowers a measure's own scoped
5425
+ // `filter` into. This map already renames `method` → `function`
5426
+ // for the engine's own vocabulary; dropping `filter` here — as this
5427
+ // bridge did before this line existed — would have made the
5428
+ // strategy's lowering a NO-OP on every real deployment that boots
5429
+ // through this auto-bridge (the default path: `new
5430
+ // AnalyticsServicePlugin({ cubes })` with no custom
5431
+ // `executeAggregate`), passing every unit test that stubs
5432
+ // `executeAggregate` directly while silently dropping the filter in
5433
+ // production — the exact declared-≠-enforced shape Prime Directive
5434
+ // #10 calls out. Omitted (not `filter: undefined`) when the
5435
+ // aggregation carries none, matching the engine's own
5436
+ // vacuous-filter convention.
4871
5437
  aggregations: aggregations?.map((a) => ({
4872
- function: a.method,
5438
+ // [#11833] `function` is the engine contract's SIX-value
5439
+ // `AggregationFunction`. This bridge's own input declared
5440
+ // `method: string` when that history was written
5441
+ // (`StrategyContext.executeAggregate`, spec
5442
+ // `contracts/analytics-service.ts`), so the two ends of this
5443
+ // rename spoke different vocabularies: narrowing the engine side
5444
+ // to the contract turned the forward into a compile error — the
5445
+ // correct signal, and the one the deleted structural type hid by
5446
+ // declaring `function: string` on both sides.
5447
+ //
5448
+ // Since #12776 (contract) and #12940 (this plugin's own config
5449
+ // mirror above), BOTH ends declare the enum, so the rename is
5450
+ // enum-to-enum and the parse below is defence in depth behind a
5451
+ // compile-time check rather than the only check — see
5452
+ // `parseEngineAggregateFunction` for why erased types still leave
5453
+ // it load-bearing.
5454
+ //
5455
+ // It was closed by PARSING with the spec enum itself rather than
5456
+ // by widening back to `string` (what hid it) or casting past it
5457
+ // (which keeps the hole and adds a lie). `AggregationFunction` is
5458
+ // the same schema `AggregationNodeSchema.function` is built from,
5459
+ // so there is one vocabulary, and its own error map already
5460
+ // carries the `array_agg`/`string_agg` retirement prescriptions.
5461
+ //
5462
+ // TIERING, deliberately: the reachable producer of a non-aggregate
5463
+ // method — a custom-SQL measure (`AggregationMetricType`
5464
+ // `number`/`string`/`boolean`) — is already refused upstream with a
5465
+ // caller-blaming 400 by `ObjectQLStrategy.resolveMeasureAggregation`
5466
+ // (#12209). Anything still arriving here is host drift, which that
5467
+ // refusal's docblock assigns to the undeclared-500 tier — so this
5468
+ // throws rather than re-blaming the caller, and it answers loudly
5469
+ // instead of letting the engine answer `null` per bucket under the
5470
+ // author's own measure name (the #4157 class).
5471
+ function: parseEngineAggregateFunction(a.method, a.alias),
4873
5472
  field: a.field,
4874
- alias: a.alias
5473
+ alias: a.alias,
5474
+ ...a.filter ? { filter: a.filter } : {}
4875
5475
  })),
4876
5476
  // ADR-0053 Phase 2: thread the reference tz so date buckets resolve on
4877
5477
  // that zone's calendar days (engine buckets in-memory when non-UTC).
@@ -4973,6 +5573,7 @@ var AnalyticsServicePlugin = class {
4973
5573
  const map = /* @__PURE__ */ new Map();
4974
5574
  const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
4975
5575
  if (!displayField || !executeAggregate || ids.length === 0) return map;
5576
+ if (scope) assertReadScopeCannotVacate(scope, targetObject);
4976
5577
  const CHUNK = 500;
4977
5578
  for (let i = 0; i < ids.length; i += CHUNK) {
4978
5579
  const idFilter = { id: { $in: ids.slice(i, i + CHUNK) } };
@@ -5040,6 +5641,16 @@ var AnalyticsServicePlugin = class {
5040
5641
  }
5041
5642
  return columnSql;
5042
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
+ };
5043
5654
  const config = {
5044
5655
  cubes: this.options.cubes,
5045
5656
  logger: ctx.logger,
@@ -5081,6 +5692,8 @@ var AnalyticsServicePlugin = class {
5081
5692
  // prevent: it drifts by one step, silently, and the drift only surfaces as
5082
5693
  // an error message pointing at the wrong database.
5083
5694
  getObjectDatasource: (objectName) => dataEngine()?.resolveEffectiveDatasource?.(objectName),
5695
+ // [#15684] The executing driver's own dialect — see `sqlDialect` above.
5696
+ sqlDialect,
5084
5697
  // ADR-0062 D6 — a federated object carries an `external` block (ADR-0015).
5085
5698
  // Reported so NativeSQLStrategy declines it (its hand-compiled FROM would
5086
5699
  // hit the wrong physical table) and the driver-correct ObjectQL path runs.