@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/CHANGELOG.md +2656 -0
- package/dist/index.cjs +1192 -166
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +386 -24
- package/dist/index.d.ts +386 -24
- package/dist/index.js +1185 -161
- package/dist/index.js.map +1 -1
- package/package.json +8 -5
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
|
-
$
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
185
|
+
return out;
|
|
150
186
|
}
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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 (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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)
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
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
|
-
|
|
633
|
-
|
|
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 `${
|
|
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
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
params.
|
|
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
|
-
|
|
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
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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:
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
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
|
|
759
|
-
selectParts.push(`${
|
|
760
|
-
groupByParts.push(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
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 = [...
|
|
2025
|
+
const mergedTimeDims = [...resolvedTimeDims, ...explicitTimeDims];
|
|
1203
2026
|
if (mergedTimeDims.length > 0) q.timeDimensions = mergedTimeDims;
|
|
1204
|
-
if (opts.
|
|
1205
|
-
if (opts.
|
|
1206
|
-
if (opts.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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],
|
|
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 (
|
|
1747
|
-
const dims = selectedDims.filter((d) => !!d.field).map((d) => ({
|
|
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,
|
|
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,
|
|
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
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
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
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
filter
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
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
|
|
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
|