@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.cjs
CHANGED
|
@@ -29,16 +29,18 @@ __export(index_exports, {
|
|
|
29
29
|
combineFilters: () => combineFilters,
|
|
30
30
|
compileDataset: () => compileDataset,
|
|
31
31
|
compileScopedFilterToSql: () => compileScopedFilterToSql,
|
|
32
|
+
createOrderLabelResolver: () => createOrderLabelResolver,
|
|
32
33
|
evaluateDerivedMeasures: () => evaluateDerivedMeasures,
|
|
33
34
|
mergeByDimensions: () => mergeByDimensions,
|
|
34
35
|
pickDisplayField: () => pickDisplayField,
|
|
35
36
|
resolveDimensionLabels: () => resolveDimensionLabels,
|
|
36
|
-
shiftRange: () => shiftRange
|
|
37
|
+
shiftRange: () => shiftRange,
|
|
38
|
+
withLabelFetchCache: () => withLabelFetchCache
|
|
37
39
|
});
|
|
38
40
|
module.exports = __toCommonJS(index_exports);
|
|
39
41
|
|
|
40
42
|
// src/analytics-service.ts
|
|
41
|
-
var
|
|
43
|
+
var import_core5 = require("@objectstack/core");
|
|
42
44
|
|
|
43
45
|
// src/cube-registry.ts
|
|
44
46
|
var CubeRegistry = class {
|
|
@@ -167,7 +169,8 @@ var MONGO_TO_CUBE_OP = {
|
|
|
167
169
|
$nin: "notIn",
|
|
168
170
|
$contains: "contains",
|
|
169
171
|
$notContains: "notContains",
|
|
170
|
-
$
|
|
172
|
+
$startsWith: "startsWith",
|
|
173
|
+
$endsWith: "endsWith"
|
|
171
174
|
};
|
|
172
175
|
function stringifyForCube(v) {
|
|
173
176
|
if (v == null) return "";
|
|
@@ -176,55 +179,102 @@ function stringifyForCube(v) {
|
|
|
176
179
|
if (typeof v === "object") return JSON.stringify(v);
|
|
177
180
|
return String(v);
|
|
178
181
|
}
|
|
179
|
-
function
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
182
|
+
function andOf(children) {
|
|
183
|
+
if (children.length === 0) return null;
|
|
184
|
+
if (children.length === 1) return children[0];
|
|
185
|
+
return { kind: "and", children };
|
|
186
|
+
}
|
|
187
|
+
function fieldLeaves(key, raw) {
|
|
188
|
+
const out = [];
|
|
189
|
+
const leaf = (operator, values) => {
|
|
190
|
+
out.push({ kind: "leaf", member: key, operator, values });
|
|
191
|
+
};
|
|
192
|
+
if (raw === null) {
|
|
193
|
+
leaf("notSet", []);
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
if (typeof raw === "object" && !Array.isArray(raw) && !(raw instanceof Date)) {
|
|
197
|
+
const wrapper = raw;
|
|
198
|
+
const opKeys = Object.keys(wrapper).filter((k) => k.startsWith("$"));
|
|
199
|
+
if (opKeys.length > 0) {
|
|
200
|
+
for (const opKey of opKeys) {
|
|
201
|
+
if (opKey === "$between") {
|
|
202
|
+
const v2 = wrapper[opKey];
|
|
203
|
+
if (!Array.isArray(v2) || v2.length !== 2) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`[analytics] "$between" on "${key}" needs a two-element [min, max] array, got ${JSON.stringify(v2)}. Dropping the predicate would silently widen the query to every row.`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
leaf("gte", [stringifyForCube(v2[0])]);
|
|
209
|
+
leaf("lte", [stringifyForCube(v2[1])]);
|
|
210
|
+
continue;
|
|
186
211
|
}
|
|
212
|
+
if (opKey === "$null" || opKey === "$exists") {
|
|
213
|
+
const isNull = opKey === "$null" ? wrapper[opKey] === true : wrapper[opKey] === false;
|
|
214
|
+
leaf(isNull ? "notSet" : "set", []);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const cubeOp = MONGO_TO_CUBE_OP[opKey];
|
|
218
|
+
if (!cubeOp) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`[analytics] Unsupported filter operator "${opKey}" on "${key}". Supported: ${Object.keys(MONGO_TO_CUBE_OP).join(", ")}, $between, $null, $exists, and the $and/$or/$not combinators. Dropping it would silently widen the query to rows the filter excludes.`
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
const v = wrapper[opKey];
|
|
224
|
+
leaf(cubeOp, Array.isArray(v) ? v.map(stringifyForCube) : [stringifyForCube(v)]);
|
|
187
225
|
}
|
|
188
|
-
|
|
226
|
+
return out;
|
|
189
227
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
out.push({ member: key, operator: "notSet", values: [] });
|
|
193
|
-
continue;
|
|
228
|
+
for (const [nestedKey, nestedVal] of Object.entries(wrapper)) {
|
|
229
|
+
out.push(...fieldLeaves(`${key}.${nestedKey}`, nestedVal));
|
|
194
230
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
if (Array.isArray(raw)) leaf("in", raw.map(stringifyForCube));
|
|
234
|
+
else leaf("equals", [stringifyForCube(raw)]);
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
function buildNode(cond) {
|
|
238
|
+
const children = [];
|
|
239
|
+
for (const [key, raw] of Object.entries(cond)) {
|
|
240
|
+
if (raw === void 0) continue;
|
|
241
|
+
if (key === "$and" || key === "$or") {
|
|
242
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`[analytics] "${key}" requires a non-empty array. An empty combinator has no defensible reading \u2014 dropping it widens the query, and treating it as "match nothing" silently empties a chart.`
|
|
245
|
+
);
|
|
210
246
|
}
|
|
247
|
+
const branches = raw.map((sub) => sub && typeof sub === "object" ? buildNode(sub) : null).filter((n) => n !== null);
|
|
248
|
+
if (branches.length === 0) continue;
|
|
249
|
+
if (key === "$and") children.push(...branches);
|
|
250
|
+
else children.push(branches.length === 1 ? branches[0] : { kind: "or", children: branches });
|
|
211
251
|
continue;
|
|
212
252
|
}
|
|
213
|
-
if (
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
253
|
+
if (key === "$not") {
|
|
254
|
+
const inner = raw && typeof raw === "object" ? buildNode(raw) : null;
|
|
255
|
+
if (inner) children.push({ kind: "not", child: inner });
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (key.startsWith("$")) {
|
|
259
|
+
throw new Error(
|
|
260
|
+
`[analytics] Unsupported top-level filter operator "${key}". Dropping it would silently widen the query to rows the filter excludes.`
|
|
261
|
+
);
|
|
217
262
|
}
|
|
263
|
+
children.push(...fieldLeaves(key, raw));
|
|
218
264
|
}
|
|
265
|
+
return andOf(children);
|
|
219
266
|
}
|
|
220
|
-
function
|
|
221
|
-
if (!query || typeof query !== "object") return
|
|
222
|
-
const out = [];
|
|
267
|
+
function normalizeAnalyticsFilterTree(query) {
|
|
268
|
+
if (!query || typeof query !== "object") return null;
|
|
223
269
|
const where = query.where;
|
|
224
|
-
if (where
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
270
|
+
if (!where || typeof where !== "object" || Array.isArray(where)) return null;
|
|
271
|
+
return buildNode(where);
|
|
272
|
+
}
|
|
273
|
+
function collectFilterLeaves(node) {
|
|
274
|
+
if (!node) return [];
|
|
275
|
+
if (node.kind === "leaf") return [{ member: node.member, operator: node.operator, values: node.values }];
|
|
276
|
+
if (node.kind === "not") return collectFilterLeaves(node.child);
|
|
277
|
+
return node.children.flatMap(collectFilterLeaves);
|
|
228
278
|
}
|
|
229
279
|
function recoverNumber(s) {
|
|
230
280
|
if (/^-?\d+(\.\d+)?$/.test(s)) {
|
|
@@ -356,6 +406,18 @@ function compileOperator(col, op, val, field, params) {
|
|
|
356
406
|
}
|
|
357
407
|
|
|
358
408
|
// src/strategies/native-sql-strategy.ts
|
|
409
|
+
var import_core = require("@objectstack/core");
|
|
410
|
+
var AGGREGATE_SQL = {
|
|
411
|
+
"count": () => "COUNT(*)",
|
|
412
|
+
"sum": (col) => `SUM(${col})`,
|
|
413
|
+
"avg": (col) => `AVG(${col})`,
|
|
414
|
+
"min": (col) => `MIN(${col})`,
|
|
415
|
+
"max": (col) => `MAX(${col})`,
|
|
416
|
+
"count_distinct": (col) => `COUNT(DISTINCT ${col})`
|
|
417
|
+
};
|
|
418
|
+
var SUPPORTED_AGGREGATE_SQL_KEYS = Object.keys(AGGREGATE_SQL);
|
|
419
|
+
var EXPRESSION_METRIC_TYPES = /* @__PURE__ */ new Set(["number", "string", "boolean"]);
|
|
420
|
+
var IDENTIFIER_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
359
421
|
var NativeSQLStrategy = class {
|
|
360
422
|
constructor() {
|
|
361
423
|
this.name = "NativeSQLStrategy";
|
|
@@ -410,15 +472,15 @@ var NativeSQLStrategy = class {
|
|
|
410
472
|
}
|
|
411
473
|
}
|
|
412
474
|
const whereClauses = [];
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
475
|
+
const filterSql = this.compileFilterNode(
|
|
476
|
+
normalizeAnalyticsFilterTree(query),
|
|
477
|
+
cube,
|
|
478
|
+
tableName,
|
|
479
|
+
joins,
|
|
480
|
+
params,
|
|
481
|
+
ctx
|
|
482
|
+
);
|
|
483
|
+
if (filterSql) whereClauses.push(filterSql);
|
|
422
484
|
if (query.timeDimensions && query.timeDimensions.length > 0) {
|
|
423
485
|
for (const td of query.timeDimensions) {
|
|
424
486
|
const colExpr = this.resolveFieldSql(cube, td.dimension, tableName, joins);
|
|
@@ -426,11 +488,17 @@ var NativeSQLStrategy = class {
|
|
|
426
488
|
const range = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
|
|
427
489
|
if (range.length === 2) {
|
|
428
490
|
const td2 = this.resolveStorageTarget(cube, td.dimension, tableName);
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
491
|
+
const column = this.temporalColumn(ctx, td2, colExpr);
|
|
492
|
+
const nextDay = (0, import_core.nextUtcCalendarDay)(range[1]);
|
|
493
|
+
params.push(this.coerceTemporal(ctx, td2, range[0]));
|
|
494
|
+
const lower = `${column} >= $${params.length}`;
|
|
495
|
+
if (nextDay != null) {
|
|
496
|
+
params.push(this.coerceTemporal(ctx, td2, nextDay));
|
|
497
|
+
whereClauses.push(`(${lower} AND ${column} < $${params.length})`);
|
|
498
|
+
} else {
|
|
499
|
+
params.push(this.coerceTemporal(ctx, td2, range[1]));
|
|
500
|
+
whereClauses.push(`(${lower} AND ${column} <= $${params.length})`);
|
|
501
|
+
}
|
|
434
502
|
}
|
|
435
503
|
}
|
|
436
504
|
}
|
|
@@ -528,6 +596,7 @@ var NativeSQLStrategy = class {
|
|
|
528
596
|
}
|
|
529
597
|
return rawSql;
|
|
530
598
|
}
|
|
599
|
+
if (!IDENTIFIER_PATH.test(rawSql)) return rawSql;
|
|
531
600
|
const segments = rawSql.split(".");
|
|
532
601
|
const column = segments[segments.length - 1];
|
|
533
602
|
const hops = segments.slice(0, -1);
|
|
@@ -587,24 +656,19 @@ var NativeSQLStrategy = class {
|
|
|
587
656
|
}
|
|
588
657
|
resolveMeasureSql(cube, member, parentTable, joins) {
|
|
589
658
|
const measure = this.lookupMember(cube, member, "measure");
|
|
590
|
-
if (!measure)
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
case "sum":
|
|
596
|
-
return `SUM(${col})`;
|
|
597
|
-
case "avg":
|
|
598
|
-
return `AVG(${col})`;
|
|
599
|
-
case "min":
|
|
600
|
-
return `MIN(${col})`;
|
|
601
|
-
case "max":
|
|
602
|
-
return `MAX(${col})`;
|
|
603
|
-
case "count_distinct":
|
|
604
|
-
return `COUNT(DISTINCT ${col})`;
|
|
605
|
-
default:
|
|
606
|
-
return `COUNT(*)`;
|
|
659
|
+
if (!measure) {
|
|
660
|
+
const declared = Object.keys(cube.measures ?? {});
|
|
661
|
+
throw new Error(
|
|
662
|
+
`[native-sql-strategy] cube "${cube.name}" declares no measure "${member}"` + (declared.length ? ` (declared: ${declared.join(", ")})` : " (it declares none)")
|
|
663
|
+
);
|
|
607
664
|
}
|
|
665
|
+
const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
|
|
666
|
+
const wrap = AGGREGATE_SQL[measure.type];
|
|
667
|
+
if (wrap) return wrap(col);
|
|
668
|
+
if (EXPRESSION_METRIC_TYPES.has(measure.type)) return col;
|
|
669
|
+
throw new Error(
|
|
670
|
+
`[native-sql-strategy] measure "${member}" on cube "${cube.name}" has unrecognised type "${measure.type}" \u2014 expected an aggregate (${SUPPORTED_AGGREGATE_SQL_KEYS.join(", ")}) or a custom-expression type (${[...EXPRESSION_METRIC_TYPES].join(", ")}).`
|
|
671
|
+
);
|
|
608
672
|
}
|
|
609
673
|
resolveFieldSql(cube, member, parentTable, joins) {
|
|
610
674
|
const dim = this.lookupMember(cube, member, "dimension");
|
|
@@ -657,7 +721,53 @@ var NativeSQLStrategy = class {
|
|
|
657
721
|
}
|
|
658
722
|
return coerceFilterValueForSql(value);
|
|
659
723
|
}
|
|
660
|
-
|
|
724
|
+
/**
|
|
725
|
+
* The column side of {@link coerceTemporal}: normalise the reference so it
|
|
726
|
+
* reads in the storage form the comparand was coerced into.
|
|
727
|
+
*
|
|
728
|
+
* A SQLite `Field.datetime` column carries an INTEGER epoch (a `Date` write)
|
|
729
|
+
* and ISO TEXT (a REST/JSON write, a `NOW()` default — including the platform's
|
|
730
|
+
* own `created_at`) at the SAME time, so coercing the value alone fixes one half
|
|
731
|
+
* and empties the other. That is #3912: a `dateRange: last_30_days` on
|
|
732
|
+
* `created_date` read 0 with 29 rows in range. Every other column and dialect
|
|
733
|
+
* gets its reference back verbatim.
|
|
734
|
+
*/
|
|
735
|
+
temporalColumn(ctx, target, col) {
|
|
736
|
+
if (typeof ctx.coerceTemporalFilterColumn !== "function") return col;
|
|
737
|
+
return ctx.coerceTemporalFilterColumn(target.object, target.field, col) || col;
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Compile a normalized filter node into a boolean SQL expression, recursing
|
|
741
|
+
* through the combinators. `null` = no constraint.
|
|
742
|
+
*
|
|
743
|
+
* Leaves go through {@link buildFilterClause} exactly as they did when this
|
|
744
|
+
* was a flat loop, so the storage-form coercion and the calendar-day
|
|
745
|
+
* upper-bound rule (#3777) apply at every depth — including inside an `$or`,
|
|
746
|
+
* where a second, combinator-aware implementation would have been free to
|
|
747
|
+
* drift from the first.
|
|
748
|
+
*
|
|
749
|
+
* Parenthesisation is explicit rather than left to SQL's precedence: `AND`
|
|
750
|
+
* does bind tighter than `OR`, so `a AND b OR c` happens to be right, but
|
|
751
|
+
* being right by construction is what keeps a future edit from making it
|
|
752
|
+
* wrong.
|
|
753
|
+
*/
|
|
754
|
+
compileFilterNode(node, cube, parentTable, joins, params, ctx) {
|
|
755
|
+
if (!node) return null;
|
|
756
|
+
if (node.kind === "leaf") {
|
|
757
|
+
const colExpr = this.resolveFieldSql(cube, node.member, parentTable, joins);
|
|
758
|
+
const target = this.resolveStorageTarget(cube, node.member, parentTable);
|
|
759
|
+
return this.buildFilterClause(colExpr, node.operator, node.values, params, ctx, target);
|
|
760
|
+
}
|
|
761
|
+
if (node.kind === "not") {
|
|
762
|
+
const inner = this.compileFilterNode(node.child, cube, parentTable, joins, params, ctx);
|
|
763
|
+
return inner ? `NOT (${inner})` : null;
|
|
764
|
+
}
|
|
765
|
+
const parts = node.children.map((child) => this.compileFilterNode(child, cube, parentTable, joins, params, ctx)).filter((s) => !!s);
|
|
766
|
+
if (parts.length === 0) return null;
|
|
767
|
+
if (parts.length === 1) return parts[0];
|
|
768
|
+
return `(${parts.join(node.kind === "or" ? " OR " : " AND ")})`;
|
|
769
|
+
}
|
|
770
|
+
buildFilterClause(rawCol, operator, values, params, ctx, target) {
|
|
661
771
|
const opMap = {
|
|
662
772
|
equals: "=",
|
|
663
773
|
notEquals: "!=",
|
|
@@ -666,26 +776,42 @@ var NativeSQLStrategy = class {
|
|
|
666
776
|
lt: "<",
|
|
667
777
|
lte: "<=",
|
|
668
778
|
contains: "LIKE",
|
|
669
|
-
notContains: "NOT LIKE"
|
|
779
|
+
notContains: "NOT LIKE",
|
|
780
|
+
startsWith: "LIKE",
|
|
781
|
+
endsWith: "LIKE"
|
|
670
782
|
};
|
|
671
|
-
|
|
672
|
-
|
|
783
|
+
const likePattern = {
|
|
784
|
+
contains: (v) => `%${v}%`,
|
|
785
|
+
notContains: (v) => `%${v}%`,
|
|
786
|
+
startsWith: (v) => `${v}%`,
|
|
787
|
+
endsWith: (v) => `%${v}`
|
|
788
|
+
};
|
|
789
|
+
if (operator === "set") return `${rawCol} IS NOT NULL`;
|
|
790
|
+
if (operator === "notSet") return `${rawCol} IS NULL`;
|
|
673
791
|
if (operator === "in" || operator === "notIn") {
|
|
674
792
|
if (!values || values.length === 0) return null;
|
|
675
793
|
const placeholders = values.map((v) => {
|
|
676
794
|
params.push(this.coerceTemporal(ctx, target, v));
|
|
677
795
|
return `$${params.length}`;
|
|
678
796
|
}).join(", ");
|
|
679
|
-
return `${
|
|
797
|
+
return `${this.temporalColumn(ctx, target, rawCol)} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
|
|
680
798
|
}
|
|
681
799
|
const sqlOp = opMap[operator];
|
|
682
800
|
if (!sqlOp || !values || values.length === 0) return null;
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
params.
|
|
801
|
+
const pattern = likePattern[operator];
|
|
802
|
+
if (pattern) {
|
|
803
|
+
params.push(pattern(values[0]));
|
|
804
|
+
return `${rawCol} ${sqlOp} $${params.length}`;
|
|
805
|
+
}
|
|
806
|
+
if (operator === "lte") {
|
|
807
|
+
const nextDay = (0, import_core.nextUtcCalendarDay)(values[0]);
|
|
808
|
+
if (nextDay != null) {
|
|
809
|
+
params.push(this.coerceTemporal(ctx, target, nextDay));
|
|
810
|
+
return `${this.temporalColumn(ctx, target, rawCol)} < $${params.length}`;
|
|
811
|
+
}
|
|
687
812
|
}
|
|
688
|
-
|
|
813
|
+
params.push(this.coerceTemporal(ctx, target, values[0]));
|
|
814
|
+
return `${this.temporalColumn(ctx, target, rawCol)} ${sqlOp} $${params.length}`;
|
|
689
815
|
}
|
|
690
816
|
extractObjectName(cube) {
|
|
691
817
|
return cube.sql.trim();
|
|
@@ -708,6 +834,72 @@ var NativeSQLStrategy = class {
|
|
|
708
834
|
};
|
|
709
835
|
|
|
710
836
|
// src/strategies/objectql-strategy.ts
|
|
837
|
+
var import_core2 = require("@objectstack/core");
|
|
838
|
+
|
|
839
|
+
// src/strategies/cross-object-rebucket.ts
|
|
840
|
+
var RECOMBINABLE_METHODS = /* @__PURE__ */ new Set([
|
|
841
|
+
"sum",
|
|
842
|
+
"count",
|
|
843
|
+
"min",
|
|
844
|
+
"max"
|
|
845
|
+
]);
|
|
846
|
+
var RESTRICTED_BUCKET = "(restricted)";
|
|
847
|
+
function orderableValue(v) {
|
|
848
|
+
if (v == null) return NaN;
|
|
849
|
+
if (typeof v === "number") return v;
|
|
850
|
+
if (v instanceof Date) return v.getTime();
|
|
851
|
+
const n = Number(v);
|
|
852
|
+
if (Number.isFinite(n)) return n;
|
|
853
|
+
return Date.parse(String(v));
|
|
854
|
+
}
|
|
855
|
+
function recombine(method, acc, next) {
|
|
856
|
+
if (method === "min" || method === "max") {
|
|
857
|
+
if (acc === void 0) return next ?? 0;
|
|
858
|
+
const a = orderableValue(acc);
|
|
859
|
+
const n2 = orderableValue(next);
|
|
860
|
+
if (Number.isNaN(n2)) return acc;
|
|
861
|
+
if (Number.isNaN(a)) return next;
|
|
862
|
+
const nextWins = method === "min" ? n2 < a : n2 > a;
|
|
863
|
+
return nextWins ? next : acc;
|
|
864
|
+
}
|
|
865
|
+
const n = Number(next ?? 0);
|
|
866
|
+
return acc === void 0 ? n : Number(acc) + n;
|
|
867
|
+
}
|
|
868
|
+
function rebucketCrossObject(baseRows, baseDimFields, crossDims, measures) {
|
|
869
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
870
|
+
for (const row of baseRows) {
|
|
871
|
+
const resolved = {};
|
|
872
|
+
for (const cd of crossDims) {
|
|
873
|
+
const fk = row[cd.fkField];
|
|
874
|
+
resolved[cd.outputName] = cd.fkToAttr.has(fk) ? cd.fkToAttr.get(fk) : RESTRICTED_BUCKET;
|
|
875
|
+
}
|
|
876
|
+
const keyParts = [];
|
|
877
|
+
for (const f of baseDimFields) keyParts.push(`${f}=${JSON.stringify(row[f] ?? null)}`);
|
|
878
|
+
for (const cd of crossDims) keyParts.push(`${cd.outputName}=${String(resolved[cd.outputName])}`);
|
|
879
|
+
const key = keyParts.join("");
|
|
880
|
+
let bucket = buckets.get(key);
|
|
881
|
+
if (!bucket) {
|
|
882
|
+
bucket = {};
|
|
883
|
+
for (const f of baseDimFields) bucket[f] = row[f];
|
|
884
|
+
for (const cd of crossDims) bucket[cd.outputName] = resolved[cd.outputName];
|
|
885
|
+
buckets.set(key, bucket);
|
|
886
|
+
}
|
|
887
|
+
for (const m of measures) {
|
|
888
|
+
bucket[m.alias] = recombine(m.method, bucket[m.alias], row[m.alias]);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
return [...buckets.values()];
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// src/strategies/objectql-strategy.ts
|
|
895
|
+
var SCALAR_SQL_OPS = {
|
|
896
|
+
equals: "=",
|
|
897
|
+
notEquals: "!=",
|
|
898
|
+
gt: ">",
|
|
899
|
+
gte: ">=",
|
|
900
|
+
lt: "<",
|
|
901
|
+
lte: "<="
|
|
902
|
+
};
|
|
711
903
|
var ObjectQLStrategy = class {
|
|
712
904
|
constructor() {
|
|
713
905
|
this.name = "ObjectQLStrategy";
|
|
@@ -745,15 +937,18 @@ var ObjectQLStrategy = class {
|
|
|
745
937
|
}
|
|
746
938
|
}
|
|
747
939
|
const filter = {};
|
|
748
|
-
const
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
940
|
+
const conjuncts = [];
|
|
941
|
+
this.applyFilterNode(normalizeAnalyticsFilterTree(query), cube, filter, conjuncts);
|
|
942
|
+
for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
|
|
943
|
+
const extra = this.mergeFilterOperand(filter, field, bounds);
|
|
944
|
+
if (extra) conjuncts.push(extra);
|
|
945
|
+
}
|
|
946
|
+
if (conjuncts.length > 0) {
|
|
947
|
+
filter.$and = [...Array.isArray(filter.$and) ? filter.$and : [], ...conjuncts];
|
|
948
|
+
}
|
|
949
|
+
const plan = this.planCrossObject(cube, query, filter);
|
|
950
|
+
if (plan) {
|
|
951
|
+
return this.executeCrossObject(cube, query, aggregations, filter, plan, ctx);
|
|
757
952
|
}
|
|
758
953
|
const rows = await ctx.executeAggregate(objectName, {
|
|
759
954
|
// Structured groupBy items ({field, dateGranularity}) pass through the
|
|
@@ -761,19 +956,23 @@ var ObjectQLStrategy = class {
|
|
|
761
956
|
// contract types groupBy as string[]; the cast carries the richer shape.
|
|
762
957
|
groupBy: groupBy.length > 0 ? groupBy : void 0,
|
|
763
958
|
aggregations: aggregations.length > 0 ? aggregations : void 0,
|
|
764
|
-
filter:
|
|
959
|
+
filter: this.withReadScope(objectName, filter, ctx),
|
|
765
960
|
// ADR-0053 Phase 2 (D2): forward the reference tz so date buckets resolve
|
|
766
961
|
// on that zone's calendar days. A non-UTC zone makes the engine bucket
|
|
767
962
|
// in-memory (uniform across drivers); UTC/unset keeps the DB fast path.
|
|
768
|
-
timezone: query.timezone
|
|
963
|
+
timezone: query.timezone,
|
|
964
|
+
// ADR-0021 D-C (#3602): the second belt. `withReadScope` above is this
|
|
965
|
+
// layer's own scoping; handing the engine the context makes ITS middleware
|
|
966
|
+
// inject RLS too, so a future strategy that forgets `withReadScope` still
|
|
967
|
+
// cannot read across tenants. Without it the operation reaches the engine
|
|
968
|
+
// principal-less and plugin-security falls open — the #3597 shape.
|
|
969
|
+
context: ctx.context
|
|
769
970
|
});
|
|
770
971
|
const mappedRows = rows.map((row) => {
|
|
771
972
|
const mapped = {};
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
if (shortName in row) mapped[dim] = row[shortName];
|
|
776
|
-
}
|
|
973
|
+
for (const dim of this.projectedDimensions(query)) {
|
|
974
|
+
const shortName = this.resolveFieldName(cube, dim, "dimension");
|
|
975
|
+
if (shortName in row) mapped[dim] = row[shortName];
|
|
777
976
|
}
|
|
778
977
|
if (query.measures) {
|
|
779
978
|
for (const m of query.measures) {
|
|
@@ -783,8 +982,28 @@ var ObjectQLStrategy = class {
|
|
|
783
982
|
return mapped;
|
|
784
983
|
});
|
|
785
984
|
const fields = this.buildFieldMeta(query, cube);
|
|
786
|
-
|
|
985
|
+
let sql;
|
|
986
|
+
try {
|
|
987
|
+
sql = (await this.generateSql(query, ctx)).sql;
|
|
988
|
+
} catch {
|
|
989
|
+
sql = void 0;
|
|
990
|
+
}
|
|
991
|
+
return sql ? { rows: mappedRows, fields, sql } : { rows: mappedRows, fields };
|
|
787
992
|
}
|
|
993
|
+
/**
|
|
994
|
+
* Render a REPRESENTATIVE SQL string for an ObjectQL aggregate query.
|
|
995
|
+
*
|
|
996
|
+
* This path executes through `engine.aggregate()`, not raw SQL, so the string
|
|
997
|
+
* is documentation rather than the literal statement — but it must be an
|
|
998
|
+
* honest account of what the query does, because dataset responses echo it
|
|
999
|
+
* and authors read it to verify their widget options landed (#3588). It
|
|
1000
|
+
* therefore renders date bucketing (`date_trunc`), the WHERE predicate,
|
|
1001
|
+
* ordering, and the row window.
|
|
1002
|
+
*
|
|
1003
|
+
* Filter VALUES are rendered as `$n` placeholders and returned in `params`,
|
|
1004
|
+
* never inlined: the echoed statement travels to the browser, and a filter
|
|
1005
|
+
* comparand can carry tenant data.
|
|
1006
|
+
*/
|
|
788
1007
|
async generateSql(query, ctx) {
|
|
789
1008
|
const cube = ctx.getCube(query.cube);
|
|
790
1009
|
if (!cube) {
|
|
@@ -792,28 +1011,302 @@ var ObjectQLStrategy = class {
|
|
|
792
1011
|
}
|
|
793
1012
|
const selectParts = [];
|
|
794
1013
|
const groupByParts = [];
|
|
1014
|
+
const params = [];
|
|
1015
|
+
const granByDim = /* @__PURE__ */ new Map();
|
|
1016
|
+
for (const td of query.timeDimensions ?? []) {
|
|
1017
|
+
if (td.granularity) granByDim.set(td.dimension, td.granularity);
|
|
1018
|
+
}
|
|
1019
|
+
const tableName = this.extractObjectName(cube);
|
|
1020
|
+
const plan = this.planCrossObject(cube, query, Object.fromEntries(
|
|
1021
|
+
collectFilterLeaves(normalizeAnalyticsFilterTree(query)).map((f) => [this.resolveFieldName(cube, f.member, "any"), true])
|
|
1022
|
+
));
|
|
1023
|
+
const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
|
|
1024
|
+
const joinClauses = [];
|
|
1025
|
+
const dimExpr = (dim) => {
|
|
1026
|
+
const cd = crossByDim.get(dim);
|
|
1027
|
+
if (cd) {
|
|
1028
|
+
joinClauses.push(
|
|
1029
|
+
`LEFT JOIN "${cd.refObject}" ON "${tableName}"."${cd.fkField}" = "${cd.refObject}"."id"`
|
|
1030
|
+
);
|
|
1031
|
+
return `"${cd.refObject}"."${cd.attr}"`;
|
|
1032
|
+
}
|
|
1033
|
+
const col = this.resolveFieldName(cube, dim, "dimension");
|
|
1034
|
+
const gran = granByDim.get(dim);
|
|
1035
|
+
return gran ? `date_trunc('${gran}', ${col})` : col;
|
|
1036
|
+
};
|
|
795
1037
|
if (query.dimensions) {
|
|
796
1038
|
for (const dim of query.dimensions) {
|
|
797
|
-
const
|
|
798
|
-
selectParts.push(`${
|
|
799
|
-
groupByParts.push(
|
|
1039
|
+
const expr = dimExpr(dim);
|
|
1040
|
+
selectParts.push(`${expr} AS "${dim}"`);
|
|
1041
|
+
groupByParts.push(expr);
|
|
800
1042
|
}
|
|
801
1043
|
}
|
|
1044
|
+
for (const [dim] of granByDim) {
|
|
1045
|
+
if (query.dimensions?.includes(dim)) continue;
|
|
1046
|
+
const expr = dimExpr(dim);
|
|
1047
|
+
selectParts.push(`${expr} AS "${dim}"`);
|
|
1048
|
+
groupByParts.push(expr);
|
|
1049
|
+
}
|
|
802
1050
|
if (query.measures) {
|
|
803
1051
|
for (const m of query.measures) {
|
|
804
1052
|
const { field, method } = this.resolveMeasureAggregation(cube, m);
|
|
805
|
-
const aggSql = method === "count" ? "COUNT(*)" : `${method.toUpperCase()}(${field})`;
|
|
1053
|
+
const aggSql = method === "count" ? "COUNT(*)" : method === "count_distinct" ? `COUNT(DISTINCT ${field})` : `${method.toUpperCase()}(${field})`;
|
|
806
1054
|
selectParts.push(`${aggSql} AS "${m}"`);
|
|
807
1055
|
}
|
|
808
1056
|
}
|
|
809
|
-
const
|
|
1057
|
+
const whereParts = [];
|
|
1058
|
+
const filterClause = this.renderFilterNodeSql(
|
|
1059
|
+
normalizeAnalyticsFilterTree(query),
|
|
1060
|
+
cube,
|
|
1061
|
+
params
|
|
1062
|
+
);
|
|
1063
|
+
if (filterClause) whereParts.push(filterClause);
|
|
1064
|
+
for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
|
|
1065
|
+
const nextDay = (0, import_core2.nextUtcCalendarDay)(bounds.$lte);
|
|
1066
|
+
params.push(bounds.$gte, nextDay ?? bounds.$lte);
|
|
1067
|
+
whereParts.push(
|
|
1068
|
+
`(${field} >= $${params.length - 1} AND ${field} ${nextDay ? "<" : "<="} $${params.length})`
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
const scope = ctx.getReadScope?.(tableName);
|
|
1072
|
+
if (scope != null) {
|
|
1073
|
+
const { sql: scopeSql, params: scopeParams } = compileScopedFilterToSql(scope, tableName);
|
|
1074
|
+
if (scopeSql) {
|
|
1075
|
+
let i = 0;
|
|
1076
|
+
const rendered = scopeSql.replace(/\?/g, () => {
|
|
1077
|
+
params.push(scopeParams[i++]);
|
|
1078
|
+
return `$${params.length}`;
|
|
1079
|
+
});
|
|
1080
|
+
whereParts.push(`(${rendered})`);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
810
1083
|
let sql = `SELECT ${selectParts.join(", ")} FROM "${tableName}"`;
|
|
1084
|
+
if (joinClauses.length > 0) sql += " " + joinClauses.join(" ");
|
|
1085
|
+
if (whereParts.length > 0) {
|
|
1086
|
+
sql += ` WHERE ${whereParts.join(" AND ")}`;
|
|
1087
|
+
}
|
|
811
1088
|
if (groupByParts.length > 0) {
|
|
812
1089
|
sql += ` GROUP BY ${groupByParts.join(", ")}`;
|
|
813
1090
|
}
|
|
814
|
-
|
|
1091
|
+
if (query.order && Object.keys(query.order).length > 0) {
|
|
1092
|
+
const orderClauses = Object.entries(query.order).map(([f, d]) => `"${f}" ${d.toUpperCase()}`);
|
|
1093
|
+
sql += ` ORDER BY ${orderClauses.join(", ")}`;
|
|
1094
|
+
}
|
|
1095
|
+
if (query.limit != null) sql += ` LIMIT ${query.limit}`;
|
|
1096
|
+
if (query.offset != null) sql += ` OFFSET ${query.offset}`;
|
|
1097
|
+
return { sql, params };
|
|
815
1098
|
}
|
|
816
1099
|
// ── Helpers ──────────────────────────────────────────────────────
|
|
1100
|
+
/**
|
|
1101
|
+
* ADR-0021 D-C (#3597) — AND the object's read scope (tenant + RLS) into the
|
|
1102
|
+
* filter handed to `engine.aggregate`.
|
|
1103
|
+
*
|
|
1104
|
+
* This path used to drop the scope entirely, and the engine could not make up
|
|
1105
|
+
* for it: the aggregate bridge passes no `ExecutionContext`, so the security
|
|
1106
|
+
* middleware's principal-less fall-open skipped its own RLS injection. Both
|
|
1107
|
+
* belts were off at once — an authenticated caller received aggregates
|
|
1108
|
+
* computed over EVERY tenant's rows.
|
|
1109
|
+
*
|
|
1110
|
+
* Composed with `$and`, never by key merge: the query's own filter and the
|
|
1111
|
+
* scope can name the SAME field (e.g. a dashboard filtering `organization_id`),
|
|
1112
|
+
* and a spread would let caller input silently overwrite the security
|
|
1113
|
+
* predicate. `$and` makes that structurally impossible.
|
|
1114
|
+
*/
|
|
1115
|
+
withReadScope(objectName, filter, ctx) {
|
|
1116
|
+
const userFilter = Object.keys(filter).length > 0 ? filter : void 0;
|
|
1117
|
+
if (typeof ctx.getReadScope !== "function") return userFilter;
|
|
1118
|
+
const scope = ctx.getReadScope(objectName);
|
|
1119
|
+
if (scope === void 0 || scope === null) return userFilter;
|
|
1120
|
+
const scopeFilter = scope;
|
|
1121
|
+
if (!userFilter) return scopeFilter;
|
|
1122
|
+
return { $and: [userFilter, scopeFilter] };
|
|
1123
|
+
}
|
|
1124
|
+
/** Is `field` a resolved cross-object (relationship-traversal) reference? */
|
|
1125
|
+
isCrossObjectField(cube, field, baseObject) {
|
|
1126
|
+
if (!field.includes(".")) return false;
|
|
1127
|
+
const alias = field.split(".")[0];
|
|
1128
|
+
const joinedObject = cube.joins?.[alias]?.name ?? alias;
|
|
1129
|
+
return joinedObject !== baseObject;
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Plan how to serve cross-object references on this join-less path (#3654).
|
|
1133
|
+
*
|
|
1134
|
+
* `engine.aggregate()` cannot join. A cross-object DIMENSION within a
|
|
1135
|
+
* supported envelope is served by an FK-expand (`executeCrossObject`): group
|
|
1136
|
+
* the base aggregate on the lookup FK, resolve the FK to the related attribute
|
|
1137
|
+
* with a SCOPED read, re-bucket in memory. Returns `null` for a base-only
|
|
1138
|
+
* query (direct path), a plan for an in-envelope cross-object query.
|
|
1139
|
+
*
|
|
1140
|
+
* THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
|
|
1141
|
+
* (needs a real join to evaluate), a MULTI-HOP dimension (`a.b.c`), or a
|
|
1142
|
+
* non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
|
|
1143
|
+
* cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
|
|
1144
|
+
* `generateSql()` calls this too, so the preview accepts/rejects the same set.
|
|
1145
|
+
*
|
|
1146
|
+
* Detection is on RESOLVED field names, so a dotted dimension the cube
|
|
1147
|
+
* flattens to a real column is treated as base, not cross-object.
|
|
1148
|
+
*/
|
|
1149
|
+
planCrossObject(cube, query, filter) {
|
|
1150
|
+
const baseObject = this.extractObjectName(cube);
|
|
1151
|
+
for (const td of query.timeDimensions ?? []) {
|
|
1152
|
+
const field = this.resolveFieldName(cube, td.dimension, "dimension");
|
|
1153
|
+
if (this.isCrossObjectField(cube, field, baseObject)) {
|
|
1154
|
+
throw new Error(
|
|
1155
|
+
`[Analytics] ObjectQLStrategy cannot bucket a cross-object time dimension ("${field}").`
|
|
1156
|
+
);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
const nonDim = [
|
|
1160
|
+
...(query.measures ?? []).map((m) => ({ where: "measure", field: this.resolveMeasureAggregation(cube, m).field })),
|
|
1161
|
+
...Object.keys(filter).map((f) => ({ where: "filter", field: f }))
|
|
1162
|
+
].filter((r) => this.isCrossObjectField(cube, r.field, baseObject));
|
|
1163
|
+
if (nonDim.length > 0) {
|
|
1164
|
+
throw new Error(
|
|
1165
|
+
`[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}.`
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
const crossDims = [];
|
|
1169
|
+
for (const dim of query.dimensions ?? []) {
|
|
1170
|
+
const field = this.resolveFieldName(cube, dim, "dimension");
|
|
1171
|
+
if (!this.isCrossObjectField(cube, field, baseObject)) continue;
|
|
1172
|
+
const [alias, ...rest] = field.split(".");
|
|
1173
|
+
const attr = rest.join(".");
|
|
1174
|
+
if (attr.includes(".")) {
|
|
1175
|
+
throw new Error(
|
|
1176
|
+
`[Analytics] ObjectQLStrategy supports only single-hop cross-object dimensions; "${field}" traverses more than one relationship.`
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
crossDims.push({ outputName: dim, fkField: alias, attr, refObject: cube.joins?.[alias]?.name ?? alias });
|
|
1180
|
+
}
|
|
1181
|
+
if (crossDims.length === 0) return null;
|
|
1182
|
+
for (const m of query.measures ?? []) {
|
|
1183
|
+
const { method } = this.resolveMeasureAggregation(cube, m);
|
|
1184
|
+
if (!RECOMBINABLE_METHODS.has(method)) {
|
|
1185
|
+
throw new Error(
|
|
1186
|
+
`[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.`
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
return { crossDims };
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Serve a cross-object-dimension query by FK-expand (#3654). The pure
|
|
1194
|
+
* re-bucketing step lives in `cross-object-rebucket.ts`.
|
|
1195
|
+
*/
|
|
1196
|
+
async executeCrossObject(cube, query, aggregations, filter, plan, ctx) {
|
|
1197
|
+
const baseObject = this.extractObjectName(cube);
|
|
1198
|
+
const crossByDim = new Map(plan.crossDims.map((cd) => [cd.outputName, cd]));
|
|
1199
|
+
const granByDim = /* @__PURE__ */ new Map();
|
|
1200
|
+
for (const td of query.timeDimensions ?? []) {
|
|
1201
|
+
if (td.granularity) granByDim.set(td.dimension, td.granularity);
|
|
1202
|
+
}
|
|
1203
|
+
const groupBy = [];
|
|
1204
|
+
const baseDimFields = [];
|
|
1205
|
+
for (const dim of query.dimensions ?? []) {
|
|
1206
|
+
const cd = crossByDim.get(dim);
|
|
1207
|
+
if (cd) {
|
|
1208
|
+
groupBy.push(cd.fkField);
|
|
1209
|
+
continue;
|
|
1210
|
+
}
|
|
1211
|
+
const field = this.resolveFieldName(cube, dim, "dimension");
|
|
1212
|
+
const gran = granByDim.get(dim);
|
|
1213
|
+
groupBy.push(gran ? { field, dateGranularity: gran } : field);
|
|
1214
|
+
baseDimFields.push(field);
|
|
1215
|
+
granByDim.delete(dim);
|
|
1216
|
+
}
|
|
1217
|
+
for (const [dim, gran] of granByDim) {
|
|
1218
|
+
const field = this.resolveFieldName(cube, dim, "dimension");
|
|
1219
|
+
groupBy.push({ field, dateGranularity: gran });
|
|
1220
|
+
baseDimFields.push(field);
|
|
1221
|
+
}
|
|
1222
|
+
const baseRows = await ctx.executeAggregate(baseObject, {
|
|
1223
|
+
groupBy: groupBy.length > 0 ? groupBy : void 0,
|
|
1224
|
+
aggregations: aggregations.length > 0 ? aggregations : void 0,
|
|
1225
|
+
filter: this.withReadScope(baseObject, filter, ctx),
|
|
1226
|
+
timezone: query.timezone,
|
|
1227
|
+
context: ctx.context
|
|
1228
|
+
});
|
|
1229
|
+
const resolvedDims = [];
|
|
1230
|
+
for (const cd of plan.crossDims) {
|
|
1231
|
+
const fkValues = [...new Set(baseRows.map((r) => r[cd.fkField]).filter((v) => v != null))];
|
|
1232
|
+
const fkToAttr = await this.resolveFkAttr(cd.refObject, cd.attr, fkValues, ctx);
|
|
1233
|
+
resolvedDims.push({ outputName: cd.outputName, fkField: cd.fkField, fkToAttr });
|
|
1234
|
+
}
|
|
1235
|
+
const measures = (query.measures ?? []).map((m) => ({
|
|
1236
|
+
alias: m,
|
|
1237
|
+
// planCrossObject already asserted every measure is recombinable.
|
|
1238
|
+
method: this.resolveMeasureAggregation(cube, m).method
|
|
1239
|
+
}));
|
|
1240
|
+
const merged = rebucketCrossObject(baseRows, baseDimFields, resolvedDims, measures);
|
|
1241
|
+
const mappedRows = merged.map((row) => {
|
|
1242
|
+
const out = {};
|
|
1243
|
+
for (const dim of this.projectedDimensions(query)) {
|
|
1244
|
+
if (crossByDim.has(dim)) {
|
|
1245
|
+
if (dim in row) out[dim] = row[dim];
|
|
1246
|
+
} else {
|
|
1247
|
+
const field = this.resolveFieldName(cube, dim, "dimension");
|
|
1248
|
+
if (field in row) out[dim] = row[field];
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
for (const m of query.measures ?? []) {
|
|
1252
|
+
if (m in row) out[m] = row[m];
|
|
1253
|
+
}
|
|
1254
|
+
return out;
|
|
1255
|
+
});
|
|
1256
|
+
return { rows: mappedRows, fields: this.buildFieldMeta(query, cube) };
|
|
1257
|
+
}
|
|
1258
|
+
/**
|
|
1259
|
+
* Resolve `fkValues` (ids of `refObject`) to their `attr` values, applying the
|
|
1260
|
+
* referenced object's OWN read scope (#3654 / #3602). Reuses the aggregate
|
|
1261
|
+
* bridge — `group by (id, attr)` is one row per record. Ids the scope hides
|
|
1262
|
+
* are simply absent from the map (⇒ RESTRICTED bucket downstream).
|
|
1263
|
+
*/
|
|
1264
|
+
async resolveFkAttr(refObject, attr, fkValues, ctx) {
|
|
1265
|
+
const map = /* @__PURE__ */ new Map();
|
|
1266
|
+
if (fkValues.length === 0 || typeof ctx.executeAggregate !== "function") return map;
|
|
1267
|
+
const idFilter = { id: { $in: fkValues } };
|
|
1268
|
+
const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
|
|
1269
|
+
const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
|
|
1270
|
+
const rows = await ctx.executeAggregate(refObject, {
|
|
1271
|
+
groupBy: ["id", attr],
|
|
1272
|
+
aggregations: [{ field: "id", method: "count", alias: "_c" }],
|
|
1273
|
+
filter,
|
|
1274
|
+
context: ctx.context
|
|
1275
|
+
});
|
|
1276
|
+
for (const r of rows) {
|
|
1277
|
+
if (r.id != null) map.set(r.id, r[attr]);
|
|
1278
|
+
}
|
|
1279
|
+
return map;
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Render one normalized filter as a display SQL predicate for `generateSql`.
|
|
1283
|
+
*
|
|
1284
|
+
* Mirrors `NativeSQLStrategy.buildFilterClause`'s operator vocabulary so the
|
|
1285
|
+
* two previews read alike, but binds through `coerceFilterValueForObjectQL`:
|
|
1286
|
+
* the comparand shown is the one THIS path actually hands the engine (a real
|
|
1287
|
+
* boolean, not SQL's 1/0). Returns null for an operator/value combination
|
|
1288
|
+
* that carries no predicate, matching `execute()`, which drops it too.
|
|
1289
|
+
*/
|
|
1290
|
+
buildFilterClauseSql(col, operator, values, params) {
|
|
1291
|
+
if (operator === "set") return `${col} IS NOT NULL`;
|
|
1292
|
+
if (operator === "notSet") return `${col} IS NULL`;
|
|
1293
|
+
if (!values || values.length === 0) return null;
|
|
1294
|
+
if (operator === "in" || operator === "notIn") {
|
|
1295
|
+
const placeholders = values.map((v) => {
|
|
1296
|
+
params.push(coerceFilterValueForObjectQL(v));
|
|
1297
|
+
return `$${params.length}`;
|
|
1298
|
+
}).join(", ");
|
|
1299
|
+
return `${col} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
|
|
1300
|
+
}
|
|
1301
|
+
if (operator === "contains" || operator === "notContains") {
|
|
1302
|
+
params.push(`%${values[0]}%`);
|
|
1303
|
+
return `${col} ${operator === "contains" ? "LIKE" : "NOT LIKE"} $${params.length}`;
|
|
1304
|
+
}
|
|
1305
|
+
const op = SCALAR_SQL_OPS[operator];
|
|
1306
|
+
if (!op) return null;
|
|
1307
|
+
params.push(coerceFilterValueForObjectQL(values[0]));
|
|
1308
|
+
return `${col} ${op} $${params.length}`;
|
|
1309
|
+
}
|
|
817
1310
|
/**
|
|
818
1311
|
* Resolve a member ref to a `{ sql, type? }` definition.
|
|
819
1312
|
*
|
|
@@ -878,6 +1371,162 @@ var ObjectQLStrategy = class {
|
|
|
878
1371
|
}
|
|
879
1372
|
return { field: "*", method: "count" };
|
|
880
1373
|
}
|
|
1374
|
+
/**
|
|
1375
|
+
* AND one more operand onto `filter[field]`, merging operator objects rather
|
|
1376
|
+
* than overwriting them. Returns a standalone conjunct when the two cannot
|
|
1377
|
+
* share one entry, or `null` when the merge absorbed the operand.
|
|
1378
|
+
*
|
|
1379
|
+
* Every predicate this strategy contributes goes through here — the caller's
|
|
1380
|
+
* `where` and the time-dimension `dateRange` alike. Two operands on one field
|
|
1381
|
+
* are the normal case (`{$gte}` from a `where` plus `{$gte,$lte}` from a
|
|
1382
|
+
* window on `close_date`), and a plain assignment would keep only the last:
|
|
1383
|
+
* that is how a range used to lose a bound.
|
|
1384
|
+
*
|
|
1385
|
+
* Spreading is sound only while the operands name DIFFERENT operators. Where
|
|
1386
|
+
* they collide — two `$gte` bounds on one field, which a window makes routine
|
|
1387
|
+
* and which a `where` can already produce on its own through `$and` — the
|
|
1388
|
+
* spread keeps whichever came last and WIDENS the query. Same for a bare
|
|
1389
|
+
* equality meeting an operator object: neither can absorb the other. Those
|
|
1390
|
+
* are handed back for the caller to AND in separately, so the engine
|
|
1391
|
+
* intersects them instead of the strategy picking a winner.
|
|
1392
|
+
*/
|
|
1393
|
+
/**
|
|
1394
|
+
* Fold a normalized filter node into the engine filter being built.
|
|
1395
|
+
*
|
|
1396
|
+
* AND-ed LEAVES merge per field through {@link mergeFilterOperand}, exactly
|
|
1397
|
+
* as the flat loop this replaced did — so a query without combinators still
|
|
1398
|
+
* produces byte-identical engine input. Anything structural (`$or`, `$not`,
|
|
1399
|
+
* a nested `$and` that cannot merge) becomes its own conjunct, which the
|
|
1400
|
+
* caller ANDs in. The engine speaks these combinators natively
|
|
1401
|
+
* (`FilterCondition` declares them and every driver compiles them), so this
|
|
1402
|
+
* path hands them over rather than lowering them.
|
|
1403
|
+
*/
|
|
1404
|
+
applyFilterNode(node, cube, filter, conjuncts) {
|
|
1405
|
+
if (!node) return;
|
|
1406
|
+
if (node.kind === "leaf") {
|
|
1407
|
+
const fieldName = this.resolveFieldName(cube, node.member, "any");
|
|
1408
|
+
const extra = this.mergeFilterOperand(filter, fieldName, this.convertFilter(node.operator, node.values));
|
|
1409
|
+
if (extra) conjuncts.push(extra);
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
if (node.kind === "and") {
|
|
1413
|
+
for (const child of node.children) this.applyFilterNode(child, cube, filter, conjuncts);
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
const rendered = this.filterNodeToCondition(node, cube);
|
|
1417
|
+
if (rendered) conjuncts.push(rendered);
|
|
1418
|
+
}
|
|
1419
|
+
/** A node as a standalone `FilterCondition` the engine can consume. */
|
|
1420
|
+
filterNodeToCondition(node, cube) {
|
|
1421
|
+
if (!node) return null;
|
|
1422
|
+
if (node.kind === "not") {
|
|
1423
|
+
const inner = this.filterNodeToCondition(node.child, cube);
|
|
1424
|
+
return inner ? { $not: inner } : null;
|
|
1425
|
+
}
|
|
1426
|
+
if (node.kind === "or") {
|
|
1427
|
+
const branches = node.children.map((child) => this.filterNodeToCondition(child, cube)).filter((c) => !!c);
|
|
1428
|
+
return branches.length > 0 ? { $or: branches } : null;
|
|
1429
|
+
}
|
|
1430
|
+
const filter = {};
|
|
1431
|
+
const conjuncts = [];
|
|
1432
|
+
this.applyFilterNode(node, cube, filter, conjuncts);
|
|
1433
|
+
if (conjuncts.length > 0) {
|
|
1434
|
+
filter.$and = [...Array.isArray(filter.$and) ? filter.$and : [], ...conjuncts];
|
|
1435
|
+
}
|
|
1436
|
+
return Object.keys(filter).length > 0 ? filter : null;
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* Render a normalized filter node as the display SQL `/analytics/sql`
|
|
1440
|
+
* echoes. Values still bind as `$n` placeholders — the echo travels to the
|
|
1441
|
+
* browser, so a comparand is never inlined.
|
|
1442
|
+
*/
|
|
1443
|
+
renderFilterNodeSql(node, cube, params) {
|
|
1444
|
+
if (!node) return null;
|
|
1445
|
+
if (node.kind === "leaf") {
|
|
1446
|
+
return this.buildFilterClauseSql(
|
|
1447
|
+
this.resolveFieldName(cube, node.member, "any"),
|
|
1448
|
+
node.operator,
|
|
1449
|
+
node.values,
|
|
1450
|
+
params
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
if (node.kind === "not") {
|
|
1454
|
+
const inner = this.renderFilterNodeSql(node.child, cube, params);
|
|
1455
|
+
return inner ? `NOT (${inner})` : null;
|
|
1456
|
+
}
|
|
1457
|
+
const parts = node.children.map((child) => this.renderFilterNodeSql(child, cube, params)).filter((s) => !!s);
|
|
1458
|
+
if (parts.length === 0) return null;
|
|
1459
|
+
if (parts.length === 1) return parts[0];
|
|
1460
|
+
return `(${parts.join(node.kind === "or" ? " OR " : " AND ")})`;
|
|
1461
|
+
}
|
|
1462
|
+
mergeFilterOperand(filter, field, operand) {
|
|
1463
|
+
const existing = filter[field];
|
|
1464
|
+
if (existing === void 0) {
|
|
1465
|
+
filter[field] = operand;
|
|
1466
|
+
return null;
|
|
1467
|
+
}
|
|
1468
|
+
const mergeable = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
1469
|
+
if (!mergeable(existing) || !mergeable(operand)) return { [field]: operand };
|
|
1470
|
+
if (Object.keys(operand).some((op) => op in existing)) return { [field]: operand };
|
|
1471
|
+
filter[field] = { ...existing, ...operand };
|
|
1472
|
+
return null;
|
|
1473
|
+
}
|
|
1474
|
+
/**
|
|
1475
|
+
* Lower `timeDimensions[].dateRange` into resolved-field bounds (#3650).
|
|
1476
|
+
*
|
|
1477
|
+
* `dateRange` states a WINDOW on a time dimension; it is a SIBLING of `where`,
|
|
1478
|
+
* never folded into it. `normalizeAnalyticsFilters` reads only `where`, so
|
|
1479
|
+
* this path used to drop the window on the floor — no error, just every row
|
|
1480
|
+
* ever recorded. Nor is that a corner case: `NativeSQLStrategy.canHandle`
|
|
1481
|
+
* declines any query carrying a `granularity`, so a date-bucketed trend lands
|
|
1482
|
+
* HERE on every driver — and "bucketed trend" is precisely the shape that also
|
|
1483
|
+
* carries a range ("last 12 months", "this quarter").
|
|
1484
|
+
*
|
|
1485
|
+
* Bounds are inclusive on both ends — logically "from day X through day Y".
|
|
1486
|
+
* The `$lte` end is left as the bare calendar day on purpose: the driver's
|
|
1487
|
+
* filter compiler owns the calendar-day → instant translation, compiling a
|
|
1488
|
+
* bare-day `$lte` on a `datetime` column into the half-open `< nextDay`
|
|
1489
|
+
* (#3777) while a `date` column keeps the plain `<=`. `NativeSQLStrategy`
|
|
1490
|
+
* performs the same half-open translation itself because it binds into raw
|
|
1491
|
+
* SQL, so one dashboard reads the same on every driver.
|
|
1492
|
+
*
|
|
1493
|
+
* Comparands are coerced by the SAME helper the `where` path uses, so an
|
|
1494
|
+
* epoch-ms bound recovers as a number and an ISO string stays a string. No
|
|
1495
|
+
* STORAGE coercion happens here, deliberately: `NativeSQLStrategy` needs
|
|
1496
|
+
* `coerceTemporal` because it binds into raw SQL and had to learn that a
|
|
1497
|
+
* SQLite `Field.datetime` is an INTEGER epoch (#2034); this path goes through
|
|
1498
|
+
* `engine.aggregate()`, where the driver's own CRUD filter coercion applies —
|
|
1499
|
+
* the very coercion that already makes a `where` bound on that same column
|
|
1500
|
+
* work today.
|
|
1501
|
+
*
|
|
1502
|
+
* A bare-string `dateRange` degenerates to the single point `[s, s]`, matching
|
|
1503
|
+
* `NativeSQLStrategy`. Relative phrases ("Last 7 days") are NOT resolved here;
|
|
1504
|
+
* neither SQL path resolves them, and inventing a second interpretation on the
|
|
1505
|
+
* driver-independent path is how the two would drift apart again.
|
|
1506
|
+
*
|
|
1507
|
+
* An oddly-sized array (the schema types `dateRange` as a plain `string[]`)
|
|
1508
|
+
* takes its first two entries, a one-entry array degenerating to a point.
|
|
1509
|
+
* `NativeSQLStrategy` drops such a window entirely — but "drop the window"
|
|
1510
|
+
* means "plot all of history", which is the very failure this fixes, so the
|
|
1511
|
+
* fallback here errs toward the narrower query instead.
|
|
1512
|
+
*/
|
|
1513
|
+
dateRangeBounds(cube, query) {
|
|
1514
|
+
const out = [];
|
|
1515
|
+
for (const td of query.timeDimensions ?? []) {
|
|
1516
|
+
if (!td.dateRange) continue;
|
|
1517
|
+
const range = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
|
|
1518
|
+
const [start, end = start] = range;
|
|
1519
|
+
if (start == null) continue;
|
|
1520
|
+
out.push({
|
|
1521
|
+
field: this.resolveFieldName(cube, td.dimension, "dimension"),
|
|
1522
|
+
bounds: {
|
|
1523
|
+
$gte: coerceFilterValueForObjectQL(String(start)),
|
|
1524
|
+
$lte: coerceFilterValueForObjectQL(String(end))
|
|
1525
|
+
}
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
return out;
|
|
1529
|
+
}
|
|
881
1530
|
convertFilter(operator, values) {
|
|
882
1531
|
if (operator === "set") return { $ne: null };
|
|
883
1532
|
if (operator === "notSet") return null;
|
|
@@ -899,24 +1548,60 @@ var ObjectQLStrategy = class {
|
|
|
899
1548
|
return { $lte: v0 };
|
|
900
1549
|
case "contains":
|
|
901
1550
|
return { $regex: values[0] };
|
|
1551
|
+
// `notContains` had no arm and fell to the `default` below, which returns
|
|
1552
|
+
// a BARE VALUE — i.e. `{field: 'x'}`, an equality. "does not contain x"
|
|
1553
|
+
// was compiled as "equals x". These three pass through as the canonical
|
|
1554
|
+
// spec operators every driver implements directly, so an anchored match
|
|
1555
|
+
// stays anchored rather than depending on regex dialect (#4128).
|
|
1556
|
+
case "notContains":
|
|
1557
|
+
return { $notContains: values[0] };
|
|
1558
|
+
case "startsWith":
|
|
1559
|
+
return { $startsWith: values[0] };
|
|
1560
|
+
case "endsWith":
|
|
1561
|
+
return { $endsWith: values[0] };
|
|
902
1562
|
case "in":
|
|
903
1563
|
return { $in: all };
|
|
904
1564
|
case "notIn":
|
|
905
1565
|
return { $nin: all };
|
|
906
1566
|
default:
|
|
907
|
-
|
|
1567
|
+
throw new Error(
|
|
1568
|
+
`[analytics] ObjectQL strategy cannot express filter operator "${operator}". Treating it as an equality would silently query something the author did not ask for.`
|
|
1569
|
+
);
|
|
908
1570
|
}
|
|
909
1571
|
}
|
|
910
1572
|
extractObjectName(cube) {
|
|
911
1573
|
return cube.sql.trim();
|
|
912
1574
|
}
|
|
1575
|
+
/**
|
|
1576
|
+
* The dimensions this query PROJECTS, in the order the result carries them:
|
|
1577
|
+
* every `dimensions` entry, then every granular `timeDimensions` entry that
|
|
1578
|
+
* is not already one of them.
|
|
1579
|
+
*
|
|
1580
|
+
* `timeDimensions` is not merely a filter carrier. An entry with a
|
|
1581
|
+
* `granularity` is GROUPED BY — see the `td.granularity` sites that build
|
|
1582
|
+
* groupBy here, in `generateSql` and in the cross-object path — so its
|
|
1583
|
+
* bucket is a COLUMN of the result; an entry without one only contributes a
|
|
1584
|
+
* `dateRange` predicate and must NOT be projected.
|
|
1585
|
+
*
|
|
1586
|
+
* Grouping, row mapping and {@link buildFieldMeta} have to agree on exactly
|
|
1587
|
+
* that set. When they did not, a bucketed query returned rows carrying only
|
|
1588
|
+
* the measures and a `fields` list that never mentioned the bucket — a trend
|
|
1589
|
+
* chart got N values and no x-axis (#4033) — even though the SQL had
|
|
1590
|
+
* selected `date_trunc(…) AS "<dim>"` all along. One definition, every
|
|
1591
|
+
* consumer.
|
|
1592
|
+
*/
|
|
1593
|
+
projectedDimensions(query) {
|
|
1594
|
+
const out = [...query.dimensions ?? []];
|
|
1595
|
+
for (const td of query.timeDimensions ?? []) {
|
|
1596
|
+
if (td.granularity && !out.includes(td.dimension)) out.push(td.dimension);
|
|
1597
|
+
}
|
|
1598
|
+
return out;
|
|
1599
|
+
}
|
|
913
1600
|
buildFieldMeta(query, cube) {
|
|
914
1601
|
const fields = [];
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
fields.push({ name: dim, type: d?.type || "string" });
|
|
919
|
-
}
|
|
1602
|
+
for (const dim of this.projectedDimensions(query)) {
|
|
1603
|
+
const d = this.lookupMember(cube, dim, "dimension");
|
|
1604
|
+
fields.push({ name: dim, type: d?.type || "string" });
|
|
920
1605
|
}
|
|
921
1606
|
if (query.measures) {
|
|
922
1607
|
for (const m of query.measures) {
|
|
@@ -928,14 +1613,16 @@ var ObjectQLStrategy = class {
|
|
|
928
1613
|
};
|
|
929
1614
|
|
|
930
1615
|
// src/dataset-compiler.ts
|
|
1616
|
+
var import_data = require("@objectstack/spec/data");
|
|
931
1617
|
var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set(["array_agg", "string_agg"]);
|
|
1618
|
+
var SUPPORTED_AGGREGATES = import_data.AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
|
|
932
1619
|
function aggregateToMetricType(m) {
|
|
933
1620
|
if (!m.aggregate) {
|
|
934
1621
|
throw new Error(`[dataset-compiler] non-derived measure "${m.name}" has no aggregate`);
|
|
935
1622
|
}
|
|
936
1623
|
if (UNSUPPORTED_AGGREGATES.has(m.aggregate)) {
|
|
937
1624
|
throw new Error(
|
|
938
|
-
`[dataset-compiler] measure "${m.name}" uses aggregate "${m.aggregate}" which is not supported by the v1 dataset runtime (supported:
|
|
1625
|
+
`[dataset-compiler] measure "${m.name}" uses aggregate "${m.aggregate}" which is not supported by the v1 dataset runtime (supported: ${SUPPORTED_AGGREGATES.join(", ")}).`
|
|
939
1626
|
);
|
|
940
1627
|
}
|
|
941
1628
|
return m.aggregate;
|
|
@@ -1062,6 +1749,23 @@ function compileDataset(dataset, resolver) {
|
|
|
1062
1749
|
}
|
|
1063
1750
|
|
|
1064
1751
|
// src/dataset-executor.ts
|
|
1752
|
+
var import_core3 = require("@objectstack/core");
|
|
1753
|
+
function resolveSelectionTokens(compiled, selection, context) {
|
|
1754
|
+
const tokenCtx = (0, import_core3.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
|
|
1755
|
+
const resolve = (v) => (0, import_core3.resolveFilterTokens)(v, tokenCtx);
|
|
1756
|
+
const filter = resolve(compiled.filter);
|
|
1757
|
+
const measureFilters = resolve(compiled.measureFilters);
|
|
1758
|
+
const runtimeFilter = resolve(selection.runtimeFilter);
|
|
1759
|
+
const timeDimensions = selection.timeDimensions?.map(
|
|
1760
|
+
(td) => td.dateRange == null ? td : { ...td, dateRange: resolve(td.dateRange) }
|
|
1761
|
+
);
|
|
1762
|
+
const compiledChanged = filter !== compiled.filter || measureFilters !== compiled.measureFilters;
|
|
1763
|
+
const selectionChanged = runtimeFilter !== selection.runtimeFilter || timeDimensions !== void 0 && timeDimensions.some((td, i) => td !== selection.timeDimensions[i]);
|
|
1764
|
+
return {
|
|
1765
|
+
compiled: compiledChanged ? { ...compiled, filter, measureFilters } : compiled,
|
|
1766
|
+
selection: selectionChanged ? { ...selection, runtimeFilter, timeDimensions } : selection
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1065
1769
|
function combineFilters(a, b) {
|
|
1066
1770
|
if (a && b) return { $and: [a, b] };
|
|
1067
1771
|
return a ?? b;
|
|
@@ -1100,6 +1804,76 @@ function computeDerived(d, row) {
|
|
|
1100
1804
|
return null;
|
|
1101
1805
|
}
|
|
1102
1806
|
}
|
|
1807
|
+
function resolveDimensionGranularity(selection, dimension, datasetDefault) {
|
|
1808
|
+
const stated = (selection.timeDimensions ?? []).find((t) => t.dimension === dimension)?.granularity;
|
|
1809
|
+
if (stated) return stated;
|
|
1810
|
+
return selection.dateGranularity ?? datasetDefault;
|
|
1811
|
+
}
|
|
1812
|
+
function compareValues(a, b) {
|
|
1813
|
+
const aNull = a == null || a === "";
|
|
1814
|
+
const bNull = b == null || b === "";
|
|
1815
|
+
if (aNull || bNull) return aNull && bNull ? 0 : aNull ? 1 : -1;
|
|
1816
|
+
if (a instanceof Date || b instanceof Date) {
|
|
1817
|
+
return Number(a instanceof Date ? a.getTime() : a) - Number(b instanceof Date ? b.getTime() : b);
|
|
1818
|
+
}
|
|
1819
|
+
if (typeof a === "boolean" || typeof b === "boolean") {
|
|
1820
|
+
return Number(a) - Number(b);
|
|
1821
|
+
}
|
|
1822
|
+
const an = typeof a === "number" ? a : Number(a);
|
|
1823
|
+
const bn = typeof b === "number" ? b : Number(b);
|
|
1824
|
+
if (Number.isFinite(an) && Number.isFinite(bn)) return an - bn;
|
|
1825
|
+
return String(a).localeCompare(String(b));
|
|
1826
|
+
}
|
|
1827
|
+
function applyOrdering(rows, order, sortKeys) {
|
|
1828
|
+
const keys = Object.entries(order ?? {});
|
|
1829
|
+
if (keys.length === 0 || rows.length < 2) return rows;
|
|
1830
|
+
return [...rows].sort((ra, rb) => {
|
|
1831
|
+
for (const [key, dir] of keys) {
|
|
1832
|
+
const map = sortKeys?.[key];
|
|
1833
|
+
const av = map?.get(ra[key]) ?? ra[key];
|
|
1834
|
+
const bv = map?.get(rb[key]) ?? rb[key];
|
|
1835
|
+
const aNull = av == null || av === "";
|
|
1836
|
+
const bNull = bv == null || bv === "";
|
|
1837
|
+
if (aNull || bNull) {
|
|
1838
|
+
if (aNull && bNull) continue;
|
|
1839
|
+
return aNull ? 1 : -1;
|
|
1840
|
+
}
|
|
1841
|
+
const c = compareValues(av, bv);
|
|
1842
|
+
if (c !== 0) return dir === "desc" ? -c : c;
|
|
1843
|
+
}
|
|
1844
|
+
return 0;
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
function applyWindow(rows, limit, offset) {
|
|
1848
|
+
const start = offset != null && offset > 0 ? offset : 0;
|
|
1849
|
+
if (start === 0 && limit == null) return rows;
|
|
1850
|
+
return rows.slice(start, limit != null ? start + limit : void 0);
|
|
1851
|
+
}
|
|
1852
|
+
function resolveOrdering(selection, dimensions, timeDimensions = []) {
|
|
1853
|
+
const order = selection.order;
|
|
1854
|
+
if (order && Object.keys(order).length > 0) {
|
|
1855
|
+
const selectable = /* @__PURE__ */ new Set([
|
|
1856
|
+
...dimensions,
|
|
1857
|
+
...selection.measures,
|
|
1858
|
+
...selection.measures.map((m) => `${m}__compare`)
|
|
1859
|
+
]);
|
|
1860
|
+
const unknown = Object.keys(order).filter((k) => !selectable.has(k));
|
|
1861
|
+
if (unknown.length) {
|
|
1862
|
+
throw new Error(
|
|
1863
|
+
`[dataset-executor] order key(s) ${unknown.map((k) => `"${k}"`).join(", ")} \u2014 not a selected dimension or measure. Selectable here: ${[...selectable].join(", ") || "(none)"}.`
|
|
1864
|
+
);
|
|
1865
|
+
}
|
|
1866
|
+
return order;
|
|
1867
|
+
}
|
|
1868
|
+
if ((selection.limit != null || selection.offset != null) && dimensions.length > 0) {
|
|
1869
|
+
return Object.fromEntries(dimensions.map((d) => [d, "asc"]));
|
|
1870
|
+
}
|
|
1871
|
+
const timeKeys = timeDimensions.filter((d) => dimensions.includes(d));
|
|
1872
|
+
if (timeKeys.length > 0) {
|
|
1873
|
+
return Object.fromEntries(timeKeys.map((d) => [d, "asc"]));
|
|
1874
|
+
}
|
|
1875
|
+
return void 0;
|
|
1876
|
+
}
|
|
1103
1877
|
function parseUTC(date) {
|
|
1104
1878
|
const ms = Date.parse(date.length === 10 ? `${date}T00:00:00Z` : date);
|
|
1105
1879
|
if (Number.isNaN(ms)) throw new Error(`[dataset-executor] invalid date in dateRange: "${date}"`);
|
|
@@ -1127,8 +1901,17 @@ function shiftRange(range, kind) {
|
|
|
1127
1901
|
return [toISODate(prevStartMs), toISODate(prevEndMs)];
|
|
1128
1902
|
}
|
|
1129
1903
|
var DatasetExecutor = class {
|
|
1130
|
-
|
|
1904
|
+
/**
|
|
1905
|
+
* @param service - The analytics service the executor issues its queries to.
|
|
1906
|
+
* @param orderLabels - Optional sort-key label hook (#3680). When provided,
|
|
1907
|
+
* an order key naming a label-bearing (`select`/`lookup`) dimension sorts
|
|
1908
|
+
* by its display label instead of the stored value. Omit to sort by stored
|
|
1909
|
+
* values everywhere (e.g. the draft-preview path, whose seed rows already
|
|
1910
|
+
* carry display names).
|
|
1911
|
+
*/
|
|
1912
|
+
constructor(service, orderLabels) {
|
|
1131
1913
|
this.service = service;
|
|
1914
|
+
this.orderLabels = orderLabels;
|
|
1132
1915
|
}
|
|
1133
1916
|
/**
|
|
1134
1917
|
* Execute a dataset selection and return the shaped rows (+ field metadata).
|
|
@@ -1137,7 +1920,8 @@ var DatasetExecutor = class {
|
|
|
1137
1920
|
* underlying `IAnalyticsService.query` so the tenant/RLS read scope is
|
|
1138
1921
|
* applied per request (ADR-0021 D-C).
|
|
1139
1922
|
*/
|
|
1140
|
-
async execute(
|
|
1923
|
+
async execute(compiledInput, selectionInput, context) {
|
|
1924
|
+
const { compiled, selection } = resolveSelectionTokens(compiledInput, selectionInput, context);
|
|
1141
1925
|
const result = await this.executeSelection(compiled, selection, context);
|
|
1142
1926
|
const groupings = selection.totals?.groupings;
|
|
1143
1927
|
if (groupings?.length) {
|
|
@@ -1181,6 +1965,14 @@ var DatasetExecutor = class {
|
|
|
1181
1965
|
}
|
|
1182
1966
|
const baseFilter = combineFilters(compiled.filter, selection.runtimeFilter);
|
|
1183
1967
|
const dimensions = selection.dimensions ?? [];
|
|
1968
|
+
const order = resolveOrdering(selection, dimensions, this.timeDimensionsOf(compiled, dimensions));
|
|
1969
|
+
const labelOrderKeys = this.orderLabels ? Object.keys(order ?? {}).filter(
|
|
1970
|
+
(k) => dimensions.includes(k) && this.orderLabels.isLabelBearing(k)
|
|
1971
|
+
) : [];
|
|
1972
|
+
const singleQuery = filtered.length === 0 && !selection.compareTo && selectedDerived.length === 0;
|
|
1973
|
+
const pushDownKeys = /* @__PURE__ */ new Set([...dimensions, ...unfiltered]);
|
|
1974
|
+
const canPushDownWindow = singleQuery && labelOrderKeys.length === 0 && Object.keys(order ?? {}).every((k) => pushDownKeys.has(k));
|
|
1975
|
+
const windowQuery = canPushDownWindow ? { order, limit: selection.limit, offset: selection.offset } : void 0;
|
|
1184
1976
|
let result;
|
|
1185
1977
|
if (unfiltered.length > 0 || filtered.length === 0) {
|
|
1186
1978
|
result = await this.service.query(this.buildQuery(compiled, {
|
|
@@ -1188,7 +1980,8 @@ var DatasetExecutor = class {
|
|
|
1188
1980
|
dimensions,
|
|
1189
1981
|
where: baseFilter,
|
|
1190
1982
|
selection,
|
|
1191
|
-
contextTimezone: context?.timezone
|
|
1983
|
+
contextTimezone: context?.timezone,
|
|
1984
|
+
window: windowQuery
|
|
1192
1985
|
}), context);
|
|
1193
1986
|
} else {
|
|
1194
1987
|
result = { rows: [], fields: [] };
|
|
@@ -1217,8 +2010,30 @@ var DatasetExecutor = class {
|
|
|
1217
2010
|
}
|
|
1218
2011
|
result.rows = evaluateDerivedMeasures(result.rows, selectedDerived);
|
|
1219
2012
|
for (const d of selectedDerived) result.fields.push({ name: d.name, type: "number" });
|
|
2013
|
+
let sortKeys;
|
|
2014
|
+
for (const key of labelOrderKeys) {
|
|
2015
|
+
const values = [...new Set(result.rows.map((r) => r[key]).filter((v) => v != null))];
|
|
2016
|
+
if (values.length === 0) continue;
|
|
2017
|
+
const labels = await this.orderLabels.resolveLabels(key, values);
|
|
2018
|
+
if (labels && labels.size > 0) (sortKeys ?? (sortKeys = {}))[key] = labels;
|
|
2019
|
+
}
|
|
2020
|
+
result.rows = applyOrdering(result.rows, order, sortKeys);
|
|
2021
|
+
result.rows = applyWindow(result.rows, selection.limit, selection.offset);
|
|
1220
2022
|
return result;
|
|
1221
2023
|
}
|
|
2024
|
+
/**
|
|
2025
|
+
* The selected dimensions the compiled cube types as `time`, in selection
|
|
2026
|
+
* order (#3916) — the axis {@link resolveOrdering} defaults to ascending.
|
|
2027
|
+
*
|
|
2028
|
+
* Membership is decided by the DIMENSION's declared type, not by whether the
|
|
2029
|
+
* selection happens to bucket it: a `date` dimension left ungranulated groups
|
|
2030
|
+
* raw timestamps, and those want chronological order every bit as much as
|
|
2031
|
+
* month buckets do. (Both sort correctly — `compareValues` compares Dates and
|
|
2032
|
+
* ISO strings chronologically, and bucket keys are minted sort-stable.)
|
|
2033
|
+
*/
|
|
2034
|
+
timeDimensionsOf(compiled, dimensions) {
|
|
2035
|
+
return dimensions.filter((d) => compiled.cube.dimensions[d]?.type === "time");
|
|
2036
|
+
}
|
|
1222
2037
|
buildQuery(compiled, opts) {
|
|
1223
2038
|
const q = {
|
|
1224
2039
|
cube: compiled.cube.name,
|
|
@@ -1231,18 +2046,28 @@ var DatasetExecutor = class {
|
|
|
1231
2046
|
if (opts.where) q.where = opts.where;
|
|
1232
2047
|
const selTimeDims = opts.selection.timeDimensions ?? [];
|
|
1233
2048
|
const selDims = new Set(selTimeDims.map((t) => t.dimension));
|
|
2049
|
+
const granularityFor = (name) => {
|
|
2050
|
+
const cd = compiled.cube.dimensions[name];
|
|
2051
|
+
if (cd?.type !== "time") return void 0;
|
|
2052
|
+
const datasetDefault = cd.granularities?.length === 1 ? String(cd.granularities[0]) : void 0;
|
|
2053
|
+
return resolveDimensionGranularity(opts.selection, name, datasetDefault);
|
|
2054
|
+
};
|
|
2055
|
+
const resolvedTimeDims = selTimeDims.map((t) => {
|
|
2056
|
+
if (t.granularity) return t;
|
|
2057
|
+
const granularity = granularityFor(t.dimension);
|
|
2058
|
+
return granularity ? { ...t, granularity } : t;
|
|
2059
|
+
});
|
|
1234
2060
|
const explicitTimeDims = [];
|
|
1235
2061
|
for (const name of opts.dimensions) {
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
}
|
|
2062
|
+
if (selDims.has(name)) continue;
|
|
2063
|
+
const granularity = granularityFor(name);
|
|
2064
|
+
if (granularity) explicitTimeDims.push({ dimension: name, granularity });
|
|
1240
2065
|
}
|
|
1241
|
-
const mergedTimeDims = [...
|
|
2066
|
+
const mergedTimeDims = [...resolvedTimeDims, ...explicitTimeDims];
|
|
1242
2067
|
if (mergedTimeDims.length > 0) q.timeDimensions = mergedTimeDims;
|
|
1243
|
-
if (opts.
|
|
1244
|
-
if (opts.
|
|
1245
|
-
if (opts.
|
|
2068
|
+
if (opts.window?.order && Object.keys(opts.window.order).length > 0) q.order = opts.window.order;
|
|
2069
|
+
if (opts.window?.limit != null) q.limit = opts.window.limit;
|
|
2070
|
+
if (opts.window?.offset != null) q.offset = opts.window.offset;
|
|
1246
2071
|
return q;
|
|
1247
2072
|
}
|
|
1248
2073
|
async runCompare(compiled, selection, measures, dimensions, baseFilter, context) {
|
|
@@ -1258,14 +2083,13 @@ var DatasetExecutor = class {
|
|
|
1258
2083
|
const shiftedTd = (selection.timeDimensions ?? []).map(
|
|
1259
2084
|
(t) => t.dimension === cmp.dimension ? { ...t, dateRange: shifted } : t
|
|
1260
2085
|
);
|
|
1261
|
-
const sub = await this.service.query({
|
|
1262
|
-
cube: compiled.cube.name,
|
|
2086
|
+
const sub = await this.service.query(this.buildQuery(compiled, {
|
|
1263
2087
|
measures,
|
|
1264
2088
|
dimensions,
|
|
1265
2089
|
where: baseFilter,
|
|
1266
|
-
timeDimensions: shiftedTd,
|
|
1267
|
-
|
|
1268
|
-
}, context);
|
|
2090
|
+
selection: { ...selection, timeDimensions: shiftedTd },
|
|
2091
|
+
contextTimezone: context?.timezone
|
|
2092
|
+
}), context);
|
|
1269
2093
|
return sub.rows.map((row) => {
|
|
1270
2094
|
const out = {};
|
|
1271
2095
|
for (const dim of dimensions) out[dim] = row[dim];
|
|
@@ -1296,11 +2120,77 @@ function mergeByDimensions(base, extra, dimensions, valueColumns) {
|
|
|
1296
2120
|
|
|
1297
2121
|
// src/dimension-labels.ts
|
|
1298
2122
|
var LOOKUP_TYPES = /* @__PURE__ */ new Set(["lookup", "master_detail"]);
|
|
2123
|
+
function createOrderLabelResolver(baseObject, dims, deps, resolveScope, context) {
|
|
2124
|
+
const dimByName = new Map(dims.map((d) => [d.name, d]));
|
|
2125
|
+
const metaFor = (dimension) => {
|
|
2126
|
+
const dim = dimByName.get(dimension);
|
|
2127
|
+
return dim ? deps.getObjectFields(baseObject)?.[dim.field] : void 0;
|
|
2128
|
+
};
|
|
2129
|
+
return {
|
|
2130
|
+
isLabelBearing(dimension) {
|
|
2131
|
+
const meta = metaFor(dimension);
|
|
2132
|
+
if (!meta) return false;
|
|
2133
|
+
if (Array.isArray(meta.options) && meta.options.length > 0) return true;
|
|
2134
|
+
return !!(meta.type && LOOKUP_TYPES.has(meta.type) && meta.reference);
|
|
2135
|
+
},
|
|
2136
|
+
async resolveLabels(dimension, values) {
|
|
2137
|
+
const meta = metaFor(dimension);
|
|
2138
|
+
if (!meta) return void 0;
|
|
2139
|
+
if (Array.isArray(meta.options) && meta.options.length > 0) {
|
|
2140
|
+
const labelByValue = /* @__PURE__ */ new Map();
|
|
2141
|
+
for (const opt of meta.options) {
|
|
2142
|
+
if (opt && opt.label != null) labelByValue.set(opt.value, String(opt.label));
|
|
2143
|
+
}
|
|
2144
|
+
return labelByValue;
|
|
2145
|
+
}
|
|
2146
|
+
if (meta.type && LOOKUP_TYPES.has(meta.type) && meta.reference) {
|
|
2147
|
+
let scope;
|
|
2148
|
+
if (resolveScope) {
|
|
2149
|
+
try {
|
|
2150
|
+
scope = await resolveScope(meta.reference);
|
|
2151
|
+
} catch {
|
|
2152
|
+
return void 0;
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
return deps.fetchRecordLabels(meta.reference, values, scope ?? void 0, context);
|
|
2156
|
+
}
|
|
2157
|
+
return void 0;
|
|
2158
|
+
}
|
|
2159
|
+
};
|
|
2160
|
+
}
|
|
2161
|
+
function withLabelFetchCache(deps) {
|
|
2162
|
+
const cache = /* @__PURE__ */ new Map();
|
|
2163
|
+
return {
|
|
2164
|
+
getObjectFields: (objectName) => deps.getObjectFields(objectName),
|
|
2165
|
+
async fetchRecordLabels(targetObject, ids, scope, context) {
|
|
2166
|
+
let known = cache.get(targetObject);
|
|
2167
|
+
if (!known) {
|
|
2168
|
+
known = /* @__PURE__ */ new Map();
|
|
2169
|
+
cache.set(targetObject, known);
|
|
2170
|
+
}
|
|
2171
|
+
const missing = ids.filter((id) => !known.has(id));
|
|
2172
|
+
if (missing.length > 0) {
|
|
2173
|
+
const fetched = await deps.fetchRecordLabels(targetObject, missing, scope, context);
|
|
2174
|
+
for (const id of missing) known.set(id, fetched.get(id) ?? null);
|
|
2175
|
+
}
|
|
2176
|
+
const out = /* @__PURE__ */ new Map();
|
|
2177
|
+
for (const id of ids) {
|
|
2178
|
+
const label = known.get(id);
|
|
2179
|
+
if (label != null) out.set(id, label);
|
|
2180
|
+
}
|
|
2181
|
+
return out;
|
|
2182
|
+
}
|
|
2183
|
+
};
|
|
2184
|
+
}
|
|
1299
2185
|
var pad = (n) => String(n).padStart(2, "0");
|
|
1300
2186
|
function formatDateBucket(value, granularity) {
|
|
1301
2187
|
if (value == null || value instanceof Date === false) {
|
|
1302
2188
|
if (typeof value !== "number" && typeof value !== "string") return value;
|
|
1303
2189
|
}
|
|
2190
|
+
if (granularity === "year") {
|
|
2191
|
+
const y2 = typeof value === "number" ? value : Number(String(value).trim());
|
|
2192
|
+
if (Number.isInteger(y2) && y2 >= 1e3 && y2 <= 9999) return String(y2);
|
|
2193
|
+
}
|
|
1304
2194
|
let d;
|
|
1305
2195
|
if (value instanceof Date) d = value;
|
|
1306
2196
|
else if (typeof value === "number") d = new Date(value);
|
|
@@ -1324,7 +2214,7 @@ function formatDateBucket(value, granularity) {
|
|
|
1324
2214
|
return `${y}-${pad(m + 1)}-${pad(d.getUTCDate())}`;
|
|
1325
2215
|
}
|
|
1326
2216
|
}
|
|
1327
|
-
async function resolveDimensionLabels(baseObject, dims, rows, deps) {
|
|
2217
|
+
async function resolveDimensionLabels(baseObject, dims, rows, deps, resolveScope, context) {
|
|
1328
2218
|
if (!rows.length || !dims.length) return;
|
|
1329
2219
|
const fields = deps.getObjectFields(baseObject);
|
|
1330
2220
|
if (!fields) return;
|
|
@@ -1356,7 +2246,15 @@ async function resolveDimensionLabels(baseObject, dims, rows, deps) {
|
|
|
1356
2246
|
new Set(rows.map((r) => r[dim.name]).filter((v) => v != null))
|
|
1357
2247
|
);
|
|
1358
2248
|
if (ids.length === 0) continue;
|
|
1359
|
-
|
|
2249
|
+
let scope;
|
|
2250
|
+
if (resolveScope) {
|
|
2251
|
+
try {
|
|
2252
|
+
scope = await resolveScope(meta.reference);
|
|
2253
|
+
} catch {
|
|
2254
|
+
continue;
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
const labelById = await deps.fetchRecordLabels(meta.reference, ids, scope ?? void 0, context);
|
|
1360
2258
|
if (!labelById || labelById.size === 0) continue;
|
|
1361
2259
|
for (const row of rows) {
|
|
1362
2260
|
const label = labelById.get(row[dim.name]);
|
|
@@ -1377,11 +2275,21 @@ function pickDisplayField(fields) {
|
|
|
1377
2275
|
}
|
|
1378
2276
|
|
|
1379
2277
|
// src/preview-evaluator.ts
|
|
1380
|
-
var
|
|
2278
|
+
var import_core4 = require("@objectstack/core");
|
|
1381
2279
|
function compare(a, b) {
|
|
1382
2280
|
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
2281
|
+
if (a instanceof Date || b instanceof Date) {
|
|
2282
|
+
const ai = (0, import_core4.utcInstantMs)(a);
|
|
2283
|
+
const bi = (0, import_core4.utcInstantMs)(b);
|
|
2284
|
+
if (ai !== null && bi !== null) return ai - bi;
|
|
2285
|
+
}
|
|
1383
2286
|
return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0;
|
|
1384
2287
|
}
|
|
2288
|
+
function lteBound(value, bound) {
|
|
2289
|
+
const nextDay = (0, import_core4.nextUtcCalendarDay)(bound);
|
|
2290
|
+
if (nextDay != null) return compare(value, nextDay) < 0;
|
|
2291
|
+
return compare(value, bound) <= 0;
|
|
2292
|
+
}
|
|
1385
2293
|
function matchOp(value, op, expected) {
|
|
1386
2294
|
switch (op) {
|
|
1387
2295
|
case "$eq":
|
|
@@ -1394,8 +2302,16 @@ function matchOp(value, op, expected) {
|
|
|
1394
2302
|
return value != null && compare(value, expected) >= 0;
|
|
1395
2303
|
case "$lt":
|
|
1396
2304
|
return value != null && compare(value, expected) < 0;
|
|
1397
|
-
case "$lte":
|
|
1398
|
-
|
|
2305
|
+
case "$lte": {
|
|
2306
|
+
if (value == null) return false;
|
|
2307
|
+
return lteBound(value, expected);
|
|
2308
|
+
}
|
|
2309
|
+
case "$between": {
|
|
2310
|
+
if (value == null || !Array.isArray(expected) || expected.length !== 2) return false;
|
|
2311
|
+
const [min, max] = expected;
|
|
2312
|
+
if (min == null || max == null) return false;
|
|
2313
|
+
return compare(value, min) >= 0 && lteBound(value, max);
|
|
2314
|
+
}
|
|
1399
2315
|
case "$in":
|
|
1400
2316
|
return Array.isArray(expected) && expected.some((e) => value === e || String(value) === String(e));
|
|
1401
2317
|
case "$nin":
|
|
@@ -1428,7 +2344,7 @@ function matchesWhere(row, where) {
|
|
|
1428
2344
|
function bucketDate(value, granularity, timezone) {
|
|
1429
2345
|
const d = new Date(String(value));
|
|
1430
2346
|
if (Number.isNaN(d.getTime())) return null;
|
|
1431
|
-
const { year: y, month, day: dayNum } = (0,
|
|
2347
|
+
const { year: y, month, day: dayNum } = (0, import_core4.calendarPartsInTzOrUtc)(d, timezone);
|
|
1432
2348
|
const m = `${month}`.padStart(2, "0");
|
|
1433
2349
|
const day = `${dayNum}`.padStart(2, "0");
|
|
1434
2350
|
switch (granularity) {
|
|
@@ -1482,7 +2398,9 @@ function evaluateAnalyticsQueryOverRows(query, cube, rows) {
|
|
|
1482
2398
|
const [start, end] = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
|
|
1483
2399
|
filtered = filtered.filter((r) => {
|
|
1484
2400
|
const v = String(r[field] ?? "");
|
|
1485
|
-
|
|
2401
|
+
const nextDay = (0, import_core4.nextUtcCalendarDay)(end);
|
|
2402
|
+
const inUpper = nextDay != null ? v < nextDay : v <= `${end}~`;
|
|
2403
|
+
return v >= String(start) && inUpper;
|
|
1486
2404
|
});
|
|
1487
2405
|
}
|
|
1488
2406
|
const dimensions = query.dimensions ?? [];
|
|
@@ -1550,7 +2468,9 @@ var AnalyticsService = class {
|
|
|
1550
2468
|
constructor(config = {}) {
|
|
1551
2469
|
/** Compiled datasets by name — feeds the join allowlist (D-C) and queryDataset. */
|
|
1552
2470
|
this.datasetRegistry = /* @__PURE__ */ new Map();
|
|
1553
|
-
|
|
2471
|
+
/** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */
|
|
2472
|
+
this.warnedNoObjectRegistry = false;
|
|
2473
|
+
this.logger = config.logger || (0, import_core5.createLogger)({ level: "info", format: "pretty" });
|
|
1554
2474
|
this.cubeRegistry = new CubeRegistry();
|
|
1555
2475
|
if (config.cubes) {
|
|
1556
2476
|
this.cubeRegistry.registerAll(config.cubes);
|
|
@@ -1560,6 +2480,7 @@ var AnalyticsService = class {
|
|
|
1560
2480
|
this.measureCurrency = config.measureCurrency;
|
|
1561
2481
|
this.labelResolver = config.labelResolver;
|
|
1562
2482
|
this.draftRowsResolver = config.draftRowsResolver;
|
|
2483
|
+
this.isRegisteredObject = config.isRegisteredObject;
|
|
1563
2484
|
if (config.datasets) {
|
|
1564
2485
|
for (const ds of config.datasets) {
|
|
1565
2486
|
try {
|
|
@@ -1579,6 +2500,7 @@ var AnalyticsService = class {
|
|
|
1579
2500
|
// fall back to any explicitly-configured provider for legacy cubes.
|
|
1580
2501
|
getAllowedRelationships: (cubeName) => this.datasetRegistry.get(cubeName)?.allowedRelationships ?? config.getAllowedRelationships?.(cubeName),
|
|
1581
2502
|
coerceTemporalFilterValue: config.coerceTemporalFilterValue,
|
|
2503
|
+
coerceTemporalFilterColumn: config.coerceTemporalFilterColumn,
|
|
1582
2504
|
isExternalObject: config.isExternalObject
|
|
1583
2505
|
};
|
|
1584
2506
|
const builtIn = [
|
|
@@ -1600,10 +2522,11 @@ var AnalyticsService = class {
|
|
|
1600
2522
|
* `getReadScope(objectName)` that already knows the active tenant.
|
|
1601
2523
|
*/
|
|
1602
2524
|
async callCtx(query, context) {
|
|
1603
|
-
if (!this.readScopeProvider) return this.baseCtx;
|
|
2525
|
+
if (!this.readScopeProvider) return { ...this.baseCtx, context };
|
|
1604
2526
|
const scopes = await this.resolveReadScopes(query, context);
|
|
1605
2527
|
return {
|
|
1606
2528
|
...this.baseCtx,
|
|
2529
|
+
context,
|
|
1607
2530
|
getReadScope: (objectName) => scopes.get(objectName) ?? null
|
|
1608
2531
|
};
|
|
1609
2532
|
}
|
|
@@ -1724,9 +2647,19 @@ var AnalyticsService = class {
|
|
|
1724
2647
|
return previewResult;
|
|
1725
2648
|
}
|
|
1726
2649
|
}
|
|
2650
|
+
const provider = this.readScopeProvider;
|
|
2651
|
+
const resolveScope = provider ? (targetObject) => provider(targetObject, context) : void 0;
|
|
2652
|
+
const labelDeps = this.labelResolver ? withLabelFetchCache(this.labelResolver) : void 0;
|
|
2653
|
+
const orderLabels = labelDeps && dataset.dimensions?.length ? createOrderLabelResolver(
|
|
2654
|
+
dataset.object,
|
|
2655
|
+
dataset.dimensions.filter((d) => !!d.field).map((d) => ({ name: d.name, field: d.field })),
|
|
2656
|
+
labelDeps,
|
|
2657
|
+
resolveScope,
|
|
2658
|
+
context
|
|
2659
|
+
) : void 0;
|
|
1727
2660
|
let result;
|
|
1728
2661
|
try {
|
|
1729
|
-
result = await new DatasetExecutor(this).execute(compiled, selection, context);
|
|
2662
|
+
result = await new DatasetExecutor(this, orderLabels).execute(compiled, selection, context);
|
|
1730
2663
|
} catch (err) {
|
|
1731
2664
|
if (isMissingSourceError(err)) {
|
|
1732
2665
|
this.logger.warn(
|
|
@@ -1762,18 +2695,20 @@ var AnalyticsService = class {
|
|
|
1762
2695
|
const rangeTz = selection.timezone ?? context?.timezone ?? "UTC";
|
|
1763
2696
|
const rangeDims = [];
|
|
1764
2697
|
for (const d of selectedDims) {
|
|
1765
|
-
if (!d.field || d.type !== "date"
|
|
2698
|
+
if (!d.field || d.type !== "date") continue;
|
|
2699
|
+
const granularity = resolveDimensionGranularity(selection, d.name, d.dateGranularity);
|
|
2700
|
+
if (!granularity) continue;
|
|
1766
2701
|
const ftype = this.measureCurrency?.(dataset.object, d.field)?.type;
|
|
1767
|
-
if (ftype === "datetime") rangeDims.push({ d, instant: true });
|
|
1768
|
-
else if (ftype === "date") rangeDims.push({ d, instant: false });
|
|
1769
|
-
else if (rangeTz === "UTC") rangeDims.push({ d, instant: false });
|
|
2702
|
+
if (ftype === "datetime") rangeDims.push({ d, granularity, instant: true });
|
|
2703
|
+
else if (ftype === "date") rangeDims.push({ d, granularity, instant: false });
|
|
2704
|
+
else if (rangeTz === "UTC") rangeDims.push({ d, granularity, instant: false });
|
|
1770
2705
|
}
|
|
1771
2706
|
if (rangeDims.length && result.rows.length) {
|
|
1772
|
-
const bound = (ymd, instant) => instant ? new Date((0,
|
|
2707
|
+
const bound = (ymd, instant) => instant ? new Date((0, import_core5.zonedDateStartToUtcMs)(ymd, rangeTz)).toISOString() : ymd;
|
|
1773
2708
|
result.drillRanges = result.rows.map((row) => {
|
|
1774
2709
|
const ranges = {};
|
|
1775
|
-
for (const { d, instant } of rangeDims) {
|
|
1776
|
-
const cal = (0,
|
|
2710
|
+
for (const { d, granularity, instant } of rangeDims) {
|
|
2711
|
+
const cal = (0, import_core5.bucketKeyToCalendarRange)(row[d.name], granularity);
|
|
1777
2712
|
if (cal) {
|
|
1778
2713
|
ranges[d.name] = { field: d.field, gte: bound(cal.start, instant), lt: bound(cal.end, instant) };
|
|
1779
2714
|
}
|
|
@@ -1782,15 +2717,20 @@ var AnalyticsService = class {
|
|
|
1782
2717
|
});
|
|
1783
2718
|
result.object = dataset.object;
|
|
1784
2719
|
}
|
|
1785
|
-
if (
|
|
1786
|
-
const dims = selectedDims.filter((d) => !!d.field).map((d) => ({
|
|
2720
|
+
if (labelDeps && selectedDims.length) {
|
|
2721
|
+
const dims = selectedDims.filter((d) => !!d.field).map((d) => ({
|
|
2722
|
+
name: d.name,
|
|
2723
|
+
field: d.field,
|
|
2724
|
+
type: d.type,
|
|
2725
|
+
dateGranularity: resolveDimensionGranularity(selection, d.name, d.dateGranularity)
|
|
2726
|
+
}));
|
|
1787
2727
|
if (dims.length) {
|
|
1788
2728
|
try {
|
|
1789
|
-
await resolveDimensionLabels(dataset.object, dims, result.rows,
|
|
2729
|
+
await resolveDimensionLabels(dataset.object, dims, result.rows, labelDeps, resolveScope, context);
|
|
1790
2730
|
for (const total of result.totals ?? []) {
|
|
1791
2731
|
const subset = dims.filter((d) => total.dimensions.includes(d.name));
|
|
1792
2732
|
if (subset.length) {
|
|
1793
|
-
await resolveDimensionLabels(dataset.object, subset, total.rows,
|
|
2733
|
+
await resolveDimensionLabels(dataset.object, subset, total.rows, labelDeps, resolveScope, context);
|
|
1794
2734
|
}
|
|
1795
2735
|
}
|
|
1796
2736
|
} catch (e) {
|
|
@@ -1878,6 +2818,7 @@ var AnalyticsService = class {
|
|
|
1878
2818
|
const name = query.cube;
|
|
1879
2819
|
let cube = this.cubeRegistry.get(name);
|
|
1880
2820
|
if (!cube) {
|
|
2821
|
+
this.assertInferableCube(name);
|
|
1881
2822
|
cube = this.inferCubeFromQuery(query);
|
|
1882
2823
|
this.cubeRegistry.register(cube);
|
|
1883
2824
|
const isScalarMetric = (query.dimensions?.length ?? 0) === 0 && (query.timeDimensions?.length ?? 0) === 0;
|
|
@@ -1904,6 +2845,39 @@ var AnalyticsService = class {
|
|
|
1904
2845
|
);
|
|
1905
2846
|
}
|
|
1906
2847
|
}
|
|
2848
|
+
/**
|
|
2849
|
+
* [#3867] Gate on the cube auto-inference path: a name with no registered
|
|
2850
|
+
* Cube may only be inferred into one if it is a registered object.
|
|
2851
|
+
*
|
|
2852
|
+
* Rejects with `status: 404` / `code: 'CUBE_NOT_FOUND'` so the HTTP boundary
|
|
2853
|
+
* answers "no such cube" instead of letting the name reach the driver as a
|
|
2854
|
+
* table and surfacing whatever the driver says about it. The message names
|
|
2855
|
+
* both ways the request could be made valid, because from here the two are
|
|
2856
|
+
* genuinely indistinguishable: register a Cube, or register the object.
|
|
2857
|
+
*
|
|
2858
|
+
* Skips when `isRegisteredObject` was not supplied — see the config field's
|
|
2859
|
+
* doc for why that tier is a deliberate stand-down and not a hole.
|
|
2860
|
+
*/
|
|
2861
|
+
assertInferableCube(name) {
|
|
2862
|
+
const isRegisteredObject = this.isRegisteredObject;
|
|
2863
|
+
if (!isRegisteredObject) {
|
|
2864
|
+
if (!this.warnedNoObjectRegistry) {
|
|
2865
|
+
this.warnedNoObjectRegistry = true;
|
|
2866
|
+
this.logger.warn(
|
|
2867
|
+
"[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."
|
|
2868
|
+
);
|
|
2869
|
+
}
|
|
2870
|
+
return;
|
|
2871
|
+
}
|
|
2872
|
+
if (isRegisteredObject(name)) return;
|
|
2873
|
+
const err = new Error(
|
|
2874
|
+
`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.`
|
|
2875
|
+
);
|
|
2876
|
+
err.code = "CUBE_NOT_FOUND";
|
|
2877
|
+
err.status = 404;
|
|
2878
|
+
err.cube = name;
|
|
2879
|
+
throw err;
|
|
2880
|
+
}
|
|
1907
2881
|
/** Build a minimal Cube from the fields referenced by an AnalyticsQuery. */
|
|
1908
2882
|
inferCubeFromQuery(query) {
|
|
1909
2883
|
const cubeName = query.cube;
|
|
@@ -2014,6 +2988,11 @@ var FallbackDelegateStrategy = class {
|
|
|
2014
2988
|
var AnalyticsServicePlugin = class {
|
|
2015
2989
|
constructor(options = {}) {
|
|
2016
2990
|
this.name = "com.objectstack.service-analytics";
|
|
2991
|
+
/**
|
|
2992
|
+
* Services init() registers on every path (ADR-0116, #4131) — lets the
|
|
2993
|
+
* kernel name this plugin when a consumer requires one before it inits.
|
|
2994
|
+
*/
|
|
2995
|
+
this.providesServices = ["analytics"];
|
|
2017
2996
|
this.version = "1.0.0";
|
|
2018
2997
|
this.type = "standard";
|
|
2019
2998
|
this.dependencies = [];
|
|
@@ -2045,7 +3024,7 @@ var AnalyticsServicePlugin = class {
|
|
|
2045
3024
|
'[Analytics] No "data" service registered yet at init; will retry per-query. Register ObjectQLPlugin or pass executeAggregate.'
|
|
2046
3025
|
);
|
|
2047
3026
|
}
|
|
2048
|
-
executeAggregate = async (objectName, { groupBy, aggregations, filter, timezone }) => {
|
|
3027
|
+
executeAggregate = async (objectName, { groupBy, aggregations, filter, timezone, context }) => {
|
|
2049
3028
|
const engine = tryGetDataEngine();
|
|
2050
3029
|
if (!engine) {
|
|
2051
3030
|
throw new Error(
|
|
@@ -2062,7 +3041,13 @@ var AnalyticsServicePlugin = class {
|
|
|
2062
3041
|
})),
|
|
2063
3042
|
// ADR-0053 Phase 2: thread the reference tz so date buckets resolve on
|
|
2064
3043
|
// that zone's calendar days (engine buckets in-memory when non-UTC).
|
|
2065
|
-
timezone
|
|
3044
|
+
timezone,
|
|
3045
|
+
// ADR-0021 D-C (#3602): thread the caller's identity so the engine's
|
|
3046
|
+
// middleware chain scopes the read itself. `BaseEngineOptions.context`
|
|
3047
|
+
// is `.optional()`, so nothing ever forced this bridge to pass it —
|
|
3048
|
+
// and it did not, which is how an authenticated aggregate reached the
|
|
3049
|
+
// engine with no principal and plugin-security fell open (#3597).
|
|
3050
|
+
context
|
|
2066
3051
|
});
|
|
2067
3052
|
return rows;
|
|
2068
3053
|
};
|
|
@@ -2110,6 +3095,7 @@ var AnalyticsServicePlugin = class {
|
|
|
2110
3095
|
}));
|
|
2111
3096
|
let getReadScope = this.options.getReadScope;
|
|
2112
3097
|
let autoBridgedReadScope = false;
|
|
3098
|
+
let securityPresentAtInit = false;
|
|
2113
3099
|
if (!getReadScope) {
|
|
2114
3100
|
const trySecurity = () => {
|
|
2115
3101
|
try {
|
|
@@ -2119,10 +3105,9 @@ var AnalyticsServicePlugin = class {
|
|
|
2119
3105
|
return void 0;
|
|
2120
3106
|
}
|
|
2121
3107
|
};
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
}
|
|
3108
|
+
securityPresentAtInit = !!trySecurity();
|
|
3109
|
+
getReadScope = (object, context) => trySecurity()?.getReadFilter(object, context);
|
|
3110
|
+
autoBridgedReadScope = true;
|
|
2126
3111
|
}
|
|
2127
3112
|
const relationshipResolver = (baseObject, relationshipName) => {
|
|
2128
3113
|
const engine = (() => {
|
|
@@ -2150,17 +3135,26 @@ var AnalyticsServicePlugin = class {
|
|
|
2150
3135
|
};
|
|
2151
3136
|
const labelResolver = {
|
|
2152
3137
|
getObjectFields: (objectName) => dataEngine()?.getObject?.(objectName)?.fields,
|
|
2153
|
-
fetchRecordLabels: async (targetObject, ids) => {
|
|
3138
|
+
fetchRecordLabels: async (targetObject, ids, scope, context) => {
|
|
2154
3139
|
const map = /* @__PURE__ */ new Map();
|
|
2155
3140
|
const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
|
|
2156
3141
|
if (!displayField || !executeAggregate || ids.length === 0) return map;
|
|
2157
|
-
const
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
filter
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
3142
|
+
const CHUNK = 500;
|
|
3143
|
+
for (let i = 0; i < ids.length; i += CHUNK) {
|
|
3144
|
+
const idFilter = { id: { $in: ids.slice(i, i + CHUNK) } };
|
|
3145
|
+
const filter = scope ? { $and: [idFilter, scope] } : idFilter;
|
|
3146
|
+
const rows = await executeAggregate(targetObject, {
|
|
3147
|
+
groupBy: ["id", displayField],
|
|
3148
|
+
aggregations: [{ field: "id", method: "count", alias: "_c" }],
|
|
3149
|
+
filter,
|
|
3150
|
+
// #3602 second belt — `scope` above is the analytics layer's own
|
|
3151
|
+
// predicate on this per-record read; the context makes the engine's
|
|
3152
|
+
// middleware scope it as well.
|
|
3153
|
+
context
|
|
3154
|
+
});
|
|
3155
|
+
for (const r of rows) {
|
|
3156
|
+
if (r.id != null && r[displayField] != null) map.set(r.id, String(r[displayField]));
|
|
3157
|
+
}
|
|
2164
3158
|
}
|
|
2165
3159
|
return map;
|
|
2166
3160
|
}
|
|
@@ -2201,6 +3195,17 @@ var AnalyticsServicePlugin = class {
|
|
|
2201
3195
|
}
|
|
2202
3196
|
return value;
|
|
2203
3197
|
};
|
|
3198
|
+
const coerceTemporalFilterColumn = (objectName, fieldName, columnSql) => {
|
|
3199
|
+
try {
|
|
3200
|
+
const svc = ctx.getService("data");
|
|
3201
|
+
const driver = svc?.getDriverForObject?.(objectName);
|
|
3202
|
+
if (driver && typeof driver.temporalFilterColumnSql === "function") {
|
|
3203
|
+
return driver.temporalFilterColumnSql(objectName, fieldName, columnSql);
|
|
3204
|
+
}
|
|
3205
|
+
} catch {
|
|
3206
|
+
}
|
|
3207
|
+
return columnSql;
|
|
3208
|
+
};
|
|
2204
3209
|
const config = {
|
|
2205
3210
|
cubes: this.options.cubes,
|
|
2206
3211
|
logger: ctx.logger,
|
|
@@ -2211,6 +3216,7 @@ var AnalyticsServicePlugin = class {
|
|
|
2211
3216
|
getReadScope,
|
|
2212
3217
|
getAllowedRelationships: this.options.getAllowedRelationships,
|
|
2213
3218
|
coerceTemporalFilterValue,
|
|
3219
|
+
coerceTemporalFilterColumn,
|
|
2214
3220
|
relationshipResolver,
|
|
2215
3221
|
labelResolver,
|
|
2216
3222
|
// ADR-0053 — source-field currency metadata for the measure currency chain.
|
|
@@ -2225,13 +3231,31 @@ var AnalyticsServicePlugin = class {
|
|
|
2225
3231
|
const obj = dataEngine()?.getObject?.(objectName);
|
|
2226
3232
|
return !!(obj && obj.external != null);
|
|
2227
3233
|
},
|
|
3234
|
+
// [#3867] Existence probe for the cube auto-inference gate. Reads the
|
|
3235
|
+
// same schema registry the data path's #3770 gate consults, through the
|
|
3236
|
+
// engine accessor this bridge already uses above — so "which objects
|
|
3237
|
+
// exist" has one answer across /data and /analytics.
|
|
3238
|
+
//
|
|
3239
|
+
// `dataEngine()` resolves lazily and may be absent entirely (analytics
|
|
3240
|
+
// installed without a data engine). Reporting `false` there would 404
|
|
3241
|
+
// every cube, so an unresolvable engine reports `true` — "cannot answer,
|
|
3242
|
+
// do not block" — mirroring the tiering #3770 took on the data path.
|
|
3243
|
+
isRegisteredObject: (name) => {
|
|
3244
|
+
const engine = dataEngine();
|
|
3245
|
+
if (!engine) return true;
|
|
3246
|
+
return engine.getObject?.(name) != null;
|
|
3247
|
+
},
|
|
2228
3248
|
draftRowsResolver
|
|
2229
3249
|
};
|
|
2230
|
-
if (autoBridgedReadScope) {
|
|
3250
|
+
if (autoBridgedReadScope && securityPresentAtInit) {
|
|
2231
3251
|
ctx.logger.info('[Analytics] Auto-bridged getReadScope \u2192 "security" service (getReadFilter)');
|
|
3252
|
+
} else if (autoBridgedReadScope) {
|
|
3253
|
+
ctx.logger.info(
|
|
3254
|
+
'[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).'
|
|
3255
|
+
);
|
|
2232
3256
|
} else if (!getReadScope) {
|
|
2233
3257
|
ctx.logger.warn(
|
|
2234
|
-
'[Analytics] No getReadScope configured and no "security" service with getReadFilter found \u2014
|
|
3258
|
+
'[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.'
|
|
2235
3259
|
);
|
|
2236
3260
|
}
|
|
2237
3261
|
if (autoBridged) {
|
|
@@ -2275,10 +3299,12 @@ var AnalyticsServicePlugin = class {
|
|
|
2275
3299
|
combineFilters,
|
|
2276
3300
|
compileDataset,
|
|
2277
3301
|
compileScopedFilterToSql,
|
|
3302
|
+
createOrderLabelResolver,
|
|
2278
3303
|
evaluateDerivedMeasures,
|
|
2279
3304
|
mergeByDimensions,
|
|
2280
3305
|
pickDisplayField,
|
|
2281
3306
|
resolveDimensionLabels,
|
|
2282
|
-
shiftRange
|
|
3307
|
+
shiftRange,
|
|
3308
|
+
withLabelFetchCache
|
|
2283
3309
|
});
|
|
2284
3310
|
//# sourceMappingURL=index.cjs.map
|