@objectstack/service-analytics 17.0.0-rc.0 → 17.0.0-rc.1

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
@@ -40,7 +40,7 @@ __export(index_exports, {
40
40
  module.exports = __toCommonJS(index_exports);
41
41
 
42
42
  // src/analytics-service.ts
43
- var import_core3 = require("@objectstack/core");
43
+ var import_core5 = require("@objectstack/core");
44
44
 
45
45
  // src/cube-registry.ts
46
46
  var CubeRegistry = class {
@@ -169,7 +169,8 @@ var MONGO_TO_CUBE_OP = {
169
169
  $nin: "notIn",
170
170
  $contains: "contains",
171
171
  $notContains: "notContains",
172
- $exists: "set"
172
+ $startsWith: "startsWith",
173
+ $endsWith: "endsWith"
173
174
  };
174
175
  function stringifyForCube(v) {
175
176
  if (v == null) return "";
@@ -178,55 +179,102 @@ function stringifyForCube(v) {
178
179
  if (typeof v === "object") return JSON.stringify(v);
179
180
  return String(v);
180
181
  }
181
- function flattenCondition(cond, out) {
182
- for (const [key, raw] of Object.entries(cond)) {
183
- if (raw === void 0) continue;
184
- if (key === "$and" && Array.isArray(raw)) {
185
- for (const sub of raw) {
186
- if (sub && typeof sub === "object") {
187
- flattenCondition(sub, out);
182
+ function andOf(children) {
183
+ if (children.length === 0) return null;
184
+ if (children.length === 1) return children[0];
185
+ return { kind: "and", children };
186
+ }
187
+ function fieldLeaves(key, raw) {
188
+ const out = [];
189
+ const leaf = (operator, values) => {
190
+ out.push({ kind: "leaf", member: key, operator, values });
191
+ };
192
+ if (raw === null) {
193
+ leaf("notSet", []);
194
+ return out;
195
+ }
196
+ if (typeof raw === "object" && !Array.isArray(raw) && !(raw instanceof Date)) {
197
+ const wrapper = raw;
198
+ const opKeys = Object.keys(wrapper).filter((k) => k.startsWith("$"));
199
+ if (opKeys.length > 0) {
200
+ for (const opKey of opKeys) {
201
+ if (opKey === "$between") {
202
+ const v2 = wrapper[opKey];
203
+ if (!Array.isArray(v2) || v2.length !== 2) {
204
+ throw new Error(
205
+ `[analytics] "$between" on "${key}" needs a two-element [min, max] array, got ${JSON.stringify(v2)}. Dropping the predicate would silently widen the query to every row.`
206
+ );
207
+ }
208
+ leaf("gte", [stringifyForCube(v2[0])]);
209
+ leaf("lte", [stringifyForCube(v2[1])]);
210
+ continue;
211
+ }
212
+ if (opKey === "$null" || opKey === "$exists") {
213
+ const isNull = opKey === "$null" ? wrapper[opKey] === true : wrapper[opKey] === false;
214
+ leaf(isNull ? "notSet" : "set", []);
215
+ continue;
188
216
  }
217
+ const cubeOp = MONGO_TO_CUBE_OP[opKey];
218
+ if (!cubeOp) {
219
+ throw new Error(
220
+ `[analytics] Unsupported filter operator "${opKey}" on "${key}". Supported: ${Object.keys(MONGO_TO_CUBE_OP).join(", ")}, $between, $null, $exists, and the $and/$or/$not combinators. Dropping it would silently widen the query to rows the filter excludes.`
221
+ );
222
+ }
223
+ const v = wrapper[opKey];
224
+ leaf(cubeOp, Array.isArray(v) ? v.map(stringifyForCube) : [stringifyForCube(v)]);
189
225
  }
190
- continue;
226
+ return out;
191
227
  }
192
- if (key === "$or" || key === "$not") continue;
193
- if (raw === null) {
194
- out.push({ member: key, operator: "notSet", values: [] });
195
- continue;
228
+ for (const [nestedKey, nestedVal] of Object.entries(wrapper)) {
229
+ out.push(...fieldLeaves(`${key}.${nestedKey}`, nestedVal));
196
230
  }
197
- if (typeof raw === "object" && !Array.isArray(raw) && !(raw instanceof Date)) {
198
- const wrapper = raw;
199
- const opKeys = Object.keys(wrapper).filter((k) => k.startsWith("$"));
200
- if (opKeys.length > 0) {
201
- for (const opKey of opKeys) {
202
- const cubeOp = MONGO_TO_CUBE_OP[opKey];
203
- if (!cubeOp) continue;
204
- const v = wrapper[opKey];
205
- const values = Array.isArray(v) ? v.map(stringifyForCube) : [stringifyForCube(v)];
206
- out.push({ member: key, operator: cubeOp, values });
207
- }
208
- continue;
209
- }
210
- for (const [nestedKey, nestedVal] of Object.entries(wrapper)) {
211
- flattenCondition({ [`${key}.${nestedKey}`]: nestedVal }, out);
231
+ return out;
232
+ }
233
+ if (Array.isArray(raw)) leaf("in", raw.map(stringifyForCube));
234
+ else leaf("equals", [stringifyForCube(raw)]);
235
+ return out;
236
+ }
237
+ function buildNode(cond) {
238
+ const children = [];
239
+ for (const [key, raw] of Object.entries(cond)) {
240
+ if (raw === void 0) continue;
241
+ if (key === "$and" || key === "$or") {
242
+ if (!Array.isArray(raw) || raw.length === 0) {
243
+ throw new Error(
244
+ `[analytics] "${key}" requires a non-empty array. An empty combinator has no defensible reading \u2014 dropping it widens the query, and treating it as "match nothing" silently empties a chart.`
245
+ );
212
246
  }
247
+ const branches = raw.map((sub) => sub && typeof sub === "object" ? buildNode(sub) : null).filter((n) => n !== null);
248
+ if (branches.length === 0) continue;
249
+ if (key === "$and") children.push(...branches);
250
+ else children.push(branches.length === 1 ? branches[0] : { kind: "or", children: branches });
213
251
  continue;
214
252
  }
215
- if (Array.isArray(raw)) {
216
- out.push({ member: key, operator: "in", values: raw.map(stringifyForCube) });
217
- } else {
218
- out.push({ member: key, operator: "equals", values: [stringifyForCube(raw)] });
253
+ if (key === "$not") {
254
+ const inner = raw && typeof raw === "object" ? buildNode(raw) : null;
255
+ if (inner) children.push({ kind: "not", child: inner });
256
+ continue;
219
257
  }
258
+ if (key.startsWith("$")) {
259
+ throw new Error(
260
+ `[analytics] Unsupported top-level filter operator "${key}". Dropping it would silently widen the query to rows the filter excludes.`
261
+ );
262
+ }
263
+ children.push(...fieldLeaves(key, raw));
220
264
  }
265
+ return andOf(children);
221
266
  }
222
- function normalizeAnalyticsFilters(query) {
223
- if (!query || typeof query !== "object") return [];
224
- const out = [];
267
+ function normalizeAnalyticsFilterTree(query) {
268
+ if (!query || typeof query !== "object") return null;
225
269
  const where = query.where;
226
- if (where && typeof where === "object" && !Array.isArray(where)) {
227
- flattenCondition(where, out);
228
- }
229
- return out;
270
+ if (!where || typeof where !== "object" || Array.isArray(where)) return null;
271
+ return buildNode(where);
272
+ }
273
+ function collectFilterLeaves(node) {
274
+ if (!node) return [];
275
+ if (node.kind === "leaf") return [{ member: node.member, operator: node.operator, values: node.values }];
276
+ if (node.kind === "not") return collectFilterLeaves(node.child);
277
+ return node.children.flatMap(collectFilterLeaves);
230
278
  }
231
279
  function recoverNumber(s) {
232
280
  if (/^-?\d+(\.\d+)?$/.test(s)) {
@@ -358,6 +406,18 @@ function compileOperator(col, op, val, field, params) {
358
406
  }
359
407
 
360
408
  // src/strategies/native-sql-strategy.ts
409
+ var import_core = require("@objectstack/core");
410
+ var AGGREGATE_SQL = {
411
+ "count": () => "COUNT(*)",
412
+ "sum": (col) => `SUM(${col})`,
413
+ "avg": (col) => `AVG(${col})`,
414
+ "min": (col) => `MIN(${col})`,
415
+ "max": (col) => `MAX(${col})`,
416
+ "count_distinct": (col) => `COUNT(DISTINCT ${col})`
417
+ };
418
+ var SUPPORTED_AGGREGATE_SQL_KEYS = Object.keys(AGGREGATE_SQL);
419
+ var EXPRESSION_METRIC_TYPES = /* @__PURE__ */ new Set(["number", "string", "boolean"]);
420
+ var IDENTIFIER_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
361
421
  var NativeSQLStrategy = class {
362
422
  constructor() {
363
423
  this.name = "NativeSQLStrategy";
@@ -412,15 +472,15 @@ var NativeSQLStrategy = class {
412
472
  }
413
473
  }
414
474
  const whereClauses = [];
415
- const normalizedFilters = normalizeAnalyticsFilters(query);
416
- if (normalizedFilters.length > 0) {
417
- for (const filter of normalizedFilters) {
418
- const colExpr = this.resolveFieldSql(cube, filter.member, tableName, joins);
419
- const target = this.resolveStorageTarget(cube, filter.member, tableName);
420
- const clause = this.buildFilterClause(colExpr, filter.operator, filter.values, params, ctx, target);
421
- if (clause) whereClauses.push(clause);
422
- }
423
- }
475
+ const filterSql = this.compileFilterNode(
476
+ normalizeAnalyticsFilterTree(query),
477
+ cube,
478
+ tableName,
479
+ joins,
480
+ params,
481
+ ctx
482
+ );
483
+ if (filterSql) whereClauses.push(filterSql);
424
484
  if (query.timeDimensions && query.timeDimensions.length > 0) {
425
485
  for (const td of query.timeDimensions) {
426
486
  const colExpr = this.resolveFieldSql(cube, td.dimension, tableName, joins);
@@ -428,11 +488,17 @@ var NativeSQLStrategy = class {
428
488
  const range = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
429
489
  if (range.length === 2) {
430
490
  const td2 = this.resolveStorageTarget(cube, td.dimension, tableName);
431
- params.push(
432
- this.coerceTemporal(ctx, td2, range[0]),
433
- this.coerceTemporal(ctx, td2, range[1])
434
- );
435
- whereClauses.push(`${colExpr} BETWEEN $${params.length - 1} AND $${params.length}`);
491
+ const column = this.temporalColumn(ctx, td2, colExpr);
492
+ const nextDay = (0, import_core.nextUtcCalendarDay)(range[1]);
493
+ params.push(this.coerceTemporal(ctx, td2, range[0]));
494
+ const lower = `${column} >= $${params.length}`;
495
+ if (nextDay != null) {
496
+ params.push(this.coerceTemporal(ctx, td2, nextDay));
497
+ whereClauses.push(`(${lower} AND ${column} < $${params.length})`);
498
+ } else {
499
+ params.push(this.coerceTemporal(ctx, td2, range[1]));
500
+ whereClauses.push(`(${lower} AND ${column} <= $${params.length})`);
501
+ }
436
502
  }
437
503
  }
438
504
  }
@@ -530,6 +596,7 @@ var NativeSQLStrategy = class {
530
596
  }
531
597
  return rawSql;
532
598
  }
599
+ if (!IDENTIFIER_PATH.test(rawSql)) return rawSql;
533
600
  const segments = rawSql.split(".");
534
601
  const column = segments[segments.length - 1];
535
602
  const hops = segments.slice(0, -1);
@@ -589,24 +656,19 @@ var NativeSQLStrategy = class {
589
656
  }
590
657
  resolveMeasureSql(cube, member, parentTable, joins) {
591
658
  const measure = this.lookupMember(cube, member, "measure");
592
- if (!measure) return `COUNT(*)`;
593
- const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
594
- switch (measure.type) {
595
- case "count":
596
- return "COUNT(*)";
597
- case "sum":
598
- return `SUM(${col})`;
599
- case "avg":
600
- return `AVG(${col})`;
601
- case "min":
602
- return `MIN(${col})`;
603
- case "max":
604
- return `MAX(${col})`;
605
- case "count_distinct":
606
- return `COUNT(DISTINCT ${col})`;
607
- default:
608
- return `COUNT(*)`;
659
+ if (!measure) {
660
+ const declared = Object.keys(cube.measures ?? {});
661
+ throw new Error(
662
+ `[native-sql-strategy] cube "${cube.name}" declares no measure "${member}"` + (declared.length ? ` (declared: ${declared.join(", ")})` : " (it declares none)")
663
+ );
609
664
  }
665
+ const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
666
+ const wrap = AGGREGATE_SQL[measure.type];
667
+ if (wrap) return wrap(col);
668
+ if (EXPRESSION_METRIC_TYPES.has(measure.type)) return col;
669
+ throw new Error(
670
+ `[native-sql-strategy] measure "${member}" on cube "${cube.name}" has unrecognised type "${measure.type}" \u2014 expected an aggregate (${SUPPORTED_AGGREGATE_SQL_KEYS.join(", ")}) or a custom-expression type (${[...EXPRESSION_METRIC_TYPES].join(", ")}).`
671
+ );
610
672
  }
611
673
  resolveFieldSql(cube, member, parentTable, joins) {
612
674
  const dim = this.lookupMember(cube, member, "dimension");
@@ -659,7 +721,53 @@ var NativeSQLStrategy = class {
659
721
  }
660
722
  return coerceFilterValueForSql(value);
661
723
  }
662
- buildFilterClause(col, operator, values, params, ctx, target) {
724
+ /**
725
+ * The column side of {@link coerceTemporal}: normalise the reference so it
726
+ * reads in the storage form the comparand was coerced into.
727
+ *
728
+ * A SQLite `Field.datetime` column carries an INTEGER epoch (a `Date` write)
729
+ * and ISO TEXT (a REST/JSON write, a `NOW()` default — including the platform's
730
+ * own `created_at`) at the SAME time, so coercing the value alone fixes one half
731
+ * and empties the other. That is #3912: a `dateRange: last_30_days` on
732
+ * `created_date` read 0 with 29 rows in range. Every other column and dialect
733
+ * gets its reference back verbatim.
734
+ */
735
+ temporalColumn(ctx, target, col) {
736
+ if (typeof ctx.coerceTemporalFilterColumn !== "function") return col;
737
+ return ctx.coerceTemporalFilterColumn(target.object, target.field, col) || col;
738
+ }
739
+ /**
740
+ * Compile a normalized filter node into a boolean SQL expression, recursing
741
+ * through the combinators. `null` = no constraint.
742
+ *
743
+ * Leaves go through {@link buildFilterClause} exactly as they did when this
744
+ * was a flat loop, so the storage-form coercion and the calendar-day
745
+ * upper-bound rule (#3777) apply at every depth — including inside an `$or`,
746
+ * where a second, combinator-aware implementation would have been free to
747
+ * drift from the first.
748
+ *
749
+ * Parenthesisation is explicit rather than left to SQL's precedence: `AND`
750
+ * does bind tighter than `OR`, so `a AND b OR c` happens to be right, but
751
+ * being right by construction is what keeps a future edit from making it
752
+ * wrong.
753
+ */
754
+ compileFilterNode(node, cube, parentTable, joins, params, ctx) {
755
+ if (!node) return null;
756
+ if (node.kind === "leaf") {
757
+ const colExpr = this.resolveFieldSql(cube, node.member, parentTable, joins);
758
+ const target = this.resolveStorageTarget(cube, node.member, parentTable);
759
+ return this.buildFilterClause(colExpr, node.operator, node.values, params, ctx, target);
760
+ }
761
+ if (node.kind === "not") {
762
+ const inner = this.compileFilterNode(node.child, cube, parentTable, joins, params, ctx);
763
+ return inner ? `NOT (${inner})` : null;
764
+ }
765
+ const parts = node.children.map((child) => this.compileFilterNode(child, cube, parentTable, joins, params, ctx)).filter((s) => !!s);
766
+ if (parts.length === 0) return null;
767
+ if (parts.length === 1) return parts[0];
768
+ return `(${parts.join(node.kind === "or" ? " OR " : " AND ")})`;
769
+ }
770
+ buildFilterClause(rawCol, operator, values, params, ctx, target) {
663
771
  const opMap = {
664
772
  equals: "=",
665
773
  notEquals: "!=",
@@ -668,26 +776,42 @@ var NativeSQLStrategy = class {
668
776
  lt: "<",
669
777
  lte: "<=",
670
778
  contains: "LIKE",
671
- notContains: "NOT LIKE"
779
+ notContains: "NOT LIKE",
780
+ startsWith: "LIKE",
781
+ endsWith: "LIKE"
672
782
  };
673
- if (operator === "set") return `${col} IS NOT NULL`;
674
- if (operator === "notSet") return `${col} IS NULL`;
783
+ const likePattern = {
784
+ contains: (v) => `%${v}%`,
785
+ notContains: (v) => `%${v}%`,
786
+ startsWith: (v) => `${v}%`,
787
+ endsWith: (v) => `%${v}`
788
+ };
789
+ if (operator === "set") return `${rawCol} IS NOT NULL`;
790
+ if (operator === "notSet") return `${rawCol} IS NULL`;
675
791
  if (operator === "in" || operator === "notIn") {
676
792
  if (!values || values.length === 0) return null;
677
793
  const placeholders = values.map((v) => {
678
794
  params.push(this.coerceTemporal(ctx, target, v));
679
795
  return `$${params.length}`;
680
796
  }).join(", ");
681
- return `${col} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
797
+ return `${this.temporalColumn(ctx, target, rawCol)} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
682
798
  }
683
799
  const sqlOp = opMap[operator];
684
800
  if (!sqlOp || !values || values.length === 0) return null;
685
- if (operator === "contains" || operator === "notContains") {
686
- params.push(`%${values[0]}%`);
687
- } else {
688
- params.push(this.coerceTemporal(ctx, target, values[0]));
801
+ const pattern = likePattern[operator];
802
+ if (pattern) {
803
+ params.push(pattern(values[0]));
804
+ return `${rawCol} ${sqlOp} $${params.length}`;
689
805
  }
690
- return `${col} ${sqlOp} $${params.length}`;
806
+ if (operator === "lte") {
807
+ const nextDay = (0, import_core.nextUtcCalendarDay)(values[0]);
808
+ if (nextDay != null) {
809
+ params.push(this.coerceTemporal(ctx, target, nextDay));
810
+ return `${this.temporalColumn(ctx, target, rawCol)} < $${params.length}`;
811
+ }
812
+ }
813
+ params.push(this.coerceTemporal(ctx, target, values[0]));
814
+ return `${this.temporalColumn(ctx, target, rawCol)} ${sqlOp} $${params.length}`;
691
815
  }
692
816
  extractObjectName(cube) {
693
817
  return cube.sql.trim();
@@ -709,6 +833,9 @@ var NativeSQLStrategy = class {
709
833
  }
710
834
  };
711
835
 
836
+ // src/strategies/objectql-strategy.ts
837
+ var import_core2 = require("@objectstack/core");
838
+
712
839
  // src/strategies/cross-object-rebucket.ts
713
840
  var RECOMBINABLE_METHODS = /* @__PURE__ */ new Set([
714
841
  "sum",
@@ -811,11 +938,7 @@ var ObjectQLStrategy = class {
811
938
  }
812
939
  const filter = {};
813
940
  const conjuncts = [];
814
- for (const f of normalizeAnalyticsFilters(query)) {
815
- const fieldName = this.resolveFieldName(cube, f.member, "any");
816
- const extra = this.mergeFilterOperand(filter, fieldName, this.convertFilter(f.operator, f.values));
817
- if (extra) conjuncts.push(extra);
818
- }
941
+ this.applyFilterNode(normalizeAnalyticsFilterTree(query), cube, filter, conjuncts);
819
942
  for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
820
943
  const extra = this.mergeFilterOperand(filter, field, bounds);
821
944
  if (extra) conjuncts.push(extra);
@@ -847,11 +970,9 @@ var ObjectQLStrategy = class {
847
970
  });
848
971
  const mappedRows = rows.map((row) => {
849
972
  const mapped = {};
850
- if (query.dimensions) {
851
- for (const dim of query.dimensions) {
852
- const shortName = this.resolveFieldName(cube, dim, "dimension");
853
- if (shortName in row) mapped[dim] = row[shortName];
854
- }
973
+ for (const dim of this.projectedDimensions(query)) {
974
+ const shortName = this.resolveFieldName(cube, dim, "dimension");
975
+ if (shortName in row) mapped[dim] = row[shortName];
855
976
  }
856
977
  if (query.measures) {
857
978
  for (const m of query.measures) {
@@ -897,7 +1018,7 @@ var ObjectQLStrategy = class {
897
1018
  }
898
1019
  const tableName = this.extractObjectName(cube);
899
1020
  const plan = this.planCrossObject(cube, query, Object.fromEntries(
900
- normalizeAnalyticsFilters(query).map((f) => [this.resolveFieldName(cube, f.member, "any"), true])
1021
+ collectFilterLeaves(normalizeAnalyticsFilterTree(query)).map((f) => [this.resolveFieldName(cube, f.member, "any"), true])
901
1022
  ));
902
1023
  const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
903
1024
  const joinClauses = [];
@@ -934,18 +1055,18 @@ var ObjectQLStrategy = class {
934
1055
  }
935
1056
  }
936
1057
  const whereParts = [];
937
- for (const f of normalizeAnalyticsFilters(query)) {
938
- const clause = this.buildFilterClauseSql(
939
- this.resolveFieldName(cube, f.member, "any"),
940
- f.operator,
941
- f.values,
942
- params
943
- );
944
- if (clause) whereParts.push(clause);
945
- }
1058
+ const filterClause = this.renderFilterNodeSql(
1059
+ normalizeAnalyticsFilterTree(query),
1060
+ cube,
1061
+ params
1062
+ );
1063
+ if (filterClause) whereParts.push(filterClause);
946
1064
  for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
947
- params.push(bounds.$gte, bounds.$lte);
948
- whereParts.push(`${field} BETWEEN $${params.length - 1} AND $${params.length}`);
1065
+ const nextDay = (0, import_core2.nextUtcCalendarDay)(bounds.$lte);
1066
+ params.push(bounds.$gte, nextDay ?? bounds.$lte);
1067
+ whereParts.push(
1068
+ `(${field} >= $${params.length - 1} AND ${field} ${nextDay ? "<" : "<="} $${params.length})`
1069
+ );
949
1070
  }
950
1071
  const scope = ctx.getReadScope?.(tableName);
951
1072
  if (scope != null) {
@@ -1119,7 +1240,7 @@ var ObjectQLStrategy = class {
1119
1240
  const merged = rebucketCrossObject(baseRows, baseDimFields, resolvedDims, measures);
1120
1241
  const mappedRows = merged.map((row) => {
1121
1242
  const out = {};
1122
- for (const dim of query.dimensions ?? []) {
1243
+ for (const dim of this.projectedDimensions(query)) {
1123
1244
  if (crossByDim.has(dim)) {
1124
1245
  if (dim in row) out[dim] = row[dim];
1125
1246
  } else {
@@ -1127,11 +1248,6 @@ var ObjectQLStrategy = class {
1127
1248
  if (field in row) out[dim] = row[field];
1128
1249
  }
1129
1250
  }
1130
- for (const td of query.timeDimensions ?? []) {
1131
- if (query.dimensions?.includes(td.dimension)) continue;
1132
- const field = this.resolveFieldName(cube, td.dimension, "dimension");
1133
- if (field in row) out[td.dimension] = row[field];
1134
- }
1135
1251
  for (const m of query.measures ?? []) {
1136
1252
  if (m in row) out[m] = row[m];
1137
1253
  }
@@ -1274,6 +1390,75 @@ var ObjectQLStrategy = class {
1274
1390
  * are handed back for the caller to AND in separately, so the engine
1275
1391
  * intersects them instead of the strategy picking a winner.
1276
1392
  */
1393
+ /**
1394
+ * Fold a normalized filter node into the engine filter being built.
1395
+ *
1396
+ * AND-ed LEAVES merge per field through {@link mergeFilterOperand}, exactly
1397
+ * as the flat loop this replaced did — so a query without combinators still
1398
+ * produces byte-identical engine input. Anything structural (`$or`, `$not`,
1399
+ * a nested `$and` that cannot merge) becomes its own conjunct, which the
1400
+ * caller ANDs in. The engine speaks these combinators natively
1401
+ * (`FilterCondition` declares them and every driver compiles them), so this
1402
+ * path hands them over rather than lowering them.
1403
+ */
1404
+ applyFilterNode(node, cube, filter, conjuncts) {
1405
+ if (!node) return;
1406
+ if (node.kind === "leaf") {
1407
+ const fieldName = this.resolveFieldName(cube, node.member, "any");
1408
+ const extra = this.mergeFilterOperand(filter, fieldName, this.convertFilter(node.operator, node.values));
1409
+ if (extra) conjuncts.push(extra);
1410
+ return;
1411
+ }
1412
+ if (node.kind === "and") {
1413
+ for (const child of node.children) this.applyFilterNode(child, cube, filter, conjuncts);
1414
+ return;
1415
+ }
1416
+ const rendered = this.filterNodeToCondition(node, cube);
1417
+ if (rendered) conjuncts.push(rendered);
1418
+ }
1419
+ /** A node as a standalone `FilterCondition` the engine can consume. */
1420
+ filterNodeToCondition(node, cube) {
1421
+ if (!node) return null;
1422
+ if (node.kind === "not") {
1423
+ const inner = this.filterNodeToCondition(node.child, cube);
1424
+ return inner ? { $not: inner } : null;
1425
+ }
1426
+ if (node.kind === "or") {
1427
+ const branches = node.children.map((child) => this.filterNodeToCondition(child, cube)).filter((c) => !!c);
1428
+ return branches.length > 0 ? { $or: branches } : null;
1429
+ }
1430
+ const filter = {};
1431
+ const conjuncts = [];
1432
+ this.applyFilterNode(node, cube, filter, conjuncts);
1433
+ if (conjuncts.length > 0) {
1434
+ filter.$and = [...Array.isArray(filter.$and) ? filter.$and : [], ...conjuncts];
1435
+ }
1436
+ return Object.keys(filter).length > 0 ? filter : null;
1437
+ }
1438
+ /**
1439
+ * Render a normalized filter node as the display SQL `/analytics/sql`
1440
+ * echoes. Values still bind as `$n` placeholders — the echo travels to the
1441
+ * browser, so a comparand is never inlined.
1442
+ */
1443
+ renderFilterNodeSql(node, cube, params) {
1444
+ if (!node) return null;
1445
+ if (node.kind === "leaf") {
1446
+ return this.buildFilterClauseSql(
1447
+ this.resolveFieldName(cube, node.member, "any"),
1448
+ node.operator,
1449
+ node.values,
1450
+ params
1451
+ );
1452
+ }
1453
+ if (node.kind === "not") {
1454
+ const inner = this.renderFilterNodeSql(node.child, cube, params);
1455
+ return inner ? `NOT (${inner})` : null;
1456
+ }
1457
+ const parts = node.children.map((child) => this.renderFilterNodeSql(child, cube, params)).filter((s) => !!s);
1458
+ if (parts.length === 0) return null;
1459
+ if (parts.length === 1) return parts[0];
1460
+ return `(${parts.join(node.kind === "or" ? " OR " : " AND ")})`;
1461
+ }
1277
1462
  mergeFilterOperand(filter, field, operand) {
1278
1463
  const existing = filter[field];
1279
1464
  if (existing === void 0) {
@@ -1297,9 +1482,13 @@ var ObjectQLStrategy = class {
1297
1482
  * HERE on every driver — and "bucketed trend" is precisely the shape that also
1298
1483
  * carries a range ("last 12 months", "this quarter").
1299
1484
  *
1300
- * Bounds are inclusive on both ends — the same `$gte`/`$lte` pair
1301
- * `NativeSQLStrategy` binds as `BETWEEN` and the memory driver builds as a
1302
- * `$match`, so one dashboard reads the same on every driver.
1485
+ * Bounds are inclusive on both ends — logically "from day X through day Y".
1486
+ * The `$lte` end is left as the bare calendar day on purpose: the driver's
1487
+ * filter compiler owns the calendar-day instant translation, compiling a
1488
+ * bare-day `$lte` on a `datetime` column into the half-open `< nextDay`
1489
+ * (#3777) while a `date` column keeps the plain `<=`. `NativeSQLStrategy`
1490
+ * performs the same half-open translation itself because it binds into raw
1491
+ * SQL, so one dashboard reads the same on every driver.
1303
1492
  *
1304
1493
  * Comparands are coerced by the SAME helper the `where` path uses, so an
1305
1494
  * epoch-ms bound recovers as a number and an ISO string stays a string. No
@@ -1359,24 +1548,60 @@ var ObjectQLStrategy = class {
1359
1548
  return { $lte: v0 };
1360
1549
  case "contains":
1361
1550
  return { $regex: values[0] };
1551
+ // `notContains` had no arm and fell to the `default` below, which returns
1552
+ // a BARE VALUE — i.e. `{field: 'x'}`, an equality. "does not contain x"
1553
+ // was compiled as "equals x". These three pass through as the canonical
1554
+ // spec operators every driver implements directly, so an anchored match
1555
+ // stays anchored rather than depending on regex dialect (#4128).
1556
+ case "notContains":
1557
+ return { $notContains: values[0] };
1558
+ case "startsWith":
1559
+ return { $startsWith: values[0] };
1560
+ case "endsWith":
1561
+ return { $endsWith: values[0] };
1362
1562
  case "in":
1363
1563
  return { $in: all };
1364
1564
  case "notIn":
1365
1565
  return { $nin: all };
1366
1566
  default:
1367
- return v0;
1567
+ throw new Error(
1568
+ `[analytics] ObjectQL strategy cannot express filter operator "${operator}". Treating it as an equality would silently query something the author did not ask for.`
1569
+ );
1368
1570
  }
1369
1571
  }
1370
1572
  extractObjectName(cube) {
1371
1573
  return cube.sql.trim();
1372
1574
  }
1575
+ /**
1576
+ * The dimensions this query PROJECTS, in the order the result carries them:
1577
+ * every `dimensions` entry, then every granular `timeDimensions` entry that
1578
+ * is not already one of them.
1579
+ *
1580
+ * `timeDimensions` is not merely a filter carrier. An entry with a
1581
+ * `granularity` is GROUPED BY — see the `td.granularity` sites that build
1582
+ * groupBy here, in `generateSql` and in the cross-object path — so its
1583
+ * bucket is a COLUMN of the result; an entry without one only contributes a
1584
+ * `dateRange` predicate and must NOT be projected.
1585
+ *
1586
+ * Grouping, row mapping and {@link buildFieldMeta} have to agree on exactly
1587
+ * that set. When they did not, a bucketed query returned rows carrying only
1588
+ * the measures and a `fields` list that never mentioned the bucket — a trend
1589
+ * chart got N values and no x-axis (#4033) — even though the SQL had
1590
+ * selected `date_trunc(…) AS "<dim>"` all along. One definition, every
1591
+ * consumer.
1592
+ */
1593
+ projectedDimensions(query) {
1594
+ const out = [...query.dimensions ?? []];
1595
+ for (const td of query.timeDimensions ?? []) {
1596
+ if (td.granularity && !out.includes(td.dimension)) out.push(td.dimension);
1597
+ }
1598
+ return out;
1599
+ }
1373
1600
  buildFieldMeta(query, cube) {
1374
1601
  const fields = [];
1375
- if (query.dimensions) {
1376
- for (const dim of query.dimensions) {
1377
- const d = this.lookupMember(cube, dim, "dimension");
1378
- fields.push({ name: dim, type: d?.type || "string" });
1379
- }
1602
+ for (const dim of this.projectedDimensions(query)) {
1603
+ const d = this.lookupMember(cube, dim, "dimension");
1604
+ fields.push({ name: dim, type: d?.type || "string" });
1380
1605
  }
1381
1606
  if (query.measures) {
1382
1607
  for (const m of query.measures) {
@@ -1388,14 +1613,16 @@ var ObjectQLStrategy = class {
1388
1613
  };
1389
1614
 
1390
1615
  // src/dataset-compiler.ts
1616
+ var import_data = require("@objectstack/spec/data");
1391
1617
  var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set(["array_agg", "string_agg"]);
1618
+ var SUPPORTED_AGGREGATES = import_data.AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
1392
1619
  function aggregateToMetricType(m) {
1393
1620
  if (!m.aggregate) {
1394
1621
  throw new Error(`[dataset-compiler] non-derived measure "${m.name}" has no aggregate`);
1395
1622
  }
1396
1623
  if (UNSUPPORTED_AGGREGATES.has(m.aggregate)) {
1397
1624
  throw new Error(
1398
- `[dataset-compiler] measure "${m.name}" uses aggregate "${m.aggregate}" which is not supported by the v1 dataset runtime (supported: count, sum, avg, min, max, count_distinct).`
1625
+ `[dataset-compiler] measure "${m.name}" uses aggregate "${m.aggregate}" which is not supported by the v1 dataset runtime (supported: ${SUPPORTED_AGGREGATES.join(", ")}).`
1399
1626
  );
1400
1627
  }
1401
1628
  return m.aggregate;
@@ -1522,10 +1749,10 @@ function compileDataset(dataset, resolver) {
1522
1749
  }
1523
1750
 
1524
1751
  // src/dataset-executor.ts
1525
- var import_core = require("@objectstack/core");
1752
+ var import_core3 = require("@objectstack/core");
1526
1753
  function resolveSelectionTokens(compiled, selection, context) {
1527
- const tokenCtx = (0, import_core.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
1528
- const resolve = (v) => (0, import_core.resolveFilterTokens)(v, tokenCtx);
1754
+ const tokenCtx = (0, import_core3.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
1755
+ const resolve = (v) => (0, import_core3.resolveFilterTokens)(v, tokenCtx);
1529
1756
  const filter = resolve(compiled.filter);
1530
1757
  const measureFilters = resolve(compiled.measureFilters);
1531
1758
  const runtimeFilter = resolve(selection.runtimeFilter);
@@ -1622,7 +1849,7 @@ function applyWindow(rows, limit, offset) {
1622
1849
  if (start === 0 && limit == null) return rows;
1623
1850
  return rows.slice(start, limit != null ? start + limit : void 0);
1624
1851
  }
1625
- function resolveOrdering(selection, dimensions) {
1852
+ function resolveOrdering(selection, dimensions, timeDimensions = []) {
1626
1853
  const order = selection.order;
1627
1854
  if (order && Object.keys(order).length > 0) {
1628
1855
  const selectable = /* @__PURE__ */ new Set([
@@ -1641,6 +1868,10 @@ function resolveOrdering(selection, dimensions) {
1641
1868
  if ((selection.limit != null || selection.offset != null) && dimensions.length > 0) {
1642
1869
  return Object.fromEntries(dimensions.map((d) => [d, "asc"]));
1643
1870
  }
1871
+ const timeKeys = timeDimensions.filter((d) => dimensions.includes(d));
1872
+ if (timeKeys.length > 0) {
1873
+ return Object.fromEntries(timeKeys.map((d) => [d, "asc"]));
1874
+ }
1644
1875
  return void 0;
1645
1876
  }
1646
1877
  function parseUTC(date) {
@@ -1734,7 +1965,7 @@ var DatasetExecutor = class {
1734
1965
  }
1735
1966
  const baseFilter = combineFilters(compiled.filter, selection.runtimeFilter);
1736
1967
  const dimensions = selection.dimensions ?? [];
1737
- const order = resolveOrdering(selection, dimensions);
1968
+ const order = resolveOrdering(selection, dimensions, this.timeDimensionsOf(compiled, dimensions));
1738
1969
  const labelOrderKeys = this.orderLabels ? Object.keys(order ?? {}).filter(
1739
1970
  (k) => dimensions.includes(k) && this.orderLabels.isLabelBearing(k)
1740
1971
  ) : [];
@@ -1790,6 +2021,19 @@ var DatasetExecutor = class {
1790
2021
  result.rows = applyWindow(result.rows, selection.limit, selection.offset);
1791
2022
  return result;
1792
2023
  }
2024
+ /**
2025
+ * The selected dimensions the compiled cube types as `time`, in selection
2026
+ * order (#3916) — the axis {@link resolveOrdering} defaults to ascending.
2027
+ *
2028
+ * Membership is decided by the DIMENSION's declared type, not by whether the
2029
+ * selection happens to bucket it: a `date` dimension left ungranulated groups
2030
+ * raw timestamps, and those want chronological order every bit as much as
2031
+ * month buckets do. (Both sort correctly — `compareValues` compares Dates and
2032
+ * ISO strings chronologically, and bucket keys are minted sort-stable.)
2033
+ */
2034
+ timeDimensionsOf(compiled, dimensions) {
2035
+ return dimensions.filter((d) => compiled.cube.dimensions[d]?.type === "time");
2036
+ }
1793
2037
  buildQuery(compiled, opts) {
1794
2038
  const q = {
1795
2039
  cube: compiled.cube.name,
@@ -2031,11 +2275,21 @@ function pickDisplayField(fields) {
2031
2275
  }
2032
2276
 
2033
2277
  // src/preview-evaluator.ts
2034
- var import_core2 = require("@objectstack/core");
2278
+ var import_core4 = require("@objectstack/core");
2035
2279
  function compare(a, b) {
2036
2280
  if (typeof a === "number" && typeof b === "number") return a - b;
2281
+ if (a instanceof Date || b instanceof Date) {
2282
+ const ai = (0, import_core4.utcInstantMs)(a);
2283
+ const bi = (0, import_core4.utcInstantMs)(b);
2284
+ if (ai !== null && bi !== null) return ai - bi;
2285
+ }
2037
2286
  return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0;
2038
2287
  }
2288
+ function lteBound(value, bound) {
2289
+ const nextDay = (0, import_core4.nextUtcCalendarDay)(bound);
2290
+ if (nextDay != null) return compare(value, nextDay) < 0;
2291
+ return compare(value, bound) <= 0;
2292
+ }
2039
2293
  function matchOp(value, op, expected) {
2040
2294
  switch (op) {
2041
2295
  case "$eq":
@@ -2048,8 +2302,16 @@ function matchOp(value, op, expected) {
2048
2302
  return value != null && compare(value, expected) >= 0;
2049
2303
  case "$lt":
2050
2304
  return value != null && compare(value, expected) < 0;
2051
- case "$lte":
2052
- return value != null && compare(value, expected) <= 0;
2305
+ case "$lte": {
2306
+ if (value == null) return false;
2307
+ return lteBound(value, expected);
2308
+ }
2309
+ case "$between": {
2310
+ if (value == null || !Array.isArray(expected) || expected.length !== 2) return false;
2311
+ const [min, max] = expected;
2312
+ if (min == null || max == null) return false;
2313
+ return compare(value, min) >= 0 && lteBound(value, max);
2314
+ }
2053
2315
  case "$in":
2054
2316
  return Array.isArray(expected) && expected.some((e) => value === e || String(value) === String(e));
2055
2317
  case "$nin":
@@ -2082,7 +2344,7 @@ function matchesWhere(row, where) {
2082
2344
  function bucketDate(value, granularity, timezone) {
2083
2345
  const d = new Date(String(value));
2084
2346
  if (Number.isNaN(d.getTime())) return null;
2085
- const { year: y, month, day: dayNum } = (0, import_core2.calendarPartsInTzOrUtc)(d, timezone);
2347
+ const { year: y, month, day: dayNum } = (0, import_core4.calendarPartsInTzOrUtc)(d, timezone);
2086
2348
  const m = `${month}`.padStart(2, "0");
2087
2349
  const day = `${dayNum}`.padStart(2, "0");
2088
2350
  switch (granularity) {
@@ -2136,7 +2398,9 @@ function evaluateAnalyticsQueryOverRows(query, cube, rows) {
2136
2398
  const [start, end] = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
2137
2399
  filtered = filtered.filter((r) => {
2138
2400
  const v = String(r[field] ?? "");
2139
- return v >= String(start) && v <= `${end}~`;
2401
+ const nextDay = (0, import_core4.nextUtcCalendarDay)(end);
2402
+ const inUpper = nextDay != null ? v < nextDay : v <= `${end}~`;
2403
+ return v >= String(start) && inUpper;
2140
2404
  });
2141
2405
  }
2142
2406
  const dimensions = query.dimensions ?? [];
@@ -2206,7 +2470,7 @@ var AnalyticsService = class {
2206
2470
  this.datasetRegistry = /* @__PURE__ */ new Map();
2207
2471
  /** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */
2208
2472
  this.warnedNoObjectRegistry = false;
2209
- this.logger = config.logger || (0, import_core3.createLogger)({ level: "info", format: "pretty" });
2473
+ this.logger = config.logger || (0, import_core5.createLogger)({ level: "info", format: "pretty" });
2210
2474
  this.cubeRegistry = new CubeRegistry();
2211
2475
  if (config.cubes) {
2212
2476
  this.cubeRegistry.registerAll(config.cubes);
@@ -2236,6 +2500,7 @@ var AnalyticsService = class {
2236
2500
  // fall back to any explicitly-configured provider for legacy cubes.
2237
2501
  getAllowedRelationships: (cubeName) => this.datasetRegistry.get(cubeName)?.allowedRelationships ?? config.getAllowedRelationships?.(cubeName),
2238
2502
  coerceTemporalFilterValue: config.coerceTemporalFilterValue,
2503
+ coerceTemporalFilterColumn: config.coerceTemporalFilterColumn,
2239
2504
  isExternalObject: config.isExternalObject
2240
2505
  };
2241
2506
  const builtIn = [
@@ -2439,11 +2704,11 @@ var AnalyticsService = class {
2439
2704
  else if (rangeTz === "UTC") rangeDims.push({ d, granularity, instant: false });
2440
2705
  }
2441
2706
  if (rangeDims.length && result.rows.length) {
2442
- const bound = (ymd, instant) => instant ? new Date((0, import_core3.zonedDateStartToUtcMs)(ymd, rangeTz)).toISOString() : ymd;
2707
+ const bound = (ymd, instant) => instant ? new Date((0, import_core5.zonedDateStartToUtcMs)(ymd, rangeTz)).toISOString() : ymd;
2443
2708
  result.drillRanges = result.rows.map((row) => {
2444
2709
  const ranges = {};
2445
2710
  for (const { d, granularity, instant } of rangeDims) {
2446
- const cal = (0, import_core3.bucketKeyToCalendarRange)(row[d.name], granularity);
2711
+ const cal = (0, import_core5.bucketKeyToCalendarRange)(row[d.name], granularity);
2447
2712
  if (cal) {
2448
2713
  ranges[d.name] = { field: d.field, gte: bound(cal.start, instant), lt: bound(cal.end, instant) };
2449
2714
  }
@@ -2723,6 +2988,11 @@ var FallbackDelegateStrategy = class {
2723
2988
  var AnalyticsServicePlugin = class {
2724
2989
  constructor(options = {}) {
2725
2990
  this.name = "com.objectstack.service-analytics";
2991
+ /**
2992
+ * Services init() registers on every path (ADR-0116, #4131) — lets the
2993
+ * kernel name this plugin when a consumer requires one before it inits.
2994
+ */
2995
+ this.providesServices = ["analytics"];
2726
2996
  this.version = "1.0.0";
2727
2997
  this.type = "standard";
2728
2998
  this.dependencies = [];
@@ -2925,6 +3195,17 @@ var AnalyticsServicePlugin = class {
2925
3195
  }
2926
3196
  return value;
2927
3197
  };
3198
+ const coerceTemporalFilterColumn = (objectName, fieldName, columnSql) => {
3199
+ try {
3200
+ const svc = ctx.getService("data");
3201
+ const driver = svc?.getDriverForObject?.(objectName);
3202
+ if (driver && typeof driver.temporalFilterColumnSql === "function") {
3203
+ return driver.temporalFilterColumnSql(objectName, fieldName, columnSql);
3204
+ }
3205
+ } catch {
3206
+ }
3207
+ return columnSql;
3208
+ };
2928
3209
  const config = {
2929
3210
  cubes: this.options.cubes,
2930
3211
  logger: ctx.logger,
@@ -2935,6 +3216,7 @@ var AnalyticsServicePlugin = class {
2935
3216
  getReadScope,
2936
3217
  getAllowedRelationships: this.options.getAllowedRelationships,
2937
3218
  coerceTemporalFilterValue,
3219
+ coerceTemporalFilterColumn,
2938
3220
  relationshipResolver,
2939
3221
  labelResolver,
2940
3222
  // ADR-0053 — source-field currency metadata for the measure currency chain.