@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.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// src/analytics-service.ts
|
|
2
|
+
import { percentScaleOf } from "@objectstack/spec/data";
|
|
2
3
|
import { createLogger, bucketKeyToCalendarRange, zonedDateStartToUtcMs } from "@objectstack/core";
|
|
3
4
|
|
|
4
5
|
// src/cube-registry.ts
|
|
@@ -128,7 +129,8 @@ var MONGO_TO_CUBE_OP = {
|
|
|
128
129
|
$nin: "notIn",
|
|
129
130
|
$contains: "contains",
|
|
130
131
|
$notContains: "notContains",
|
|
131
|
-
$
|
|
132
|
+
$startsWith: "startsWith",
|
|
133
|
+
$endsWith: "endsWith"
|
|
132
134
|
};
|
|
133
135
|
function stringifyForCube(v) {
|
|
134
136
|
if (v == null) return "";
|
|
@@ -137,55 +139,102 @@ function stringifyForCube(v) {
|
|
|
137
139
|
if (typeof v === "object") return JSON.stringify(v);
|
|
138
140
|
return String(v);
|
|
139
141
|
}
|
|
140
|
-
function
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
142
|
+
function andOf(children) {
|
|
143
|
+
if (children.length === 0) return null;
|
|
144
|
+
if (children.length === 1) return children[0];
|
|
145
|
+
return { kind: "and", children };
|
|
146
|
+
}
|
|
147
|
+
function fieldLeaves(key, raw) {
|
|
148
|
+
const out = [];
|
|
149
|
+
const leaf = (operator, values) => {
|
|
150
|
+
out.push({ kind: "leaf", member: key, operator, values });
|
|
151
|
+
};
|
|
152
|
+
if (raw === null) {
|
|
153
|
+
leaf("notSet", []);
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
if (typeof raw === "object" && !Array.isArray(raw) && !(raw instanceof Date)) {
|
|
157
|
+
const wrapper = raw;
|
|
158
|
+
const opKeys = Object.keys(wrapper).filter((k) => k.startsWith("$"));
|
|
159
|
+
if (opKeys.length > 0) {
|
|
160
|
+
for (const opKey of opKeys) {
|
|
161
|
+
if (opKey === "$between") {
|
|
162
|
+
const v2 = wrapper[opKey];
|
|
163
|
+
if (!Array.isArray(v2) || v2.length !== 2) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`[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.`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
leaf("gte", [stringifyForCube(v2[0])]);
|
|
169
|
+
leaf("lte", [stringifyForCube(v2[1])]);
|
|
170
|
+
continue;
|
|
147
171
|
}
|
|
172
|
+
if (opKey === "$null" || opKey === "$exists") {
|
|
173
|
+
const isNull = opKey === "$null" ? wrapper[opKey] === true : wrapper[opKey] === false;
|
|
174
|
+
leaf(isNull ? "notSet" : "set", []);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const cubeOp = MONGO_TO_CUBE_OP[opKey];
|
|
178
|
+
if (!cubeOp) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`[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.`
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
const v = wrapper[opKey];
|
|
184
|
+
leaf(cubeOp, Array.isArray(v) ? v.map(stringifyForCube) : [stringifyForCube(v)]);
|
|
148
185
|
}
|
|
149
|
-
|
|
186
|
+
return out;
|
|
150
187
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
out.push({ member: key, operator: "notSet", values: [] });
|
|
154
|
-
continue;
|
|
188
|
+
for (const [nestedKey, nestedVal] of Object.entries(wrapper)) {
|
|
189
|
+
out.push(...fieldLeaves(`${key}.${nestedKey}`, nestedVal));
|
|
155
190
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
if (Array.isArray(raw)) leaf("in", raw.map(stringifyForCube));
|
|
194
|
+
else leaf("equals", [stringifyForCube(raw)]);
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
function buildNode(cond) {
|
|
198
|
+
const children = [];
|
|
199
|
+
for (const [key, raw] of Object.entries(cond)) {
|
|
200
|
+
if (raw === void 0) continue;
|
|
201
|
+
if (key === "$and" || key === "$or") {
|
|
202
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
`[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.`
|
|
205
|
+
);
|
|
171
206
|
}
|
|
207
|
+
const branches = raw.map((sub) => sub && typeof sub === "object" ? buildNode(sub) : null).filter((n) => n !== null);
|
|
208
|
+
if (branches.length === 0) continue;
|
|
209
|
+
if (key === "$and") children.push(...branches);
|
|
210
|
+
else children.push(branches.length === 1 ? branches[0] : { kind: "or", children: branches });
|
|
172
211
|
continue;
|
|
173
212
|
}
|
|
174
|
-
if (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
213
|
+
if (key === "$not") {
|
|
214
|
+
const inner = raw && typeof raw === "object" ? buildNode(raw) : null;
|
|
215
|
+
if (inner) children.push({ kind: "not", child: inner });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (key.startsWith("$")) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`[analytics] Unsupported top-level filter operator "${key}". Dropping it would silently widen the query to rows the filter excludes.`
|
|
221
|
+
);
|
|
178
222
|
}
|
|
223
|
+
children.push(...fieldLeaves(key, raw));
|
|
179
224
|
}
|
|
225
|
+
return andOf(children);
|
|
180
226
|
}
|
|
181
|
-
function
|
|
182
|
-
if (!query || typeof query !== "object") return
|
|
183
|
-
const out = [];
|
|
227
|
+
function normalizeAnalyticsFilterTree(query) {
|
|
228
|
+
if (!query || typeof query !== "object") return null;
|
|
184
229
|
const where = query.where;
|
|
185
|
-
if (where
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
230
|
+
if (!where || typeof where !== "object" || Array.isArray(where)) return null;
|
|
231
|
+
return buildNode(where);
|
|
232
|
+
}
|
|
233
|
+
function collectFilterLeaves(node) {
|
|
234
|
+
if (!node) return [];
|
|
235
|
+
if (node.kind === "leaf") return [{ member: node.member, operator: node.operator, values: node.values }];
|
|
236
|
+
if (node.kind === "not") return collectFilterLeaves(node.child);
|
|
237
|
+
return node.children.flatMap(collectFilterLeaves);
|
|
189
238
|
}
|
|
190
239
|
function recoverNumber(s) {
|
|
191
240
|
if (/^-?\d+(\.\d+)?$/.test(s)) {
|
|
@@ -317,6 +366,18 @@ function compileOperator(col, op, val, field, params) {
|
|
|
317
366
|
}
|
|
318
367
|
|
|
319
368
|
// src/strategies/native-sql-strategy.ts
|
|
369
|
+
import { nextUtcCalendarDay } from "@objectstack/core";
|
|
370
|
+
var AGGREGATE_SQL = {
|
|
371
|
+
"count": () => "COUNT(*)",
|
|
372
|
+
"sum": (col) => `SUM(${col})`,
|
|
373
|
+
"avg": (col) => `AVG(${col})`,
|
|
374
|
+
"min": (col) => `MIN(${col})`,
|
|
375
|
+
"max": (col) => `MAX(${col})`,
|
|
376
|
+
"count_distinct": (col) => `COUNT(DISTINCT ${col})`
|
|
377
|
+
};
|
|
378
|
+
var SUPPORTED_AGGREGATE_SQL_KEYS = Object.keys(AGGREGATE_SQL);
|
|
379
|
+
var EXPRESSION_METRIC_TYPES = /* @__PURE__ */ new Set(["number", "string", "boolean"]);
|
|
380
|
+
var IDENTIFIER_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
320
381
|
var NativeSQLStrategy = class {
|
|
321
382
|
constructor() {
|
|
322
383
|
this.name = "NativeSQLStrategy";
|
|
@@ -371,15 +432,15 @@ var NativeSQLStrategy = class {
|
|
|
371
432
|
}
|
|
372
433
|
}
|
|
373
434
|
const whereClauses = [];
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
435
|
+
const filterSql = this.compileFilterNode(
|
|
436
|
+
normalizeAnalyticsFilterTree(query),
|
|
437
|
+
cube,
|
|
438
|
+
tableName,
|
|
439
|
+
joins,
|
|
440
|
+
params,
|
|
441
|
+
ctx
|
|
442
|
+
);
|
|
443
|
+
if (filterSql) whereClauses.push(filterSql);
|
|
383
444
|
if (query.timeDimensions && query.timeDimensions.length > 0) {
|
|
384
445
|
for (const td of query.timeDimensions) {
|
|
385
446
|
const colExpr = this.resolveFieldSql(cube, td.dimension, tableName, joins);
|
|
@@ -387,11 +448,17 @@ var NativeSQLStrategy = class {
|
|
|
387
448
|
const range = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
|
|
388
449
|
if (range.length === 2) {
|
|
389
450
|
const td2 = this.resolveStorageTarget(cube, td.dimension, tableName);
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
451
|
+
const column = this.temporalColumn(ctx, td2, colExpr);
|
|
452
|
+
const nextDay = nextUtcCalendarDay(range[1]);
|
|
453
|
+
params.push(this.coerceTemporal(ctx, td2, range[0]));
|
|
454
|
+
const lower = `${column} >= $${params.length}`;
|
|
455
|
+
if (nextDay != null) {
|
|
456
|
+
params.push(this.coerceTemporal(ctx, td2, nextDay));
|
|
457
|
+
whereClauses.push(`(${lower} AND ${column} < $${params.length})`);
|
|
458
|
+
} else {
|
|
459
|
+
params.push(this.coerceTemporal(ctx, td2, range[1]));
|
|
460
|
+
whereClauses.push(`(${lower} AND ${column} <= $${params.length})`);
|
|
461
|
+
}
|
|
395
462
|
}
|
|
396
463
|
}
|
|
397
464
|
}
|
|
@@ -489,6 +556,7 @@ var NativeSQLStrategy = class {
|
|
|
489
556
|
}
|
|
490
557
|
return rawSql;
|
|
491
558
|
}
|
|
559
|
+
if (!IDENTIFIER_PATH.test(rawSql)) return rawSql;
|
|
492
560
|
const segments = rawSql.split(".");
|
|
493
561
|
const column = segments[segments.length - 1];
|
|
494
562
|
const hops = segments.slice(0, -1);
|
|
@@ -548,24 +616,19 @@ var NativeSQLStrategy = class {
|
|
|
548
616
|
}
|
|
549
617
|
resolveMeasureSql(cube, member, parentTable, joins) {
|
|
550
618
|
const measure = this.lookupMember(cube, member, "measure");
|
|
551
|
-
if (!measure)
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
case "sum":
|
|
557
|
-
return `SUM(${col})`;
|
|
558
|
-
case "avg":
|
|
559
|
-
return `AVG(${col})`;
|
|
560
|
-
case "min":
|
|
561
|
-
return `MIN(${col})`;
|
|
562
|
-
case "max":
|
|
563
|
-
return `MAX(${col})`;
|
|
564
|
-
case "count_distinct":
|
|
565
|
-
return `COUNT(DISTINCT ${col})`;
|
|
566
|
-
default:
|
|
567
|
-
return `COUNT(*)`;
|
|
619
|
+
if (!measure) {
|
|
620
|
+
const declared = Object.keys(cube.measures ?? {});
|
|
621
|
+
throw new Error(
|
|
622
|
+
`[native-sql-strategy] cube "${cube.name}" declares no measure "${member}"` + (declared.length ? ` (declared: ${declared.join(", ")})` : " (it declares none)")
|
|
623
|
+
);
|
|
568
624
|
}
|
|
625
|
+
const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
|
|
626
|
+
const wrap = AGGREGATE_SQL[measure.type];
|
|
627
|
+
if (wrap) return wrap(col);
|
|
628
|
+
if (EXPRESSION_METRIC_TYPES.has(measure.type)) return col;
|
|
629
|
+
throw new Error(
|
|
630
|
+
`[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(", ")}).`
|
|
631
|
+
);
|
|
569
632
|
}
|
|
570
633
|
resolveFieldSql(cube, member, parentTable, joins) {
|
|
571
634
|
const dim = this.lookupMember(cube, member, "dimension");
|
|
@@ -618,7 +681,53 @@ var NativeSQLStrategy = class {
|
|
|
618
681
|
}
|
|
619
682
|
return coerceFilterValueForSql(value);
|
|
620
683
|
}
|
|
621
|
-
|
|
684
|
+
/**
|
|
685
|
+
* The column side of {@link coerceTemporal}: normalise the reference so it
|
|
686
|
+
* reads in the storage form the comparand was coerced into.
|
|
687
|
+
*
|
|
688
|
+
* A SQLite `Field.datetime` column carries an INTEGER epoch (a `Date` write)
|
|
689
|
+
* and ISO TEXT (a REST/JSON write, a `NOW()` default — including the platform's
|
|
690
|
+
* own `created_at`) at the SAME time, so coercing the value alone fixes one half
|
|
691
|
+
* and empties the other. That is #3912: a `dateRange: last_30_days` on
|
|
692
|
+
* `created_date` read 0 with 29 rows in range. Every other column and dialect
|
|
693
|
+
* gets its reference back verbatim.
|
|
694
|
+
*/
|
|
695
|
+
temporalColumn(ctx, target, col) {
|
|
696
|
+
if (typeof ctx.coerceTemporalFilterColumn !== "function") return col;
|
|
697
|
+
return ctx.coerceTemporalFilterColumn(target.object, target.field, col) || col;
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Compile a normalized filter node into a boolean SQL expression, recursing
|
|
701
|
+
* through the combinators. `null` = no constraint.
|
|
702
|
+
*
|
|
703
|
+
* Leaves go through {@link buildFilterClause} exactly as they did when this
|
|
704
|
+
* was a flat loop, so the storage-form coercion and the calendar-day
|
|
705
|
+
* upper-bound rule (#3777) apply at every depth — including inside an `$or`,
|
|
706
|
+
* where a second, combinator-aware implementation would have been free to
|
|
707
|
+
* drift from the first.
|
|
708
|
+
*
|
|
709
|
+
* Parenthesisation is explicit rather than left to SQL's precedence: `AND`
|
|
710
|
+
* does bind tighter than `OR`, so `a AND b OR c` happens to be right, but
|
|
711
|
+
* being right by construction is what keeps a future edit from making it
|
|
712
|
+
* wrong.
|
|
713
|
+
*/
|
|
714
|
+
compileFilterNode(node, cube, parentTable, joins, params, ctx) {
|
|
715
|
+
if (!node) return null;
|
|
716
|
+
if (node.kind === "leaf") {
|
|
717
|
+
const colExpr = this.resolveFieldSql(cube, node.member, parentTable, joins);
|
|
718
|
+
const target = this.resolveStorageTarget(cube, node.member, parentTable);
|
|
719
|
+
return this.buildFilterClause(colExpr, node.operator, node.values, params, ctx, target);
|
|
720
|
+
}
|
|
721
|
+
if (node.kind === "not") {
|
|
722
|
+
const inner = this.compileFilterNode(node.child, cube, parentTable, joins, params, ctx);
|
|
723
|
+
return inner ? `NOT (${inner})` : null;
|
|
724
|
+
}
|
|
725
|
+
const parts = node.children.map((child) => this.compileFilterNode(child, cube, parentTable, joins, params, ctx)).filter((s) => !!s);
|
|
726
|
+
if (parts.length === 0) return null;
|
|
727
|
+
if (parts.length === 1) return parts[0];
|
|
728
|
+
return `(${parts.join(node.kind === "or" ? " OR " : " AND ")})`;
|
|
729
|
+
}
|
|
730
|
+
buildFilterClause(rawCol, operator, values, params, ctx, target) {
|
|
622
731
|
const opMap = {
|
|
623
732
|
equals: "=",
|
|
624
733
|
notEquals: "!=",
|
|
@@ -627,26 +736,42 @@ var NativeSQLStrategy = class {
|
|
|
627
736
|
lt: "<",
|
|
628
737
|
lte: "<=",
|
|
629
738
|
contains: "LIKE",
|
|
630
|
-
notContains: "NOT LIKE"
|
|
739
|
+
notContains: "NOT LIKE",
|
|
740
|
+
startsWith: "LIKE",
|
|
741
|
+
endsWith: "LIKE"
|
|
631
742
|
};
|
|
632
|
-
|
|
633
|
-
|
|
743
|
+
const likePattern = {
|
|
744
|
+
contains: (v) => `%${v}%`,
|
|
745
|
+
notContains: (v) => `%${v}%`,
|
|
746
|
+
startsWith: (v) => `${v}%`,
|
|
747
|
+
endsWith: (v) => `%${v}`
|
|
748
|
+
};
|
|
749
|
+
if (operator === "set") return `${rawCol} IS NOT NULL`;
|
|
750
|
+
if (operator === "notSet") return `${rawCol} IS NULL`;
|
|
634
751
|
if (operator === "in" || operator === "notIn") {
|
|
635
752
|
if (!values || values.length === 0) return null;
|
|
636
753
|
const placeholders = values.map((v) => {
|
|
637
754
|
params.push(this.coerceTemporal(ctx, target, v));
|
|
638
755
|
return `$${params.length}`;
|
|
639
756
|
}).join(", ");
|
|
640
|
-
return `${
|
|
757
|
+
return `${this.temporalColumn(ctx, target, rawCol)} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
|
|
641
758
|
}
|
|
642
759
|
const sqlOp = opMap[operator];
|
|
643
760
|
if (!sqlOp || !values || values.length === 0) return null;
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
params.
|
|
761
|
+
const pattern = likePattern[operator];
|
|
762
|
+
if (pattern) {
|
|
763
|
+
params.push(pattern(values[0]));
|
|
764
|
+
return `${rawCol} ${sqlOp} $${params.length}`;
|
|
648
765
|
}
|
|
649
|
-
|
|
766
|
+
if (operator === "lte") {
|
|
767
|
+
const nextDay = nextUtcCalendarDay(values[0]);
|
|
768
|
+
if (nextDay != null) {
|
|
769
|
+
params.push(this.coerceTemporal(ctx, target, nextDay));
|
|
770
|
+
return `${this.temporalColumn(ctx, target, rawCol)} < $${params.length}`;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
params.push(this.coerceTemporal(ctx, target, values[0]));
|
|
774
|
+
return `${this.temporalColumn(ctx, target, rawCol)} ${sqlOp} $${params.length}`;
|
|
650
775
|
}
|
|
651
776
|
extractObjectName(cube) {
|
|
652
777
|
return cube.sql.trim();
|
|
@@ -668,6 +793,9 @@ var NativeSQLStrategy = class {
|
|
|
668
793
|
}
|
|
669
794
|
};
|
|
670
795
|
|
|
796
|
+
// src/strategies/objectql-strategy.ts
|
|
797
|
+
import { nextUtcCalendarDay as nextUtcCalendarDay2 } from "@objectstack/core";
|
|
798
|
+
|
|
671
799
|
// src/strategies/cross-object-rebucket.ts
|
|
672
800
|
var RECOMBINABLE_METHODS = /* @__PURE__ */ new Set([
|
|
673
801
|
"sum",
|
|
@@ -770,11 +898,7 @@ var ObjectQLStrategy = class {
|
|
|
770
898
|
}
|
|
771
899
|
const filter = {};
|
|
772
900
|
const conjuncts = [];
|
|
773
|
-
|
|
774
|
-
const fieldName = this.resolveFieldName(cube, f.member, "any");
|
|
775
|
-
const extra = this.mergeFilterOperand(filter, fieldName, this.convertFilter(f.operator, f.values));
|
|
776
|
-
if (extra) conjuncts.push(extra);
|
|
777
|
-
}
|
|
901
|
+
this.applyFilterNode(normalizeAnalyticsFilterTree(query), cube, filter, conjuncts);
|
|
778
902
|
for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
|
|
779
903
|
const extra = this.mergeFilterOperand(filter, field, bounds);
|
|
780
904
|
if (extra) conjuncts.push(extra);
|
|
@@ -806,11 +930,9 @@ var ObjectQLStrategy = class {
|
|
|
806
930
|
});
|
|
807
931
|
const mappedRows = rows.map((row) => {
|
|
808
932
|
const mapped = {};
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
if (shortName in row) mapped[dim] = row[shortName];
|
|
813
|
-
}
|
|
933
|
+
for (const dim of this.projectedDimensions(query)) {
|
|
934
|
+
const shortName = this.resolveFieldName(cube, dim, "dimension");
|
|
935
|
+
if (shortName in row) mapped[dim] = row[shortName];
|
|
814
936
|
}
|
|
815
937
|
if (query.measures) {
|
|
816
938
|
for (const m of query.measures) {
|
|
@@ -856,7 +978,7 @@ var ObjectQLStrategy = class {
|
|
|
856
978
|
}
|
|
857
979
|
const tableName = this.extractObjectName(cube);
|
|
858
980
|
const plan = this.planCrossObject(cube, query, Object.fromEntries(
|
|
859
|
-
|
|
981
|
+
collectFilterLeaves(normalizeAnalyticsFilterTree(query)).map((f) => [this.resolveFieldName(cube, f.member, "any"), true])
|
|
860
982
|
));
|
|
861
983
|
const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
|
|
862
984
|
const joinClauses = [];
|
|
@@ -893,18 +1015,18 @@ var ObjectQLStrategy = class {
|
|
|
893
1015
|
}
|
|
894
1016
|
}
|
|
895
1017
|
const whereParts = [];
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
);
|
|
903
|
-
if (clause) whereParts.push(clause);
|
|
904
|
-
}
|
|
1018
|
+
const filterClause = this.renderFilterNodeSql(
|
|
1019
|
+
normalizeAnalyticsFilterTree(query),
|
|
1020
|
+
cube,
|
|
1021
|
+
params
|
|
1022
|
+
);
|
|
1023
|
+
if (filterClause) whereParts.push(filterClause);
|
|
905
1024
|
for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
|
|
906
|
-
|
|
907
|
-
|
|
1025
|
+
const nextDay = nextUtcCalendarDay2(bounds.$lte);
|
|
1026
|
+
params.push(bounds.$gte, nextDay ?? bounds.$lte);
|
|
1027
|
+
whereParts.push(
|
|
1028
|
+
`(${field} >= $${params.length - 1} AND ${field} ${nextDay ? "<" : "<="} $${params.length})`
|
|
1029
|
+
);
|
|
908
1030
|
}
|
|
909
1031
|
const scope = ctx.getReadScope?.(tableName);
|
|
910
1032
|
if (scope != null) {
|
|
@@ -1078,7 +1200,7 @@ var ObjectQLStrategy = class {
|
|
|
1078
1200
|
const merged = rebucketCrossObject(baseRows, baseDimFields, resolvedDims, measures);
|
|
1079
1201
|
const mappedRows = merged.map((row) => {
|
|
1080
1202
|
const out = {};
|
|
1081
|
-
for (const dim of query
|
|
1203
|
+
for (const dim of this.projectedDimensions(query)) {
|
|
1082
1204
|
if (crossByDim.has(dim)) {
|
|
1083
1205
|
if (dim in row) out[dim] = row[dim];
|
|
1084
1206
|
} else {
|
|
@@ -1086,11 +1208,6 @@ var ObjectQLStrategy = class {
|
|
|
1086
1208
|
if (field in row) out[dim] = row[field];
|
|
1087
1209
|
}
|
|
1088
1210
|
}
|
|
1089
|
-
for (const td of query.timeDimensions ?? []) {
|
|
1090
|
-
if (query.dimensions?.includes(td.dimension)) continue;
|
|
1091
|
-
const field = this.resolveFieldName(cube, td.dimension, "dimension");
|
|
1092
|
-
if (field in row) out[td.dimension] = row[field];
|
|
1093
|
-
}
|
|
1094
1211
|
for (const m of query.measures ?? []) {
|
|
1095
1212
|
if (m in row) out[m] = row[m];
|
|
1096
1213
|
}
|
|
@@ -1233,6 +1350,75 @@ var ObjectQLStrategy = class {
|
|
|
1233
1350
|
* are handed back for the caller to AND in separately, so the engine
|
|
1234
1351
|
* intersects them instead of the strategy picking a winner.
|
|
1235
1352
|
*/
|
|
1353
|
+
/**
|
|
1354
|
+
* Fold a normalized filter node into the engine filter being built.
|
|
1355
|
+
*
|
|
1356
|
+
* AND-ed LEAVES merge per field through {@link mergeFilterOperand}, exactly
|
|
1357
|
+
* as the flat loop this replaced did — so a query without combinators still
|
|
1358
|
+
* produces byte-identical engine input. Anything structural (`$or`, `$not`,
|
|
1359
|
+
* a nested `$and` that cannot merge) becomes its own conjunct, which the
|
|
1360
|
+
* caller ANDs in. The engine speaks these combinators natively
|
|
1361
|
+
* (`FilterCondition` declares them and every driver compiles them), so this
|
|
1362
|
+
* path hands them over rather than lowering them.
|
|
1363
|
+
*/
|
|
1364
|
+
applyFilterNode(node, cube, filter, conjuncts) {
|
|
1365
|
+
if (!node) return;
|
|
1366
|
+
if (node.kind === "leaf") {
|
|
1367
|
+
const fieldName = this.resolveFieldName(cube, node.member, "any");
|
|
1368
|
+
const extra = this.mergeFilterOperand(filter, fieldName, this.convertFilter(node.operator, node.values));
|
|
1369
|
+
if (extra) conjuncts.push(extra);
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
if (node.kind === "and") {
|
|
1373
|
+
for (const child of node.children) this.applyFilterNode(child, cube, filter, conjuncts);
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
const rendered = this.filterNodeToCondition(node, cube);
|
|
1377
|
+
if (rendered) conjuncts.push(rendered);
|
|
1378
|
+
}
|
|
1379
|
+
/** A node as a standalone `FilterCondition` the engine can consume. */
|
|
1380
|
+
filterNodeToCondition(node, cube) {
|
|
1381
|
+
if (!node) return null;
|
|
1382
|
+
if (node.kind === "not") {
|
|
1383
|
+
const inner = this.filterNodeToCondition(node.child, cube);
|
|
1384
|
+
return inner ? { $not: inner } : null;
|
|
1385
|
+
}
|
|
1386
|
+
if (node.kind === "or") {
|
|
1387
|
+
const branches = node.children.map((child) => this.filterNodeToCondition(child, cube)).filter((c) => !!c);
|
|
1388
|
+
return branches.length > 0 ? { $or: branches } : null;
|
|
1389
|
+
}
|
|
1390
|
+
const filter = {};
|
|
1391
|
+
const conjuncts = [];
|
|
1392
|
+
this.applyFilterNode(node, cube, filter, conjuncts);
|
|
1393
|
+
if (conjuncts.length > 0) {
|
|
1394
|
+
filter.$and = [...Array.isArray(filter.$and) ? filter.$and : [], ...conjuncts];
|
|
1395
|
+
}
|
|
1396
|
+
return Object.keys(filter).length > 0 ? filter : null;
|
|
1397
|
+
}
|
|
1398
|
+
/**
|
|
1399
|
+
* Render a normalized filter node as the display SQL `/analytics/sql`
|
|
1400
|
+
* echoes. Values still bind as `$n` placeholders — the echo travels to the
|
|
1401
|
+
* browser, so a comparand is never inlined.
|
|
1402
|
+
*/
|
|
1403
|
+
renderFilterNodeSql(node, cube, params) {
|
|
1404
|
+
if (!node) return null;
|
|
1405
|
+
if (node.kind === "leaf") {
|
|
1406
|
+
return this.buildFilterClauseSql(
|
|
1407
|
+
this.resolveFieldName(cube, node.member, "any"),
|
|
1408
|
+
node.operator,
|
|
1409
|
+
node.values,
|
|
1410
|
+
params
|
|
1411
|
+
);
|
|
1412
|
+
}
|
|
1413
|
+
if (node.kind === "not") {
|
|
1414
|
+
const inner = this.renderFilterNodeSql(node.child, cube, params);
|
|
1415
|
+
return inner ? `NOT (${inner})` : null;
|
|
1416
|
+
}
|
|
1417
|
+
const parts = node.children.map((child) => this.renderFilterNodeSql(child, cube, params)).filter((s) => !!s);
|
|
1418
|
+
if (parts.length === 0) return null;
|
|
1419
|
+
if (parts.length === 1) return parts[0];
|
|
1420
|
+
return `(${parts.join(node.kind === "or" ? " OR " : " AND ")})`;
|
|
1421
|
+
}
|
|
1236
1422
|
mergeFilterOperand(filter, field, operand) {
|
|
1237
1423
|
const existing = filter[field];
|
|
1238
1424
|
if (existing === void 0) {
|
|
@@ -1256,9 +1442,13 @@ var ObjectQLStrategy = class {
|
|
|
1256
1442
|
* HERE on every driver — and "bucketed trend" is precisely the shape that also
|
|
1257
1443
|
* carries a range ("last 12 months", "this quarter").
|
|
1258
1444
|
*
|
|
1259
|
-
* Bounds are inclusive on both ends —
|
|
1260
|
-
* `
|
|
1261
|
-
*
|
|
1445
|
+
* Bounds are inclusive on both ends — logically "from day X through day Y".
|
|
1446
|
+
* The `$lte` end is left as the bare calendar day on purpose: the driver's
|
|
1447
|
+
* filter compiler owns the calendar-day → instant translation, compiling a
|
|
1448
|
+
* bare-day `$lte` on a `datetime` column into the half-open `< nextDay`
|
|
1449
|
+
* (#3777) while a `date` column keeps the plain `<=`. `NativeSQLStrategy`
|
|
1450
|
+
* performs the same half-open translation itself because it binds into raw
|
|
1451
|
+
* SQL, so one dashboard reads the same on every driver.
|
|
1262
1452
|
*
|
|
1263
1453
|
* Comparands are coerced by the SAME helper the `where` path uses, so an
|
|
1264
1454
|
* epoch-ms bound recovers as a number and an ISO string stays a string. No
|
|
@@ -1318,24 +1508,60 @@ var ObjectQLStrategy = class {
|
|
|
1318
1508
|
return { $lte: v0 };
|
|
1319
1509
|
case "contains":
|
|
1320
1510
|
return { $regex: values[0] };
|
|
1511
|
+
// `notContains` had no arm and fell to the `default` below, which returns
|
|
1512
|
+
// a BARE VALUE — i.e. `{field: 'x'}`, an equality. "does not contain x"
|
|
1513
|
+
// was compiled as "equals x". These three pass through as the canonical
|
|
1514
|
+
// spec operators every driver implements directly, so an anchored match
|
|
1515
|
+
// stays anchored rather than depending on regex dialect (#4128).
|
|
1516
|
+
case "notContains":
|
|
1517
|
+
return { $notContains: values[0] };
|
|
1518
|
+
case "startsWith":
|
|
1519
|
+
return { $startsWith: values[0] };
|
|
1520
|
+
case "endsWith":
|
|
1521
|
+
return { $endsWith: values[0] };
|
|
1321
1522
|
case "in":
|
|
1322
1523
|
return { $in: all };
|
|
1323
1524
|
case "notIn":
|
|
1324
1525
|
return { $nin: all };
|
|
1325
1526
|
default:
|
|
1326
|
-
|
|
1527
|
+
throw new Error(
|
|
1528
|
+
`[analytics] ObjectQL strategy cannot express filter operator "${operator}". Treating it as an equality would silently query something the author did not ask for.`
|
|
1529
|
+
);
|
|
1327
1530
|
}
|
|
1328
1531
|
}
|
|
1329
1532
|
extractObjectName(cube) {
|
|
1330
1533
|
return cube.sql.trim();
|
|
1331
1534
|
}
|
|
1535
|
+
/**
|
|
1536
|
+
* The dimensions this query PROJECTS, in the order the result carries them:
|
|
1537
|
+
* every `dimensions` entry, then every granular `timeDimensions` entry that
|
|
1538
|
+
* is not already one of them.
|
|
1539
|
+
*
|
|
1540
|
+
* `timeDimensions` is not merely a filter carrier. An entry with a
|
|
1541
|
+
* `granularity` is GROUPED BY — see the `td.granularity` sites that build
|
|
1542
|
+
* groupBy here, in `generateSql` and in the cross-object path — so its
|
|
1543
|
+
* bucket is a COLUMN of the result; an entry without one only contributes a
|
|
1544
|
+
* `dateRange` predicate and must NOT be projected.
|
|
1545
|
+
*
|
|
1546
|
+
* Grouping, row mapping and {@link buildFieldMeta} have to agree on exactly
|
|
1547
|
+
* that set. When they did not, a bucketed query returned rows carrying only
|
|
1548
|
+
* the measures and a `fields` list that never mentioned the bucket — a trend
|
|
1549
|
+
* chart got N values and no x-axis (#4033) — even though the SQL had
|
|
1550
|
+
* selected `date_trunc(…) AS "<dim>"` all along. One definition, every
|
|
1551
|
+
* consumer.
|
|
1552
|
+
*/
|
|
1553
|
+
projectedDimensions(query) {
|
|
1554
|
+
const out = [...query.dimensions ?? []];
|
|
1555
|
+
for (const td of query.timeDimensions ?? []) {
|
|
1556
|
+
if (td.granularity && !out.includes(td.dimension)) out.push(td.dimension);
|
|
1557
|
+
}
|
|
1558
|
+
return out;
|
|
1559
|
+
}
|
|
1332
1560
|
buildFieldMeta(query, cube) {
|
|
1333
1561
|
const fields = [];
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
fields.push({ name: dim, type: d?.type || "string" });
|
|
1338
|
-
}
|
|
1562
|
+
for (const dim of this.projectedDimensions(query)) {
|
|
1563
|
+
const d = this.lookupMember(cube, dim, "dimension");
|
|
1564
|
+
fields.push({ name: dim, type: d?.type || "string" });
|
|
1339
1565
|
}
|
|
1340
1566
|
if (query.measures) {
|
|
1341
1567
|
for (const m of query.measures) {
|
|
@@ -1347,14 +1573,16 @@ var ObjectQLStrategy = class {
|
|
|
1347
1573
|
};
|
|
1348
1574
|
|
|
1349
1575
|
// src/dataset-compiler.ts
|
|
1576
|
+
import { AggregationFunction } from "@objectstack/spec/data";
|
|
1350
1577
|
var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set(["array_agg", "string_agg"]);
|
|
1578
|
+
var SUPPORTED_AGGREGATES = AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
|
|
1351
1579
|
function aggregateToMetricType(m) {
|
|
1352
1580
|
if (!m.aggregate) {
|
|
1353
1581
|
throw new Error(`[dataset-compiler] non-derived measure "${m.name}" has no aggregate`);
|
|
1354
1582
|
}
|
|
1355
1583
|
if (UNSUPPORTED_AGGREGATES.has(m.aggregate)) {
|
|
1356
1584
|
throw new Error(
|
|
1357
|
-
`[dataset-compiler] measure "${m.name}" uses aggregate "${m.aggregate}" which is not supported by the v1 dataset runtime (supported:
|
|
1585
|
+
`[dataset-compiler] measure "${m.name}" uses aggregate "${m.aggregate}" which is not supported by the v1 dataset runtime (supported: ${SUPPORTED_AGGREGATES.join(", ")}).`
|
|
1358
1586
|
);
|
|
1359
1587
|
}
|
|
1360
1588
|
return m.aggregate;
|
|
@@ -1481,6 +1709,7 @@ function compileDataset(dataset, resolver) {
|
|
|
1481
1709
|
}
|
|
1482
1710
|
|
|
1483
1711
|
// src/dataset-executor.ts
|
|
1712
|
+
import { emptyGroupValueFor } from "@objectstack/spec/data";
|
|
1484
1713
|
import { filterTokenContextFrom, resolveFilterTokens } from "@objectstack/core";
|
|
1485
1714
|
function resolveSelectionTokens(compiled, selection, context) {
|
|
1486
1715
|
const tokenCtx = filterTokenContextFrom(context, /* @__PURE__ */ new Date());
|
|
@@ -1502,6 +1731,12 @@ function combineFilters(a, b) {
|
|
|
1502
1731
|
if (a && b) return { $and: [a, b] };
|
|
1503
1732
|
return a ?? b;
|
|
1504
1733
|
}
|
|
1734
|
+
function splitMeasuresByFilter(measures, measureFilters) {
|
|
1735
|
+
const unfiltered = [];
|
|
1736
|
+
const filtered = [];
|
|
1737
|
+
for (const m of measures) (measureFilters[m] ? filtered : unfiltered).push(m);
|
|
1738
|
+
return { unfiltered, filtered };
|
|
1739
|
+
}
|
|
1505
1740
|
function evaluateDerivedMeasures(rows, derived) {
|
|
1506
1741
|
if (derived.length === 0) return rows;
|
|
1507
1742
|
return rows.map((row) => {
|
|
@@ -1512,6 +1747,14 @@ function evaluateDerivedMeasures(rows, derived) {
|
|
|
1512
1747
|
return out;
|
|
1513
1748
|
});
|
|
1514
1749
|
}
|
|
1750
|
+
function fillEmptyGroups(rows, columnAggregates) {
|
|
1751
|
+
for (const [column, aggregate2] of Object.entries(columnAggregates)) {
|
|
1752
|
+
const empty = emptyGroupValueFor(aggregate2);
|
|
1753
|
+
if (empty === void 0) continue;
|
|
1754
|
+
for (const row of rows) if (row[column] == null) row[column] = empty;
|
|
1755
|
+
}
|
|
1756
|
+
return rows;
|
|
1757
|
+
}
|
|
1515
1758
|
function num(v) {
|
|
1516
1759
|
if (v == null) return null;
|
|
1517
1760
|
const n = typeof v === "number" ? v : Number(v);
|
|
@@ -1581,7 +1824,7 @@ function applyWindow(rows, limit, offset) {
|
|
|
1581
1824
|
if (start === 0 && limit == null) return rows;
|
|
1582
1825
|
return rows.slice(start, limit != null ? start + limit : void 0);
|
|
1583
1826
|
}
|
|
1584
|
-
function resolveOrdering(selection, dimensions) {
|
|
1827
|
+
function resolveOrdering(selection, dimensions, timeDimensions = []) {
|
|
1585
1828
|
const order = selection.order;
|
|
1586
1829
|
if (order && Object.keys(order).length > 0) {
|
|
1587
1830
|
const selectable = /* @__PURE__ */ new Set([
|
|
@@ -1600,6 +1843,10 @@ function resolveOrdering(selection, dimensions) {
|
|
|
1600
1843
|
if ((selection.limit != null || selection.offset != null) && dimensions.length > 0) {
|
|
1601
1844
|
return Object.fromEntries(dimensions.map((d) => [d, "asc"]));
|
|
1602
1845
|
}
|
|
1846
|
+
const timeKeys = timeDimensions.filter((d) => dimensions.includes(d));
|
|
1847
|
+
if (timeKeys.length > 0) {
|
|
1848
|
+
return Object.fromEntries(timeKeys.map((d) => [d, "asc"]));
|
|
1849
|
+
}
|
|
1603
1850
|
return void 0;
|
|
1604
1851
|
}
|
|
1605
1852
|
function parseUTC(date) {
|
|
@@ -1686,14 +1933,10 @@ var DatasetExecutor = class {
|
|
|
1686
1933
|
for (const d of selectedDerived) {
|
|
1687
1934
|
for (const dep of d.of) baseMeasures.add(dep);
|
|
1688
1935
|
}
|
|
1689
|
-
const unfiltered =
|
|
1690
|
-
const filtered = [];
|
|
1691
|
-
for (const m of baseMeasures) {
|
|
1692
|
-
(compiled.measureFilters[m] ? filtered : unfiltered).push(m);
|
|
1693
|
-
}
|
|
1936
|
+
const { unfiltered, filtered } = splitMeasuresByFilter(baseMeasures, compiled.measureFilters);
|
|
1694
1937
|
const baseFilter = combineFilters(compiled.filter, selection.runtimeFilter);
|
|
1695
1938
|
const dimensions = selection.dimensions ?? [];
|
|
1696
|
-
const order = resolveOrdering(selection, dimensions);
|
|
1939
|
+
const order = resolveOrdering(selection, dimensions, this.timeDimensionsOf(compiled, dimensions));
|
|
1697
1940
|
const labelOrderKeys = this.orderLabels ? Object.keys(order ?? {}).filter(
|
|
1698
1941
|
(k) => dimensions.includes(k) && this.orderLabels.isLabelBearing(k)
|
|
1699
1942
|
) : [];
|
|
@@ -1701,6 +1944,76 @@ var DatasetExecutor = class {
|
|
|
1701
1944
|
const pushDownKeys = /* @__PURE__ */ new Set([...dimensions, ...unfiltered]);
|
|
1702
1945
|
const canPushDownWindow = singleQuery && labelOrderKeys.length === 0 && Object.keys(order ?? {}).every((k) => pushDownKeys.has(k));
|
|
1703
1946
|
const windowQuery = canPushDownWindow ? { order, limit: selection.limit, offset: selection.offset } : void 0;
|
|
1947
|
+
const result = await this.runMeasurePass(compiled, selection, {
|
|
1948
|
+
measures: [...baseMeasures],
|
|
1949
|
+
dimensions,
|
|
1950
|
+
baseFilter,
|
|
1951
|
+
window: windowQuery,
|
|
1952
|
+
context
|
|
1953
|
+
});
|
|
1954
|
+
if (selection.compareTo) {
|
|
1955
|
+
const compareRows = await this.runCompare(compiled, selection, [...baseMeasures], dimensions, baseFilter, context);
|
|
1956
|
+
result.rows = mergeByDimensions(
|
|
1957
|
+
result.rows,
|
|
1958
|
+
compareRows,
|
|
1959
|
+
dimensions,
|
|
1960
|
+
[...baseMeasures].map((m) => `${m}__compare`)
|
|
1961
|
+
);
|
|
1962
|
+
for (const m of baseMeasures) result.fields.push({ name: `${m}__compare`, type: "number" });
|
|
1963
|
+
}
|
|
1964
|
+
const fillColumns = {};
|
|
1965
|
+
for (const m of baseMeasures) {
|
|
1966
|
+
const aggregate2 = compiled.cube.measures?.[m]?.type;
|
|
1967
|
+
fillColumns[m] = aggregate2;
|
|
1968
|
+
if (selection.compareTo) fillColumns[`${m}__compare`] = aggregate2;
|
|
1969
|
+
}
|
|
1970
|
+
fillEmptyGroups(result.rows, fillColumns);
|
|
1971
|
+
result.rows = evaluateDerivedMeasures(result.rows, selectedDerived);
|
|
1972
|
+
for (const d of selectedDerived) result.fields.push({ name: d.name, type: "number" });
|
|
1973
|
+
let sortKeys;
|
|
1974
|
+
for (const key of labelOrderKeys) {
|
|
1975
|
+
const values = [...new Set(result.rows.map((r) => r[key]).filter((v) => v != null))];
|
|
1976
|
+
if (values.length === 0) continue;
|
|
1977
|
+
const labels = await this.orderLabels.resolveLabels(key, values);
|
|
1978
|
+
if (labels && labels.size > 0) (sortKeys ?? (sortKeys = {}))[key] = labels;
|
|
1979
|
+
}
|
|
1980
|
+
result.rows = applyOrdering(result.rows, order, sortKeys);
|
|
1981
|
+
result.rows = applyWindow(result.rows, selection.limit, selection.offset);
|
|
1982
|
+
return result;
|
|
1983
|
+
}
|
|
1984
|
+
/**
|
|
1985
|
+
* Run ONE grouped pass over a set of base measures, honouring each measure's
|
|
1986
|
+
* own scoped `filter`: the unfiltered measures in a single query, plus one
|
|
1987
|
+
* supplementary query per filter-scoped measure, merged back by dimension key.
|
|
1988
|
+
*
|
|
1989
|
+
* **This is the executor's only implementation of "how a measure filter is
|
|
1990
|
+
* applied", and every window goes through it** — the current period, each
|
|
1991
|
+
* `totals` subset (which re-enters via `executeSelection`), and the
|
|
1992
|
+
* `compareTo` window. Before #4820 the comparison window had its own,
|
|
1993
|
+
* simpler answer: one shifted query over all base measures with only the
|
|
1994
|
+
* base filter, so `compiled.measureFilters` was never read on that path.
|
|
1995
|
+
* `won_count` counted won deals and `won_count__compare` counted every deal,
|
|
1996
|
+
* under one label, in adjacent columns. Only measures carrying a filter were
|
|
1997
|
+
* wrong — which is what made it survive: the unfiltered ones next to them
|
|
1998
|
+
* compared correctly.
|
|
1999
|
+
*
|
|
2000
|
+
* The caller supplies the `selection` this pass queries under, which is how
|
|
2001
|
+
* the comparison window differs at all: same measures, same dimensions, same
|
|
2002
|
+
* filters — a `timeDimensions` shifted by {@link shiftRange}. Nothing else
|
|
2003
|
+
* about the two passes may drift, because anything that does becomes a
|
|
2004
|
+
* discrepancy between two columns the reader is invited to subtract.
|
|
2005
|
+
*
|
|
2006
|
+
* Cost: one extra query per filter-scoped measure when `compareTo` is set.
|
|
2007
|
+
* The alternative — declaring the discrepancy in the response — is not one,
|
|
2008
|
+
* since the two columns exist to be directly comparable.
|
|
2009
|
+
*
|
|
2010
|
+
* @param window - Ordering/window to push into the SQL. Only ever set for a
|
|
2011
|
+
* selection the caller proved is a single self-sufficient query; a pass
|
|
2012
|
+
* that fans out must return its whole grid for the merge.
|
|
2013
|
+
*/
|
|
2014
|
+
async runMeasurePass(compiled, selection, opts) {
|
|
2015
|
+
const { measures, dimensions, baseFilter, window, context } = opts;
|
|
2016
|
+
const { unfiltered, filtered } = splitMeasuresByFilter(measures, compiled.measureFilters);
|
|
1704
2017
|
let result;
|
|
1705
2018
|
if (unfiltered.length > 0 || filtered.length === 0) {
|
|
1706
2019
|
result = await this.service.query(this.buildQuery(compiled, {
|
|
@@ -1709,7 +2022,7 @@ var DatasetExecutor = class {
|
|
|
1709
2022
|
where: baseFilter,
|
|
1710
2023
|
selection,
|
|
1711
2024
|
contextTimezone: context?.timezone,
|
|
1712
|
-
window
|
|
2025
|
+
window
|
|
1713
2026
|
}), context);
|
|
1714
2027
|
} else {
|
|
1715
2028
|
result = { rows: [], fields: [] };
|
|
@@ -1726,29 +2039,21 @@ var DatasetExecutor = class {
|
|
|
1726
2039
|
result.rows = mergeByDimensions(result.rows, sub.rows, dimensions, [m]);
|
|
1727
2040
|
result.fields.push({ name: m, type: "number" });
|
|
1728
2041
|
}
|
|
1729
|
-
if (selection.compareTo) {
|
|
1730
|
-
const compareRows = await this.runCompare(compiled, selection, [...baseMeasures], dimensions, baseFilter, context);
|
|
1731
|
-
result.rows = mergeByDimensions(
|
|
1732
|
-
result.rows,
|
|
1733
|
-
compareRows,
|
|
1734
|
-
dimensions,
|
|
1735
|
-
[...baseMeasures].map((m) => `${m}__compare`)
|
|
1736
|
-
);
|
|
1737
|
-
for (const m of baseMeasures) result.fields.push({ name: `${m}__compare`, type: "number" });
|
|
1738
|
-
}
|
|
1739
|
-
result.rows = evaluateDerivedMeasures(result.rows, selectedDerived);
|
|
1740
|
-
for (const d of selectedDerived) result.fields.push({ name: d.name, type: "number" });
|
|
1741
|
-
let sortKeys;
|
|
1742
|
-
for (const key of labelOrderKeys) {
|
|
1743
|
-
const values = [...new Set(result.rows.map((r) => r[key]).filter((v) => v != null))];
|
|
1744
|
-
if (values.length === 0) continue;
|
|
1745
|
-
const labels = await this.orderLabels.resolveLabels(key, values);
|
|
1746
|
-
if (labels && labels.size > 0) (sortKeys ?? (sortKeys = {}))[key] = labels;
|
|
1747
|
-
}
|
|
1748
|
-
result.rows = applyOrdering(result.rows, order, sortKeys);
|
|
1749
|
-
result.rows = applyWindow(result.rows, selection.limit, selection.offset);
|
|
1750
2042
|
return result;
|
|
1751
2043
|
}
|
|
2044
|
+
/**
|
|
2045
|
+
* The selected dimensions the compiled cube types as `time`, in selection
|
|
2046
|
+
* order (#3916) — the axis {@link resolveOrdering} defaults to ascending.
|
|
2047
|
+
*
|
|
2048
|
+
* Membership is decided by the DIMENSION's declared type, not by whether the
|
|
2049
|
+
* selection happens to bucket it: a `date` dimension left ungranulated groups
|
|
2050
|
+
* raw timestamps, and those want chronological order every bit as much as
|
|
2051
|
+
* month buckets do. (Both sort correctly — `compareValues` compares Dates and
|
|
2052
|
+
* ISO strings chronologically, and bucket keys are minted sort-stable.)
|
|
2053
|
+
*/
|
|
2054
|
+
timeDimensionsOf(compiled, dimensions) {
|
|
2055
|
+
return dimensions.filter((d) => compiled.cube.dimensions[d]?.type === "time");
|
|
2056
|
+
}
|
|
1752
2057
|
buildQuery(compiled, opts) {
|
|
1753
2058
|
const q = {
|
|
1754
2059
|
cube: compiled.cube.name,
|
|
@@ -1798,13 +2103,11 @@ var DatasetExecutor = class {
|
|
|
1798
2103
|
const shiftedTd = (selection.timeDimensions ?? []).map(
|
|
1799
2104
|
(t) => t.dimension === cmp.dimension ? { ...t, dateRange: shifted } : t
|
|
1800
2105
|
);
|
|
1801
|
-
const sub = await this.
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
contextTimezone: context?.timezone
|
|
1807
|
-
}), context);
|
|
2106
|
+
const sub = await this.runMeasurePass(
|
|
2107
|
+
compiled,
|
|
2108
|
+
{ ...selection, timeDimensions: shiftedTd },
|
|
2109
|
+
{ measures, dimensions, baseFilter, context }
|
|
2110
|
+
);
|
|
1808
2111
|
return sub.rows.map((row) => {
|
|
1809
2112
|
const out = {};
|
|
1810
2113
|
for (const dim of dimensions) out[dim] = row[dim];
|
|
@@ -1990,11 +2293,21 @@ function pickDisplayField(fields) {
|
|
|
1990
2293
|
}
|
|
1991
2294
|
|
|
1992
2295
|
// src/preview-evaluator.ts
|
|
1993
|
-
import { calendarPartsInTzOrUtc } from "@objectstack/core";
|
|
2296
|
+
import { calendarPartsInTzOrUtc, nextUtcCalendarDay as nextUtcCalendarDay3, utcInstantMs } from "@objectstack/core";
|
|
1994
2297
|
function compare(a, b) {
|
|
1995
2298
|
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
2299
|
+
if (a instanceof Date || b instanceof Date) {
|
|
2300
|
+
const ai = utcInstantMs(a);
|
|
2301
|
+
const bi = utcInstantMs(b);
|
|
2302
|
+
if (ai !== null && bi !== null) return ai - bi;
|
|
2303
|
+
}
|
|
1996
2304
|
return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0;
|
|
1997
2305
|
}
|
|
2306
|
+
function lteBound(value, bound) {
|
|
2307
|
+
const nextDay = nextUtcCalendarDay3(bound);
|
|
2308
|
+
if (nextDay != null) return compare(value, nextDay) < 0;
|
|
2309
|
+
return compare(value, bound) <= 0;
|
|
2310
|
+
}
|
|
1998
2311
|
function matchOp(value, op, expected) {
|
|
1999
2312
|
switch (op) {
|
|
2000
2313
|
case "$eq":
|
|
@@ -2007,8 +2320,16 @@ function matchOp(value, op, expected) {
|
|
|
2007
2320
|
return value != null && compare(value, expected) >= 0;
|
|
2008
2321
|
case "$lt":
|
|
2009
2322
|
return value != null && compare(value, expected) < 0;
|
|
2010
|
-
case "$lte":
|
|
2011
|
-
|
|
2323
|
+
case "$lte": {
|
|
2324
|
+
if (value == null) return false;
|
|
2325
|
+
return lteBound(value, expected);
|
|
2326
|
+
}
|
|
2327
|
+
case "$between": {
|
|
2328
|
+
if (value == null || !Array.isArray(expected) || expected.length !== 2) return false;
|
|
2329
|
+
const [min, max] = expected;
|
|
2330
|
+
if (min == null || max == null) return false;
|
|
2331
|
+
return compare(value, min) >= 0 && lteBound(value, max);
|
|
2332
|
+
}
|
|
2012
2333
|
case "$in":
|
|
2013
2334
|
return Array.isArray(expected) && expected.some((e) => value === e || String(value) === String(e));
|
|
2014
2335
|
case "$nin":
|
|
@@ -2095,7 +2416,9 @@ function evaluateAnalyticsQueryOverRows(query, cube, rows) {
|
|
|
2095
2416
|
const [start, end] = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
|
|
2096
2417
|
filtered = filtered.filter((r) => {
|
|
2097
2418
|
const v = String(r[field] ?? "");
|
|
2098
|
-
|
|
2419
|
+
const nextDay = nextUtcCalendarDay3(end);
|
|
2420
|
+
const inUpper = nextDay != null ? v < nextDay : v <= `${end}~`;
|
|
2421
|
+
return v >= String(start) && inUpper;
|
|
2099
2422
|
});
|
|
2100
2423
|
}
|
|
2101
2424
|
const dimensions = query.dimensions ?? [];
|
|
@@ -2154,6 +2477,7 @@ function isMissingSourceError(err) {
|
|
|
2154
2477
|
msg.includes("not registered") || // framework: object not in registry
|
|
2155
2478
|
msg.includes("unknown object") || msg.includes("is not a registered object");
|
|
2156
2479
|
}
|
|
2480
|
+
var BARE_IDENTIFIER = /^[a-z_][a-z0-9_]*$/i;
|
|
2157
2481
|
var DEFAULT_CAPABILITIES = {
|
|
2158
2482
|
nativeSql: false,
|
|
2159
2483
|
objectqlAggregate: false,
|
|
@@ -2172,10 +2496,11 @@ var AnalyticsService = class {
|
|
|
2172
2496
|
}
|
|
2173
2497
|
this.readScopeProvider = config.getReadScope;
|
|
2174
2498
|
this.relationshipResolver = config.relationshipResolver;
|
|
2175
|
-
this.
|
|
2499
|
+
this.sourceFieldMeta = config.sourceFieldMeta;
|
|
2176
2500
|
this.labelResolver = config.labelResolver;
|
|
2177
2501
|
this.draftRowsResolver = config.draftRowsResolver;
|
|
2178
2502
|
this.isRegisteredObject = config.isRegisteredObject;
|
|
2503
|
+
this.getObjectFieldNames = config.getObjectFieldNames;
|
|
2179
2504
|
if (config.datasets) {
|
|
2180
2505
|
for (const ds of config.datasets) {
|
|
2181
2506
|
try {
|
|
@@ -2195,6 +2520,7 @@ var AnalyticsService = class {
|
|
|
2195
2520
|
// fall back to any explicitly-configured provider for legacy cubes.
|
|
2196
2521
|
getAllowedRelationships: (cubeName) => this.datasetRegistry.get(cubeName)?.allowedRelationships ?? config.getAllowedRelationships?.(cubeName),
|
|
2197
2522
|
coerceTemporalFilterValue: config.coerceTemporalFilterValue,
|
|
2523
|
+
coerceTemporalFilterColumn: config.coerceTemporalFilterColumn,
|
|
2198
2524
|
isExternalObject: config.isExternalObject
|
|
2199
2525
|
};
|
|
2200
2526
|
const builtIn = [
|
|
@@ -2392,7 +2718,7 @@ var AnalyticsService = class {
|
|
|
2392
2718
|
if (!d.field || d.type !== "date") continue;
|
|
2393
2719
|
const granularity = resolveDimensionGranularity(selection, d.name, d.dateGranularity);
|
|
2394
2720
|
if (!granularity) continue;
|
|
2395
|
-
const ftype = this.
|
|
2721
|
+
const ftype = this.sourceFieldMeta?.(dataset.object, d.field)?.type;
|
|
2396
2722
|
if (ftype === "datetime") rangeDims.push({ d, granularity, instant: true });
|
|
2397
2723
|
else if (ftype === "date") rangeDims.push({ d, granularity, instant: false });
|
|
2398
2724
|
else if (rangeTz === "UTC") rangeDims.push({ d, granularity, instant: false });
|
|
@@ -2441,14 +2767,17 @@ var AnalyticsService = class {
|
|
|
2441
2767
|
if (f.format == null && m.format) f.format = m.format;
|
|
2442
2768
|
const fc = f;
|
|
2443
2769
|
const mc = m;
|
|
2770
|
+
const meta = m.field ? this.sourceFieldMeta?.(dataset.object, m.field) : void 0;
|
|
2444
2771
|
if (fc.currency == null) {
|
|
2445
|
-
const meta = m.field ? this.measureCurrency?.(dataset.object, m.field) : void 0;
|
|
2446
2772
|
const monetary = !!mc.currency || meta?.type === "currency";
|
|
2447
2773
|
if (monetary) {
|
|
2448
2774
|
const resolved = mc.currency ?? meta?.defaultCurrency ?? context?.currency;
|
|
2449
2775
|
if (resolved) fc.currency = resolved;
|
|
2450
2776
|
}
|
|
2451
2777
|
}
|
|
2778
|
+
if (f.percentScale == null) {
|
|
2779
|
+
f.percentScale = m.derived?.op === "ratio" ? "fraction" : percentScaleOf(meta);
|
|
2780
|
+
}
|
|
2452
2781
|
}
|
|
2453
2782
|
}
|
|
2454
2783
|
if (result.fields?.length && selectedDims.length) {
|
|
@@ -2514,6 +2843,7 @@ var AnalyticsService = class {
|
|
|
2514
2843
|
if (!cube) {
|
|
2515
2844
|
this.assertInferableCube(name);
|
|
2516
2845
|
cube = this.inferCubeFromQuery(query);
|
|
2846
|
+
this.assertMeasureFields(query, cube, Object.keys(cube.measures));
|
|
2517
2847
|
this.cubeRegistry.register(cube);
|
|
2518
2848
|
const isScalarMetric = (query.dimensions?.length ?? 0) === 0 && (query.timeDimensions?.length ?? 0) === 0;
|
|
2519
2849
|
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.`;
|
|
@@ -2533,10 +2863,82 @@ var AnalyticsService = class {
|
|
|
2533
2863
|
...cube,
|
|
2534
2864
|
measures: { ...cube.measures, ...extraMeasures }
|
|
2535
2865
|
};
|
|
2866
|
+
this.assertMeasureFields(query, augmented, Object.keys(cube.measures));
|
|
2536
2867
|
this.cubeRegistry.register(augmented);
|
|
2537
2868
|
this.logger.debug(
|
|
2538
2869
|
`[Analytics] Augmented cube "${name}" with inferred measures: ${Object.keys(extraMeasures).join(",")}`
|
|
2539
2870
|
);
|
|
2871
|
+
} else {
|
|
2872
|
+
this.assertMeasureFields(query, cube, Object.keys(cube.measures));
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
/**
|
|
2876
|
+
* [#4437] Reject a measure whose SOURCE FIELD the backing object does not
|
|
2877
|
+
* have, BEFORE the strategy compiles it into SQL.
|
|
2878
|
+
*
|
|
2879
|
+
* `inferMeasure` maps a suffix convention onto a field name and has no way to
|
|
2880
|
+
* know whether that field exists: `ghost_sum` happily became `SUM(ghost)`, the
|
|
2881
|
+
* driver threw `no such column`, and the caller got
|
|
2882
|
+
* `500 {"code":"SQLITE_ERROR","message":"Internal server error"}` — a driver
|
|
2883
|
+
* error class on the wire, and nothing actionable, for what is a plain typo.
|
|
2884
|
+
* The DATA route has refused the same mistake with a `400 INVALID_FIELD`
|
|
2885
|
+
* naming the field since #4315/#4254; this is the analytics half of that
|
|
2886
|
+
* answer, and it is deliberately the SAME envelope (`code`/`field`/`object`/
|
|
2887
|
+
* `param`) so one mistake has one shape across both routes.
|
|
2888
|
+
*
|
|
2889
|
+
* What it checks, and what it deliberately does not:
|
|
2890
|
+
*
|
|
2891
|
+
* - Only when the cube's `sql` is a bare OBJECT NAME. An authored cube whose
|
|
2892
|
+
* `sql` is a real SQL expression has no field list to check against.
|
|
2893
|
+
* - Only when {@link AnalyticsServiceConfig.getObjectFieldNames} answers.
|
|
2894
|
+
* Absent hook / unknown object → stand down (see the config field's doc).
|
|
2895
|
+
* - Only measures whose source is a BARE COLUMN. `count(*)` has no source
|
|
2896
|
+
* field, and a dotted reference (`account.industry`) resolves through a
|
|
2897
|
+
* join whose target this check cannot see — both pass through untouched.
|
|
2898
|
+
* - `id` / `created_at` / `updated_at` are admitted unconditionally, matching
|
|
2899
|
+
* the data path's `resolveQueryFields`: they are engine-assigned rather than
|
|
2900
|
+
* declared, and a gate stricter than the engine it guards would reject
|
|
2901
|
+
* queries that used to work.
|
|
2902
|
+
*/
|
|
2903
|
+
assertMeasureFields(query, cube, declaredMeasures) {
|
|
2904
|
+
const probe = this.getObjectFieldNames;
|
|
2905
|
+
if (!probe) return;
|
|
2906
|
+
const measures = query.measures ?? [];
|
|
2907
|
+
if (measures.length === 0) return;
|
|
2908
|
+
const object = typeof cube.sql === "string" ? cube.sql.trim() : "";
|
|
2909
|
+
if (!object || !BARE_IDENTIFIER.test(object)) return;
|
|
2910
|
+
const fieldNames = probe(object);
|
|
2911
|
+
if (!fieldNames || fieldNames.length === 0) return;
|
|
2912
|
+
const known = /* @__PURE__ */ new Set([...fieldNames, "id", "created_at", "updated_at"]);
|
|
2913
|
+
const stripPrefix = (m) => m.includes(".") ? m.split(".").slice(1).join(".") : m;
|
|
2914
|
+
const sourceFieldOf = (measure) => {
|
|
2915
|
+
const metric = cube.measures[stripPrefix(measure)];
|
|
2916
|
+
if (!metric) return null;
|
|
2917
|
+
if (metric.type === "count" && (metric.sql === "*" || metric.sql == null)) return null;
|
|
2918
|
+
const source = typeof metric.sql === "string" ? metric.sql.trim() : "";
|
|
2919
|
+
if (!source || source === "*" || !BARE_IDENTIFIER.test(source)) return null;
|
|
2920
|
+
return source;
|
|
2921
|
+
};
|
|
2922
|
+
const invalid = /* @__PURE__ */ new Set();
|
|
2923
|
+
for (const measure of measures) {
|
|
2924
|
+
const source = sourceFieldOf(measure);
|
|
2925
|
+
if (source && !known.has(source)) invalid.add(stripPrefix(measure));
|
|
2926
|
+
}
|
|
2927
|
+
if (invalid.size === 0) return;
|
|
2928
|
+
const usable = declaredMeasures.filter((m) => !invalid.has(m));
|
|
2929
|
+
for (const measure of measures) {
|
|
2930
|
+
const source = sourceFieldOf(measure);
|
|
2931
|
+
if (!source || known.has(source)) continue;
|
|
2932
|
+
const err = new Error(
|
|
2933
|
+
`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(", ")}.`
|
|
2934
|
+
);
|
|
2935
|
+
err.code = "INVALID_FIELD";
|
|
2936
|
+
err.status = 400;
|
|
2937
|
+
err.field = source;
|
|
2938
|
+
err.object = object;
|
|
2939
|
+
err.param = "measures";
|
|
2940
|
+
err.measure = measure;
|
|
2941
|
+
throw err;
|
|
2540
2942
|
}
|
|
2541
2943
|
}
|
|
2542
2944
|
/**
|
|
@@ -2682,9 +3084,22 @@ var FallbackDelegateStrategy = class {
|
|
|
2682
3084
|
var AnalyticsServicePlugin = class {
|
|
2683
3085
|
constructor(options = {}) {
|
|
2684
3086
|
this.name = "com.objectstack.service-analytics";
|
|
3087
|
+
/**
|
|
3088
|
+
* Services init() registers on every path (ADR-0116, #4131) — lets the
|
|
3089
|
+
* kernel name this plugin when a consumer requires one before it inits.
|
|
3090
|
+
*/
|
|
3091
|
+
this.providesServices = ["analytics"];
|
|
2685
3092
|
this.version = "1.0.0";
|
|
2686
3093
|
this.type = "standard";
|
|
2687
3094
|
this.dependencies = [];
|
|
3095
|
+
/**
|
|
3096
|
+
* init() probes the `data` engine ObjectQLPlugin provides for the
|
|
3097
|
+
* auto-bridge — order-if-present so the probe verdict is deterministic
|
|
3098
|
+
* (ADR-0116, #4471). Soft, not hard: without an engine the plugin
|
|
3099
|
+
* degrades on purpose (per-query lazy resolution / explicit
|
|
3100
|
+
* `executeAggregate`).
|
|
3101
|
+
*/
|
|
3102
|
+
this.optionalDependencies = ["com.objectstack.engine.objectql"];
|
|
2688
3103
|
this.options = options;
|
|
2689
3104
|
}
|
|
2690
3105
|
async init(ctx) {
|
|
@@ -2884,6 +3299,17 @@ var AnalyticsServicePlugin = class {
|
|
|
2884
3299
|
}
|
|
2885
3300
|
return value;
|
|
2886
3301
|
};
|
|
3302
|
+
const coerceTemporalFilterColumn = (objectName, fieldName, columnSql) => {
|
|
3303
|
+
try {
|
|
3304
|
+
const svc = ctx.getService("data");
|
|
3305
|
+
const driver = svc?.getDriverForObject?.(objectName);
|
|
3306
|
+
if (driver && typeof driver.temporalFilterColumnSql === "function") {
|
|
3307
|
+
return driver.temporalFilterColumnSql(objectName, fieldName, columnSql);
|
|
3308
|
+
}
|
|
3309
|
+
} catch {
|
|
3310
|
+
}
|
|
3311
|
+
return columnSql;
|
|
3312
|
+
};
|
|
2887
3313
|
const config = {
|
|
2888
3314
|
cubes: this.options.cubes,
|
|
2889
3315
|
logger: ctx.logger,
|
|
@@ -2894,12 +3320,15 @@ var AnalyticsServicePlugin = class {
|
|
|
2894
3320
|
getReadScope,
|
|
2895
3321
|
getAllowedRelationships: this.options.getAllowedRelationships,
|
|
2896
3322
|
coerceTemporalFilterValue,
|
|
3323
|
+
coerceTemporalFilterColumn,
|
|
2897
3324
|
relationshipResolver,
|
|
2898
3325
|
labelResolver,
|
|
2899
|
-
//
|
|
2900
|
-
|
|
3326
|
+
// Source-field metadata behind the display chains on result columns:
|
|
3327
|
+
// ADR-0053 currency (`currencyConfig.defaultCurrency`) and percent scale
|
|
3328
|
+
// (`max`, which is what marks whole-percent storage — objectui#3136).
|
|
3329
|
+
sourceFieldMeta: (object, field) => {
|
|
2901
3330
|
const f = dataEngine()?.getObject?.(object)?.fields?.[field];
|
|
2902
|
-
return f ? { type: f.type, defaultCurrency: f.currencyConfig?.defaultCurrency } : void 0;
|
|
3331
|
+
return f ? { type: f.type, max: f.max, defaultCurrency: f.currencyConfig?.defaultCurrency } : void 0;
|
|
2903
3332
|
},
|
|
2904
3333
|
// ADR-0062 D6 — a federated object carries an `external` block (ADR-0015).
|
|
2905
3334
|
// Reported so NativeSQLStrategy declines it (its hand-compiled FROM would
|
|
@@ -2922,6 +3351,18 @@ var AnalyticsServicePlugin = class {
|
|
|
2922
3351
|
if (!engine) return true;
|
|
2923
3352
|
return engine.getObject?.(name) != null;
|
|
2924
3353
|
},
|
|
3354
|
+
// [#4437] Field names for the measure source-field gate. Read from the
|
|
3355
|
+
// SAME schema registry `isRegisteredObject` above consults (and the data
|
|
3356
|
+
// path's #4315 gate reads), so "which fields exist" has one answer across
|
|
3357
|
+
// /data and /analytics. `undefined` — no engine, unknown object, or an
|
|
3358
|
+
// object with no field map (an external datasource whose columns are not
|
|
3359
|
+
// mirrored locally) — means "cannot answer", and the gate stands down.
|
|
3360
|
+
getObjectFieldNames: (objectName) => {
|
|
3361
|
+
const fields = dataEngine()?.getObject?.(objectName)?.fields;
|
|
3362
|
+
if (!fields || typeof fields !== "object") return void 0;
|
|
3363
|
+
const names = Object.keys(fields);
|
|
3364
|
+
return names.length > 0 ? names : void 0;
|
|
3365
|
+
},
|
|
2925
3366
|
draftRowsResolver
|
|
2926
3367
|
};
|
|
2927
3368
|
if (autoBridgedReadScope && securityPresentAtInit) {
|
|
@@ -2977,6 +3418,7 @@ export {
|
|
|
2977
3418
|
compileScopedFilterToSql,
|
|
2978
3419
|
createOrderLabelResolver,
|
|
2979
3420
|
evaluateDerivedMeasures,
|
|
3421
|
+
fillEmptyGroups,
|
|
2980
3422
|
mergeByDimensions,
|
|
2981
3423
|
pickDisplayField,
|
|
2982
3424
|
resolveDimensionLabels,
|