@objectstack/service-analytics 17.0.0-rc.0 → 17.0.0-rc.2
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 +3093 -0
- package/dist/index.cjs +615 -172
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +258 -15
- package/dist/index.d.ts +258 -15
- package/dist/index.js +606 -164
- package/dist/index.js.map +1 -1
- package/package.json +6 -5
package/dist/index.cjs
CHANGED
|
@@ -31,6 +31,7 @@ __export(index_exports, {
|
|
|
31
31
|
compileScopedFilterToSql: () => compileScopedFilterToSql,
|
|
32
32
|
createOrderLabelResolver: () => createOrderLabelResolver,
|
|
33
33
|
evaluateDerivedMeasures: () => evaluateDerivedMeasures,
|
|
34
|
+
fillEmptyGroups: () => fillEmptyGroups,
|
|
34
35
|
mergeByDimensions: () => mergeByDimensions,
|
|
35
36
|
pickDisplayField: () => pickDisplayField,
|
|
36
37
|
resolveDimensionLabels: () => resolveDimensionLabels,
|
|
@@ -40,7 +41,8 @@ __export(index_exports, {
|
|
|
40
41
|
module.exports = __toCommonJS(index_exports);
|
|
41
42
|
|
|
42
43
|
// src/analytics-service.ts
|
|
43
|
-
var
|
|
44
|
+
var import_data3 = require("@objectstack/spec/data");
|
|
45
|
+
var import_core5 = require("@objectstack/core");
|
|
44
46
|
|
|
45
47
|
// src/cube-registry.ts
|
|
46
48
|
var CubeRegistry = class {
|
|
@@ -169,7 +171,8 @@ var MONGO_TO_CUBE_OP = {
|
|
|
169
171
|
$nin: "notIn",
|
|
170
172
|
$contains: "contains",
|
|
171
173
|
$notContains: "notContains",
|
|
172
|
-
$
|
|
174
|
+
$startsWith: "startsWith",
|
|
175
|
+
$endsWith: "endsWith"
|
|
173
176
|
};
|
|
174
177
|
function stringifyForCube(v) {
|
|
175
178
|
if (v == null) return "";
|
|
@@ -178,55 +181,102 @@ function stringifyForCube(v) {
|
|
|
178
181
|
if (typeof v === "object") return JSON.stringify(v);
|
|
179
182
|
return String(v);
|
|
180
183
|
}
|
|
181
|
-
function
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
184
|
+
function andOf(children) {
|
|
185
|
+
if (children.length === 0) return null;
|
|
186
|
+
if (children.length === 1) return children[0];
|
|
187
|
+
return { kind: "and", children };
|
|
188
|
+
}
|
|
189
|
+
function fieldLeaves(key, raw) {
|
|
190
|
+
const out = [];
|
|
191
|
+
const leaf = (operator, values) => {
|
|
192
|
+
out.push({ kind: "leaf", member: key, operator, values });
|
|
193
|
+
};
|
|
194
|
+
if (raw === null) {
|
|
195
|
+
leaf("notSet", []);
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
if (typeof raw === "object" && !Array.isArray(raw) && !(raw instanceof Date)) {
|
|
199
|
+
const wrapper = raw;
|
|
200
|
+
const opKeys = Object.keys(wrapper).filter((k) => k.startsWith("$"));
|
|
201
|
+
if (opKeys.length > 0) {
|
|
202
|
+
for (const opKey of opKeys) {
|
|
203
|
+
if (opKey === "$between") {
|
|
204
|
+
const v2 = wrapper[opKey];
|
|
205
|
+
if (!Array.isArray(v2) || v2.length !== 2) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`[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.`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
leaf("gte", [stringifyForCube(v2[0])]);
|
|
211
|
+
leaf("lte", [stringifyForCube(v2[1])]);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (opKey === "$null" || opKey === "$exists") {
|
|
215
|
+
const isNull = opKey === "$null" ? wrapper[opKey] === true : wrapper[opKey] === false;
|
|
216
|
+
leaf(isNull ? "notSet" : "set", []);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const cubeOp = MONGO_TO_CUBE_OP[opKey];
|
|
220
|
+
if (!cubeOp) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`[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.`
|
|
223
|
+
);
|
|
188
224
|
}
|
|
225
|
+
const v = wrapper[opKey];
|
|
226
|
+
leaf(cubeOp, Array.isArray(v) ? v.map(stringifyForCube) : [stringifyForCube(v)]);
|
|
189
227
|
}
|
|
190
|
-
|
|
228
|
+
return out;
|
|
191
229
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
out.push({ member: key, operator: "notSet", values: [] });
|
|
195
|
-
continue;
|
|
230
|
+
for (const [nestedKey, nestedVal] of Object.entries(wrapper)) {
|
|
231
|
+
out.push(...fieldLeaves(`${key}.${nestedKey}`, nestedVal));
|
|
196
232
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
if (Array.isArray(raw)) leaf("in", raw.map(stringifyForCube));
|
|
236
|
+
else leaf("equals", [stringifyForCube(raw)]);
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
function buildNode(cond) {
|
|
240
|
+
const children = [];
|
|
241
|
+
for (const [key, raw] of Object.entries(cond)) {
|
|
242
|
+
if (raw === void 0) continue;
|
|
243
|
+
if (key === "$and" || key === "$or") {
|
|
244
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`[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.`
|
|
247
|
+
);
|
|
212
248
|
}
|
|
249
|
+
const branches = raw.map((sub) => sub && typeof sub === "object" ? buildNode(sub) : null).filter((n) => n !== null);
|
|
250
|
+
if (branches.length === 0) continue;
|
|
251
|
+
if (key === "$and") children.push(...branches);
|
|
252
|
+
else children.push(branches.length === 1 ? branches[0] : { kind: "or", children: branches });
|
|
213
253
|
continue;
|
|
214
254
|
}
|
|
215
|
-
if (
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
255
|
+
if (key === "$not") {
|
|
256
|
+
const inner = raw && typeof raw === "object" ? buildNode(raw) : null;
|
|
257
|
+
if (inner) children.push({ kind: "not", child: inner });
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (key.startsWith("$")) {
|
|
261
|
+
throw new Error(
|
|
262
|
+
`[analytics] Unsupported top-level filter operator "${key}". Dropping it would silently widen the query to rows the filter excludes.`
|
|
263
|
+
);
|
|
219
264
|
}
|
|
265
|
+
children.push(...fieldLeaves(key, raw));
|
|
220
266
|
}
|
|
267
|
+
return andOf(children);
|
|
221
268
|
}
|
|
222
|
-
function
|
|
223
|
-
if (!query || typeof query !== "object") return
|
|
224
|
-
const out = [];
|
|
269
|
+
function normalizeAnalyticsFilterTree(query) {
|
|
270
|
+
if (!query || typeof query !== "object") return null;
|
|
225
271
|
const where = query.where;
|
|
226
|
-
if (where
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
272
|
+
if (!where || typeof where !== "object" || Array.isArray(where)) return null;
|
|
273
|
+
return buildNode(where);
|
|
274
|
+
}
|
|
275
|
+
function collectFilterLeaves(node) {
|
|
276
|
+
if (!node) return [];
|
|
277
|
+
if (node.kind === "leaf") return [{ member: node.member, operator: node.operator, values: node.values }];
|
|
278
|
+
if (node.kind === "not") return collectFilterLeaves(node.child);
|
|
279
|
+
return node.children.flatMap(collectFilterLeaves);
|
|
230
280
|
}
|
|
231
281
|
function recoverNumber(s) {
|
|
232
282
|
if (/^-?\d+(\.\d+)?$/.test(s)) {
|
|
@@ -358,6 +408,18 @@ function compileOperator(col, op, val, field, params) {
|
|
|
358
408
|
}
|
|
359
409
|
|
|
360
410
|
// src/strategies/native-sql-strategy.ts
|
|
411
|
+
var import_core = require("@objectstack/core");
|
|
412
|
+
var AGGREGATE_SQL = {
|
|
413
|
+
"count": () => "COUNT(*)",
|
|
414
|
+
"sum": (col) => `SUM(${col})`,
|
|
415
|
+
"avg": (col) => `AVG(${col})`,
|
|
416
|
+
"min": (col) => `MIN(${col})`,
|
|
417
|
+
"max": (col) => `MAX(${col})`,
|
|
418
|
+
"count_distinct": (col) => `COUNT(DISTINCT ${col})`
|
|
419
|
+
};
|
|
420
|
+
var SUPPORTED_AGGREGATE_SQL_KEYS = Object.keys(AGGREGATE_SQL);
|
|
421
|
+
var EXPRESSION_METRIC_TYPES = /* @__PURE__ */ new Set(["number", "string", "boolean"]);
|
|
422
|
+
var IDENTIFIER_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
361
423
|
var NativeSQLStrategy = class {
|
|
362
424
|
constructor() {
|
|
363
425
|
this.name = "NativeSQLStrategy";
|
|
@@ -412,15 +474,15 @@ var NativeSQLStrategy = class {
|
|
|
412
474
|
}
|
|
413
475
|
}
|
|
414
476
|
const whereClauses = [];
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
477
|
+
const filterSql = this.compileFilterNode(
|
|
478
|
+
normalizeAnalyticsFilterTree(query),
|
|
479
|
+
cube,
|
|
480
|
+
tableName,
|
|
481
|
+
joins,
|
|
482
|
+
params,
|
|
483
|
+
ctx
|
|
484
|
+
);
|
|
485
|
+
if (filterSql) whereClauses.push(filterSql);
|
|
424
486
|
if (query.timeDimensions && query.timeDimensions.length > 0) {
|
|
425
487
|
for (const td of query.timeDimensions) {
|
|
426
488
|
const colExpr = this.resolveFieldSql(cube, td.dimension, tableName, joins);
|
|
@@ -428,11 +490,17 @@ var NativeSQLStrategy = class {
|
|
|
428
490
|
const range = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
|
|
429
491
|
if (range.length === 2) {
|
|
430
492
|
const td2 = this.resolveStorageTarget(cube, td.dimension, tableName);
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
493
|
+
const column = this.temporalColumn(ctx, td2, colExpr);
|
|
494
|
+
const nextDay = (0, import_core.nextUtcCalendarDay)(range[1]);
|
|
495
|
+
params.push(this.coerceTemporal(ctx, td2, range[0]));
|
|
496
|
+
const lower = `${column} >= $${params.length}`;
|
|
497
|
+
if (nextDay != null) {
|
|
498
|
+
params.push(this.coerceTemporal(ctx, td2, nextDay));
|
|
499
|
+
whereClauses.push(`(${lower} AND ${column} < $${params.length})`);
|
|
500
|
+
} else {
|
|
501
|
+
params.push(this.coerceTemporal(ctx, td2, range[1]));
|
|
502
|
+
whereClauses.push(`(${lower} AND ${column} <= $${params.length})`);
|
|
503
|
+
}
|
|
436
504
|
}
|
|
437
505
|
}
|
|
438
506
|
}
|
|
@@ -530,6 +598,7 @@ var NativeSQLStrategy = class {
|
|
|
530
598
|
}
|
|
531
599
|
return rawSql;
|
|
532
600
|
}
|
|
601
|
+
if (!IDENTIFIER_PATH.test(rawSql)) return rawSql;
|
|
533
602
|
const segments = rawSql.split(".");
|
|
534
603
|
const column = segments[segments.length - 1];
|
|
535
604
|
const hops = segments.slice(0, -1);
|
|
@@ -589,24 +658,19 @@ var NativeSQLStrategy = class {
|
|
|
589
658
|
}
|
|
590
659
|
resolveMeasureSql(cube, member, parentTable, joins) {
|
|
591
660
|
const measure = this.lookupMember(cube, member, "measure");
|
|
592
|
-
if (!measure)
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
case "sum":
|
|
598
|
-
return `SUM(${col})`;
|
|
599
|
-
case "avg":
|
|
600
|
-
return `AVG(${col})`;
|
|
601
|
-
case "min":
|
|
602
|
-
return `MIN(${col})`;
|
|
603
|
-
case "max":
|
|
604
|
-
return `MAX(${col})`;
|
|
605
|
-
case "count_distinct":
|
|
606
|
-
return `COUNT(DISTINCT ${col})`;
|
|
607
|
-
default:
|
|
608
|
-
return `COUNT(*)`;
|
|
661
|
+
if (!measure) {
|
|
662
|
+
const declared = Object.keys(cube.measures ?? {});
|
|
663
|
+
throw new Error(
|
|
664
|
+
`[native-sql-strategy] cube "${cube.name}" declares no measure "${member}"` + (declared.length ? ` (declared: ${declared.join(", ")})` : " (it declares none)")
|
|
665
|
+
);
|
|
609
666
|
}
|
|
667
|
+
const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
|
|
668
|
+
const wrap = AGGREGATE_SQL[measure.type];
|
|
669
|
+
if (wrap) return wrap(col);
|
|
670
|
+
if (EXPRESSION_METRIC_TYPES.has(measure.type)) return col;
|
|
671
|
+
throw new Error(
|
|
672
|
+
`[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(", ")}).`
|
|
673
|
+
);
|
|
610
674
|
}
|
|
611
675
|
resolveFieldSql(cube, member, parentTable, joins) {
|
|
612
676
|
const dim = this.lookupMember(cube, member, "dimension");
|
|
@@ -659,7 +723,53 @@ var NativeSQLStrategy = class {
|
|
|
659
723
|
}
|
|
660
724
|
return coerceFilterValueForSql(value);
|
|
661
725
|
}
|
|
662
|
-
|
|
726
|
+
/**
|
|
727
|
+
* The column side of {@link coerceTemporal}: normalise the reference so it
|
|
728
|
+
* reads in the storage form the comparand was coerced into.
|
|
729
|
+
*
|
|
730
|
+
* A SQLite `Field.datetime` column carries an INTEGER epoch (a `Date` write)
|
|
731
|
+
* and ISO TEXT (a REST/JSON write, a `NOW()` default — including the platform's
|
|
732
|
+
* own `created_at`) at the SAME time, so coercing the value alone fixes one half
|
|
733
|
+
* and empties the other. That is #3912: a `dateRange: last_30_days` on
|
|
734
|
+
* `created_date` read 0 with 29 rows in range. Every other column and dialect
|
|
735
|
+
* gets its reference back verbatim.
|
|
736
|
+
*/
|
|
737
|
+
temporalColumn(ctx, target, col) {
|
|
738
|
+
if (typeof ctx.coerceTemporalFilterColumn !== "function") return col;
|
|
739
|
+
return ctx.coerceTemporalFilterColumn(target.object, target.field, col) || col;
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Compile a normalized filter node into a boolean SQL expression, recursing
|
|
743
|
+
* through the combinators. `null` = no constraint.
|
|
744
|
+
*
|
|
745
|
+
* Leaves go through {@link buildFilterClause} exactly as they did when this
|
|
746
|
+
* was a flat loop, so the storage-form coercion and the calendar-day
|
|
747
|
+
* upper-bound rule (#3777) apply at every depth — including inside an `$or`,
|
|
748
|
+
* where a second, combinator-aware implementation would have been free to
|
|
749
|
+
* drift from the first.
|
|
750
|
+
*
|
|
751
|
+
* Parenthesisation is explicit rather than left to SQL's precedence: `AND`
|
|
752
|
+
* does bind tighter than `OR`, so `a AND b OR c` happens to be right, but
|
|
753
|
+
* being right by construction is what keeps a future edit from making it
|
|
754
|
+
* wrong.
|
|
755
|
+
*/
|
|
756
|
+
compileFilterNode(node, cube, parentTable, joins, params, ctx) {
|
|
757
|
+
if (!node) return null;
|
|
758
|
+
if (node.kind === "leaf") {
|
|
759
|
+
const colExpr = this.resolveFieldSql(cube, node.member, parentTable, joins);
|
|
760
|
+
const target = this.resolveStorageTarget(cube, node.member, parentTable);
|
|
761
|
+
return this.buildFilterClause(colExpr, node.operator, node.values, params, ctx, target);
|
|
762
|
+
}
|
|
763
|
+
if (node.kind === "not") {
|
|
764
|
+
const inner = this.compileFilterNode(node.child, cube, parentTable, joins, params, ctx);
|
|
765
|
+
return inner ? `NOT (${inner})` : null;
|
|
766
|
+
}
|
|
767
|
+
const parts = node.children.map((child) => this.compileFilterNode(child, cube, parentTable, joins, params, ctx)).filter((s) => !!s);
|
|
768
|
+
if (parts.length === 0) return null;
|
|
769
|
+
if (parts.length === 1) return parts[0];
|
|
770
|
+
return `(${parts.join(node.kind === "or" ? " OR " : " AND ")})`;
|
|
771
|
+
}
|
|
772
|
+
buildFilterClause(rawCol, operator, values, params, ctx, target) {
|
|
663
773
|
const opMap = {
|
|
664
774
|
equals: "=",
|
|
665
775
|
notEquals: "!=",
|
|
@@ -668,26 +778,42 @@ var NativeSQLStrategy = class {
|
|
|
668
778
|
lt: "<",
|
|
669
779
|
lte: "<=",
|
|
670
780
|
contains: "LIKE",
|
|
671
|
-
notContains: "NOT LIKE"
|
|
781
|
+
notContains: "NOT LIKE",
|
|
782
|
+
startsWith: "LIKE",
|
|
783
|
+
endsWith: "LIKE"
|
|
672
784
|
};
|
|
673
|
-
|
|
674
|
-
|
|
785
|
+
const likePattern = {
|
|
786
|
+
contains: (v) => `%${v}%`,
|
|
787
|
+
notContains: (v) => `%${v}%`,
|
|
788
|
+
startsWith: (v) => `${v}%`,
|
|
789
|
+
endsWith: (v) => `%${v}`
|
|
790
|
+
};
|
|
791
|
+
if (operator === "set") return `${rawCol} IS NOT NULL`;
|
|
792
|
+
if (operator === "notSet") return `${rawCol} IS NULL`;
|
|
675
793
|
if (operator === "in" || operator === "notIn") {
|
|
676
794
|
if (!values || values.length === 0) return null;
|
|
677
795
|
const placeholders = values.map((v) => {
|
|
678
796
|
params.push(this.coerceTemporal(ctx, target, v));
|
|
679
797
|
return `$${params.length}`;
|
|
680
798
|
}).join(", ");
|
|
681
|
-
return `${
|
|
799
|
+
return `${this.temporalColumn(ctx, target, rawCol)} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
|
|
682
800
|
}
|
|
683
801
|
const sqlOp = opMap[operator];
|
|
684
802
|
if (!sqlOp || !values || values.length === 0) return null;
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
params.
|
|
803
|
+
const pattern = likePattern[operator];
|
|
804
|
+
if (pattern) {
|
|
805
|
+
params.push(pattern(values[0]));
|
|
806
|
+
return `${rawCol} ${sqlOp} $${params.length}`;
|
|
807
|
+
}
|
|
808
|
+
if (operator === "lte") {
|
|
809
|
+
const nextDay = (0, import_core.nextUtcCalendarDay)(values[0]);
|
|
810
|
+
if (nextDay != null) {
|
|
811
|
+
params.push(this.coerceTemporal(ctx, target, nextDay));
|
|
812
|
+
return `${this.temporalColumn(ctx, target, rawCol)} < $${params.length}`;
|
|
813
|
+
}
|
|
689
814
|
}
|
|
690
|
-
|
|
815
|
+
params.push(this.coerceTemporal(ctx, target, values[0]));
|
|
816
|
+
return `${this.temporalColumn(ctx, target, rawCol)} ${sqlOp} $${params.length}`;
|
|
691
817
|
}
|
|
692
818
|
extractObjectName(cube) {
|
|
693
819
|
return cube.sql.trim();
|
|
@@ -709,6 +835,9 @@ var NativeSQLStrategy = class {
|
|
|
709
835
|
}
|
|
710
836
|
};
|
|
711
837
|
|
|
838
|
+
// src/strategies/objectql-strategy.ts
|
|
839
|
+
var import_core2 = require("@objectstack/core");
|
|
840
|
+
|
|
712
841
|
// src/strategies/cross-object-rebucket.ts
|
|
713
842
|
var RECOMBINABLE_METHODS = /* @__PURE__ */ new Set([
|
|
714
843
|
"sum",
|
|
@@ -811,11 +940,7 @@ var ObjectQLStrategy = class {
|
|
|
811
940
|
}
|
|
812
941
|
const filter = {};
|
|
813
942
|
const conjuncts = [];
|
|
814
|
-
|
|
815
|
-
const fieldName = this.resolveFieldName(cube, f.member, "any");
|
|
816
|
-
const extra = this.mergeFilterOperand(filter, fieldName, this.convertFilter(f.operator, f.values));
|
|
817
|
-
if (extra) conjuncts.push(extra);
|
|
818
|
-
}
|
|
943
|
+
this.applyFilterNode(normalizeAnalyticsFilterTree(query), cube, filter, conjuncts);
|
|
819
944
|
for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
|
|
820
945
|
const extra = this.mergeFilterOperand(filter, field, bounds);
|
|
821
946
|
if (extra) conjuncts.push(extra);
|
|
@@ -847,11 +972,9 @@ var ObjectQLStrategy = class {
|
|
|
847
972
|
});
|
|
848
973
|
const mappedRows = rows.map((row) => {
|
|
849
974
|
const mapped = {};
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
if (shortName in row) mapped[dim] = row[shortName];
|
|
854
|
-
}
|
|
975
|
+
for (const dim of this.projectedDimensions(query)) {
|
|
976
|
+
const shortName = this.resolveFieldName(cube, dim, "dimension");
|
|
977
|
+
if (shortName in row) mapped[dim] = row[shortName];
|
|
855
978
|
}
|
|
856
979
|
if (query.measures) {
|
|
857
980
|
for (const m of query.measures) {
|
|
@@ -897,7 +1020,7 @@ var ObjectQLStrategy = class {
|
|
|
897
1020
|
}
|
|
898
1021
|
const tableName = this.extractObjectName(cube);
|
|
899
1022
|
const plan = this.planCrossObject(cube, query, Object.fromEntries(
|
|
900
|
-
|
|
1023
|
+
collectFilterLeaves(normalizeAnalyticsFilterTree(query)).map((f) => [this.resolveFieldName(cube, f.member, "any"), true])
|
|
901
1024
|
));
|
|
902
1025
|
const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
|
|
903
1026
|
const joinClauses = [];
|
|
@@ -934,18 +1057,18 @@ var ObjectQLStrategy = class {
|
|
|
934
1057
|
}
|
|
935
1058
|
}
|
|
936
1059
|
const whereParts = [];
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
);
|
|
944
|
-
if (clause) whereParts.push(clause);
|
|
945
|
-
}
|
|
1060
|
+
const filterClause = this.renderFilterNodeSql(
|
|
1061
|
+
normalizeAnalyticsFilterTree(query),
|
|
1062
|
+
cube,
|
|
1063
|
+
params
|
|
1064
|
+
);
|
|
1065
|
+
if (filterClause) whereParts.push(filterClause);
|
|
946
1066
|
for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
|
|
947
|
-
|
|
948
|
-
|
|
1067
|
+
const nextDay = (0, import_core2.nextUtcCalendarDay)(bounds.$lte);
|
|
1068
|
+
params.push(bounds.$gte, nextDay ?? bounds.$lte);
|
|
1069
|
+
whereParts.push(
|
|
1070
|
+
`(${field} >= $${params.length - 1} AND ${field} ${nextDay ? "<" : "<="} $${params.length})`
|
|
1071
|
+
);
|
|
949
1072
|
}
|
|
950
1073
|
const scope = ctx.getReadScope?.(tableName);
|
|
951
1074
|
if (scope != null) {
|
|
@@ -1119,7 +1242,7 @@ var ObjectQLStrategy = class {
|
|
|
1119
1242
|
const merged = rebucketCrossObject(baseRows, baseDimFields, resolvedDims, measures);
|
|
1120
1243
|
const mappedRows = merged.map((row) => {
|
|
1121
1244
|
const out = {};
|
|
1122
|
-
for (const dim of query
|
|
1245
|
+
for (const dim of this.projectedDimensions(query)) {
|
|
1123
1246
|
if (crossByDim.has(dim)) {
|
|
1124
1247
|
if (dim in row) out[dim] = row[dim];
|
|
1125
1248
|
} else {
|
|
@@ -1127,11 +1250,6 @@ var ObjectQLStrategy = class {
|
|
|
1127
1250
|
if (field in row) out[dim] = row[field];
|
|
1128
1251
|
}
|
|
1129
1252
|
}
|
|
1130
|
-
for (const td of query.timeDimensions ?? []) {
|
|
1131
|
-
if (query.dimensions?.includes(td.dimension)) continue;
|
|
1132
|
-
const field = this.resolveFieldName(cube, td.dimension, "dimension");
|
|
1133
|
-
if (field in row) out[td.dimension] = row[field];
|
|
1134
|
-
}
|
|
1135
1253
|
for (const m of query.measures ?? []) {
|
|
1136
1254
|
if (m in row) out[m] = row[m];
|
|
1137
1255
|
}
|
|
@@ -1274,6 +1392,75 @@ var ObjectQLStrategy = class {
|
|
|
1274
1392
|
* are handed back for the caller to AND in separately, so the engine
|
|
1275
1393
|
* intersects them instead of the strategy picking a winner.
|
|
1276
1394
|
*/
|
|
1395
|
+
/**
|
|
1396
|
+
* Fold a normalized filter node into the engine filter being built.
|
|
1397
|
+
*
|
|
1398
|
+
* AND-ed LEAVES merge per field through {@link mergeFilterOperand}, exactly
|
|
1399
|
+
* as the flat loop this replaced did — so a query without combinators still
|
|
1400
|
+
* produces byte-identical engine input. Anything structural (`$or`, `$not`,
|
|
1401
|
+
* a nested `$and` that cannot merge) becomes its own conjunct, which the
|
|
1402
|
+
* caller ANDs in. The engine speaks these combinators natively
|
|
1403
|
+
* (`FilterCondition` declares them and every driver compiles them), so this
|
|
1404
|
+
* path hands them over rather than lowering them.
|
|
1405
|
+
*/
|
|
1406
|
+
applyFilterNode(node, cube, filter, conjuncts) {
|
|
1407
|
+
if (!node) return;
|
|
1408
|
+
if (node.kind === "leaf") {
|
|
1409
|
+
const fieldName = this.resolveFieldName(cube, node.member, "any");
|
|
1410
|
+
const extra = this.mergeFilterOperand(filter, fieldName, this.convertFilter(node.operator, node.values));
|
|
1411
|
+
if (extra) conjuncts.push(extra);
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
if (node.kind === "and") {
|
|
1415
|
+
for (const child of node.children) this.applyFilterNode(child, cube, filter, conjuncts);
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
const rendered = this.filterNodeToCondition(node, cube);
|
|
1419
|
+
if (rendered) conjuncts.push(rendered);
|
|
1420
|
+
}
|
|
1421
|
+
/** A node as a standalone `FilterCondition` the engine can consume. */
|
|
1422
|
+
filterNodeToCondition(node, cube) {
|
|
1423
|
+
if (!node) return null;
|
|
1424
|
+
if (node.kind === "not") {
|
|
1425
|
+
const inner = this.filterNodeToCondition(node.child, cube);
|
|
1426
|
+
return inner ? { $not: inner } : null;
|
|
1427
|
+
}
|
|
1428
|
+
if (node.kind === "or") {
|
|
1429
|
+
const branches = node.children.map((child) => this.filterNodeToCondition(child, cube)).filter((c) => !!c);
|
|
1430
|
+
return branches.length > 0 ? { $or: branches } : null;
|
|
1431
|
+
}
|
|
1432
|
+
const filter = {};
|
|
1433
|
+
const conjuncts = [];
|
|
1434
|
+
this.applyFilterNode(node, cube, filter, conjuncts);
|
|
1435
|
+
if (conjuncts.length > 0) {
|
|
1436
|
+
filter.$and = [...Array.isArray(filter.$and) ? filter.$and : [], ...conjuncts];
|
|
1437
|
+
}
|
|
1438
|
+
return Object.keys(filter).length > 0 ? filter : null;
|
|
1439
|
+
}
|
|
1440
|
+
/**
|
|
1441
|
+
* Render a normalized filter node as the display SQL `/analytics/sql`
|
|
1442
|
+
* echoes. Values still bind as `$n` placeholders — the echo travels to the
|
|
1443
|
+
* browser, so a comparand is never inlined.
|
|
1444
|
+
*/
|
|
1445
|
+
renderFilterNodeSql(node, cube, params) {
|
|
1446
|
+
if (!node) return null;
|
|
1447
|
+
if (node.kind === "leaf") {
|
|
1448
|
+
return this.buildFilterClauseSql(
|
|
1449
|
+
this.resolveFieldName(cube, node.member, "any"),
|
|
1450
|
+
node.operator,
|
|
1451
|
+
node.values,
|
|
1452
|
+
params
|
|
1453
|
+
);
|
|
1454
|
+
}
|
|
1455
|
+
if (node.kind === "not") {
|
|
1456
|
+
const inner = this.renderFilterNodeSql(node.child, cube, params);
|
|
1457
|
+
return inner ? `NOT (${inner})` : null;
|
|
1458
|
+
}
|
|
1459
|
+
const parts = node.children.map((child) => this.renderFilterNodeSql(child, cube, params)).filter((s) => !!s);
|
|
1460
|
+
if (parts.length === 0) return null;
|
|
1461
|
+
if (parts.length === 1) return parts[0];
|
|
1462
|
+
return `(${parts.join(node.kind === "or" ? " OR " : " AND ")})`;
|
|
1463
|
+
}
|
|
1277
1464
|
mergeFilterOperand(filter, field, operand) {
|
|
1278
1465
|
const existing = filter[field];
|
|
1279
1466
|
if (existing === void 0) {
|
|
@@ -1297,9 +1484,13 @@ var ObjectQLStrategy = class {
|
|
|
1297
1484
|
* HERE on every driver — and "bucketed trend" is precisely the shape that also
|
|
1298
1485
|
* carries a range ("last 12 months", "this quarter").
|
|
1299
1486
|
*
|
|
1300
|
-
* Bounds are inclusive on both ends —
|
|
1301
|
-
* `
|
|
1302
|
-
*
|
|
1487
|
+
* Bounds are inclusive on both ends — logically "from day X through day Y".
|
|
1488
|
+
* The `$lte` end is left as the bare calendar day on purpose: the driver's
|
|
1489
|
+
* filter compiler owns the calendar-day → instant translation, compiling a
|
|
1490
|
+
* bare-day `$lte` on a `datetime` column into the half-open `< nextDay`
|
|
1491
|
+
* (#3777) while a `date` column keeps the plain `<=`. `NativeSQLStrategy`
|
|
1492
|
+
* performs the same half-open translation itself because it binds into raw
|
|
1493
|
+
* SQL, so one dashboard reads the same on every driver.
|
|
1303
1494
|
*
|
|
1304
1495
|
* Comparands are coerced by the SAME helper the `where` path uses, so an
|
|
1305
1496
|
* epoch-ms bound recovers as a number and an ISO string stays a string. No
|
|
@@ -1359,24 +1550,60 @@ var ObjectQLStrategy = class {
|
|
|
1359
1550
|
return { $lte: v0 };
|
|
1360
1551
|
case "contains":
|
|
1361
1552
|
return { $regex: values[0] };
|
|
1553
|
+
// `notContains` had no arm and fell to the `default` below, which returns
|
|
1554
|
+
// a BARE VALUE — i.e. `{field: 'x'}`, an equality. "does not contain x"
|
|
1555
|
+
// was compiled as "equals x". These three pass through as the canonical
|
|
1556
|
+
// spec operators every driver implements directly, so an anchored match
|
|
1557
|
+
// stays anchored rather than depending on regex dialect (#4128).
|
|
1558
|
+
case "notContains":
|
|
1559
|
+
return { $notContains: values[0] };
|
|
1560
|
+
case "startsWith":
|
|
1561
|
+
return { $startsWith: values[0] };
|
|
1562
|
+
case "endsWith":
|
|
1563
|
+
return { $endsWith: values[0] };
|
|
1362
1564
|
case "in":
|
|
1363
1565
|
return { $in: all };
|
|
1364
1566
|
case "notIn":
|
|
1365
1567
|
return { $nin: all };
|
|
1366
1568
|
default:
|
|
1367
|
-
|
|
1569
|
+
throw new Error(
|
|
1570
|
+
`[analytics] ObjectQL strategy cannot express filter operator "${operator}". Treating it as an equality would silently query something the author did not ask for.`
|
|
1571
|
+
);
|
|
1368
1572
|
}
|
|
1369
1573
|
}
|
|
1370
1574
|
extractObjectName(cube) {
|
|
1371
1575
|
return cube.sql.trim();
|
|
1372
1576
|
}
|
|
1577
|
+
/**
|
|
1578
|
+
* The dimensions this query PROJECTS, in the order the result carries them:
|
|
1579
|
+
* every `dimensions` entry, then every granular `timeDimensions` entry that
|
|
1580
|
+
* is not already one of them.
|
|
1581
|
+
*
|
|
1582
|
+
* `timeDimensions` is not merely a filter carrier. An entry with a
|
|
1583
|
+
* `granularity` is GROUPED BY — see the `td.granularity` sites that build
|
|
1584
|
+
* groupBy here, in `generateSql` and in the cross-object path — so its
|
|
1585
|
+
* bucket is a COLUMN of the result; an entry without one only contributes a
|
|
1586
|
+
* `dateRange` predicate and must NOT be projected.
|
|
1587
|
+
*
|
|
1588
|
+
* Grouping, row mapping and {@link buildFieldMeta} have to agree on exactly
|
|
1589
|
+
* that set. When they did not, a bucketed query returned rows carrying only
|
|
1590
|
+
* the measures and a `fields` list that never mentioned the bucket — a trend
|
|
1591
|
+
* chart got N values and no x-axis (#4033) — even though the SQL had
|
|
1592
|
+
* selected `date_trunc(…) AS "<dim>"` all along. One definition, every
|
|
1593
|
+
* consumer.
|
|
1594
|
+
*/
|
|
1595
|
+
projectedDimensions(query) {
|
|
1596
|
+
const out = [...query.dimensions ?? []];
|
|
1597
|
+
for (const td of query.timeDimensions ?? []) {
|
|
1598
|
+
if (td.granularity && !out.includes(td.dimension)) out.push(td.dimension);
|
|
1599
|
+
}
|
|
1600
|
+
return out;
|
|
1601
|
+
}
|
|
1373
1602
|
buildFieldMeta(query, cube) {
|
|
1374
1603
|
const fields = [];
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
fields.push({ name: dim, type: d?.type || "string" });
|
|
1379
|
-
}
|
|
1604
|
+
for (const dim of this.projectedDimensions(query)) {
|
|
1605
|
+
const d = this.lookupMember(cube, dim, "dimension");
|
|
1606
|
+
fields.push({ name: dim, type: d?.type || "string" });
|
|
1380
1607
|
}
|
|
1381
1608
|
if (query.measures) {
|
|
1382
1609
|
for (const m of query.measures) {
|
|
@@ -1388,14 +1615,16 @@ var ObjectQLStrategy = class {
|
|
|
1388
1615
|
};
|
|
1389
1616
|
|
|
1390
1617
|
// src/dataset-compiler.ts
|
|
1618
|
+
var import_data = require("@objectstack/spec/data");
|
|
1391
1619
|
var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set(["array_agg", "string_agg"]);
|
|
1620
|
+
var SUPPORTED_AGGREGATES = import_data.AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
|
|
1392
1621
|
function aggregateToMetricType(m) {
|
|
1393
1622
|
if (!m.aggregate) {
|
|
1394
1623
|
throw new Error(`[dataset-compiler] non-derived measure "${m.name}" has no aggregate`);
|
|
1395
1624
|
}
|
|
1396
1625
|
if (UNSUPPORTED_AGGREGATES.has(m.aggregate)) {
|
|
1397
1626
|
throw new Error(
|
|
1398
|
-
`[dataset-compiler] measure "${m.name}" uses aggregate "${m.aggregate}" which is not supported by the v1 dataset runtime (supported:
|
|
1627
|
+
`[dataset-compiler] measure "${m.name}" uses aggregate "${m.aggregate}" which is not supported by the v1 dataset runtime (supported: ${SUPPORTED_AGGREGATES.join(", ")}).`
|
|
1399
1628
|
);
|
|
1400
1629
|
}
|
|
1401
1630
|
return m.aggregate;
|
|
@@ -1522,10 +1751,11 @@ function compileDataset(dataset, resolver) {
|
|
|
1522
1751
|
}
|
|
1523
1752
|
|
|
1524
1753
|
// src/dataset-executor.ts
|
|
1525
|
-
var
|
|
1754
|
+
var import_data2 = require("@objectstack/spec/data");
|
|
1755
|
+
var import_core3 = require("@objectstack/core");
|
|
1526
1756
|
function resolveSelectionTokens(compiled, selection, context) {
|
|
1527
|
-
const tokenCtx = (0,
|
|
1528
|
-
const resolve = (v) => (0,
|
|
1757
|
+
const tokenCtx = (0, import_core3.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
|
|
1758
|
+
const resolve = (v) => (0, import_core3.resolveFilterTokens)(v, tokenCtx);
|
|
1529
1759
|
const filter = resolve(compiled.filter);
|
|
1530
1760
|
const measureFilters = resolve(compiled.measureFilters);
|
|
1531
1761
|
const runtimeFilter = resolve(selection.runtimeFilter);
|
|
@@ -1543,6 +1773,12 @@ function combineFilters(a, b) {
|
|
|
1543
1773
|
if (a && b) return { $and: [a, b] };
|
|
1544
1774
|
return a ?? b;
|
|
1545
1775
|
}
|
|
1776
|
+
function splitMeasuresByFilter(measures, measureFilters) {
|
|
1777
|
+
const unfiltered = [];
|
|
1778
|
+
const filtered = [];
|
|
1779
|
+
for (const m of measures) (measureFilters[m] ? filtered : unfiltered).push(m);
|
|
1780
|
+
return { unfiltered, filtered };
|
|
1781
|
+
}
|
|
1546
1782
|
function evaluateDerivedMeasures(rows, derived) {
|
|
1547
1783
|
if (derived.length === 0) return rows;
|
|
1548
1784
|
return rows.map((row) => {
|
|
@@ -1553,6 +1789,14 @@ function evaluateDerivedMeasures(rows, derived) {
|
|
|
1553
1789
|
return out;
|
|
1554
1790
|
});
|
|
1555
1791
|
}
|
|
1792
|
+
function fillEmptyGroups(rows, columnAggregates) {
|
|
1793
|
+
for (const [column, aggregate2] of Object.entries(columnAggregates)) {
|
|
1794
|
+
const empty = (0, import_data2.emptyGroupValueFor)(aggregate2);
|
|
1795
|
+
if (empty === void 0) continue;
|
|
1796
|
+
for (const row of rows) if (row[column] == null) row[column] = empty;
|
|
1797
|
+
}
|
|
1798
|
+
return rows;
|
|
1799
|
+
}
|
|
1556
1800
|
function num(v) {
|
|
1557
1801
|
if (v == null) return null;
|
|
1558
1802
|
const n = typeof v === "number" ? v : Number(v);
|
|
@@ -1622,7 +1866,7 @@ function applyWindow(rows, limit, offset) {
|
|
|
1622
1866
|
if (start === 0 && limit == null) return rows;
|
|
1623
1867
|
return rows.slice(start, limit != null ? start + limit : void 0);
|
|
1624
1868
|
}
|
|
1625
|
-
function resolveOrdering(selection, dimensions) {
|
|
1869
|
+
function resolveOrdering(selection, dimensions, timeDimensions = []) {
|
|
1626
1870
|
const order = selection.order;
|
|
1627
1871
|
if (order && Object.keys(order).length > 0) {
|
|
1628
1872
|
const selectable = /* @__PURE__ */ new Set([
|
|
@@ -1641,6 +1885,10 @@ function resolveOrdering(selection, dimensions) {
|
|
|
1641
1885
|
if ((selection.limit != null || selection.offset != null) && dimensions.length > 0) {
|
|
1642
1886
|
return Object.fromEntries(dimensions.map((d) => [d, "asc"]));
|
|
1643
1887
|
}
|
|
1888
|
+
const timeKeys = timeDimensions.filter((d) => dimensions.includes(d));
|
|
1889
|
+
if (timeKeys.length > 0) {
|
|
1890
|
+
return Object.fromEntries(timeKeys.map((d) => [d, "asc"]));
|
|
1891
|
+
}
|
|
1644
1892
|
return void 0;
|
|
1645
1893
|
}
|
|
1646
1894
|
function parseUTC(date) {
|
|
@@ -1727,14 +1975,10 @@ var DatasetExecutor = class {
|
|
|
1727
1975
|
for (const d of selectedDerived) {
|
|
1728
1976
|
for (const dep of d.of) baseMeasures.add(dep);
|
|
1729
1977
|
}
|
|
1730
|
-
const unfiltered =
|
|
1731
|
-
const filtered = [];
|
|
1732
|
-
for (const m of baseMeasures) {
|
|
1733
|
-
(compiled.measureFilters[m] ? filtered : unfiltered).push(m);
|
|
1734
|
-
}
|
|
1978
|
+
const { unfiltered, filtered } = splitMeasuresByFilter(baseMeasures, compiled.measureFilters);
|
|
1735
1979
|
const baseFilter = combineFilters(compiled.filter, selection.runtimeFilter);
|
|
1736
1980
|
const dimensions = selection.dimensions ?? [];
|
|
1737
|
-
const order = resolveOrdering(selection, dimensions);
|
|
1981
|
+
const order = resolveOrdering(selection, dimensions, this.timeDimensionsOf(compiled, dimensions));
|
|
1738
1982
|
const labelOrderKeys = this.orderLabels ? Object.keys(order ?? {}).filter(
|
|
1739
1983
|
(k) => dimensions.includes(k) && this.orderLabels.isLabelBearing(k)
|
|
1740
1984
|
) : [];
|
|
@@ -1742,6 +1986,76 @@ var DatasetExecutor = class {
|
|
|
1742
1986
|
const pushDownKeys = /* @__PURE__ */ new Set([...dimensions, ...unfiltered]);
|
|
1743
1987
|
const canPushDownWindow = singleQuery && labelOrderKeys.length === 0 && Object.keys(order ?? {}).every((k) => pushDownKeys.has(k));
|
|
1744
1988
|
const windowQuery = canPushDownWindow ? { order, limit: selection.limit, offset: selection.offset } : void 0;
|
|
1989
|
+
const result = await this.runMeasurePass(compiled, selection, {
|
|
1990
|
+
measures: [...baseMeasures],
|
|
1991
|
+
dimensions,
|
|
1992
|
+
baseFilter,
|
|
1993
|
+
window: windowQuery,
|
|
1994
|
+
context
|
|
1995
|
+
});
|
|
1996
|
+
if (selection.compareTo) {
|
|
1997
|
+
const compareRows = await this.runCompare(compiled, selection, [...baseMeasures], dimensions, baseFilter, context);
|
|
1998
|
+
result.rows = mergeByDimensions(
|
|
1999
|
+
result.rows,
|
|
2000
|
+
compareRows,
|
|
2001
|
+
dimensions,
|
|
2002
|
+
[...baseMeasures].map((m) => `${m}__compare`)
|
|
2003
|
+
);
|
|
2004
|
+
for (const m of baseMeasures) result.fields.push({ name: `${m}__compare`, type: "number" });
|
|
2005
|
+
}
|
|
2006
|
+
const fillColumns = {};
|
|
2007
|
+
for (const m of baseMeasures) {
|
|
2008
|
+
const aggregate2 = compiled.cube.measures?.[m]?.type;
|
|
2009
|
+
fillColumns[m] = aggregate2;
|
|
2010
|
+
if (selection.compareTo) fillColumns[`${m}__compare`] = aggregate2;
|
|
2011
|
+
}
|
|
2012
|
+
fillEmptyGroups(result.rows, fillColumns);
|
|
2013
|
+
result.rows = evaluateDerivedMeasures(result.rows, selectedDerived);
|
|
2014
|
+
for (const d of selectedDerived) result.fields.push({ name: d.name, type: "number" });
|
|
2015
|
+
let sortKeys;
|
|
2016
|
+
for (const key of labelOrderKeys) {
|
|
2017
|
+
const values = [...new Set(result.rows.map((r) => r[key]).filter((v) => v != null))];
|
|
2018
|
+
if (values.length === 0) continue;
|
|
2019
|
+
const labels = await this.orderLabels.resolveLabels(key, values);
|
|
2020
|
+
if (labels && labels.size > 0) (sortKeys ?? (sortKeys = {}))[key] = labels;
|
|
2021
|
+
}
|
|
2022
|
+
result.rows = applyOrdering(result.rows, order, sortKeys);
|
|
2023
|
+
result.rows = applyWindow(result.rows, selection.limit, selection.offset);
|
|
2024
|
+
return result;
|
|
2025
|
+
}
|
|
2026
|
+
/**
|
|
2027
|
+
* Run ONE grouped pass over a set of base measures, honouring each measure's
|
|
2028
|
+
* own scoped `filter`: the unfiltered measures in a single query, plus one
|
|
2029
|
+
* supplementary query per filter-scoped measure, merged back by dimension key.
|
|
2030
|
+
*
|
|
2031
|
+
* **This is the executor's only implementation of "how a measure filter is
|
|
2032
|
+
* applied", and every window goes through it** — the current period, each
|
|
2033
|
+
* `totals` subset (which re-enters via `executeSelection`), and the
|
|
2034
|
+
* `compareTo` window. Before #4820 the comparison window had its own,
|
|
2035
|
+
* simpler answer: one shifted query over all base measures with only the
|
|
2036
|
+
* base filter, so `compiled.measureFilters` was never read on that path.
|
|
2037
|
+
* `won_count` counted won deals and `won_count__compare` counted every deal,
|
|
2038
|
+
* under one label, in adjacent columns. Only measures carrying a filter were
|
|
2039
|
+
* wrong — which is what made it survive: the unfiltered ones next to them
|
|
2040
|
+
* compared correctly.
|
|
2041
|
+
*
|
|
2042
|
+
* The caller supplies the `selection` this pass queries under, which is how
|
|
2043
|
+
* the comparison window differs at all: same measures, same dimensions, same
|
|
2044
|
+
* filters — a `timeDimensions` shifted by {@link shiftRange}. Nothing else
|
|
2045
|
+
* about the two passes may drift, because anything that does becomes a
|
|
2046
|
+
* discrepancy between two columns the reader is invited to subtract.
|
|
2047
|
+
*
|
|
2048
|
+
* Cost: one extra query per filter-scoped measure when `compareTo` is set.
|
|
2049
|
+
* The alternative — declaring the discrepancy in the response — is not one,
|
|
2050
|
+
* since the two columns exist to be directly comparable.
|
|
2051
|
+
*
|
|
2052
|
+
* @param window - Ordering/window to push into the SQL. Only ever set for a
|
|
2053
|
+
* selection the caller proved is a single self-sufficient query; a pass
|
|
2054
|
+
* that fans out must return its whole grid for the merge.
|
|
2055
|
+
*/
|
|
2056
|
+
async runMeasurePass(compiled, selection, opts) {
|
|
2057
|
+
const { measures, dimensions, baseFilter, window, context } = opts;
|
|
2058
|
+
const { unfiltered, filtered } = splitMeasuresByFilter(measures, compiled.measureFilters);
|
|
1745
2059
|
let result;
|
|
1746
2060
|
if (unfiltered.length > 0 || filtered.length === 0) {
|
|
1747
2061
|
result = await this.service.query(this.buildQuery(compiled, {
|
|
@@ -1750,7 +2064,7 @@ var DatasetExecutor = class {
|
|
|
1750
2064
|
where: baseFilter,
|
|
1751
2065
|
selection,
|
|
1752
2066
|
contextTimezone: context?.timezone,
|
|
1753
|
-
window
|
|
2067
|
+
window
|
|
1754
2068
|
}), context);
|
|
1755
2069
|
} else {
|
|
1756
2070
|
result = { rows: [], fields: [] };
|
|
@@ -1767,29 +2081,21 @@ var DatasetExecutor = class {
|
|
|
1767
2081
|
result.rows = mergeByDimensions(result.rows, sub.rows, dimensions, [m]);
|
|
1768
2082
|
result.fields.push({ name: m, type: "number" });
|
|
1769
2083
|
}
|
|
1770
|
-
if (selection.compareTo) {
|
|
1771
|
-
const compareRows = await this.runCompare(compiled, selection, [...baseMeasures], dimensions, baseFilter, context);
|
|
1772
|
-
result.rows = mergeByDimensions(
|
|
1773
|
-
result.rows,
|
|
1774
|
-
compareRows,
|
|
1775
|
-
dimensions,
|
|
1776
|
-
[...baseMeasures].map((m) => `${m}__compare`)
|
|
1777
|
-
);
|
|
1778
|
-
for (const m of baseMeasures) result.fields.push({ name: `${m}__compare`, type: "number" });
|
|
1779
|
-
}
|
|
1780
|
-
result.rows = evaluateDerivedMeasures(result.rows, selectedDerived);
|
|
1781
|
-
for (const d of selectedDerived) result.fields.push({ name: d.name, type: "number" });
|
|
1782
|
-
let sortKeys;
|
|
1783
|
-
for (const key of labelOrderKeys) {
|
|
1784
|
-
const values = [...new Set(result.rows.map((r) => r[key]).filter((v) => v != null))];
|
|
1785
|
-
if (values.length === 0) continue;
|
|
1786
|
-
const labels = await this.orderLabels.resolveLabels(key, values);
|
|
1787
|
-
if (labels && labels.size > 0) (sortKeys ?? (sortKeys = {}))[key] = labels;
|
|
1788
|
-
}
|
|
1789
|
-
result.rows = applyOrdering(result.rows, order, sortKeys);
|
|
1790
|
-
result.rows = applyWindow(result.rows, selection.limit, selection.offset);
|
|
1791
2084
|
return result;
|
|
1792
2085
|
}
|
|
2086
|
+
/**
|
|
2087
|
+
* The selected dimensions the compiled cube types as `time`, in selection
|
|
2088
|
+
* order (#3916) — the axis {@link resolveOrdering} defaults to ascending.
|
|
2089
|
+
*
|
|
2090
|
+
* Membership is decided by the DIMENSION's declared type, not by whether the
|
|
2091
|
+
* selection happens to bucket it: a `date` dimension left ungranulated groups
|
|
2092
|
+
* raw timestamps, and those want chronological order every bit as much as
|
|
2093
|
+
* month buckets do. (Both sort correctly — `compareValues` compares Dates and
|
|
2094
|
+
* ISO strings chronologically, and bucket keys are minted sort-stable.)
|
|
2095
|
+
*/
|
|
2096
|
+
timeDimensionsOf(compiled, dimensions) {
|
|
2097
|
+
return dimensions.filter((d) => compiled.cube.dimensions[d]?.type === "time");
|
|
2098
|
+
}
|
|
1793
2099
|
buildQuery(compiled, opts) {
|
|
1794
2100
|
const q = {
|
|
1795
2101
|
cube: compiled.cube.name,
|
|
@@ -1839,13 +2145,11 @@ var DatasetExecutor = class {
|
|
|
1839
2145
|
const shiftedTd = (selection.timeDimensions ?? []).map(
|
|
1840
2146
|
(t) => t.dimension === cmp.dimension ? { ...t, dateRange: shifted } : t
|
|
1841
2147
|
);
|
|
1842
|
-
const sub = await this.
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
contextTimezone: context?.timezone
|
|
1848
|
-
}), context);
|
|
2148
|
+
const sub = await this.runMeasurePass(
|
|
2149
|
+
compiled,
|
|
2150
|
+
{ ...selection, timeDimensions: shiftedTd },
|
|
2151
|
+
{ measures, dimensions, baseFilter, context }
|
|
2152
|
+
);
|
|
1849
2153
|
return sub.rows.map((row) => {
|
|
1850
2154
|
const out = {};
|
|
1851
2155
|
for (const dim of dimensions) out[dim] = row[dim];
|
|
@@ -2031,11 +2335,21 @@ function pickDisplayField(fields) {
|
|
|
2031
2335
|
}
|
|
2032
2336
|
|
|
2033
2337
|
// src/preview-evaluator.ts
|
|
2034
|
-
var
|
|
2338
|
+
var import_core4 = require("@objectstack/core");
|
|
2035
2339
|
function compare(a, b) {
|
|
2036
2340
|
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
2341
|
+
if (a instanceof Date || b instanceof Date) {
|
|
2342
|
+
const ai = (0, import_core4.utcInstantMs)(a);
|
|
2343
|
+
const bi = (0, import_core4.utcInstantMs)(b);
|
|
2344
|
+
if (ai !== null && bi !== null) return ai - bi;
|
|
2345
|
+
}
|
|
2037
2346
|
return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0;
|
|
2038
2347
|
}
|
|
2348
|
+
function lteBound(value, bound) {
|
|
2349
|
+
const nextDay = (0, import_core4.nextUtcCalendarDay)(bound);
|
|
2350
|
+
if (nextDay != null) return compare(value, nextDay) < 0;
|
|
2351
|
+
return compare(value, bound) <= 0;
|
|
2352
|
+
}
|
|
2039
2353
|
function matchOp(value, op, expected) {
|
|
2040
2354
|
switch (op) {
|
|
2041
2355
|
case "$eq":
|
|
@@ -2048,8 +2362,16 @@ function matchOp(value, op, expected) {
|
|
|
2048
2362
|
return value != null && compare(value, expected) >= 0;
|
|
2049
2363
|
case "$lt":
|
|
2050
2364
|
return value != null && compare(value, expected) < 0;
|
|
2051
|
-
case "$lte":
|
|
2052
|
-
|
|
2365
|
+
case "$lte": {
|
|
2366
|
+
if (value == null) return false;
|
|
2367
|
+
return lteBound(value, expected);
|
|
2368
|
+
}
|
|
2369
|
+
case "$between": {
|
|
2370
|
+
if (value == null || !Array.isArray(expected) || expected.length !== 2) return false;
|
|
2371
|
+
const [min, max] = expected;
|
|
2372
|
+
if (min == null || max == null) return false;
|
|
2373
|
+
return compare(value, min) >= 0 && lteBound(value, max);
|
|
2374
|
+
}
|
|
2053
2375
|
case "$in":
|
|
2054
2376
|
return Array.isArray(expected) && expected.some((e) => value === e || String(value) === String(e));
|
|
2055
2377
|
case "$nin":
|
|
@@ -2082,7 +2404,7 @@ function matchesWhere(row, where) {
|
|
|
2082
2404
|
function bucketDate(value, granularity, timezone) {
|
|
2083
2405
|
const d = new Date(String(value));
|
|
2084
2406
|
if (Number.isNaN(d.getTime())) return null;
|
|
2085
|
-
const { year: y, month, day: dayNum } = (0,
|
|
2407
|
+
const { year: y, month, day: dayNum } = (0, import_core4.calendarPartsInTzOrUtc)(d, timezone);
|
|
2086
2408
|
const m = `${month}`.padStart(2, "0");
|
|
2087
2409
|
const day = `${dayNum}`.padStart(2, "0");
|
|
2088
2410
|
switch (granularity) {
|
|
@@ -2136,7 +2458,9 @@ function evaluateAnalyticsQueryOverRows(query, cube, rows) {
|
|
|
2136
2458
|
const [start, end] = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
|
|
2137
2459
|
filtered = filtered.filter((r) => {
|
|
2138
2460
|
const v = String(r[field] ?? "");
|
|
2139
|
-
|
|
2461
|
+
const nextDay = (0, import_core4.nextUtcCalendarDay)(end);
|
|
2462
|
+
const inUpper = nextDay != null ? v < nextDay : v <= `${end}~`;
|
|
2463
|
+
return v >= String(start) && inUpper;
|
|
2140
2464
|
});
|
|
2141
2465
|
}
|
|
2142
2466
|
const dimensions = query.dimensions ?? [];
|
|
@@ -2195,6 +2519,7 @@ function isMissingSourceError(err) {
|
|
|
2195
2519
|
msg.includes("not registered") || // framework: object not in registry
|
|
2196
2520
|
msg.includes("unknown object") || msg.includes("is not a registered object");
|
|
2197
2521
|
}
|
|
2522
|
+
var BARE_IDENTIFIER = /^[a-z_][a-z0-9_]*$/i;
|
|
2198
2523
|
var DEFAULT_CAPABILITIES = {
|
|
2199
2524
|
nativeSql: false,
|
|
2200
2525
|
objectqlAggregate: false,
|
|
@@ -2206,17 +2531,18 @@ var AnalyticsService = class {
|
|
|
2206
2531
|
this.datasetRegistry = /* @__PURE__ */ new Map();
|
|
2207
2532
|
/** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */
|
|
2208
2533
|
this.warnedNoObjectRegistry = false;
|
|
2209
|
-
this.logger = config.logger || (0,
|
|
2534
|
+
this.logger = config.logger || (0, import_core5.createLogger)({ level: "info", format: "pretty" });
|
|
2210
2535
|
this.cubeRegistry = new CubeRegistry();
|
|
2211
2536
|
if (config.cubes) {
|
|
2212
2537
|
this.cubeRegistry.registerAll(config.cubes);
|
|
2213
2538
|
}
|
|
2214
2539
|
this.readScopeProvider = config.getReadScope;
|
|
2215
2540
|
this.relationshipResolver = config.relationshipResolver;
|
|
2216
|
-
this.
|
|
2541
|
+
this.sourceFieldMeta = config.sourceFieldMeta;
|
|
2217
2542
|
this.labelResolver = config.labelResolver;
|
|
2218
2543
|
this.draftRowsResolver = config.draftRowsResolver;
|
|
2219
2544
|
this.isRegisteredObject = config.isRegisteredObject;
|
|
2545
|
+
this.getObjectFieldNames = config.getObjectFieldNames;
|
|
2220
2546
|
if (config.datasets) {
|
|
2221
2547
|
for (const ds of config.datasets) {
|
|
2222
2548
|
try {
|
|
@@ -2236,6 +2562,7 @@ var AnalyticsService = class {
|
|
|
2236
2562
|
// fall back to any explicitly-configured provider for legacy cubes.
|
|
2237
2563
|
getAllowedRelationships: (cubeName) => this.datasetRegistry.get(cubeName)?.allowedRelationships ?? config.getAllowedRelationships?.(cubeName),
|
|
2238
2564
|
coerceTemporalFilterValue: config.coerceTemporalFilterValue,
|
|
2565
|
+
coerceTemporalFilterColumn: config.coerceTemporalFilterColumn,
|
|
2239
2566
|
isExternalObject: config.isExternalObject
|
|
2240
2567
|
};
|
|
2241
2568
|
const builtIn = [
|
|
@@ -2433,17 +2760,17 @@ var AnalyticsService = class {
|
|
|
2433
2760
|
if (!d.field || d.type !== "date") continue;
|
|
2434
2761
|
const granularity = resolveDimensionGranularity(selection, d.name, d.dateGranularity);
|
|
2435
2762
|
if (!granularity) continue;
|
|
2436
|
-
const ftype = this.
|
|
2763
|
+
const ftype = this.sourceFieldMeta?.(dataset.object, d.field)?.type;
|
|
2437
2764
|
if (ftype === "datetime") rangeDims.push({ d, granularity, instant: true });
|
|
2438
2765
|
else if (ftype === "date") rangeDims.push({ d, granularity, instant: false });
|
|
2439
2766
|
else if (rangeTz === "UTC") rangeDims.push({ d, granularity, instant: false });
|
|
2440
2767
|
}
|
|
2441
2768
|
if (rangeDims.length && result.rows.length) {
|
|
2442
|
-
const bound = (ymd, instant) => instant ? new Date((0,
|
|
2769
|
+
const bound = (ymd, instant) => instant ? new Date((0, import_core5.zonedDateStartToUtcMs)(ymd, rangeTz)).toISOString() : ymd;
|
|
2443
2770
|
result.drillRanges = result.rows.map((row) => {
|
|
2444
2771
|
const ranges = {};
|
|
2445
2772
|
for (const { d, granularity, instant } of rangeDims) {
|
|
2446
|
-
const cal = (0,
|
|
2773
|
+
const cal = (0, import_core5.bucketKeyToCalendarRange)(row[d.name], granularity);
|
|
2447
2774
|
if (cal) {
|
|
2448
2775
|
ranges[d.name] = { field: d.field, gte: bound(cal.start, instant), lt: bound(cal.end, instant) };
|
|
2449
2776
|
}
|
|
@@ -2482,14 +2809,17 @@ var AnalyticsService = class {
|
|
|
2482
2809
|
if (f.format == null && m.format) f.format = m.format;
|
|
2483
2810
|
const fc = f;
|
|
2484
2811
|
const mc = m;
|
|
2812
|
+
const meta = m.field ? this.sourceFieldMeta?.(dataset.object, m.field) : void 0;
|
|
2485
2813
|
if (fc.currency == null) {
|
|
2486
|
-
const meta = m.field ? this.measureCurrency?.(dataset.object, m.field) : void 0;
|
|
2487
2814
|
const monetary = !!mc.currency || meta?.type === "currency";
|
|
2488
2815
|
if (monetary) {
|
|
2489
2816
|
const resolved = mc.currency ?? meta?.defaultCurrency ?? context?.currency;
|
|
2490
2817
|
if (resolved) fc.currency = resolved;
|
|
2491
2818
|
}
|
|
2492
2819
|
}
|
|
2820
|
+
if (f.percentScale == null) {
|
|
2821
|
+
f.percentScale = m.derived?.op === "ratio" ? "fraction" : (0, import_data3.percentScaleOf)(meta);
|
|
2822
|
+
}
|
|
2493
2823
|
}
|
|
2494
2824
|
}
|
|
2495
2825
|
if (result.fields?.length && selectedDims.length) {
|
|
@@ -2555,6 +2885,7 @@ var AnalyticsService = class {
|
|
|
2555
2885
|
if (!cube) {
|
|
2556
2886
|
this.assertInferableCube(name);
|
|
2557
2887
|
cube = this.inferCubeFromQuery(query);
|
|
2888
|
+
this.assertMeasureFields(query, cube, Object.keys(cube.measures));
|
|
2558
2889
|
this.cubeRegistry.register(cube);
|
|
2559
2890
|
const isScalarMetric = (query.dimensions?.length ?? 0) === 0 && (query.timeDimensions?.length ?? 0) === 0;
|
|
2560
2891
|
const message = `[Analytics] No cube registered for "${name}"; auto-inferred a minimal cube (sql="${name}", measures=${Object.keys(cube.measures).join(",") || "(none)"}, dimensions=${Object.keys(cube.dimensions).join(",") || "(none)"}). Define an explicit Cube in your stack for full control.`;
|
|
@@ -2574,10 +2905,82 @@ var AnalyticsService = class {
|
|
|
2574
2905
|
...cube,
|
|
2575
2906
|
measures: { ...cube.measures, ...extraMeasures }
|
|
2576
2907
|
};
|
|
2908
|
+
this.assertMeasureFields(query, augmented, Object.keys(cube.measures));
|
|
2577
2909
|
this.cubeRegistry.register(augmented);
|
|
2578
2910
|
this.logger.debug(
|
|
2579
2911
|
`[Analytics] Augmented cube "${name}" with inferred measures: ${Object.keys(extraMeasures).join(",")}`
|
|
2580
2912
|
);
|
|
2913
|
+
} else {
|
|
2914
|
+
this.assertMeasureFields(query, cube, Object.keys(cube.measures));
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
/**
|
|
2918
|
+
* [#4437] Reject a measure whose SOURCE FIELD the backing object does not
|
|
2919
|
+
* have, BEFORE the strategy compiles it into SQL.
|
|
2920
|
+
*
|
|
2921
|
+
* `inferMeasure` maps a suffix convention onto a field name and has no way to
|
|
2922
|
+
* know whether that field exists: `ghost_sum` happily became `SUM(ghost)`, the
|
|
2923
|
+
* driver threw `no such column`, and the caller got
|
|
2924
|
+
* `500 {"code":"SQLITE_ERROR","message":"Internal server error"}` — a driver
|
|
2925
|
+
* error class on the wire, and nothing actionable, for what is a plain typo.
|
|
2926
|
+
* The DATA route has refused the same mistake with a `400 INVALID_FIELD`
|
|
2927
|
+
* naming the field since #4315/#4254; this is the analytics half of that
|
|
2928
|
+
* answer, and it is deliberately the SAME envelope (`code`/`field`/`object`/
|
|
2929
|
+
* `param`) so one mistake has one shape across both routes.
|
|
2930
|
+
*
|
|
2931
|
+
* What it checks, and what it deliberately does not:
|
|
2932
|
+
*
|
|
2933
|
+
* - Only when the cube's `sql` is a bare OBJECT NAME. An authored cube whose
|
|
2934
|
+
* `sql` is a real SQL expression has no field list to check against.
|
|
2935
|
+
* - Only when {@link AnalyticsServiceConfig.getObjectFieldNames} answers.
|
|
2936
|
+
* Absent hook / unknown object → stand down (see the config field's doc).
|
|
2937
|
+
* - Only measures whose source is a BARE COLUMN. `count(*)` has no source
|
|
2938
|
+
* field, and a dotted reference (`account.industry`) resolves through a
|
|
2939
|
+
* join whose target this check cannot see — both pass through untouched.
|
|
2940
|
+
* - `id` / `created_at` / `updated_at` are admitted unconditionally, matching
|
|
2941
|
+
* the data path's `resolveQueryFields`: they are engine-assigned rather than
|
|
2942
|
+
* declared, and a gate stricter than the engine it guards would reject
|
|
2943
|
+
* queries that used to work.
|
|
2944
|
+
*/
|
|
2945
|
+
assertMeasureFields(query, cube, declaredMeasures) {
|
|
2946
|
+
const probe = this.getObjectFieldNames;
|
|
2947
|
+
if (!probe) return;
|
|
2948
|
+
const measures = query.measures ?? [];
|
|
2949
|
+
if (measures.length === 0) return;
|
|
2950
|
+
const object = typeof cube.sql === "string" ? cube.sql.trim() : "";
|
|
2951
|
+
if (!object || !BARE_IDENTIFIER.test(object)) return;
|
|
2952
|
+
const fieldNames = probe(object);
|
|
2953
|
+
if (!fieldNames || fieldNames.length === 0) return;
|
|
2954
|
+
const known = /* @__PURE__ */ new Set([...fieldNames, "id", "created_at", "updated_at"]);
|
|
2955
|
+
const stripPrefix = (m) => m.includes(".") ? m.split(".").slice(1).join(".") : m;
|
|
2956
|
+
const sourceFieldOf = (measure) => {
|
|
2957
|
+
const metric = cube.measures[stripPrefix(measure)];
|
|
2958
|
+
if (!metric) return null;
|
|
2959
|
+
if (metric.type === "count" && (metric.sql === "*" || metric.sql == null)) return null;
|
|
2960
|
+
const source = typeof metric.sql === "string" ? metric.sql.trim() : "";
|
|
2961
|
+
if (!source || source === "*" || !BARE_IDENTIFIER.test(source)) return null;
|
|
2962
|
+
return source;
|
|
2963
|
+
};
|
|
2964
|
+
const invalid = /* @__PURE__ */ new Set();
|
|
2965
|
+
for (const measure of measures) {
|
|
2966
|
+
const source = sourceFieldOf(measure);
|
|
2967
|
+
if (source && !known.has(source)) invalid.add(stripPrefix(measure));
|
|
2968
|
+
}
|
|
2969
|
+
if (invalid.size === 0) return;
|
|
2970
|
+
const usable = declaredMeasures.filter((m) => !invalid.has(m));
|
|
2971
|
+
for (const measure of measures) {
|
|
2972
|
+
const source = sourceFieldOf(measure);
|
|
2973
|
+
if (!source || known.has(source)) continue;
|
|
2974
|
+
const err = new Error(
|
|
2975
|
+
`Measure '${measure}' on cube '${cube.name}' aggregates field '${source}', which object '${object}' does not have. Valid measures: ${usable.join(", ") || "(none)"}. Other measures are inferred from the object's OWN fields as '<field>_sum' / '_avg' / '_min' / '_max' / '_count_distinct', so check the spelling of '${source}' \u2014 known fields: ${[...fieldNames].sort().join(", ")}.`
|
|
2976
|
+
);
|
|
2977
|
+
err.code = "INVALID_FIELD";
|
|
2978
|
+
err.status = 400;
|
|
2979
|
+
err.field = source;
|
|
2980
|
+
err.object = object;
|
|
2981
|
+
err.param = "measures";
|
|
2982
|
+
err.measure = measure;
|
|
2983
|
+
throw err;
|
|
2581
2984
|
}
|
|
2582
2985
|
}
|
|
2583
2986
|
/**
|
|
@@ -2723,9 +3126,22 @@ var FallbackDelegateStrategy = class {
|
|
|
2723
3126
|
var AnalyticsServicePlugin = class {
|
|
2724
3127
|
constructor(options = {}) {
|
|
2725
3128
|
this.name = "com.objectstack.service-analytics";
|
|
3129
|
+
/**
|
|
3130
|
+
* Services init() registers on every path (ADR-0116, #4131) — lets the
|
|
3131
|
+
* kernel name this plugin when a consumer requires one before it inits.
|
|
3132
|
+
*/
|
|
3133
|
+
this.providesServices = ["analytics"];
|
|
2726
3134
|
this.version = "1.0.0";
|
|
2727
3135
|
this.type = "standard";
|
|
2728
3136
|
this.dependencies = [];
|
|
3137
|
+
/**
|
|
3138
|
+
* init() probes the `data` engine ObjectQLPlugin provides for the
|
|
3139
|
+
* auto-bridge — order-if-present so the probe verdict is deterministic
|
|
3140
|
+
* (ADR-0116, #4471). Soft, not hard: without an engine the plugin
|
|
3141
|
+
* degrades on purpose (per-query lazy resolution / explicit
|
|
3142
|
+
* `executeAggregate`).
|
|
3143
|
+
*/
|
|
3144
|
+
this.optionalDependencies = ["com.objectstack.engine.objectql"];
|
|
2729
3145
|
this.options = options;
|
|
2730
3146
|
}
|
|
2731
3147
|
async init(ctx) {
|
|
@@ -2925,6 +3341,17 @@ var AnalyticsServicePlugin = class {
|
|
|
2925
3341
|
}
|
|
2926
3342
|
return value;
|
|
2927
3343
|
};
|
|
3344
|
+
const coerceTemporalFilterColumn = (objectName, fieldName, columnSql) => {
|
|
3345
|
+
try {
|
|
3346
|
+
const svc = ctx.getService("data");
|
|
3347
|
+
const driver = svc?.getDriverForObject?.(objectName);
|
|
3348
|
+
if (driver && typeof driver.temporalFilterColumnSql === "function") {
|
|
3349
|
+
return driver.temporalFilterColumnSql(objectName, fieldName, columnSql);
|
|
3350
|
+
}
|
|
3351
|
+
} catch {
|
|
3352
|
+
}
|
|
3353
|
+
return columnSql;
|
|
3354
|
+
};
|
|
2928
3355
|
const config = {
|
|
2929
3356
|
cubes: this.options.cubes,
|
|
2930
3357
|
logger: ctx.logger,
|
|
@@ -2935,12 +3362,15 @@ var AnalyticsServicePlugin = class {
|
|
|
2935
3362
|
getReadScope,
|
|
2936
3363
|
getAllowedRelationships: this.options.getAllowedRelationships,
|
|
2937
3364
|
coerceTemporalFilterValue,
|
|
3365
|
+
coerceTemporalFilterColumn,
|
|
2938
3366
|
relationshipResolver,
|
|
2939
3367
|
labelResolver,
|
|
2940
|
-
//
|
|
2941
|
-
|
|
3368
|
+
// Source-field metadata behind the display chains on result columns:
|
|
3369
|
+
// ADR-0053 currency (`currencyConfig.defaultCurrency`) and percent scale
|
|
3370
|
+
// (`max`, which is what marks whole-percent storage — objectui#3136).
|
|
3371
|
+
sourceFieldMeta: (object, field) => {
|
|
2942
3372
|
const f = dataEngine()?.getObject?.(object)?.fields?.[field];
|
|
2943
|
-
return f ? { type: f.type, defaultCurrency: f.currencyConfig?.defaultCurrency } : void 0;
|
|
3373
|
+
return f ? { type: f.type, max: f.max, defaultCurrency: f.currencyConfig?.defaultCurrency } : void 0;
|
|
2944
3374
|
},
|
|
2945
3375
|
// ADR-0062 D6 — a federated object carries an `external` block (ADR-0015).
|
|
2946
3376
|
// Reported so NativeSQLStrategy declines it (its hand-compiled FROM would
|
|
@@ -2963,6 +3393,18 @@ var AnalyticsServicePlugin = class {
|
|
|
2963
3393
|
if (!engine) return true;
|
|
2964
3394
|
return engine.getObject?.(name) != null;
|
|
2965
3395
|
},
|
|
3396
|
+
// [#4437] Field names for the measure source-field gate. Read from the
|
|
3397
|
+
// SAME schema registry `isRegisteredObject` above consults (and the data
|
|
3398
|
+
// path's #4315 gate reads), so "which fields exist" has one answer across
|
|
3399
|
+
// /data and /analytics. `undefined` — no engine, unknown object, or an
|
|
3400
|
+
// object with no field map (an external datasource whose columns are not
|
|
3401
|
+
// mirrored locally) — means "cannot answer", and the gate stands down.
|
|
3402
|
+
getObjectFieldNames: (objectName) => {
|
|
3403
|
+
const fields = dataEngine()?.getObject?.(objectName)?.fields;
|
|
3404
|
+
if (!fields || typeof fields !== "object") return void 0;
|
|
3405
|
+
const names = Object.keys(fields);
|
|
3406
|
+
return names.length > 0 ? names : void 0;
|
|
3407
|
+
},
|
|
2966
3408
|
draftRowsResolver
|
|
2967
3409
|
};
|
|
2968
3410
|
if (autoBridgedReadScope && securityPresentAtInit) {
|
|
@@ -3019,6 +3461,7 @@ var AnalyticsServicePlugin = class {
|
|
|
3019
3461
|
compileScopedFilterToSql,
|
|
3020
3462
|
createOrderLabelResolver,
|
|
3021
3463
|
evaluateDerivedMeasures,
|
|
3464
|
+
fillEmptyGroups,
|
|
3022
3465
|
mergeByDimensions,
|
|
3023
3466
|
pickDisplayField,
|
|
3024
3467
|
resolveDimensionLabels,
|