@objectstack/service-analytics 16.1.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.js CHANGED
@@ -128,7 +128,8 @@ var MONGO_TO_CUBE_OP = {
128
128
  $nin: "notIn",
129
129
  $contains: "contains",
130
130
  $notContains: "notContains",
131
- $exists: "set"
131
+ $startsWith: "startsWith",
132
+ $endsWith: "endsWith"
132
133
  };
133
134
  function stringifyForCube(v) {
134
135
  if (v == null) return "";
@@ -137,55 +138,102 @@ function stringifyForCube(v) {
137
138
  if (typeof v === "object") return JSON.stringify(v);
138
139
  return String(v);
139
140
  }
140
- function flattenCondition(cond, out) {
141
- for (const [key, raw] of Object.entries(cond)) {
142
- if (raw === void 0) continue;
143
- if (key === "$and" && Array.isArray(raw)) {
144
- for (const sub of raw) {
145
- if (sub && typeof sub === "object") {
146
- flattenCondition(sub, out);
141
+ function andOf(children) {
142
+ if (children.length === 0) return null;
143
+ if (children.length === 1) return children[0];
144
+ return { kind: "and", children };
145
+ }
146
+ function fieldLeaves(key, raw) {
147
+ const out = [];
148
+ const leaf = (operator, values) => {
149
+ out.push({ kind: "leaf", member: key, operator, values });
150
+ };
151
+ if (raw === null) {
152
+ leaf("notSet", []);
153
+ return out;
154
+ }
155
+ if (typeof raw === "object" && !Array.isArray(raw) && !(raw instanceof Date)) {
156
+ const wrapper = raw;
157
+ const opKeys = Object.keys(wrapper).filter((k) => k.startsWith("$"));
158
+ if (opKeys.length > 0) {
159
+ for (const opKey of opKeys) {
160
+ if (opKey === "$between") {
161
+ const v2 = wrapper[opKey];
162
+ if (!Array.isArray(v2) || v2.length !== 2) {
163
+ throw new Error(
164
+ `[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.`
165
+ );
166
+ }
167
+ leaf("gte", [stringifyForCube(v2[0])]);
168
+ leaf("lte", [stringifyForCube(v2[1])]);
169
+ continue;
147
170
  }
171
+ if (opKey === "$null" || opKey === "$exists") {
172
+ const isNull = opKey === "$null" ? wrapper[opKey] === true : wrapper[opKey] === false;
173
+ leaf(isNull ? "notSet" : "set", []);
174
+ continue;
175
+ }
176
+ const cubeOp = MONGO_TO_CUBE_OP[opKey];
177
+ if (!cubeOp) {
178
+ throw new Error(
179
+ `[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.`
180
+ );
181
+ }
182
+ const v = wrapper[opKey];
183
+ leaf(cubeOp, Array.isArray(v) ? v.map(stringifyForCube) : [stringifyForCube(v)]);
148
184
  }
149
- continue;
185
+ return out;
150
186
  }
151
- if (key === "$or" || key === "$not") continue;
152
- if (raw === null) {
153
- out.push({ member: key, operator: "notSet", values: [] });
154
- continue;
187
+ for (const [nestedKey, nestedVal] of Object.entries(wrapper)) {
188
+ out.push(...fieldLeaves(`${key}.${nestedKey}`, nestedVal));
155
189
  }
156
- if (typeof raw === "object" && !Array.isArray(raw) && !(raw instanceof Date)) {
157
- const wrapper = raw;
158
- const opKeys = Object.keys(wrapper).filter((k) => k.startsWith("$"));
159
- if (opKeys.length > 0) {
160
- for (const opKey of opKeys) {
161
- const cubeOp = MONGO_TO_CUBE_OP[opKey];
162
- if (!cubeOp) continue;
163
- const v = wrapper[opKey];
164
- const values = Array.isArray(v) ? v.map(stringifyForCube) : [stringifyForCube(v)];
165
- out.push({ member: key, operator: cubeOp, values });
166
- }
167
- continue;
168
- }
169
- for (const [nestedKey, nestedVal] of Object.entries(wrapper)) {
170
- flattenCondition({ [`${key}.${nestedKey}`]: nestedVal }, out);
190
+ return out;
191
+ }
192
+ if (Array.isArray(raw)) leaf("in", raw.map(stringifyForCube));
193
+ else leaf("equals", [stringifyForCube(raw)]);
194
+ return out;
195
+ }
196
+ function buildNode(cond) {
197
+ const children = [];
198
+ for (const [key, raw] of Object.entries(cond)) {
199
+ if (raw === void 0) continue;
200
+ if (key === "$and" || key === "$or") {
201
+ if (!Array.isArray(raw) || raw.length === 0) {
202
+ throw new Error(
203
+ `[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.`
204
+ );
171
205
  }
206
+ const branches = raw.map((sub) => sub && typeof sub === "object" ? buildNode(sub) : null).filter((n) => n !== null);
207
+ if (branches.length === 0) continue;
208
+ if (key === "$and") children.push(...branches);
209
+ else children.push(branches.length === 1 ? branches[0] : { kind: "or", children: branches });
172
210
  continue;
173
211
  }
174
- if (Array.isArray(raw)) {
175
- out.push({ member: key, operator: "in", values: raw.map(stringifyForCube) });
176
- } else {
177
- out.push({ member: key, operator: "equals", values: [stringifyForCube(raw)] });
212
+ if (key === "$not") {
213
+ const inner = raw && typeof raw === "object" ? buildNode(raw) : null;
214
+ if (inner) children.push({ kind: "not", child: inner });
215
+ continue;
178
216
  }
217
+ if (key.startsWith("$")) {
218
+ throw new Error(
219
+ `[analytics] Unsupported top-level filter operator "${key}". Dropping it would silently widen the query to rows the filter excludes.`
220
+ );
221
+ }
222
+ children.push(...fieldLeaves(key, raw));
179
223
  }
224
+ return andOf(children);
180
225
  }
181
- function normalizeAnalyticsFilters(query) {
182
- if (!query || typeof query !== "object") return [];
183
- const out = [];
226
+ function normalizeAnalyticsFilterTree(query) {
227
+ if (!query || typeof query !== "object") return null;
184
228
  const where = query.where;
185
- if (where && typeof where === "object" && !Array.isArray(where)) {
186
- flattenCondition(where, out);
187
- }
188
- return out;
229
+ if (!where || typeof where !== "object" || Array.isArray(where)) return null;
230
+ return buildNode(where);
231
+ }
232
+ function collectFilterLeaves(node) {
233
+ if (!node) return [];
234
+ if (node.kind === "leaf") return [{ member: node.member, operator: node.operator, values: node.values }];
235
+ if (node.kind === "not") return collectFilterLeaves(node.child);
236
+ return node.children.flatMap(collectFilterLeaves);
189
237
  }
190
238
  function recoverNumber(s) {
191
239
  if (/^-?\d+(\.\d+)?$/.test(s)) {
@@ -317,6 +365,18 @@ function compileOperator(col, op, val, field, params) {
317
365
  }
318
366
 
319
367
  // src/strategies/native-sql-strategy.ts
368
+ import { nextUtcCalendarDay } from "@objectstack/core";
369
+ var AGGREGATE_SQL = {
370
+ "count": () => "COUNT(*)",
371
+ "sum": (col) => `SUM(${col})`,
372
+ "avg": (col) => `AVG(${col})`,
373
+ "min": (col) => `MIN(${col})`,
374
+ "max": (col) => `MAX(${col})`,
375
+ "count_distinct": (col) => `COUNT(DISTINCT ${col})`
376
+ };
377
+ var SUPPORTED_AGGREGATE_SQL_KEYS = Object.keys(AGGREGATE_SQL);
378
+ var EXPRESSION_METRIC_TYPES = /* @__PURE__ */ new Set(["number", "string", "boolean"]);
379
+ var IDENTIFIER_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
320
380
  var NativeSQLStrategy = class {
321
381
  constructor() {
322
382
  this.name = "NativeSQLStrategy";
@@ -371,15 +431,15 @@ var NativeSQLStrategy = class {
371
431
  }
372
432
  }
373
433
  const whereClauses = [];
374
- const normalizedFilters = normalizeAnalyticsFilters(query);
375
- if (normalizedFilters.length > 0) {
376
- for (const filter of normalizedFilters) {
377
- const colExpr = this.resolveFieldSql(cube, filter.member, tableName, joins);
378
- const target = this.resolveStorageTarget(cube, filter.member, tableName);
379
- const clause = this.buildFilterClause(colExpr, filter.operator, filter.values, params, ctx, target);
380
- if (clause) whereClauses.push(clause);
381
- }
382
- }
434
+ const filterSql = this.compileFilterNode(
435
+ normalizeAnalyticsFilterTree(query),
436
+ cube,
437
+ tableName,
438
+ joins,
439
+ params,
440
+ ctx
441
+ );
442
+ if (filterSql) whereClauses.push(filterSql);
383
443
  if (query.timeDimensions && query.timeDimensions.length > 0) {
384
444
  for (const td of query.timeDimensions) {
385
445
  const colExpr = this.resolveFieldSql(cube, td.dimension, tableName, joins);
@@ -387,11 +447,17 @@ var NativeSQLStrategy = class {
387
447
  const range = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
388
448
  if (range.length === 2) {
389
449
  const td2 = this.resolveStorageTarget(cube, td.dimension, tableName);
390
- params.push(
391
- this.coerceTemporal(ctx, td2, range[0]),
392
- this.coerceTemporal(ctx, td2, range[1])
393
- );
394
- whereClauses.push(`${colExpr} BETWEEN $${params.length - 1} AND $${params.length}`);
450
+ const column = this.temporalColumn(ctx, td2, colExpr);
451
+ const nextDay = nextUtcCalendarDay(range[1]);
452
+ params.push(this.coerceTemporal(ctx, td2, range[0]));
453
+ const lower = `${column} >= $${params.length}`;
454
+ if (nextDay != null) {
455
+ params.push(this.coerceTemporal(ctx, td2, nextDay));
456
+ whereClauses.push(`(${lower} AND ${column} < $${params.length})`);
457
+ } else {
458
+ params.push(this.coerceTemporal(ctx, td2, range[1]));
459
+ whereClauses.push(`(${lower} AND ${column} <= $${params.length})`);
460
+ }
395
461
  }
396
462
  }
397
463
  }
@@ -489,6 +555,7 @@ var NativeSQLStrategy = class {
489
555
  }
490
556
  return rawSql;
491
557
  }
558
+ if (!IDENTIFIER_PATH.test(rawSql)) return rawSql;
492
559
  const segments = rawSql.split(".");
493
560
  const column = segments[segments.length - 1];
494
561
  const hops = segments.slice(0, -1);
@@ -548,24 +615,19 @@ var NativeSQLStrategy = class {
548
615
  }
549
616
  resolveMeasureSql(cube, member, parentTable, joins) {
550
617
  const measure = this.lookupMember(cube, member, "measure");
551
- if (!measure) return `COUNT(*)`;
552
- const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
553
- switch (measure.type) {
554
- case "count":
555
- return "COUNT(*)";
556
- case "sum":
557
- return `SUM(${col})`;
558
- case "avg":
559
- return `AVG(${col})`;
560
- case "min":
561
- return `MIN(${col})`;
562
- case "max":
563
- return `MAX(${col})`;
564
- case "count_distinct":
565
- return `COUNT(DISTINCT ${col})`;
566
- default:
567
- return `COUNT(*)`;
618
+ if (!measure) {
619
+ const declared = Object.keys(cube.measures ?? {});
620
+ throw new Error(
621
+ `[native-sql-strategy] cube "${cube.name}" declares no measure "${member}"` + (declared.length ? ` (declared: ${declared.join(", ")})` : " (it declares none)")
622
+ );
568
623
  }
624
+ const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
625
+ const wrap = AGGREGATE_SQL[measure.type];
626
+ if (wrap) return wrap(col);
627
+ if (EXPRESSION_METRIC_TYPES.has(measure.type)) return col;
628
+ throw new Error(
629
+ `[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(", ")}).`
630
+ );
569
631
  }
570
632
  resolveFieldSql(cube, member, parentTable, joins) {
571
633
  const dim = this.lookupMember(cube, member, "dimension");
@@ -618,7 +680,53 @@ var NativeSQLStrategy = class {
618
680
  }
619
681
  return coerceFilterValueForSql(value);
620
682
  }
621
- buildFilterClause(col, operator, values, params, ctx, target) {
683
+ /**
684
+ * The column side of {@link coerceTemporal}: normalise the reference so it
685
+ * reads in the storage form the comparand was coerced into.
686
+ *
687
+ * A SQLite `Field.datetime` column carries an INTEGER epoch (a `Date` write)
688
+ * and ISO TEXT (a REST/JSON write, a `NOW()` default — including the platform's
689
+ * own `created_at`) at the SAME time, so coercing the value alone fixes one half
690
+ * and empties the other. That is #3912: a `dateRange: last_30_days` on
691
+ * `created_date` read 0 with 29 rows in range. Every other column and dialect
692
+ * gets its reference back verbatim.
693
+ */
694
+ temporalColumn(ctx, target, col) {
695
+ if (typeof ctx.coerceTemporalFilterColumn !== "function") return col;
696
+ return ctx.coerceTemporalFilterColumn(target.object, target.field, col) || col;
697
+ }
698
+ /**
699
+ * Compile a normalized filter node into a boolean SQL expression, recursing
700
+ * through the combinators. `null` = no constraint.
701
+ *
702
+ * Leaves go through {@link buildFilterClause} exactly as they did when this
703
+ * was a flat loop, so the storage-form coercion and the calendar-day
704
+ * upper-bound rule (#3777) apply at every depth — including inside an `$or`,
705
+ * where a second, combinator-aware implementation would have been free to
706
+ * drift from the first.
707
+ *
708
+ * Parenthesisation is explicit rather than left to SQL's precedence: `AND`
709
+ * does bind tighter than `OR`, so `a AND b OR c` happens to be right, but
710
+ * being right by construction is what keeps a future edit from making it
711
+ * wrong.
712
+ */
713
+ compileFilterNode(node, cube, parentTable, joins, params, ctx) {
714
+ if (!node) return null;
715
+ if (node.kind === "leaf") {
716
+ const colExpr = this.resolveFieldSql(cube, node.member, parentTable, joins);
717
+ const target = this.resolveStorageTarget(cube, node.member, parentTable);
718
+ return this.buildFilterClause(colExpr, node.operator, node.values, params, ctx, target);
719
+ }
720
+ if (node.kind === "not") {
721
+ const inner = this.compileFilterNode(node.child, cube, parentTable, joins, params, ctx);
722
+ return inner ? `NOT (${inner})` : null;
723
+ }
724
+ const parts = node.children.map((child) => this.compileFilterNode(child, cube, parentTable, joins, params, ctx)).filter((s) => !!s);
725
+ if (parts.length === 0) return null;
726
+ if (parts.length === 1) return parts[0];
727
+ return `(${parts.join(node.kind === "or" ? " OR " : " AND ")})`;
728
+ }
729
+ buildFilterClause(rawCol, operator, values, params, ctx, target) {
622
730
  const opMap = {
623
731
  equals: "=",
624
732
  notEquals: "!=",
@@ -627,26 +735,42 @@ var NativeSQLStrategy = class {
627
735
  lt: "<",
628
736
  lte: "<=",
629
737
  contains: "LIKE",
630
- notContains: "NOT LIKE"
738
+ notContains: "NOT LIKE",
739
+ startsWith: "LIKE",
740
+ endsWith: "LIKE"
631
741
  };
632
- if (operator === "set") return `${col} IS NOT NULL`;
633
- if (operator === "notSet") return `${col} IS NULL`;
742
+ const likePattern = {
743
+ contains: (v) => `%${v}%`,
744
+ notContains: (v) => `%${v}%`,
745
+ startsWith: (v) => `${v}%`,
746
+ endsWith: (v) => `%${v}`
747
+ };
748
+ if (operator === "set") return `${rawCol} IS NOT NULL`;
749
+ if (operator === "notSet") return `${rawCol} IS NULL`;
634
750
  if (operator === "in" || operator === "notIn") {
635
751
  if (!values || values.length === 0) return null;
636
752
  const placeholders = values.map((v) => {
637
753
  params.push(this.coerceTemporal(ctx, target, v));
638
754
  return `$${params.length}`;
639
755
  }).join(", ");
640
- return `${col} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
756
+ return `${this.temporalColumn(ctx, target, rawCol)} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
641
757
  }
642
758
  const sqlOp = opMap[operator];
643
759
  if (!sqlOp || !values || values.length === 0) return null;
644
- if (operator === "contains" || operator === "notContains") {
645
- params.push(`%${values[0]}%`);
646
- } else {
647
- params.push(this.coerceTemporal(ctx, target, values[0]));
760
+ const pattern = likePattern[operator];
761
+ if (pattern) {
762
+ params.push(pattern(values[0]));
763
+ return `${rawCol} ${sqlOp} $${params.length}`;
764
+ }
765
+ if (operator === "lte") {
766
+ const nextDay = nextUtcCalendarDay(values[0]);
767
+ if (nextDay != null) {
768
+ params.push(this.coerceTemporal(ctx, target, nextDay));
769
+ return `${this.temporalColumn(ctx, target, rawCol)} < $${params.length}`;
770
+ }
648
771
  }
649
- return `${col} ${sqlOp} $${params.length}`;
772
+ params.push(this.coerceTemporal(ctx, target, values[0]));
773
+ return `${this.temporalColumn(ctx, target, rawCol)} ${sqlOp} $${params.length}`;
650
774
  }
651
775
  extractObjectName(cube) {
652
776
  return cube.sql.trim();
@@ -669,6 +793,72 @@ var NativeSQLStrategy = class {
669
793
  };
670
794
 
671
795
  // src/strategies/objectql-strategy.ts
796
+ import { nextUtcCalendarDay as nextUtcCalendarDay2 } from "@objectstack/core";
797
+
798
+ // src/strategies/cross-object-rebucket.ts
799
+ var RECOMBINABLE_METHODS = /* @__PURE__ */ new Set([
800
+ "sum",
801
+ "count",
802
+ "min",
803
+ "max"
804
+ ]);
805
+ var RESTRICTED_BUCKET = "(restricted)";
806
+ function orderableValue(v) {
807
+ if (v == null) return NaN;
808
+ if (typeof v === "number") return v;
809
+ if (v instanceof Date) return v.getTime();
810
+ const n = Number(v);
811
+ if (Number.isFinite(n)) return n;
812
+ return Date.parse(String(v));
813
+ }
814
+ function recombine(method, acc, next) {
815
+ if (method === "min" || method === "max") {
816
+ if (acc === void 0) return next ?? 0;
817
+ const a = orderableValue(acc);
818
+ const n2 = orderableValue(next);
819
+ if (Number.isNaN(n2)) return acc;
820
+ if (Number.isNaN(a)) return next;
821
+ const nextWins = method === "min" ? n2 < a : n2 > a;
822
+ return nextWins ? next : acc;
823
+ }
824
+ const n = Number(next ?? 0);
825
+ return acc === void 0 ? n : Number(acc) + n;
826
+ }
827
+ function rebucketCrossObject(baseRows, baseDimFields, crossDims, measures) {
828
+ const buckets = /* @__PURE__ */ new Map();
829
+ for (const row of baseRows) {
830
+ const resolved = {};
831
+ for (const cd of crossDims) {
832
+ const fk = row[cd.fkField];
833
+ resolved[cd.outputName] = cd.fkToAttr.has(fk) ? cd.fkToAttr.get(fk) : RESTRICTED_BUCKET;
834
+ }
835
+ const keyParts = [];
836
+ for (const f of baseDimFields) keyParts.push(`${f}=${JSON.stringify(row[f] ?? null)}`);
837
+ for (const cd of crossDims) keyParts.push(`${cd.outputName}=${String(resolved[cd.outputName])}`);
838
+ const key = keyParts.join("");
839
+ let bucket = buckets.get(key);
840
+ if (!bucket) {
841
+ bucket = {};
842
+ for (const f of baseDimFields) bucket[f] = row[f];
843
+ for (const cd of crossDims) bucket[cd.outputName] = resolved[cd.outputName];
844
+ buckets.set(key, bucket);
845
+ }
846
+ for (const m of measures) {
847
+ bucket[m.alias] = recombine(m.method, bucket[m.alias], row[m.alias]);
848
+ }
849
+ }
850
+ return [...buckets.values()];
851
+ }
852
+
853
+ // src/strategies/objectql-strategy.ts
854
+ var SCALAR_SQL_OPS = {
855
+ equals: "=",
856
+ notEquals: "!=",
857
+ gt: ">",
858
+ gte: ">=",
859
+ lt: "<",
860
+ lte: "<="
861
+ };
672
862
  var ObjectQLStrategy = class {
673
863
  constructor() {
674
864
  this.name = "ObjectQLStrategy";
@@ -706,15 +896,18 @@ var ObjectQLStrategy = class {
706
896
  }
707
897
  }
708
898
  const filter = {};
709
- const normalizedFilters = normalizeAnalyticsFilters(query);
710
- if (normalizedFilters.length > 0) {
711
- for (const f of normalizedFilters) {
712
- const fieldName = this.resolveFieldName(cube, f.member, "any");
713
- const converted = this.convertFilter(f.operator, f.values);
714
- const existing = filter[fieldName];
715
- const mergeable = (v) => !!v && typeof v === "object" && !Array.isArray(v);
716
- filter[fieldName] = mergeable(existing) && mergeable(converted) ? { ...existing, ...converted } : converted;
717
- }
899
+ const conjuncts = [];
900
+ this.applyFilterNode(normalizeAnalyticsFilterTree(query), cube, filter, conjuncts);
901
+ for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
902
+ const extra = this.mergeFilterOperand(filter, field, bounds);
903
+ if (extra) conjuncts.push(extra);
904
+ }
905
+ if (conjuncts.length > 0) {
906
+ filter.$and = [...Array.isArray(filter.$and) ? filter.$and : [], ...conjuncts];
907
+ }
908
+ const plan = this.planCrossObject(cube, query, filter);
909
+ if (plan) {
910
+ return this.executeCrossObject(cube, query, aggregations, filter, plan, ctx);
718
911
  }
719
912
  const rows = await ctx.executeAggregate(objectName, {
720
913
  // Structured groupBy items ({field, dateGranularity}) pass through the
@@ -722,19 +915,23 @@ var ObjectQLStrategy = class {
722
915
  // contract types groupBy as string[]; the cast carries the richer shape.
723
916
  groupBy: groupBy.length > 0 ? groupBy : void 0,
724
917
  aggregations: aggregations.length > 0 ? aggregations : void 0,
725
- filter: Object.keys(filter).length > 0 ? filter : void 0,
918
+ filter: this.withReadScope(objectName, filter, ctx),
726
919
  // ADR-0053 Phase 2 (D2): forward the reference tz so date buckets resolve
727
920
  // on that zone's calendar days. A non-UTC zone makes the engine bucket
728
921
  // in-memory (uniform across drivers); UTC/unset keeps the DB fast path.
729
- timezone: query.timezone
922
+ timezone: query.timezone,
923
+ // ADR-0021 D-C (#3602): the second belt. `withReadScope` above is this
924
+ // layer's own scoping; handing the engine the context makes ITS middleware
925
+ // inject RLS too, so a future strategy that forgets `withReadScope` still
926
+ // cannot read across tenants. Without it the operation reaches the engine
927
+ // principal-less and plugin-security falls open — the #3597 shape.
928
+ context: ctx.context
730
929
  });
731
930
  const mappedRows = rows.map((row) => {
732
931
  const mapped = {};
733
- if (query.dimensions) {
734
- for (const dim of query.dimensions) {
735
- const shortName = this.resolveFieldName(cube, dim, "dimension");
736
- if (shortName in row) mapped[dim] = row[shortName];
737
- }
932
+ for (const dim of this.projectedDimensions(query)) {
933
+ const shortName = this.resolveFieldName(cube, dim, "dimension");
934
+ if (shortName in row) mapped[dim] = row[shortName];
738
935
  }
739
936
  if (query.measures) {
740
937
  for (const m of query.measures) {
@@ -744,8 +941,28 @@ var ObjectQLStrategy = class {
744
941
  return mapped;
745
942
  });
746
943
  const fields = this.buildFieldMeta(query, cube);
747
- return { rows: mappedRows, fields };
944
+ let sql;
945
+ try {
946
+ sql = (await this.generateSql(query, ctx)).sql;
947
+ } catch {
948
+ sql = void 0;
949
+ }
950
+ return sql ? { rows: mappedRows, fields, sql } : { rows: mappedRows, fields };
748
951
  }
952
+ /**
953
+ * Render a REPRESENTATIVE SQL string for an ObjectQL aggregate query.
954
+ *
955
+ * This path executes through `engine.aggregate()`, not raw SQL, so the string
956
+ * is documentation rather than the literal statement — but it must be an
957
+ * honest account of what the query does, because dataset responses echo it
958
+ * and authors read it to verify their widget options landed (#3588). It
959
+ * therefore renders date bucketing (`date_trunc`), the WHERE predicate,
960
+ * ordering, and the row window.
961
+ *
962
+ * Filter VALUES are rendered as `$n` placeholders and returned in `params`,
963
+ * never inlined: the echoed statement travels to the browser, and a filter
964
+ * comparand can carry tenant data.
965
+ */
749
966
  async generateSql(query, ctx) {
750
967
  const cube = ctx.getCube(query.cube);
751
968
  if (!cube) {
@@ -753,28 +970,302 @@ var ObjectQLStrategy = class {
753
970
  }
754
971
  const selectParts = [];
755
972
  const groupByParts = [];
973
+ const params = [];
974
+ const granByDim = /* @__PURE__ */ new Map();
975
+ for (const td of query.timeDimensions ?? []) {
976
+ if (td.granularity) granByDim.set(td.dimension, td.granularity);
977
+ }
978
+ const tableName = this.extractObjectName(cube);
979
+ const plan = this.planCrossObject(cube, query, Object.fromEntries(
980
+ collectFilterLeaves(normalizeAnalyticsFilterTree(query)).map((f) => [this.resolveFieldName(cube, f.member, "any"), true])
981
+ ));
982
+ const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
983
+ const joinClauses = [];
984
+ const dimExpr = (dim) => {
985
+ const cd = crossByDim.get(dim);
986
+ if (cd) {
987
+ joinClauses.push(
988
+ `LEFT JOIN "${cd.refObject}" ON "${tableName}"."${cd.fkField}" = "${cd.refObject}"."id"`
989
+ );
990
+ return `"${cd.refObject}"."${cd.attr}"`;
991
+ }
992
+ const col = this.resolveFieldName(cube, dim, "dimension");
993
+ const gran = granByDim.get(dim);
994
+ return gran ? `date_trunc('${gran}', ${col})` : col;
995
+ };
756
996
  if (query.dimensions) {
757
997
  for (const dim of query.dimensions) {
758
- const col = this.resolveFieldName(cube, dim, "dimension");
759
- selectParts.push(`${col} AS "${dim}"`);
760
- groupByParts.push(col);
998
+ const expr = dimExpr(dim);
999
+ selectParts.push(`${expr} AS "${dim}"`);
1000
+ groupByParts.push(expr);
761
1001
  }
762
1002
  }
1003
+ for (const [dim] of granByDim) {
1004
+ if (query.dimensions?.includes(dim)) continue;
1005
+ const expr = dimExpr(dim);
1006
+ selectParts.push(`${expr} AS "${dim}"`);
1007
+ groupByParts.push(expr);
1008
+ }
763
1009
  if (query.measures) {
764
1010
  for (const m of query.measures) {
765
1011
  const { field, method } = this.resolveMeasureAggregation(cube, m);
766
- const aggSql = method === "count" ? "COUNT(*)" : `${method.toUpperCase()}(${field})`;
1012
+ const aggSql = method === "count" ? "COUNT(*)" : method === "count_distinct" ? `COUNT(DISTINCT ${field})` : `${method.toUpperCase()}(${field})`;
767
1013
  selectParts.push(`${aggSql} AS "${m}"`);
768
1014
  }
769
1015
  }
770
- const tableName = this.extractObjectName(cube);
1016
+ const whereParts = [];
1017
+ const filterClause = this.renderFilterNodeSql(
1018
+ normalizeAnalyticsFilterTree(query),
1019
+ cube,
1020
+ params
1021
+ );
1022
+ if (filterClause) whereParts.push(filterClause);
1023
+ for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
1024
+ const nextDay = nextUtcCalendarDay2(bounds.$lte);
1025
+ params.push(bounds.$gte, nextDay ?? bounds.$lte);
1026
+ whereParts.push(
1027
+ `(${field} >= $${params.length - 1} AND ${field} ${nextDay ? "<" : "<="} $${params.length})`
1028
+ );
1029
+ }
1030
+ const scope = ctx.getReadScope?.(tableName);
1031
+ if (scope != null) {
1032
+ const { sql: scopeSql, params: scopeParams } = compileScopedFilterToSql(scope, tableName);
1033
+ if (scopeSql) {
1034
+ let i = 0;
1035
+ const rendered = scopeSql.replace(/\?/g, () => {
1036
+ params.push(scopeParams[i++]);
1037
+ return `$${params.length}`;
1038
+ });
1039
+ whereParts.push(`(${rendered})`);
1040
+ }
1041
+ }
771
1042
  let sql = `SELECT ${selectParts.join(", ")} FROM "${tableName}"`;
1043
+ if (joinClauses.length > 0) sql += " " + joinClauses.join(" ");
1044
+ if (whereParts.length > 0) {
1045
+ sql += ` WHERE ${whereParts.join(" AND ")}`;
1046
+ }
772
1047
  if (groupByParts.length > 0) {
773
1048
  sql += ` GROUP BY ${groupByParts.join(", ")}`;
774
1049
  }
775
- return { sql, params: [] };
1050
+ if (query.order && Object.keys(query.order).length > 0) {
1051
+ const orderClauses = Object.entries(query.order).map(([f, d]) => `"${f}" ${d.toUpperCase()}`);
1052
+ sql += ` ORDER BY ${orderClauses.join(", ")}`;
1053
+ }
1054
+ if (query.limit != null) sql += ` LIMIT ${query.limit}`;
1055
+ if (query.offset != null) sql += ` OFFSET ${query.offset}`;
1056
+ return { sql, params };
776
1057
  }
777
1058
  // ── Helpers ──────────────────────────────────────────────────────
1059
+ /**
1060
+ * ADR-0021 D-C (#3597) — AND the object's read scope (tenant + RLS) into the
1061
+ * filter handed to `engine.aggregate`.
1062
+ *
1063
+ * This path used to drop the scope entirely, and the engine could not make up
1064
+ * for it: the aggregate bridge passes no `ExecutionContext`, so the security
1065
+ * middleware's principal-less fall-open skipped its own RLS injection. Both
1066
+ * belts were off at once — an authenticated caller received aggregates
1067
+ * computed over EVERY tenant's rows.
1068
+ *
1069
+ * Composed with `$and`, never by key merge: the query's own filter and the
1070
+ * scope can name the SAME field (e.g. a dashboard filtering `organization_id`),
1071
+ * and a spread would let caller input silently overwrite the security
1072
+ * predicate. `$and` makes that structurally impossible.
1073
+ */
1074
+ withReadScope(objectName, filter, ctx) {
1075
+ const userFilter = Object.keys(filter).length > 0 ? filter : void 0;
1076
+ if (typeof ctx.getReadScope !== "function") return userFilter;
1077
+ const scope = ctx.getReadScope(objectName);
1078
+ if (scope === void 0 || scope === null) return userFilter;
1079
+ const scopeFilter = scope;
1080
+ if (!userFilter) return scopeFilter;
1081
+ return { $and: [userFilter, scopeFilter] };
1082
+ }
1083
+ /** Is `field` a resolved cross-object (relationship-traversal) reference? */
1084
+ isCrossObjectField(cube, field, baseObject) {
1085
+ if (!field.includes(".")) return false;
1086
+ const alias = field.split(".")[0];
1087
+ const joinedObject = cube.joins?.[alias]?.name ?? alias;
1088
+ return joinedObject !== baseObject;
1089
+ }
1090
+ /**
1091
+ * Plan how to serve cross-object references on this join-less path (#3654).
1092
+ *
1093
+ * `engine.aggregate()` cannot join. A cross-object DIMENSION within a
1094
+ * supported envelope is served by an FK-expand (`executeCrossObject`): group
1095
+ * the base aggregate on the lookup FK, resolve the FK to the related attribute
1096
+ * with a SCOPED read, re-bucket in memory. Returns `null` for a base-only
1097
+ * query (direct path), a plan for an in-envelope cross-object query.
1098
+ *
1099
+ * THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
1100
+ * (needs a real join to evaluate), a MULTI-HOP dimension (`a.b.c`), or a
1101
+ * non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
1102
+ * cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
1103
+ * `generateSql()` calls this too, so the preview accepts/rejects the same set.
1104
+ *
1105
+ * Detection is on RESOLVED field names, so a dotted dimension the cube
1106
+ * flattens to a real column is treated as base, not cross-object.
1107
+ */
1108
+ planCrossObject(cube, query, filter) {
1109
+ const baseObject = this.extractObjectName(cube);
1110
+ for (const td of query.timeDimensions ?? []) {
1111
+ const field = this.resolveFieldName(cube, td.dimension, "dimension");
1112
+ if (this.isCrossObjectField(cube, field, baseObject)) {
1113
+ throw new Error(
1114
+ `[Analytics] ObjectQLStrategy cannot bucket a cross-object time dimension ("${field}").`
1115
+ );
1116
+ }
1117
+ }
1118
+ const nonDim = [
1119
+ ...(query.measures ?? []).map((m) => ({ where: "measure", field: this.resolveMeasureAggregation(cube, m).field })),
1120
+ ...Object.keys(filter).map((f) => ({ where: "filter", field: f }))
1121
+ ].filter((r) => this.isCrossObjectField(cube, r.field, baseObject));
1122
+ if (nonDim.length > 0) {
1123
+ throw new Error(
1124
+ `[Analytics] ObjectQLStrategy cannot evaluate a cross-object ${nonDim[0].where} ("${nonDim[0].field}") \u2014 the engine cannot join in an aggregate. Run this query on a native-SQL driver, or remove the cross-object ${nonDim[0].where}.`
1125
+ );
1126
+ }
1127
+ const crossDims = [];
1128
+ for (const dim of query.dimensions ?? []) {
1129
+ const field = this.resolveFieldName(cube, dim, "dimension");
1130
+ if (!this.isCrossObjectField(cube, field, baseObject)) continue;
1131
+ const [alias, ...rest] = field.split(".");
1132
+ const attr = rest.join(".");
1133
+ if (attr.includes(".")) {
1134
+ throw new Error(
1135
+ `[Analytics] ObjectQLStrategy supports only single-hop cross-object dimensions; "${field}" traverses more than one relationship.`
1136
+ );
1137
+ }
1138
+ crossDims.push({ outputName: dim, fkField: alias, attr, refObject: cube.joins?.[alias]?.name ?? alias });
1139
+ }
1140
+ if (crossDims.length === 0) return null;
1141
+ for (const m of query.measures ?? []) {
1142
+ const { method } = this.resolveMeasureAggregation(cube, m);
1143
+ if (!RECOMBINABLE_METHODS.has(method)) {
1144
+ throw new Error(
1145
+ `[Analytics] ObjectQLStrategy cannot group by a cross-object dimension with a "${method}" measure ("${m}") \u2014 its value cannot be recombined across the intermediate FK grouping. Use sum/count/min/max, or run on a native-SQL driver.`
1146
+ );
1147
+ }
1148
+ }
1149
+ return { crossDims };
1150
+ }
1151
+ /**
1152
+ * Serve a cross-object-dimension query by FK-expand (#3654). The pure
1153
+ * re-bucketing step lives in `cross-object-rebucket.ts`.
1154
+ */
1155
+ async executeCrossObject(cube, query, aggregations, filter, plan, ctx) {
1156
+ const baseObject = this.extractObjectName(cube);
1157
+ const crossByDim = new Map(plan.crossDims.map((cd) => [cd.outputName, cd]));
1158
+ const granByDim = /* @__PURE__ */ new Map();
1159
+ for (const td of query.timeDimensions ?? []) {
1160
+ if (td.granularity) granByDim.set(td.dimension, td.granularity);
1161
+ }
1162
+ const groupBy = [];
1163
+ const baseDimFields = [];
1164
+ for (const dim of query.dimensions ?? []) {
1165
+ const cd = crossByDim.get(dim);
1166
+ if (cd) {
1167
+ groupBy.push(cd.fkField);
1168
+ continue;
1169
+ }
1170
+ const field = this.resolveFieldName(cube, dim, "dimension");
1171
+ const gran = granByDim.get(dim);
1172
+ groupBy.push(gran ? { field, dateGranularity: gran } : field);
1173
+ baseDimFields.push(field);
1174
+ granByDim.delete(dim);
1175
+ }
1176
+ for (const [dim, gran] of granByDim) {
1177
+ const field = this.resolveFieldName(cube, dim, "dimension");
1178
+ groupBy.push({ field, dateGranularity: gran });
1179
+ baseDimFields.push(field);
1180
+ }
1181
+ const baseRows = await ctx.executeAggregate(baseObject, {
1182
+ groupBy: groupBy.length > 0 ? groupBy : void 0,
1183
+ aggregations: aggregations.length > 0 ? aggregations : void 0,
1184
+ filter: this.withReadScope(baseObject, filter, ctx),
1185
+ timezone: query.timezone,
1186
+ context: ctx.context
1187
+ });
1188
+ const resolvedDims = [];
1189
+ for (const cd of plan.crossDims) {
1190
+ const fkValues = [...new Set(baseRows.map((r) => r[cd.fkField]).filter((v) => v != null))];
1191
+ const fkToAttr = await this.resolveFkAttr(cd.refObject, cd.attr, fkValues, ctx);
1192
+ resolvedDims.push({ outputName: cd.outputName, fkField: cd.fkField, fkToAttr });
1193
+ }
1194
+ const measures = (query.measures ?? []).map((m) => ({
1195
+ alias: m,
1196
+ // planCrossObject already asserted every measure is recombinable.
1197
+ method: this.resolveMeasureAggregation(cube, m).method
1198
+ }));
1199
+ const merged = rebucketCrossObject(baseRows, baseDimFields, resolvedDims, measures);
1200
+ const mappedRows = merged.map((row) => {
1201
+ const out = {};
1202
+ for (const dim of this.projectedDimensions(query)) {
1203
+ if (crossByDim.has(dim)) {
1204
+ if (dim in row) out[dim] = row[dim];
1205
+ } else {
1206
+ const field = this.resolveFieldName(cube, dim, "dimension");
1207
+ if (field in row) out[dim] = row[field];
1208
+ }
1209
+ }
1210
+ for (const m of query.measures ?? []) {
1211
+ if (m in row) out[m] = row[m];
1212
+ }
1213
+ return out;
1214
+ });
1215
+ return { rows: mappedRows, fields: this.buildFieldMeta(query, cube) };
1216
+ }
1217
+ /**
1218
+ * Resolve `fkValues` (ids of `refObject`) to their `attr` values, applying the
1219
+ * referenced object's OWN read scope (#3654 / #3602). Reuses the aggregate
1220
+ * bridge — `group by (id, attr)` is one row per record. Ids the scope hides
1221
+ * are simply absent from the map (⇒ RESTRICTED bucket downstream).
1222
+ */
1223
+ async resolveFkAttr(refObject, attr, fkValues, ctx) {
1224
+ const map = /* @__PURE__ */ new Map();
1225
+ if (fkValues.length === 0 || typeof ctx.executeAggregate !== "function") return map;
1226
+ const idFilter = { id: { $in: fkValues } };
1227
+ const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
1228
+ const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
1229
+ const rows = await ctx.executeAggregate(refObject, {
1230
+ groupBy: ["id", attr],
1231
+ aggregations: [{ field: "id", method: "count", alias: "_c" }],
1232
+ filter,
1233
+ context: ctx.context
1234
+ });
1235
+ for (const r of rows) {
1236
+ if (r.id != null) map.set(r.id, r[attr]);
1237
+ }
1238
+ return map;
1239
+ }
1240
+ /**
1241
+ * Render one normalized filter as a display SQL predicate for `generateSql`.
1242
+ *
1243
+ * Mirrors `NativeSQLStrategy.buildFilterClause`'s operator vocabulary so the
1244
+ * two previews read alike, but binds through `coerceFilterValueForObjectQL`:
1245
+ * the comparand shown is the one THIS path actually hands the engine (a real
1246
+ * boolean, not SQL's 1/0). Returns null for an operator/value combination
1247
+ * that carries no predicate, matching `execute()`, which drops it too.
1248
+ */
1249
+ buildFilterClauseSql(col, operator, values, params) {
1250
+ if (operator === "set") return `${col} IS NOT NULL`;
1251
+ if (operator === "notSet") return `${col} IS NULL`;
1252
+ if (!values || values.length === 0) return null;
1253
+ if (operator === "in" || operator === "notIn") {
1254
+ const placeholders = values.map((v) => {
1255
+ params.push(coerceFilterValueForObjectQL(v));
1256
+ return `$${params.length}`;
1257
+ }).join(", ");
1258
+ return `${col} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
1259
+ }
1260
+ if (operator === "contains" || operator === "notContains") {
1261
+ params.push(`%${values[0]}%`);
1262
+ return `${col} ${operator === "contains" ? "LIKE" : "NOT LIKE"} $${params.length}`;
1263
+ }
1264
+ const op = SCALAR_SQL_OPS[operator];
1265
+ if (!op) return null;
1266
+ params.push(coerceFilterValueForObjectQL(values[0]));
1267
+ return `${col} ${op} $${params.length}`;
1268
+ }
778
1269
  /**
779
1270
  * Resolve a member ref to a `{ sql, type? }` definition.
780
1271
  *
@@ -839,6 +1330,162 @@ var ObjectQLStrategy = class {
839
1330
  }
840
1331
  return { field: "*", method: "count" };
841
1332
  }
1333
+ /**
1334
+ * AND one more operand onto `filter[field]`, merging operator objects rather
1335
+ * than overwriting them. Returns a standalone conjunct when the two cannot
1336
+ * share one entry, or `null` when the merge absorbed the operand.
1337
+ *
1338
+ * Every predicate this strategy contributes goes through here — the caller's
1339
+ * `where` and the time-dimension `dateRange` alike. Two operands on one field
1340
+ * are the normal case (`{$gte}` from a `where` plus `{$gte,$lte}` from a
1341
+ * window on `close_date`), and a plain assignment would keep only the last:
1342
+ * that is how a range used to lose a bound.
1343
+ *
1344
+ * Spreading is sound only while the operands name DIFFERENT operators. Where
1345
+ * they collide — two `$gte` bounds on one field, which a window makes routine
1346
+ * and which a `where` can already produce on its own through `$and` — the
1347
+ * spread keeps whichever came last and WIDENS the query. Same for a bare
1348
+ * equality meeting an operator object: neither can absorb the other. Those
1349
+ * are handed back for the caller to AND in separately, so the engine
1350
+ * intersects them instead of the strategy picking a winner.
1351
+ */
1352
+ /**
1353
+ * Fold a normalized filter node into the engine filter being built.
1354
+ *
1355
+ * AND-ed LEAVES merge per field through {@link mergeFilterOperand}, exactly
1356
+ * as the flat loop this replaced did — so a query without combinators still
1357
+ * produces byte-identical engine input. Anything structural (`$or`, `$not`,
1358
+ * a nested `$and` that cannot merge) becomes its own conjunct, which the
1359
+ * caller ANDs in. The engine speaks these combinators natively
1360
+ * (`FilterCondition` declares them and every driver compiles them), so this
1361
+ * path hands them over rather than lowering them.
1362
+ */
1363
+ applyFilterNode(node, cube, filter, conjuncts) {
1364
+ if (!node) return;
1365
+ if (node.kind === "leaf") {
1366
+ const fieldName = this.resolveFieldName(cube, node.member, "any");
1367
+ const extra = this.mergeFilterOperand(filter, fieldName, this.convertFilter(node.operator, node.values));
1368
+ if (extra) conjuncts.push(extra);
1369
+ return;
1370
+ }
1371
+ if (node.kind === "and") {
1372
+ for (const child of node.children) this.applyFilterNode(child, cube, filter, conjuncts);
1373
+ return;
1374
+ }
1375
+ const rendered = this.filterNodeToCondition(node, cube);
1376
+ if (rendered) conjuncts.push(rendered);
1377
+ }
1378
+ /** A node as a standalone `FilterCondition` the engine can consume. */
1379
+ filterNodeToCondition(node, cube) {
1380
+ if (!node) return null;
1381
+ if (node.kind === "not") {
1382
+ const inner = this.filterNodeToCondition(node.child, cube);
1383
+ return inner ? { $not: inner } : null;
1384
+ }
1385
+ if (node.kind === "or") {
1386
+ const branches = node.children.map((child) => this.filterNodeToCondition(child, cube)).filter((c) => !!c);
1387
+ return branches.length > 0 ? { $or: branches } : null;
1388
+ }
1389
+ const filter = {};
1390
+ const conjuncts = [];
1391
+ this.applyFilterNode(node, cube, filter, conjuncts);
1392
+ if (conjuncts.length > 0) {
1393
+ filter.$and = [...Array.isArray(filter.$and) ? filter.$and : [], ...conjuncts];
1394
+ }
1395
+ return Object.keys(filter).length > 0 ? filter : null;
1396
+ }
1397
+ /**
1398
+ * Render a normalized filter node as the display SQL `/analytics/sql`
1399
+ * echoes. Values still bind as `$n` placeholders — the echo travels to the
1400
+ * browser, so a comparand is never inlined.
1401
+ */
1402
+ renderFilterNodeSql(node, cube, params) {
1403
+ if (!node) return null;
1404
+ if (node.kind === "leaf") {
1405
+ return this.buildFilterClauseSql(
1406
+ this.resolveFieldName(cube, node.member, "any"),
1407
+ node.operator,
1408
+ node.values,
1409
+ params
1410
+ );
1411
+ }
1412
+ if (node.kind === "not") {
1413
+ const inner = this.renderFilterNodeSql(node.child, cube, params);
1414
+ return inner ? `NOT (${inner})` : null;
1415
+ }
1416
+ const parts = node.children.map((child) => this.renderFilterNodeSql(child, cube, params)).filter((s) => !!s);
1417
+ if (parts.length === 0) return null;
1418
+ if (parts.length === 1) return parts[0];
1419
+ return `(${parts.join(node.kind === "or" ? " OR " : " AND ")})`;
1420
+ }
1421
+ mergeFilterOperand(filter, field, operand) {
1422
+ const existing = filter[field];
1423
+ if (existing === void 0) {
1424
+ filter[field] = operand;
1425
+ return null;
1426
+ }
1427
+ const mergeable = (v) => !!v && typeof v === "object" && !Array.isArray(v);
1428
+ if (!mergeable(existing) || !mergeable(operand)) return { [field]: operand };
1429
+ if (Object.keys(operand).some((op) => op in existing)) return { [field]: operand };
1430
+ filter[field] = { ...existing, ...operand };
1431
+ return null;
1432
+ }
1433
+ /**
1434
+ * Lower `timeDimensions[].dateRange` into resolved-field bounds (#3650).
1435
+ *
1436
+ * `dateRange` states a WINDOW on a time dimension; it is a SIBLING of `where`,
1437
+ * never folded into it. `normalizeAnalyticsFilters` reads only `where`, so
1438
+ * this path used to drop the window on the floor — no error, just every row
1439
+ * ever recorded. Nor is that a corner case: `NativeSQLStrategy.canHandle`
1440
+ * declines any query carrying a `granularity`, so a date-bucketed trend lands
1441
+ * HERE on every driver — and "bucketed trend" is precisely the shape that also
1442
+ * carries a range ("last 12 months", "this quarter").
1443
+ *
1444
+ * Bounds are inclusive on both ends — logically "from day X through day Y".
1445
+ * The `$lte` end is left as the bare calendar day on purpose: the driver's
1446
+ * filter compiler owns the calendar-day → instant translation, compiling a
1447
+ * bare-day `$lte` on a `datetime` column into the half-open `< nextDay`
1448
+ * (#3777) while a `date` column keeps the plain `<=`. `NativeSQLStrategy`
1449
+ * performs the same half-open translation itself because it binds into raw
1450
+ * SQL, so one dashboard reads the same on every driver.
1451
+ *
1452
+ * Comparands are coerced by the SAME helper the `where` path uses, so an
1453
+ * epoch-ms bound recovers as a number and an ISO string stays a string. No
1454
+ * STORAGE coercion happens here, deliberately: `NativeSQLStrategy` needs
1455
+ * `coerceTemporal` because it binds into raw SQL and had to learn that a
1456
+ * SQLite `Field.datetime` is an INTEGER epoch (#2034); this path goes through
1457
+ * `engine.aggregate()`, where the driver's own CRUD filter coercion applies —
1458
+ * the very coercion that already makes a `where` bound on that same column
1459
+ * work today.
1460
+ *
1461
+ * A bare-string `dateRange` degenerates to the single point `[s, s]`, matching
1462
+ * `NativeSQLStrategy`. Relative phrases ("Last 7 days") are NOT resolved here;
1463
+ * neither SQL path resolves them, and inventing a second interpretation on the
1464
+ * driver-independent path is how the two would drift apart again.
1465
+ *
1466
+ * An oddly-sized array (the schema types `dateRange` as a plain `string[]`)
1467
+ * takes its first two entries, a one-entry array degenerating to a point.
1468
+ * `NativeSQLStrategy` drops such a window entirely — but "drop the window"
1469
+ * means "plot all of history", which is the very failure this fixes, so the
1470
+ * fallback here errs toward the narrower query instead.
1471
+ */
1472
+ dateRangeBounds(cube, query) {
1473
+ const out = [];
1474
+ for (const td of query.timeDimensions ?? []) {
1475
+ if (!td.dateRange) continue;
1476
+ const range = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
1477
+ const [start, end = start] = range;
1478
+ if (start == null) continue;
1479
+ out.push({
1480
+ field: this.resolveFieldName(cube, td.dimension, "dimension"),
1481
+ bounds: {
1482
+ $gte: coerceFilterValueForObjectQL(String(start)),
1483
+ $lte: coerceFilterValueForObjectQL(String(end))
1484
+ }
1485
+ });
1486
+ }
1487
+ return out;
1488
+ }
842
1489
  convertFilter(operator, values) {
843
1490
  if (operator === "set") return { $ne: null };
844
1491
  if (operator === "notSet") return null;
@@ -860,24 +1507,60 @@ var ObjectQLStrategy = class {
860
1507
  return { $lte: v0 };
861
1508
  case "contains":
862
1509
  return { $regex: values[0] };
1510
+ // `notContains` had no arm and fell to the `default` below, which returns
1511
+ // a BARE VALUE — i.e. `{field: 'x'}`, an equality. "does not contain x"
1512
+ // was compiled as "equals x". These three pass through as the canonical
1513
+ // spec operators every driver implements directly, so an anchored match
1514
+ // stays anchored rather than depending on regex dialect (#4128).
1515
+ case "notContains":
1516
+ return { $notContains: values[0] };
1517
+ case "startsWith":
1518
+ return { $startsWith: values[0] };
1519
+ case "endsWith":
1520
+ return { $endsWith: values[0] };
863
1521
  case "in":
864
1522
  return { $in: all };
865
1523
  case "notIn":
866
1524
  return { $nin: all };
867
1525
  default:
868
- return v0;
1526
+ throw new Error(
1527
+ `[analytics] ObjectQL strategy cannot express filter operator "${operator}". Treating it as an equality would silently query something the author did not ask for.`
1528
+ );
869
1529
  }
870
1530
  }
871
1531
  extractObjectName(cube) {
872
1532
  return cube.sql.trim();
873
1533
  }
1534
+ /**
1535
+ * The dimensions this query PROJECTS, in the order the result carries them:
1536
+ * every `dimensions` entry, then every granular `timeDimensions` entry that
1537
+ * is not already one of them.
1538
+ *
1539
+ * `timeDimensions` is not merely a filter carrier. An entry with a
1540
+ * `granularity` is GROUPED BY — see the `td.granularity` sites that build
1541
+ * groupBy here, in `generateSql` and in the cross-object path — so its
1542
+ * bucket is a COLUMN of the result; an entry without one only contributes a
1543
+ * `dateRange` predicate and must NOT be projected.
1544
+ *
1545
+ * Grouping, row mapping and {@link buildFieldMeta} have to agree on exactly
1546
+ * that set. When they did not, a bucketed query returned rows carrying only
1547
+ * the measures and a `fields` list that never mentioned the bucket — a trend
1548
+ * chart got N values and no x-axis (#4033) — even though the SQL had
1549
+ * selected `date_trunc(…) AS "<dim>"` all along. One definition, every
1550
+ * consumer.
1551
+ */
1552
+ projectedDimensions(query) {
1553
+ const out = [...query.dimensions ?? []];
1554
+ for (const td of query.timeDimensions ?? []) {
1555
+ if (td.granularity && !out.includes(td.dimension)) out.push(td.dimension);
1556
+ }
1557
+ return out;
1558
+ }
874
1559
  buildFieldMeta(query, cube) {
875
1560
  const fields = [];
876
- if (query.dimensions) {
877
- for (const dim of query.dimensions) {
878
- const d = this.lookupMember(cube, dim, "dimension");
879
- fields.push({ name: dim, type: d?.type || "string" });
880
- }
1561
+ for (const dim of this.projectedDimensions(query)) {
1562
+ const d = this.lookupMember(cube, dim, "dimension");
1563
+ fields.push({ name: dim, type: d?.type || "string" });
881
1564
  }
882
1565
  if (query.measures) {
883
1566
  for (const m of query.measures) {
@@ -889,14 +1572,16 @@ var ObjectQLStrategy = class {
889
1572
  };
890
1573
 
891
1574
  // src/dataset-compiler.ts
1575
+ import { AggregationFunction } from "@objectstack/spec/data";
892
1576
  var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set(["array_agg", "string_agg"]);
1577
+ var SUPPORTED_AGGREGATES = AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
893
1578
  function aggregateToMetricType(m) {
894
1579
  if (!m.aggregate) {
895
1580
  throw new Error(`[dataset-compiler] non-derived measure "${m.name}" has no aggregate`);
896
1581
  }
897
1582
  if (UNSUPPORTED_AGGREGATES.has(m.aggregate)) {
898
1583
  throw new Error(
899
- `[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).`
1584
+ `[dataset-compiler] measure "${m.name}" uses aggregate "${m.aggregate}" which is not supported by the v1 dataset runtime (supported: ${SUPPORTED_AGGREGATES.join(", ")}).`
900
1585
  );
901
1586
  }
902
1587
  return m.aggregate;
@@ -1023,6 +1708,23 @@ function compileDataset(dataset, resolver) {
1023
1708
  }
1024
1709
 
1025
1710
  // src/dataset-executor.ts
1711
+ import { filterTokenContextFrom, resolveFilterTokens } from "@objectstack/core";
1712
+ function resolveSelectionTokens(compiled, selection, context) {
1713
+ const tokenCtx = filterTokenContextFrom(context, /* @__PURE__ */ new Date());
1714
+ const resolve = (v) => resolveFilterTokens(v, tokenCtx);
1715
+ const filter = resolve(compiled.filter);
1716
+ const measureFilters = resolve(compiled.measureFilters);
1717
+ const runtimeFilter = resolve(selection.runtimeFilter);
1718
+ const timeDimensions = selection.timeDimensions?.map(
1719
+ (td) => td.dateRange == null ? td : { ...td, dateRange: resolve(td.dateRange) }
1720
+ );
1721
+ const compiledChanged = filter !== compiled.filter || measureFilters !== compiled.measureFilters;
1722
+ const selectionChanged = runtimeFilter !== selection.runtimeFilter || timeDimensions !== void 0 && timeDimensions.some((td, i) => td !== selection.timeDimensions[i]);
1723
+ return {
1724
+ compiled: compiledChanged ? { ...compiled, filter, measureFilters } : compiled,
1725
+ selection: selectionChanged ? { ...selection, runtimeFilter, timeDimensions } : selection
1726
+ };
1727
+ }
1026
1728
  function combineFilters(a, b) {
1027
1729
  if (a && b) return { $and: [a, b] };
1028
1730
  return a ?? b;
@@ -1061,6 +1763,76 @@ function computeDerived(d, row) {
1061
1763
  return null;
1062
1764
  }
1063
1765
  }
1766
+ function resolveDimensionGranularity(selection, dimension, datasetDefault) {
1767
+ const stated = (selection.timeDimensions ?? []).find((t) => t.dimension === dimension)?.granularity;
1768
+ if (stated) return stated;
1769
+ return selection.dateGranularity ?? datasetDefault;
1770
+ }
1771
+ function compareValues(a, b) {
1772
+ const aNull = a == null || a === "";
1773
+ const bNull = b == null || b === "";
1774
+ if (aNull || bNull) return aNull && bNull ? 0 : aNull ? 1 : -1;
1775
+ if (a instanceof Date || b instanceof Date) {
1776
+ return Number(a instanceof Date ? a.getTime() : a) - Number(b instanceof Date ? b.getTime() : b);
1777
+ }
1778
+ if (typeof a === "boolean" || typeof b === "boolean") {
1779
+ return Number(a) - Number(b);
1780
+ }
1781
+ const an = typeof a === "number" ? a : Number(a);
1782
+ const bn = typeof b === "number" ? b : Number(b);
1783
+ if (Number.isFinite(an) && Number.isFinite(bn)) return an - bn;
1784
+ return String(a).localeCompare(String(b));
1785
+ }
1786
+ function applyOrdering(rows, order, sortKeys) {
1787
+ const keys = Object.entries(order ?? {});
1788
+ if (keys.length === 0 || rows.length < 2) return rows;
1789
+ return [...rows].sort((ra, rb) => {
1790
+ for (const [key, dir] of keys) {
1791
+ const map = sortKeys?.[key];
1792
+ const av = map?.get(ra[key]) ?? ra[key];
1793
+ const bv = map?.get(rb[key]) ?? rb[key];
1794
+ const aNull = av == null || av === "";
1795
+ const bNull = bv == null || bv === "";
1796
+ if (aNull || bNull) {
1797
+ if (aNull && bNull) continue;
1798
+ return aNull ? 1 : -1;
1799
+ }
1800
+ const c = compareValues(av, bv);
1801
+ if (c !== 0) return dir === "desc" ? -c : c;
1802
+ }
1803
+ return 0;
1804
+ });
1805
+ }
1806
+ function applyWindow(rows, limit, offset) {
1807
+ const start = offset != null && offset > 0 ? offset : 0;
1808
+ if (start === 0 && limit == null) return rows;
1809
+ return rows.slice(start, limit != null ? start + limit : void 0);
1810
+ }
1811
+ function resolveOrdering(selection, dimensions, timeDimensions = []) {
1812
+ const order = selection.order;
1813
+ if (order && Object.keys(order).length > 0) {
1814
+ const selectable = /* @__PURE__ */ new Set([
1815
+ ...dimensions,
1816
+ ...selection.measures,
1817
+ ...selection.measures.map((m) => `${m}__compare`)
1818
+ ]);
1819
+ const unknown = Object.keys(order).filter((k) => !selectable.has(k));
1820
+ if (unknown.length) {
1821
+ throw new Error(
1822
+ `[dataset-executor] order key(s) ${unknown.map((k) => `"${k}"`).join(", ")} \u2014 not a selected dimension or measure. Selectable here: ${[...selectable].join(", ") || "(none)"}.`
1823
+ );
1824
+ }
1825
+ return order;
1826
+ }
1827
+ if ((selection.limit != null || selection.offset != null) && dimensions.length > 0) {
1828
+ return Object.fromEntries(dimensions.map((d) => [d, "asc"]));
1829
+ }
1830
+ const timeKeys = timeDimensions.filter((d) => dimensions.includes(d));
1831
+ if (timeKeys.length > 0) {
1832
+ return Object.fromEntries(timeKeys.map((d) => [d, "asc"]));
1833
+ }
1834
+ return void 0;
1835
+ }
1064
1836
  function parseUTC(date) {
1065
1837
  const ms = Date.parse(date.length === 10 ? `${date}T00:00:00Z` : date);
1066
1838
  if (Number.isNaN(ms)) throw new Error(`[dataset-executor] invalid date in dateRange: "${date}"`);
@@ -1088,8 +1860,17 @@ function shiftRange(range, kind) {
1088
1860
  return [toISODate(prevStartMs), toISODate(prevEndMs)];
1089
1861
  }
1090
1862
  var DatasetExecutor = class {
1091
- constructor(service) {
1863
+ /**
1864
+ * @param service - The analytics service the executor issues its queries to.
1865
+ * @param orderLabels - Optional sort-key label hook (#3680). When provided,
1866
+ * an order key naming a label-bearing (`select`/`lookup`) dimension sorts
1867
+ * by its display label instead of the stored value. Omit to sort by stored
1868
+ * values everywhere (e.g. the draft-preview path, whose seed rows already
1869
+ * carry display names).
1870
+ */
1871
+ constructor(service, orderLabels) {
1092
1872
  this.service = service;
1873
+ this.orderLabels = orderLabels;
1093
1874
  }
1094
1875
  /**
1095
1876
  * Execute a dataset selection and return the shaped rows (+ field metadata).
@@ -1098,7 +1879,8 @@ var DatasetExecutor = class {
1098
1879
  * underlying `IAnalyticsService.query` so the tenant/RLS read scope is
1099
1880
  * applied per request (ADR-0021 D-C).
1100
1881
  */
1101
- async execute(compiled, selection, context) {
1882
+ async execute(compiledInput, selectionInput, context) {
1883
+ const { compiled, selection } = resolveSelectionTokens(compiledInput, selectionInput, context);
1102
1884
  const result = await this.executeSelection(compiled, selection, context);
1103
1885
  const groupings = selection.totals?.groupings;
1104
1886
  if (groupings?.length) {
@@ -1142,6 +1924,14 @@ var DatasetExecutor = class {
1142
1924
  }
1143
1925
  const baseFilter = combineFilters(compiled.filter, selection.runtimeFilter);
1144
1926
  const dimensions = selection.dimensions ?? [];
1927
+ const order = resolveOrdering(selection, dimensions, this.timeDimensionsOf(compiled, dimensions));
1928
+ const labelOrderKeys = this.orderLabels ? Object.keys(order ?? {}).filter(
1929
+ (k) => dimensions.includes(k) && this.orderLabels.isLabelBearing(k)
1930
+ ) : [];
1931
+ const singleQuery = filtered.length === 0 && !selection.compareTo && selectedDerived.length === 0;
1932
+ const pushDownKeys = /* @__PURE__ */ new Set([...dimensions, ...unfiltered]);
1933
+ const canPushDownWindow = singleQuery && labelOrderKeys.length === 0 && Object.keys(order ?? {}).every((k) => pushDownKeys.has(k));
1934
+ const windowQuery = canPushDownWindow ? { order, limit: selection.limit, offset: selection.offset } : void 0;
1145
1935
  let result;
1146
1936
  if (unfiltered.length > 0 || filtered.length === 0) {
1147
1937
  result = await this.service.query(this.buildQuery(compiled, {
@@ -1149,7 +1939,8 @@ var DatasetExecutor = class {
1149
1939
  dimensions,
1150
1940
  where: baseFilter,
1151
1941
  selection,
1152
- contextTimezone: context?.timezone
1942
+ contextTimezone: context?.timezone,
1943
+ window: windowQuery
1153
1944
  }), context);
1154
1945
  } else {
1155
1946
  result = { rows: [], fields: [] };
@@ -1178,8 +1969,30 @@ var DatasetExecutor = class {
1178
1969
  }
1179
1970
  result.rows = evaluateDerivedMeasures(result.rows, selectedDerived);
1180
1971
  for (const d of selectedDerived) result.fields.push({ name: d.name, type: "number" });
1972
+ let sortKeys;
1973
+ for (const key of labelOrderKeys) {
1974
+ const values = [...new Set(result.rows.map((r) => r[key]).filter((v) => v != null))];
1975
+ if (values.length === 0) continue;
1976
+ const labels = await this.orderLabels.resolveLabels(key, values);
1977
+ if (labels && labels.size > 0) (sortKeys ?? (sortKeys = {}))[key] = labels;
1978
+ }
1979
+ result.rows = applyOrdering(result.rows, order, sortKeys);
1980
+ result.rows = applyWindow(result.rows, selection.limit, selection.offset);
1181
1981
  return result;
1182
1982
  }
1983
+ /**
1984
+ * The selected dimensions the compiled cube types as `time`, in selection
1985
+ * order (#3916) — the axis {@link resolveOrdering} defaults to ascending.
1986
+ *
1987
+ * Membership is decided by the DIMENSION's declared type, not by whether the
1988
+ * selection happens to bucket it: a `date` dimension left ungranulated groups
1989
+ * raw timestamps, and those want chronological order every bit as much as
1990
+ * month buckets do. (Both sort correctly — `compareValues` compares Dates and
1991
+ * ISO strings chronologically, and bucket keys are minted sort-stable.)
1992
+ */
1993
+ timeDimensionsOf(compiled, dimensions) {
1994
+ return dimensions.filter((d) => compiled.cube.dimensions[d]?.type === "time");
1995
+ }
1183
1996
  buildQuery(compiled, opts) {
1184
1997
  const q = {
1185
1998
  cube: compiled.cube.name,
@@ -1192,18 +2005,28 @@ var DatasetExecutor = class {
1192
2005
  if (opts.where) q.where = opts.where;
1193
2006
  const selTimeDims = opts.selection.timeDimensions ?? [];
1194
2007
  const selDims = new Set(selTimeDims.map((t) => t.dimension));
2008
+ const granularityFor = (name) => {
2009
+ const cd = compiled.cube.dimensions[name];
2010
+ if (cd?.type !== "time") return void 0;
2011
+ const datasetDefault = cd.granularities?.length === 1 ? String(cd.granularities[0]) : void 0;
2012
+ return resolveDimensionGranularity(opts.selection, name, datasetDefault);
2013
+ };
2014
+ const resolvedTimeDims = selTimeDims.map((t) => {
2015
+ if (t.granularity) return t;
2016
+ const granularity = granularityFor(t.dimension);
2017
+ return granularity ? { ...t, granularity } : t;
2018
+ });
1195
2019
  const explicitTimeDims = [];
1196
2020
  for (const name of opts.dimensions) {
1197
- const cd = compiled.cube.dimensions[name];
1198
- if (cd?.type === "time" && cd.granularities?.length === 1 && !selDims.has(name)) {
1199
- explicitTimeDims.push({ dimension: name, granularity: String(cd.granularities[0]) });
1200
- }
2021
+ if (selDims.has(name)) continue;
2022
+ const granularity = granularityFor(name);
2023
+ if (granularity) explicitTimeDims.push({ dimension: name, granularity });
1201
2024
  }
1202
- const mergedTimeDims = [...selTimeDims, ...explicitTimeDims];
2025
+ const mergedTimeDims = [...resolvedTimeDims, ...explicitTimeDims];
1203
2026
  if (mergedTimeDims.length > 0) q.timeDimensions = mergedTimeDims;
1204
- if (opts.selection.order) q.order = opts.selection.order;
1205
- if (opts.selection.limit != null) q.limit = opts.selection.limit;
1206
- if (opts.selection.offset != null) q.offset = opts.selection.offset;
2027
+ if (opts.window?.order && Object.keys(opts.window.order).length > 0) q.order = opts.window.order;
2028
+ if (opts.window?.limit != null) q.limit = opts.window.limit;
2029
+ if (opts.window?.offset != null) q.offset = opts.window.offset;
1207
2030
  return q;
1208
2031
  }
1209
2032
  async runCompare(compiled, selection, measures, dimensions, baseFilter, context) {
@@ -1219,14 +2042,13 @@ var DatasetExecutor = class {
1219
2042
  const shiftedTd = (selection.timeDimensions ?? []).map(
1220
2043
  (t) => t.dimension === cmp.dimension ? { ...t, dateRange: shifted } : t
1221
2044
  );
1222
- const sub = await this.service.query({
1223
- cube: compiled.cube.name,
2045
+ const sub = await this.service.query(this.buildQuery(compiled, {
1224
2046
  measures,
1225
2047
  dimensions,
1226
2048
  where: baseFilter,
1227
- timeDimensions: shiftedTd,
1228
- timezone: selection.timezone ?? context?.timezone ?? "UTC"
1229
- }, context);
2049
+ selection: { ...selection, timeDimensions: shiftedTd },
2050
+ contextTimezone: context?.timezone
2051
+ }), context);
1230
2052
  return sub.rows.map((row) => {
1231
2053
  const out = {};
1232
2054
  for (const dim of dimensions) out[dim] = row[dim];
@@ -1257,11 +2079,77 @@ function mergeByDimensions(base, extra, dimensions, valueColumns) {
1257
2079
 
1258
2080
  // src/dimension-labels.ts
1259
2081
  var LOOKUP_TYPES = /* @__PURE__ */ new Set(["lookup", "master_detail"]);
2082
+ function createOrderLabelResolver(baseObject, dims, deps, resolveScope, context) {
2083
+ const dimByName = new Map(dims.map((d) => [d.name, d]));
2084
+ const metaFor = (dimension) => {
2085
+ const dim = dimByName.get(dimension);
2086
+ return dim ? deps.getObjectFields(baseObject)?.[dim.field] : void 0;
2087
+ };
2088
+ return {
2089
+ isLabelBearing(dimension) {
2090
+ const meta = metaFor(dimension);
2091
+ if (!meta) return false;
2092
+ if (Array.isArray(meta.options) && meta.options.length > 0) return true;
2093
+ return !!(meta.type && LOOKUP_TYPES.has(meta.type) && meta.reference);
2094
+ },
2095
+ async resolveLabels(dimension, values) {
2096
+ const meta = metaFor(dimension);
2097
+ if (!meta) return void 0;
2098
+ if (Array.isArray(meta.options) && meta.options.length > 0) {
2099
+ const labelByValue = /* @__PURE__ */ new Map();
2100
+ for (const opt of meta.options) {
2101
+ if (opt && opt.label != null) labelByValue.set(opt.value, String(opt.label));
2102
+ }
2103
+ return labelByValue;
2104
+ }
2105
+ if (meta.type && LOOKUP_TYPES.has(meta.type) && meta.reference) {
2106
+ let scope;
2107
+ if (resolveScope) {
2108
+ try {
2109
+ scope = await resolveScope(meta.reference);
2110
+ } catch {
2111
+ return void 0;
2112
+ }
2113
+ }
2114
+ return deps.fetchRecordLabels(meta.reference, values, scope ?? void 0, context);
2115
+ }
2116
+ return void 0;
2117
+ }
2118
+ };
2119
+ }
2120
+ function withLabelFetchCache(deps) {
2121
+ const cache = /* @__PURE__ */ new Map();
2122
+ return {
2123
+ getObjectFields: (objectName) => deps.getObjectFields(objectName),
2124
+ async fetchRecordLabels(targetObject, ids, scope, context) {
2125
+ let known = cache.get(targetObject);
2126
+ if (!known) {
2127
+ known = /* @__PURE__ */ new Map();
2128
+ cache.set(targetObject, known);
2129
+ }
2130
+ const missing = ids.filter((id) => !known.has(id));
2131
+ if (missing.length > 0) {
2132
+ const fetched = await deps.fetchRecordLabels(targetObject, missing, scope, context);
2133
+ for (const id of missing) known.set(id, fetched.get(id) ?? null);
2134
+ }
2135
+ const out = /* @__PURE__ */ new Map();
2136
+ for (const id of ids) {
2137
+ const label = known.get(id);
2138
+ if (label != null) out.set(id, label);
2139
+ }
2140
+ return out;
2141
+ }
2142
+ };
2143
+ }
1260
2144
  var pad = (n) => String(n).padStart(2, "0");
1261
2145
  function formatDateBucket(value, granularity) {
1262
2146
  if (value == null || value instanceof Date === false) {
1263
2147
  if (typeof value !== "number" && typeof value !== "string") return value;
1264
2148
  }
2149
+ if (granularity === "year") {
2150
+ const y2 = typeof value === "number" ? value : Number(String(value).trim());
2151
+ if (Number.isInteger(y2) && y2 >= 1e3 && y2 <= 9999) return String(y2);
2152
+ }
1265
2153
  let d;
1266
2154
  if (value instanceof Date) d = value;
1267
2155
  else if (typeof value === "number") d = new Date(value);
@@ -1285,7 +2173,7 @@ function formatDateBucket(value, granularity) {
1285
2173
  return `${y}-${pad(m + 1)}-${pad(d.getUTCDate())}`;
1286
2174
  }
1287
2175
  }
1288
- async function resolveDimensionLabels(baseObject, dims, rows, deps) {
2176
+ async function resolveDimensionLabels(baseObject, dims, rows, deps, resolveScope, context) {
1289
2177
  if (!rows.length || !dims.length) return;
1290
2178
  const fields = deps.getObjectFields(baseObject);
1291
2179
  if (!fields) return;
@@ -1317,7 +2205,15 @@ async function resolveDimensionLabels(baseObject, dims, rows, deps) {
1317
2205
  new Set(rows.map((r) => r[dim.name]).filter((v) => v != null))
1318
2206
  );
1319
2207
  if (ids.length === 0) continue;
1320
- const labelById = await deps.fetchRecordLabels(meta.reference, ids);
2208
+ let scope;
2209
+ if (resolveScope) {
2210
+ try {
2211
+ scope = await resolveScope(meta.reference);
2212
+ } catch {
2213
+ continue;
2214
+ }
2215
+ }
2216
+ const labelById = await deps.fetchRecordLabels(meta.reference, ids, scope ?? void 0, context);
1321
2217
  if (!labelById || labelById.size === 0) continue;
1322
2218
  for (const row of rows) {
1323
2219
  const label = labelById.get(row[dim.name]);
@@ -1338,11 +2234,21 @@ function pickDisplayField(fields) {
1338
2234
  }
1339
2235
 
1340
2236
  // src/preview-evaluator.ts
1341
- import { calendarPartsInTzOrUtc } from "@objectstack/core";
2237
+ import { calendarPartsInTzOrUtc, nextUtcCalendarDay as nextUtcCalendarDay3, utcInstantMs } from "@objectstack/core";
1342
2238
  function compare(a, b) {
1343
2239
  if (typeof a === "number" && typeof b === "number") return a - b;
2240
+ if (a instanceof Date || b instanceof Date) {
2241
+ const ai = utcInstantMs(a);
2242
+ const bi = utcInstantMs(b);
2243
+ if (ai !== null && bi !== null) return ai - bi;
2244
+ }
1344
2245
  return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0;
1345
2246
  }
2247
+ function lteBound(value, bound) {
2248
+ const nextDay = nextUtcCalendarDay3(bound);
2249
+ if (nextDay != null) return compare(value, nextDay) < 0;
2250
+ return compare(value, bound) <= 0;
2251
+ }
1346
2252
  function matchOp(value, op, expected) {
1347
2253
  switch (op) {
1348
2254
  case "$eq":
@@ -1355,8 +2261,16 @@ function matchOp(value, op, expected) {
1355
2261
  return value != null && compare(value, expected) >= 0;
1356
2262
  case "$lt":
1357
2263
  return value != null && compare(value, expected) < 0;
1358
- case "$lte":
1359
- return value != null && compare(value, expected) <= 0;
2264
+ case "$lte": {
2265
+ if (value == null) return false;
2266
+ return lteBound(value, expected);
2267
+ }
2268
+ case "$between": {
2269
+ if (value == null || !Array.isArray(expected) || expected.length !== 2) return false;
2270
+ const [min, max] = expected;
2271
+ if (min == null || max == null) return false;
2272
+ return compare(value, min) >= 0 && lteBound(value, max);
2273
+ }
1360
2274
  case "$in":
1361
2275
  return Array.isArray(expected) && expected.some((e) => value === e || String(value) === String(e));
1362
2276
  case "$nin":
@@ -1443,7 +2357,9 @@ function evaluateAnalyticsQueryOverRows(query, cube, rows) {
1443
2357
  const [start, end] = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
1444
2358
  filtered = filtered.filter((r) => {
1445
2359
  const v = String(r[field] ?? "");
1446
- return v >= String(start) && v <= `${end}~`;
2360
+ const nextDay = nextUtcCalendarDay3(end);
2361
+ const inUpper = nextDay != null ? v < nextDay : v <= `${end}~`;
2362
+ return v >= String(start) && inUpper;
1447
2363
  });
1448
2364
  }
1449
2365
  const dimensions = query.dimensions ?? [];
@@ -1511,6 +2427,8 @@ var AnalyticsService = class {
1511
2427
  constructor(config = {}) {
1512
2428
  /** Compiled datasets by name — feeds the join allowlist (D-C) and queryDataset. */
1513
2429
  this.datasetRegistry = /* @__PURE__ */ new Map();
2430
+ /** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */
2431
+ this.warnedNoObjectRegistry = false;
1514
2432
  this.logger = config.logger || createLogger({ level: "info", format: "pretty" });
1515
2433
  this.cubeRegistry = new CubeRegistry();
1516
2434
  if (config.cubes) {
@@ -1521,6 +2439,7 @@ var AnalyticsService = class {
1521
2439
  this.measureCurrency = config.measureCurrency;
1522
2440
  this.labelResolver = config.labelResolver;
1523
2441
  this.draftRowsResolver = config.draftRowsResolver;
2442
+ this.isRegisteredObject = config.isRegisteredObject;
1524
2443
  if (config.datasets) {
1525
2444
  for (const ds of config.datasets) {
1526
2445
  try {
@@ -1540,6 +2459,7 @@ var AnalyticsService = class {
1540
2459
  // fall back to any explicitly-configured provider for legacy cubes.
1541
2460
  getAllowedRelationships: (cubeName) => this.datasetRegistry.get(cubeName)?.allowedRelationships ?? config.getAllowedRelationships?.(cubeName),
1542
2461
  coerceTemporalFilterValue: config.coerceTemporalFilterValue,
2462
+ coerceTemporalFilterColumn: config.coerceTemporalFilterColumn,
1543
2463
  isExternalObject: config.isExternalObject
1544
2464
  };
1545
2465
  const builtIn = [
@@ -1561,10 +2481,11 @@ var AnalyticsService = class {
1561
2481
  * `getReadScope(objectName)` that already knows the active tenant.
1562
2482
  */
1563
2483
  async callCtx(query, context) {
1564
- if (!this.readScopeProvider) return this.baseCtx;
2484
+ if (!this.readScopeProvider) return { ...this.baseCtx, context };
1565
2485
  const scopes = await this.resolveReadScopes(query, context);
1566
2486
  return {
1567
2487
  ...this.baseCtx,
2488
+ context,
1568
2489
  getReadScope: (objectName) => scopes.get(objectName) ?? null
1569
2490
  };
1570
2491
  }
@@ -1685,9 +2606,19 @@ var AnalyticsService = class {
1685
2606
  return previewResult;
1686
2607
  }
1687
2608
  }
2609
+ const provider = this.readScopeProvider;
2610
+ const resolveScope = provider ? (targetObject) => provider(targetObject, context) : void 0;
2611
+ const labelDeps = this.labelResolver ? withLabelFetchCache(this.labelResolver) : void 0;
2612
+ const orderLabels = labelDeps && dataset.dimensions?.length ? createOrderLabelResolver(
2613
+ dataset.object,
2614
+ dataset.dimensions.filter((d) => !!d.field).map((d) => ({ name: d.name, field: d.field })),
2615
+ labelDeps,
2616
+ resolveScope,
2617
+ context
2618
+ ) : void 0;
1688
2619
  let result;
1689
2620
  try {
1690
- result = await new DatasetExecutor(this).execute(compiled, selection, context);
2621
+ result = await new DatasetExecutor(this, orderLabels).execute(compiled, selection, context);
1691
2622
  } catch (err) {
1692
2623
  if (isMissingSourceError(err)) {
1693
2624
  this.logger.warn(
@@ -1723,18 +2654,20 @@ var AnalyticsService = class {
1723
2654
  const rangeTz = selection.timezone ?? context?.timezone ?? "UTC";
1724
2655
  const rangeDims = [];
1725
2656
  for (const d of selectedDims) {
1726
- if (!d.field || d.type !== "date" || !d.dateGranularity) continue;
2657
+ if (!d.field || d.type !== "date") continue;
2658
+ const granularity = resolveDimensionGranularity(selection, d.name, d.dateGranularity);
2659
+ if (!granularity) continue;
1727
2660
  const ftype = this.measureCurrency?.(dataset.object, d.field)?.type;
1728
- if (ftype === "datetime") rangeDims.push({ d, instant: true });
1729
- else if (ftype === "date") rangeDims.push({ d, instant: false });
1730
- else if (rangeTz === "UTC") rangeDims.push({ d, instant: false });
2661
+ if (ftype === "datetime") rangeDims.push({ d, granularity, instant: true });
2662
+ else if (ftype === "date") rangeDims.push({ d, granularity, instant: false });
2663
+ else if (rangeTz === "UTC") rangeDims.push({ d, granularity, instant: false });
1731
2664
  }
1732
2665
  if (rangeDims.length && result.rows.length) {
1733
2666
  const bound = (ymd, instant) => instant ? new Date(zonedDateStartToUtcMs(ymd, rangeTz)).toISOString() : ymd;
1734
2667
  result.drillRanges = result.rows.map((row) => {
1735
2668
  const ranges = {};
1736
- for (const { d, instant } of rangeDims) {
1737
- const cal = bucketKeyToCalendarRange(row[d.name], d.dateGranularity);
2669
+ for (const { d, granularity, instant } of rangeDims) {
2670
+ const cal = bucketKeyToCalendarRange(row[d.name], granularity);
1738
2671
  if (cal) {
1739
2672
  ranges[d.name] = { field: d.field, gte: bound(cal.start, instant), lt: bound(cal.end, instant) };
1740
2673
  }
@@ -1743,15 +2676,20 @@ var AnalyticsService = class {
1743
2676
  });
1744
2677
  result.object = dataset.object;
1745
2678
  }
1746
- if (this.labelResolver && selectedDims.length) {
1747
- const dims = selectedDims.filter((d) => !!d.field).map((d) => ({ name: d.name, field: d.field, type: d.type, dateGranularity: d.dateGranularity }));
2679
+ if (labelDeps && selectedDims.length) {
2680
+ const dims = selectedDims.filter((d) => !!d.field).map((d) => ({
2681
+ name: d.name,
2682
+ field: d.field,
2683
+ type: d.type,
2684
+ dateGranularity: resolveDimensionGranularity(selection, d.name, d.dateGranularity)
2685
+ }));
1748
2686
  if (dims.length) {
1749
2687
  try {
1750
- await resolveDimensionLabels(dataset.object, dims, result.rows, this.labelResolver);
2688
+ await resolveDimensionLabels(dataset.object, dims, result.rows, labelDeps, resolveScope, context);
1751
2689
  for (const total of result.totals ?? []) {
1752
2690
  const subset = dims.filter((d) => total.dimensions.includes(d.name));
1753
2691
  if (subset.length) {
1754
- await resolveDimensionLabels(dataset.object, subset, total.rows, this.labelResolver);
2692
+ await resolveDimensionLabels(dataset.object, subset, total.rows, labelDeps, resolveScope, context);
1755
2693
  }
1756
2694
  }
1757
2695
  } catch (e) {
@@ -1839,6 +2777,7 @@ var AnalyticsService = class {
1839
2777
  const name = query.cube;
1840
2778
  let cube = this.cubeRegistry.get(name);
1841
2779
  if (!cube) {
2780
+ this.assertInferableCube(name);
1842
2781
  cube = this.inferCubeFromQuery(query);
1843
2782
  this.cubeRegistry.register(cube);
1844
2783
  const isScalarMetric = (query.dimensions?.length ?? 0) === 0 && (query.timeDimensions?.length ?? 0) === 0;
@@ -1865,6 +2804,39 @@ var AnalyticsService = class {
1865
2804
  );
1866
2805
  }
1867
2806
  }
2807
+ /**
2808
+ * [#3867] Gate on the cube auto-inference path: a name with no registered
2809
+ * Cube may only be inferred into one if it is a registered object.
2810
+ *
2811
+ * Rejects with `status: 404` / `code: 'CUBE_NOT_FOUND'` so the HTTP boundary
2812
+ * answers "no such cube" instead of letting the name reach the driver as a
2813
+ * table and surfacing whatever the driver says about it. The message names
2814
+ * both ways the request could be made valid, because from here the two are
2815
+ * genuinely indistinguishable: register a Cube, or register the object.
2816
+ *
2817
+ * Skips when `isRegisteredObject` was not supplied — see the config field's
2818
+ * doc for why that tier is a deliberate stand-down and not a hole.
2819
+ */
2820
+ assertInferableCube(name) {
2821
+ const isRegisteredObject = this.isRegisteredObject;
2822
+ if (!isRegisteredObject) {
2823
+ if (!this.warnedNoObjectRegistry) {
2824
+ this.warnedNoObjectRegistry = true;
2825
+ this.logger.warn(
2826
+ "[Analytics] no object-registry hook configured \u2014 the cube-inference existence gate (#3867) is INACTIVE for this service; an unregistered cube name reaches the driver as a raw table name."
2827
+ );
2828
+ }
2829
+ return;
2830
+ }
2831
+ if (isRegisteredObject(name)) return;
2832
+ const err = new Error(
2833
+ `Cube '${name}' not found: no cube is registered under that name, and it is not a registered object either (a cube can only be auto-inferred from a registered object). Define a Cube in your stack, or check the object name.`
2834
+ );
2835
+ err.code = "CUBE_NOT_FOUND";
2836
+ err.status = 404;
2837
+ err.cube = name;
2838
+ throw err;
2839
+ }
1868
2840
  /** Build a minimal Cube from the fields referenced by an AnalyticsQuery. */
1869
2841
  inferCubeFromQuery(query) {
1870
2842
  const cubeName = query.cube;
@@ -1975,6 +2947,11 @@ var FallbackDelegateStrategy = class {
1975
2947
  var AnalyticsServicePlugin = class {
1976
2948
  constructor(options = {}) {
1977
2949
  this.name = "com.objectstack.service-analytics";
2950
+ /**
2951
+ * Services init() registers on every path (ADR-0116, #4131) — lets the
2952
+ * kernel name this plugin when a consumer requires one before it inits.
2953
+ */
2954
+ this.providesServices = ["analytics"];
1978
2955
  this.version = "1.0.0";
1979
2956
  this.type = "standard";
1980
2957
  this.dependencies = [];
@@ -2006,7 +2983,7 @@ var AnalyticsServicePlugin = class {
2006
2983
  '[Analytics] No "data" service registered yet at init; will retry per-query. Register ObjectQLPlugin or pass executeAggregate.'
2007
2984
  );
2008
2985
  }
2009
- executeAggregate = async (objectName, { groupBy, aggregations, filter, timezone }) => {
2986
+ executeAggregate = async (objectName, { groupBy, aggregations, filter, timezone, context }) => {
2010
2987
  const engine = tryGetDataEngine();
2011
2988
  if (!engine) {
2012
2989
  throw new Error(
@@ -2023,7 +3000,13 @@ var AnalyticsServicePlugin = class {
2023
3000
  })),
2024
3001
  // ADR-0053 Phase 2: thread the reference tz so date buckets resolve on
2025
3002
  // that zone's calendar days (engine buckets in-memory when non-UTC).
2026
- timezone
3003
+ timezone,
3004
+ // ADR-0021 D-C (#3602): thread the caller's identity so the engine's
3005
+ // middleware chain scopes the read itself. `BaseEngineOptions.context`
3006
+ // is `.optional()`, so nothing ever forced this bridge to pass it —
3007
+ // and it did not, which is how an authenticated aggregate reached the
3008
+ // engine with no principal and plugin-security fell open (#3597).
3009
+ context
2027
3010
  });
2028
3011
  return rows;
2029
3012
  };
@@ -2071,6 +3054,7 @@ var AnalyticsServicePlugin = class {
2071
3054
  }));
2072
3055
  let getReadScope = this.options.getReadScope;
2073
3056
  let autoBridgedReadScope = false;
3057
+ let securityPresentAtInit = false;
2074
3058
  if (!getReadScope) {
2075
3059
  const trySecurity = () => {
2076
3060
  try {
@@ -2080,10 +3064,9 @@ var AnalyticsServicePlugin = class {
2080
3064
  return void 0;
2081
3065
  }
2082
3066
  };
2083
- if (trySecurity()) {
2084
- getReadScope = (object, context) => trySecurity()?.getReadFilter(object, context);
2085
- autoBridgedReadScope = true;
2086
- }
3067
+ securityPresentAtInit = !!trySecurity();
3068
+ getReadScope = (object, context) => trySecurity()?.getReadFilter(object, context);
3069
+ autoBridgedReadScope = true;
2087
3070
  }
2088
3071
  const relationshipResolver = (baseObject, relationshipName) => {
2089
3072
  const engine = (() => {
@@ -2111,17 +3094,26 @@ var AnalyticsServicePlugin = class {
2111
3094
  };
2112
3095
  const labelResolver = {
2113
3096
  getObjectFields: (objectName) => dataEngine()?.getObject?.(objectName)?.fields,
2114
- fetchRecordLabels: async (targetObject, ids) => {
3097
+ fetchRecordLabels: async (targetObject, ids, scope, context) => {
2115
3098
  const map = /* @__PURE__ */ new Map();
2116
3099
  const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
2117
3100
  if (!displayField || !executeAggregate || ids.length === 0) return map;
2118
- const rows = await executeAggregate(targetObject, {
2119
- groupBy: ["id", displayField],
2120
- aggregations: [{ field: "id", method: "count", alias: "_c" }],
2121
- filter: { id: { $in: ids } }
2122
- });
2123
- for (const r of rows) {
2124
- if (r.id != null && r[displayField] != null) map.set(r.id, String(r[displayField]));
3101
+ const CHUNK = 500;
3102
+ for (let i = 0; i < ids.length; i += CHUNK) {
3103
+ const idFilter = { id: { $in: ids.slice(i, i + CHUNK) } };
3104
+ const filter = scope ? { $and: [idFilter, scope] } : idFilter;
3105
+ const rows = await executeAggregate(targetObject, {
3106
+ groupBy: ["id", displayField],
3107
+ aggregations: [{ field: "id", method: "count", alias: "_c" }],
3108
+ filter,
3109
+ // #3602 second belt — `scope` above is the analytics layer's own
3110
+ // predicate on this per-record read; the context makes the engine's
3111
+ // middleware scope it as well.
3112
+ context
3113
+ });
3114
+ for (const r of rows) {
3115
+ if (r.id != null && r[displayField] != null) map.set(r.id, String(r[displayField]));
3116
+ }
2125
3117
  }
2126
3118
  return map;
2127
3119
  }
@@ -2162,6 +3154,17 @@ var AnalyticsServicePlugin = class {
2162
3154
  }
2163
3155
  return value;
2164
3156
  };
3157
+ const coerceTemporalFilterColumn = (objectName, fieldName, columnSql) => {
3158
+ try {
3159
+ const svc = ctx.getService("data");
3160
+ const driver = svc?.getDriverForObject?.(objectName);
3161
+ if (driver && typeof driver.temporalFilterColumnSql === "function") {
3162
+ return driver.temporalFilterColumnSql(objectName, fieldName, columnSql);
3163
+ }
3164
+ } catch {
3165
+ }
3166
+ return columnSql;
3167
+ };
2165
3168
  const config = {
2166
3169
  cubes: this.options.cubes,
2167
3170
  logger: ctx.logger,
@@ -2172,6 +3175,7 @@ var AnalyticsServicePlugin = class {
2172
3175
  getReadScope,
2173
3176
  getAllowedRelationships: this.options.getAllowedRelationships,
2174
3177
  coerceTemporalFilterValue,
3178
+ coerceTemporalFilterColumn,
2175
3179
  relationshipResolver,
2176
3180
  labelResolver,
2177
3181
  // ADR-0053 — source-field currency metadata for the measure currency chain.
@@ -2186,13 +3190,31 @@ var AnalyticsServicePlugin = class {
2186
3190
  const obj = dataEngine()?.getObject?.(objectName);
2187
3191
  return !!(obj && obj.external != null);
2188
3192
  },
3193
+ // [#3867] Existence probe for the cube auto-inference gate. Reads the
3194
+ // same schema registry the data path's #3770 gate consults, through the
3195
+ // engine accessor this bridge already uses above — so "which objects
3196
+ // exist" has one answer across /data and /analytics.
3197
+ //
3198
+ // `dataEngine()` resolves lazily and may be absent entirely (analytics
3199
+ // installed without a data engine). Reporting `false` there would 404
3200
+ // every cube, so an unresolvable engine reports `true` — "cannot answer,
3201
+ // do not block" — mirroring the tiering #3770 took on the data path.
3202
+ isRegisteredObject: (name) => {
3203
+ const engine = dataEngine();
3204
+ if (!engine) return true;
3205
+ return engine.getObject?.(name) != null;
3206
+ },
2189
3207
  draftRowsResolver
2190
3208
  };
2191
- if (autoBridgedReadScope) {
3209
+ if (autoBridgedReadScope && securityPresentAtInit) {
2192
3210
  ctx.logger.info('[Analytics] Auto-bridged getReadScope \u2192 "security" service (getReadFilter)');
3211
+ } else if (autoBridgedReadScope) {
3212
+ ctx.logger.info(
3213
+ '[Analytics] getReadScope bridged to the "security" service; that service is not registered yet at init and will be resolved per query (plugin order is not significant).'
3214
+ );
2193
3215
  } else if (!getReadScope) {
2194
3216
  ctx.logger.warn(
2195
- '[Analytics] No getReadScope configured and no "security" service with getReadFilter found \u2014 the raw-SQL analytics path will NOT enforce tenant/RLS scoping on joined objects (ADR-0021 D-C). Supply getReadScope or register a security service in multi-tenant deployments.'
3217
+ '[Analytics] No getReadScope configured and no "security" service with getReadFilter found \u2014 analytics queries will NOT enforce tenant/RLS scoping (ADR-0021 D-C). Supply getReadScope or register a security service in multi-tenant deployments.'
2196
3218
  );
2197
3219
  }
2198
3220
  if (autoBridged) {
@@ -2235,10 +3257,12 @@ export {
2235
3257
  combineFilters,
2236
3258
  compileDataset,
2237
3259
  compileScopedFilterToSql,
3260
+ createOrderLabelResolver,
2238
3261
  evaluateDerivedMeasures,
2239
3262
  mergeByDimensions,
2240
3263
  pickDisplayField,
2241
3264
  resolveDimensionLabels,
2242
- shiftRange
3265
+ shiftRange,
3266
+ withLabelFetchCache
2243
3267
  };
2244
3268
  //# sourceMappingURL=index.js.map