@objectstack/service-analytics 17.0.0 → 17.2.0
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 +444 -0
- package/README.md +136 -335
- package/dist/index.cjs +307 -64
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +113 -13
- package/dist/index.d.ts +113 -13
- package/dist/index.js +278 -30
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
package/dist/index.cjs
CHANGED
|
@@ -41,9 +41,9 @@ __export(index_exports, {
|
|
|
41
41
|
module.exports = __toCommonJS(index_exports);
|
|
42
42
|
|
|
43
43
|
// src/analytics-service.ts
|
|
44
|
-
var
|
|
44
|
+
var import_data6 = require("@objectstack/spec/data");
|
|
45
45
|
var import_ui2 = require("@objectstack/spec/ui");
|
|
46
|
-
var
|
|
46
|
+
var import_core6 = require("@objectstack/core");
|
|
47
47
|
var import_types = require("@objectstack/types");
|
|
48
48
|
|
|
49
49
|
// src/cube-registry.ts
|
|
@@ -162,21 +162,18 @@ var CubeRegistry = class {
|
|
|
162
162
|
};
|
|
163
163
|
|
|
164
164
|
// src/strategies/filter-normalizer.ts
|
|
165
|
-
var
|
|
165
|
+
var import_data2 = require("@objectstack/spec/data");
|
|
166
166
|
var import_api = require("@objectstack/spec/api");
|
|
167
167
|
|
|
168
168
|
// src/comparand-shape.ts
|
|
169
|
+
var import_core = require("@objectstack/core");
|
|
170
|
+
var import_data = require("@objectstack/spec/data");
|
|
169
171
|
function isBindableComparand(value) {
|
|
170
|
-
if (value ===
|
|
171
|
-
|
|
172
|
-
if (kind === "string" || kind === "number" || kind === "bigint" || kind === "boolean") return true;
|
|
173
|
-
return value instanceof Date || ArrayBuffer.isView(value);
|
|
172
|
+
if (value === void 0) return true;
|
|
173
|
+
return (0, import_data.isAcceptedFilterComparand)(value) || ArrayBuffer.isView(value);
|
|
174
174
|
}
|
|
175
175
|
function isRenderableTextComparand(value) {
|
|
176
|
-
|
|
177
|
-
const kind = typeof value;
|
|
178
|
-
if (kind === "string" || kind === "number" || kind === "bigint" || kind === "boolean") return true;
|
|
179
|
-
return value instanceof Date;
|
|
176
|
+
return value === void 0 || (0, import_data.isAcceptedFilterComparand)(value);
|
|
180
177
|
}
|
|
181
178
|
function isFieldReference(value) {
|
|
182
179
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
@@ -212,6 +209,51 @@ function findIn(node, field) {
|
|
|
212
209
|
}
|
|
213
210
|
return null;
|
|
214
211
|
}
|
|
212
|
+
function findUninterpretableTemporalMember(filter, kindOf) {
|
|
213
|
+
return findUninterpretableIn(filter, "", kindOf);
|
|
214
|
+
}
|
|
215
|
+
function findUninterpretableIn(node, field, kindOf) {
|
|
216
|
+
if (!node || typeof node !== "object") return null;
|
|
217
|
+
if (Array.isArray(node)) {
|
|
218
|
+
for (const child of node) {
|
|
219
|
+
const hit = findUninterpretableIn(child, field, kindOf);
|
|
220
|
+
if (hit) return hit;
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
if (node instanceof Date || ArrayBuffer.isView(node)) return null;
|
|
225
|
+
if (isFieldReference(node)) return null;
|
|
226
|
+
for (const [key, value] of Object.entries(node)) {
|
|
227
|
+
const scope = key.startsWith("$") ? field : key;
|
|
228
|
+
const kind = scope ? kindOf(scope) : null;
|
|
229
|
+
if (kind) {
|
|
230
|
+
const hit2 = judgeTemporalLiterals(value, scope, kind);
|
|
231
|
+
if (hit2) return hit2;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const hit = findUninterpretableIn(value, scope, kindOf);
|
|
235
|
+
if (hit) return hit;
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
function judgeTemporalLiterals(value, field, kind) {
|
|
240
|
+
if (Array.isArray(value)) {
|
|
241
|
+
for (const member of value) {
|
|
242
|
+
const hit = judgeTemporalLiterals(member, field, kind);
|
|
243
|
+
if (hit) return hit;
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
if (value && typeof value === "object") {
|
|
248
|
+
if (value instanceof Date || ArrayBuffer.isView(value) || isFieldReference(value)) return null;
|
|
249
|
+
for (const nested of Object.values(value)) {
|
|
250
|
+
const hit = judgeTemporalLiterals(nested, field, kind);
|
|
251
|
+
if (hit) return hit;
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
return (0, import_core.isUninterpretableTemporalComparand)(kind, value) ? { field, kind, value } : null;
|
|
256
|
+
}
|
|
215
257
|
var TEXT_PATTERN_OPERATORS = /* @__PURE__ */ new Set([
|
|
216
258
|
"$contains",
|
|
217
259
|
"$notContains",
|
|
@@ -229,7 +271,7 @@ function shapePreview(value) {
|
|
|
229
271
|
}
|
|
230
272
|
}
|
|
231
273
|
function unrenderableTextComparandMessage(op, field, value) {
|
|
232
|
-
return `"${op}" on "${field}" matches against the TEXT of a pattern, but its comparand is ${Array.isArray(value) ? "an array" : "an object"} (${shapePreview(value)}). filter.zod.ts declares it a string (StringOperatorSchema);
|
|
274
|
+
return `"${op}" on "${field}" matches against the TEXT of a pattern, but its comparand is ${Array.isArray(value) ? "an array" : "an object"} (${shapePreview(value)}). filter.zod.ts declares it a string (StringOperatorSchema); ${import_data.ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} is accepted. Refusing rather than stringifying it: String({}) is "[object Object]", so the pattern that ran would be one nobody wrote \u2014 and a row storing that literal text matches it.`;
|
|
233
275
|
}
|
|
234
276
|
function fieldReferenceComparandMessage(op, field, ref, position) {
|
|
235
277
|
return `"${op}" on "${field}"${position ? ` (${position})` : ""} compares against the field reference { "$field": "${ref}" }, which this compiler does not lower into a column-to-column comparison. Refusing rather than binding it: the reference object used to become the BOUND VALUE of the comparison, so the emitted predicate compared "${field}" against the reference itself \u2014 a value no row can hold \u2014 and a read scope built from it answered the wrong row set with nothing to read. \u26A0\uFE0F This is NOT the platform declining the rule. @objectstack/spec declares this shape (FieldReferenceSchema), @objectstack/formula resolves it per record in memory, driver-sql / driver-sqlite-wasm compile it to a same-table column comparison for the six scalar operators since #5222, and since the 2026-08-12 ruling on #7598 the analytics native-SQL strategy DECLINES such a query so it routes to the ObjectQL engine path and runs there \u2014 the driver enforcing declared-only enumeration, the tenant-isolation ban and the comparison class with metadata it owns. What refuses here is this SQL lowering, whose only remaining caller is the /analytics/sql display echo; it has no faithful rendering of the predicate the engine path actually runs, and half-rendering one would describe a query that returns different rows. Run the query itself (/analytics/query) to get its rows (#7598).`;
|
|
@@ -238,7 +280,7 @@ function fieldReferenceBetweenBoundMessage(op, field, ref, index) {
|
|
|
238
280
|
return `"${op}" on "${field}" has the field reference { "$field": "${ref}" } at index ${index} of its [min, max] bounds. A range BOUND may not be a field reference on any backend: driver-sql and driver-sqlite-wasm refuse both endpoints (#5222), @objectstack/formula does not resolve a reference inside a list either \u2014 it orders the bounds against the raw reference object, which no value compares meaningfully to \u2014 and @objectstack/spec no longer declares the position at all (#7596 removed FieldReferenceSchema from the $between endpoint union, ADR-0049 declared = enforced). Refusing rather than lowering it: this compiler splits $between into its two bounds, so the reference would arrive at the driver under a "$gte" / "$lte" the author never wrote \u2014 a position the SQL drivers DO compile \u2014 and the range would quietly succeed here while the identical filter is refused everywhere else. Use a literal bound, or spell the comparison you meant as a scalar one ({ "${field}": { "$gte": { "$field": "${ref}" } } }), which IS served \u2014 on the ObjectQL engine path, where the driver enforces the #5222 rulings (#7598).`;
|
|
239
281
|
}
|
|
240
282
|
function unbindableListMemberMessage(op, field, value, index) {
|
|
241
|
-
return `"${op}" on "${field}" has a value at index ${index} of its list that cannot be bound as a SQL parameter: ${shapePreview(value)}. Every member of an $in/$nin/$between list is a comparand in its own right \u2014 use
|
|
283
|
+
return `"${op}" on "${field}" has a value at index ${index} of its list that cannot be bound as a SQL parameter: ${shapePreview(value)}. Every member of an $in/$nin/$between list is a comparand in its own right \u2014 use ${import_data.ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} (or a binary value). Refusing rather than binding it: the member can equal no stored value, so the list silently loses that entry (and a $nin loses the exclusion the caller wrote).`;
|
|
242
284
|
}
|
|
243
285
|
|
|
244
286
|
// src/strategies/filter-normalizer.ts
|
|
@@ -586,7 +628,7 @@ function nullSafeNegationOperand(node) {
|
|
|
586
628
|
}
|
|
587
629
|
function filterArrayNotLowerableError(where) {
|
|
588
630
|
return invalidFilterError(
|
|
589
|
-
`[analytics] received a 'where' array that is not a filter: ${JSON.stringify(where)}. A filter array is a comparison [field, operator, value], a logical node ["and"|"or", ...conditions], or a list of those \u2014 it is INPUT-ONLY sugar (spec 'FilterArray'), lowered to a FilterCondition by @objectstack/spec parseFilterAST() at every door, this one included (#5158/#5334). This value cannot be lowered, and an unapplied filter would have charted the UNFILTERED dataset. Recognised operators: ${[...
|
|
631
|
+
`[analytics] received a 'where' array that is not a filter: ${JSON.stringify(where)}. A filter array is a comparison [field, operator, value], a logical node ["and"|"or", ...conditions], or a list of those \u2014 it is INPUT-ONLY sugar (spec 'FilterArray'), lowered to a FilterCondition by @objectstack/spec parseFilterAST() at every door, this one included (#5158/#5334). This value cannot be lowered, and an unapplied filter would have charted the UNFILTERED dataset. Recognised operators: ${[...import_data2.VALID_AST_OPERATORS].sort().join(", ")}. Infix joins ([condA, "or", condB]) are NOT one of the shapes \u2014 write the prefix form ["or", condA, condB].`
|
|
590
632
|
);
|
|
591
633
|
}
|
|
592
634
|
function lowerAnalyticsWhere(query) {
|
|
@@ -595,8 +637,8 @@ function lowerAnalyticsWhere(query) {
|
|
|
595
637
|
if (!where || typeof where !== "object") return null;
|
|
596
638
|
if (Array.isArray(where)) {
|
|
597
639
|
if (where.length === 0) return null;
|
|
598
|
-
if (!(0,
|
|
599
|
-
const condition = (0,
|
|
640
|
+
if (!(0, import_data2.isFilterAST)(where)) throw filterArrayNotLowerableError(where);
|
|
641
|
+
const condition = (0, import_data2.parseFilterAST)(where);
|
|
600
642
|
if (!condition || typeof condition !== "object" || Array.isArray(condition)) {
|
|
601
643
|
throw invalidFilterError(
|
|
602
644
|
`[analytics] filter array ${JSON.stringify(where)} passed isFilterAST() but parseFilterAST() lowered it to ${JSON.stringify(condition)}. Refusing rather than charting the dataset unfiltered (#5158/#5334).`
|
|
@@ -1019,16 +1061,33 @@ function invalidMemberError(message, meta) {
|
|
|
1019
1061
|
}
|
|
1020
1062
|
|
|
1021
1063
|
// src/strategies/native-sql-strategy.ts
|
|
1022
|
-
var
|
|
1064
|
+
var import_core2 = require("@objectstack/core");
|
|
1023
1065
|
var AGGREGATE_SQL = {
|
|
1024
|
-
|
|
1066
|
+
// [#10298] `count` takes its COLUMN when the measure declares one. The
|
|
1067
|
+
// wrapper used to discard `col` and always emit `COUNT(*)`, so a measure
|
|
1068
|
+
// written `{ aggregate: 'count', field: 'resolved_by_article' }` counted
|
|
1069
|
+
// ROWS instead of non-null values — and a deflection rate built as
|
|
1070
|
+
// `kb_resolved_count / closed_count` read 100% where the truth was 12.5%,
|
|
1071
|
+
// with the numerator and denominator printed beside it as 8 and 8. `*` is
|
|
1072
|
+
// still `COUNT(*)`: the compiler writes `sql: m.field ?? '*'`, so the star
|
|
1073
|
+
// IS the "no field declared" spelling and must keep counting rows.
|
|
1074
|
+
"count": (col) => col === "*" ? "COUNT(*)" : `COUNT(${col})`,
|
|
1025
1075
|
"sum": (col) => `SUM(${col})`,
|
|
1026
1076
|
"avg": (col) => `AVG(${col})`,
|
|
1027
1077
|
"min": (col) => `MIN(${col})`,
|
|
1028
1078
|
"max": (col) => `MAX(${col})`,
|
|
1029
1079
|
"count_distinct": (col) => `COUNT(DISTINCT ${col})`
|
|
1030
1080
|
};
|
|
1081
|
+
var CONDITIONAL_AGGREGATE_SQL = {
|
|
1082
|
+
"count": (col, pred) => `COUNT(CASE WHEN ${pred} THEN ${col === "*" ? "1" : col} END)`,
|
|
1083
|
+
"sum": (col, pred) => `SUM(CASE WHEN ${pred} THEN ${col} END)`,
|
|
1084
|
+
"avg": (col, pred) => `AVG(CASE WHEN ${pred} THEN ${col} END)`,
|
|
1085
|
+
"min": (col, pred) => `MIN(CASE WHEN ${pred} THEN ${col} END)`,
|
|
1086
|
+
"max": (col, pred) => `MAX(CASE WHEN ${pred} THEN ${col} END)`,
|
|
1087
|
+
"count_distinct": (col, pred) => `COUNT(DISTINCT CASE WHEN ${pred} THEN ${col} END)`
|
|
1088
|
+
};
|
|
1031
1089
|
var SUPPORTED_AGGREGATE_SQL_KEYS = Object.keys(AGGREGATE_SQL);
|
|
1090
|
+
var CONDITIONAL_AGGREGATE_SQL_KEYS = Object.keys(CONDITIONAL_AGGREGATE_SQL);
|
|
1032
1091
|
var EXPRESSION_METRIC_TYPES = /* @__PURE__ */ new Set(["number", "string", "boolean"]);
|
|
1033
1092
|
var IDENTIFIER_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
1034
1093
|
var NativeSQLStrategy = class {
|
|
@@ -1051,9 +1110,51 @@ var NativeSQLStrategy = class {
|
|
|
1051
1110
|
}
|
|
1052
1111
|
}
|
|
1053
1112
|
if (this.carriesCrossFieldComparison(query, ctx)) return false;
|
|
1113
|
+
if (this.carriesUninterpretableTemporalComparand(query, ctx)) return false;
|
|
1054
1114
|
const caps = ctx.queryCapabilities(query.cube);
|
|
1055
1115
|
return caps.nativeSql && typeof ctx.executeRawSql === "function";
|
|
1056
1116
|
}
|
|
1117
|
+
/**
|
|
1118
|
+
* [#8690] Does the query's `where` compare a declared TIME dimension against
|
|
1119
|
+
* a value no temporal storage rule can read? See the ruling at
|
|
1120
|
+
* {@link canHandle}.
|
|
1121
|
+
*
|
|
1122
|
+
* The classification comes from the CUBE, the only metadata this package has:
|
|
1123
|
+
* a dimension declares `type: 'time'` (compiled from the dataset dimension's
|
|
1124
|
+
* `type: 'date'`), and {@link lookupMember} is the same resolution every other
|
|
1125
|
+
* member lookup in this strategy uses, so "the member the gate classified"
|
|
1126
|
+
* and "the member the compiler emits" cannot drift apart.
|
|
1127
|
+
*
|
|
1128
|
+
* A `time` dimension is read with the DATETIME rule — the permissive one of
|
|
1129
|
+
* the three. That is the right direction because this is a routing decision,
|
|
1130
|
+
* not a verdict: the engine door re-judges with the field's real declared
|
|
1131
|
+
* type and has the final say, so under-classifying an exotic spelling merely
|
|
1132
|
+
* leaves today's behaviour, while over-classifying would silently move a
|
|
1133
|
+
* working dashboard off the fast path. The comparands this card measured
|
|
1134
|
+
* (`last_30_days`, `not-a-date-at-all`) are unreadable under all three rules,
|
|
1135
|
+
* so the decline fires for them whichever backing type the dimension has.
|
|
1136
|
+
*
|
|
1137
|
+
* `lowerAnalyticsWhere` rather than `query.where` raw, so the authored ARRAY
|
|
1138
|
+
* sugar is seen after `parseFilterAST` has lowered it; a THROW from that
|
|
1139
|
+
* lowering is not this gate's to answer — the filter is malformed either way
|
|
1140
|
+
* and `normalizeAnalyticsFilterTree` refuses it a moment later with the
|
|
1141
|
+
* message and envelope it has always had.
|
|
1142
|
+
*/
|
|
1143
|
+
carriesUninterpretableTemporalComparand(query, ctx) {
|
|
1144
|
+
const cube = query.cube ? ctx.getCube(query.cube) : void 0;
|
|
1145
|
+
if (!cube) return false;
|
|
1146
|
+
let where = null;
|
|
1147
|
+
try {
|
|
1148
|
+
where = lowerAnalyticsWhere(query);
|
|
1149
|
+
} catch {
|
|
1150
|
+
return false;
|
|
1151
|
+
}
|
|
1152
|
+
if (!where) return false;
|
|
1153
|
+
return findUninterpretableTemporalMember(
|
|
1154
|
+
where,
|
|
1155
|
+
(member) => this.lookupMember(cube, member, "dimension")?.type === "time" ? "datetime" : null
|
|
1156
|
+
) !== null;
|
|
1157
|
+
}
|
|
1057
1158
|
/**
|
|
1058
1159
|
* [#7598] Does serving this query require the cross-field capability this
|
|
1059
1160
|
* strategy declines? See the ruling recorded at {@link canHandle}.
|
|
@@ -1152,9 +1253,19 @@ var NativeSQLStrategy = class {
|
|
|
1152
1253
|
groupByClauses.push(colExpr);
|
|
1153
1254
|
}
|
|
1154
1255
|
}
|
|
1256
|
+
const datasetScope = ctx.getDatasetScope?.(query.cube);
|
|
1155
1257
|
if (query.measures && query.measures.length > 0) {
|
|
1156
1258
|
for (const measure of query.measures) {
|
|
1157
|
-
const
|
|
1259
|
+
const measureFilter = datasetScope?.measureFilters?.[measure];
|
|
1260
|
+
const predicate = measureFilter ? this.compileFilterNode(
|
|
1261
|
+
normalizeAnalyticsFilterTree({ where: measureFilter }),
|
|
1262
|
+
cube,
|
|
1263
|
+
tableName,
|
|
1264
|
+
joins,
|
|
1265
|
+
params,
|
|
1266
|
+
ctx
|
|
1267
|
+
) : null;
|
|
1268
|
+
const aggExpr = this.resolveMeasureSql(cube, measure, tableName, joins, predicate);
|
|
1158
1269
|
selectClauses.push(`${aggExpr} AS "${measure}"`);
|
|
1159
1270
|
}
|
|
1160
1271
|
}
|
|
@@ -1168,6 +1279,17 @@ var NativeSQLStrategy = class {
|
|
|
1168
1279
|
ctx
|
|
1169
1280
|
);
|
|
1170
1281
|
if (filterSql) whereClauses.push(filterSql);
|
|
1282
|
+
if (datasetScope?.filter) {
|
|
1283
|
+
const scopeSql = this.compileFilterNode(
|
|
1284
|
+
normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
|
|
1285
|
+
cube,
|
|
1286
|
+
tableName,
|
|
1287
|
+
joins,
|
|
1288
|
+
params,
|
|
1289
|
+
ctx
|
|
1290
|
+
);
|
|
1291
|
+
if (scopeSql) whereClauses.push(scopeSql);
|
|
1292
|
+
}
|
|
1171
1293
|
if (query.timeDimensions && query.timeDimensions.length > 0) {
|
|
1172
1294
|
for (const td of query.timeDimensions) {
|
|
1173
1295
|
const colExpr = this.resolveFieldSql(cube, td.dimension, tableName, joins);
|
|
@@ -1176,7 +1298,7 @@ var NativeSQLStrategy = class {
|
|
|
1176
1298
|
if (range.length === 2) {
|
|
1177
1299
|
const td2 = this.resolveStorageTarget(cube, td.dimension, tableName);
|
|
1178
1300
|
const column = this.temporalColumn(ctx, td2, colExpr);
|
|
1179
|
-
const nextDay = (0,
|
|
1301
|
+
const nextDay = (0, import_core2.nextUtcCalendarDay)(range[1]);
|
|
1180
1302
|
params.push(this.coerceTemporal(ctx, td2, range[0]));
|
|
1181
1303
|
const lower = `${column} >= $${params.length}`;
|
|
1182
1304
|
if (nextDay != null) {
|
|
@@ -1341,7 +1463,12 @@ var NativeSQLStrategy = class {
|
|
|
1341
1463
|
const raw = dim ? dim.sql : member.includes(".") ? member.split(".")[1] : member;
|
|
1342
1464
|
return this.qualifyAndRegisterJoin(raw, parentTable, joins, cube);
|
|
1343
1465
|
}
|
|
1344
|
-
|
|
1466
|
+
/**
|
|
1467
|
+
* @param predicate - The measure's own scoped filter, already compiled to a
|
|
1468
|
+
* SQL boolean (`null` = the measure declares none, or declares one that
|
|
1469
|
+
* constrains nothing — `compileFilterNode`'s TRUE). #10298.
|
|
1470
|
+
*/
|
|
1471
|
+
resolveMeasureSql(cube, member, parentTable, joins, predicate = null) {
|
|
1345
1472
|
const measure = this.lookupMember(cube, member, "measure");
|
|
1346
1473
|
if (!measure) {
|
|
1347
1474
|
const declared = Object.keys(cube.measures ?? {});
|
|
@@ -1351,6 +1478,13 @@ var NativeSQLStrategy = class {
|
|
|
1351
1478
|
);
|
|
1352
1479
|
}
|
|
1353
1480
|
const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
|
|
1481
|
+
if (predicate !== null) {
|
|
1482
|
+
const wrapConditional = CONDITIONAL_AGGREGATE_SQL[measure.type];
|
|
1483
|
+
if (wrapConditional) return wrapConditional(col, predicate);
|
|
1484
|
+
throw new Error(
|
|
1485
|
+
`[native-sql-strategy] measure "${member}" on cube "${cube.name}" carries a scoped filter, but its type "${measure.type}" has no conditional form (conditional: ${CONDITIONAL_AGGREGATE_SQL_KEYS.join(", ")}).`
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1354
1488
|
const wrap = AGGREGATE_SQL[measure.type];
|
|
1355
1489
|
if (wrap) return wrap(col);
|
|
1356
1490
|
if (EXPRESSION_METRIC_TYPES.has(measure.type)) return col;
|
|
@@ -1546,7 +1680,7 @@ var NativeSQLStrategy = class {
|
|
|
1546
1680
|
return `${rawCol} ${sqlOp} ${patternRef} ESCAPE $${params.length}`;
|
|
1547
1681
|
}
|
|
1548
1682
|
if (operator === "lte") {
|
|
1549
|
-
const nextDay = (0,
|
|
1683
|
+
const nextDay = (0, import_core2.nextUtcCalendarDay)(values[0]);
|
|
1550
1684
|
if (nextDay != null) {
|
|
1551
1685
|
params.push(this.coerceTemporal(ctx, target, nextDay));
|
|
1552
1686
|
return `${this.temporalColumn(ctx, target, rawCol)} < $${params.length}`;
|
|
@@ -1576,8 +1710,8 @@ var NativeSQLStrategy = class {
|
|
|
1576
1710
|
};
|
|
1577
1711
|
|
|
1578
1712
|
// src/strategies/objectql-strategy.ts
|
|
1579
|
-
var
|
|
1580
|
-
var
|
|
1713
|
+
var import_data3 = require("@objectstack/spec/data");
|
|
1714
|
+
var import_core3 = require("@objectstack/core");
|
|
1581
1715
|
|
|
1582
1716
|
// src/strategies/cross-object-rebucket.ts
|
|
1583
1717
|
var RECOMBINABLE_METHODS = /* @__PURE__ */ new Set([
|
|
@@ -1697,10 +1831,18 @@ var ObjectQLStrategy = class {
|
|
|
1697
1831
|
const extra = this.mergeFilterOperand(filter, field, bounds);
|
|
1698
1832
|
if (extra) conjuncts.push(extra);
|
|
1699
1833
|
}
|
|
1834
|
+
const datasetScope = ctx.getDatasetScope?.(query.cube);
|
|
1835
|
+
if (datasetScope?.filter) {
|
|
1836
|
+
const scopeCondition = this.filterNodeToCondition(
|
|
1837
|
+
normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
|
|
1838
|
+
cube
|
|
1839
|
+
);
|
|
1840
|
+
if (scopeCondition) conjuncts.push(scopeCondition);
|
|
1841
|
+
}
|
|
1700
1842
|
if (conjuncts.length > 0) {
|
|
1701
1843
|
filter.$and = [...Array.isArray(filter.$and) ? filter.$and : [], ...conjuncts];
|
|
1702
1844
|
}
|
|
1703
|
-
const plan = this.planCrossObject(cube, query,
|
|
1845
|
+
const plan = this.planCrossObject(cube, query, this.filterMemberView(cube, query, ctx));
|
|
1704
1846
|
if (plan) {
|
|
1705
1847
|
return this.executeCrossObject(cube, query, aggregations, filter, plan, ctx);
|
|
1706
1848
|
}
|
|
@@ -1777,9 +1919,7 @@ var ObjectQLStrategy = class {
|
|
|
1777
1919
|
if (td.granularity) granByDim.set(td.dimension, td.granularity);
|
|
1778
1920
|
}
|
|
1779
1921
|
const tableName = this.extractObjectName(cube);
|
|
1780
|
-
const plan = this.planCrossObject(cube, query,
|
|
1781
|
-
collectFilterLeaves(normalizeAnalyticsFilterTree(query)).map((f) => [this.resolveFieldName(cube, f.member, "any"), true])
|
|
1782
|
-
));
|
|
1922
|
+
const plan = this.planCrossObject(cube, query, this.filterMemberView(cube, query, ctx));
|
|
1783
1923
|
const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
|
|
1784
1924
|
const joinClauses = [];
|
|
1785
1925
|
const dimExpr = (dim) => {
|
|
@@ -1821,8 +1961,17 @@ var ObjectQLStrategy = class {
|
|
|
1821
1961
|
params
|
|
1822
1962
|
);
|
|
1823
1963
|
if (filterClause) whereParts.push(filterClause);
|
|
1964
|
+
const echoedDatasetFilter = ctx.getDatasetScope?.(query.cube)?.filter;
|
|
1965
|
+
if (echoedDatasetFilter) {
|
|
1966
|
+
const scopeSql = this.renderFilterNodeSql(
|
|
1967
|
+
normalizeAnalyticsFilterTree({ where: echoedDatasetFilter }),
|
|
1968
|
+
cube,
|
|
1969
|
+
params
|
|
1970
|
+
);
|
|
1971
|
+
if (scopeSql) whereParts.push(scopeSql);
|
|
1972
|
+
}
|
|
1824
1973
|
for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
|
|
1825
|
-
const nextDay = (0,
|
|
1974
|
+
const nextDay = (0, import_core3.nextUtcCalendarDay)(bounds.$lte);
|
|
1826
1975
|
params.push(bounds.$gte, nextDay ?? bounds.$lte);
|
|
1827
1976
|
whereParts.push(
|
|
1828
1977
|
`(${field} >= $${params.length - 1} AND ${field} ${nextDay ? "<" : "<="} $${params.length})`
|
|
@@ -1873,11 +2022,11 @@ var ObjectQLStrategy = class {
|
|
|
1873
2022
|
* predicate. `$and` makes that structurally impossible.
|
|
1874
2023
|
*/
|
|
1875
2024
|
withReadScope(objectName, filter, ctx) {
|
|
1876
|
-
const userFilter = Object.keys(filter).length > 0 ? (0,
|
|
2025
|
+
const userFilter = Object.keys(filter).length > 0 ? (0, import_data3.markFilterSubtreeProvenance)(filter, "author") : void 0;
|
|
1877
2026
|
if (typeof ctx.getReadScope !== "function") return userFilter;
|
|
1878
2027
|
const scope = ctx.getReadScope(objectName);
|
|
1879
2028
|
if (scope === void 0 || scope === null) return userFilter;
|
|
1880
|
-
const scopeFilter = (0,
|
|
2029
|
+
const scopeFilter = (0, import_data3.markFilterSubtreeProvenance)(scope, "policy");
|
|
1881
2030
|
if (!userFilter) return scopeFilter;
|
|
1882
2031
|
return { $and: [userFilter, scopeFilter] };
|
|
1883
2032
|
}
|
|
@@ -1888,6 +2037,68 @@ var ObjectQLStrategy = class {
|
|
|
1888
2037
|
const joinedObject = cube.joins?.[alias]?.name ?? alias;
|
|
1889
2038
|
return joinedObject !== baseObject;
|
|
1890
2039
|
}
|
|
2040
|
+
/**
|
|
2041
|
+
* The member view {@link planCrossObject} judges a filter by: EVERY member
|
|
2042
|
+
* that will end up in the engine's predicate, structure discarded, keyed by
|
|
2043
|
+
* RESOLVED field name (#10759), valued by WHERE THE MEMBER CAME FROM
|
|
2044
|
+
* (#10861).
|
|
2045
|
+
*
|
|
2046
|
+
* Both call sites — `execute()` and `generateSql()` — are handed this and
|
|
2047
|
+
* nothing else, which is what makes the invariant `planCrossObject` states
|
|
2048
|
+
* for itself ("the preview accepts/rejects the same set") structural rather
|
|
2049
|
+
* than a coincidence maintained by hand. They used to build the view
|
|
2050
|
+
* separately: the echo flattened the tree, `execute()` passed the ENGINE
|
|
2051
|
+
* FILTER, and a filter record answers a different question — it is a
|
|
2052
|
+
* predicate to evaluate, not an inventory of members. An `$or`, a `$not` or
|
|
2053
|
+
* an unmergeable nested `$and` travels in it as one opaque `$and` entry, so
|
|
2054
|
+
* the members inside were unreadable from the outside and the envelope check
|
|
2055
|
+
* could not reject what it could not see.
|
|
2056
|
+
*
|
|
2057
|
+
* ## Two producers, one inventory (#10861)
|
|
2058
|
+
*
|
|
2059
|
+
* The caller's `where` is not the only thing that reaches `engine.aggregate`
|
|
2060
|
+
* as a predicate. Since PR #10758 the compiled dataset's own definition-level
|
|
2061
|
+
* `filter` is lowered onto `execute()`'s `conjuncts` and rendered by
|
|
2062
|
+
* `generateSql()`, so a dataset declaring `filter: { 'account.region': 'West' }`
|
|
2063
|
+
* sent `{"$and":[{"account.region":"West"}]}` to an engine that cannot join —
|
|
2064
|
+
* measured on both doors, which AGREED in accepting it, so #10759's
|
|
2065
|
+
* preview/execution symmetry had nothing to restore. Refusing it is a
|
|
2066
|
+
* widening of the refusal set, ruled by the maintainer on 2026-08-22 (Option
|
|
2067
|
+
* A, query-time refusal): fold the scope's leaves in HERE, where driver
|
|
2068
|
+
* capability is known, rather than in `dataset-compiler.ts`, which cannot see
|
|
2069
|
+
* which driver will serve the dataset and would refuse a dataset that is
|
|
2070
|
+
* perfectly legal on a native-SQL deployment.
|
|
2071
|
+
*
|
|
2072
|
+
* Structure is discarded on purpose — a member is cross-object or it is not,
|
|
2073
|
+
* and which branch of a disjunction it sits in cannot make
|
|
2074
|
+
* `engine.aggregate` able to join it. PROVENANCE is not discarded, because it
|
|
2075
|
+
* decides what the refusal can tell the caller to go fix: `AnalyticsRequestKey`
|
|
2076
|
+
* is the analytics REQUEST vocabulary and a dataset's `filter` is not in it,
|
|
2077
|
+
* so a scope-borne member must not be reported as `param: 'where'` — see
|
|
2078
|
+
* `planCrossObject`. The value slot carries that and nothing else; it never
|
|
2079
|
+
* reaches a driver.
|
|
2080
|
+
*
|
|
2081
|
+
* Dataset leaves are inserted FIRST so a member named by BOTH producers keeps
|
|
2082
|
+
* the caller's provenance (last write wins on a duplicate key): if it is in
|
|
2083
|
+
* the request too, the request is the actionable place to fix it.
|
|
2084
|
+
*
|
|
2085
|
+
* Time-dimension WINDOWS are deliberately absent (they live in
|
|
2086
|
+
* `dateRangeBounds`, not in `where`). They need no arm here: a cross-object
|
|
2087
|
+
* time dimension is refused by `planCrossObject`'s own first loop, over
|
|
2088
|
+
* `query.timeDimensions`, and refused as the time dimension the author wrote
|
|
2089
|
+
* rather than as the lowered predicate it becomes — which is the better
|
|
2090
|
+
* diagnostic and the reason that loop runs first.
|
|
2091
|
+
*/
|
|
2092
|
+
filterMemberView(cube, query, ctx) {
|
|
2093
|
+
const datasetFilter = ctx.getDatasetScope?.(query.cube)?.filter;
|
|
2094
|
+
const leaves = (node, origin) => collectFilterLeaves(node).map(
|
|
2095
|
+
(f) => [this.resolveFieldName(cube, f.member, "any"), origin]
|
|
2096
|
+
);
|
|
2097
|
+
return Object.fromEntries([
|
|
2098
|
+
...datasetFilter ? leaves(normalizeAnalyticsFilterTree({ where: datasetFilter }), "dataset-filter") : [],
|
|
2099
|
+
...leaves(normalizeAnalyticsFilterTree(query), "where")
|
|
2100
|
+
]);
|
|
2101
|
+
}
|
|
1891
2102
|
/**
|
|
1892
2103
|
* Plan how to serve cross-object references on this join-less path (#3654).
|
|
1893
2104
|
*
|
|
@@ -1898,21 +2109,36 @@ var ObjectQLStrategy = class {
|
|
|
1898
2109
|
* query (direct path), a plan for an in-envelope cross-object query.
|
|
1899
2110
|
*
|
|
1900
2111
|
* THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
|
|
1901
|
-
* (needs a real join to evaluate), a
|
|
2112
|
+
* (needs a real join to evaluate), a cross-object leaf in the DATASET's own
|
|
2113
|
+
* definition-level `filter` (#10861 — same join it does not have, arriving
|
|
2114
|
+
* from the producer PR #10758 added), a MULTI-HOP dimension (`a.b.c`), or a
|
|
1902
2115
|
* non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
|
|
1903
2116
|
* cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
|
|
1904
|
-
* `generateSql()` calls this too, so the preview accepts/rejects the same set
|
|
2117
|
+
* `generateSql()` calls this too, so the preview accepts/rejects the same set
|
|
2118
|
+
* — and since #10759 both callers derive `filter` from the one
|
|
2119
|
+
* {@link filterMemberView}, so that sentence is enforced by construction
|
|
2120
|
+
* instead of restated at two call sites.
|
|
1905
2121
|
*
|
|
1906
|
-
* [#5716] All
|
|
1907
|
-
* 400, naming the member — and the
|
|
1908
|
-
* diagnostics, and #5923's tests read
|
|
1909
|
-
* facts and nothing else: a member
|
|
1910
|
-
*
|
|
1911
|
-
*
|
|
1912
|
-
*
|
|
1913
|
-
*
|
|
1914
|
-
*
|
|
1915
|
-
*
|
|
2122
|
+
* [#5716] All five refusals below are `invalidMemberError` — `INVALID_FIELD` /
|
|
2123
|
+
* 400, naming the member — and the four that predate #10861 keep their
|
|
2124
|
+
* MESSAGES unchanged (they are good diagnostics, and #5923's tests read
|
|
2125
|
+
* them). Each is decided by two facts and nothing else: a member that will
|
|
2126
|
+
* reach the engine's predicate, and whether that member resolves across a
|
|
2127
|
+
* join. Neither is an internal invariant — a cube where the member exists and
|
|
2128
|
+
* a driver that could serve it are both perfectly ordinary, which is exactly
|
|
2129
|
+
* what the "run this on a native-SQL driver" half of every message says. They
|
|
2130
|
+
* are member-level rather than dataset-level (hence not `datasetInvalidError`)
|
|
2131
|
+
* because the fix is always to change or drop ONE named member, and because
|
|
2132
|
+
* four of them fire on `/analytics/query` where no dataset exists.
|
|
2133
|
+
*
|
|
2134
|
+
* [#10861] The fifth is the exception that proves the rule and is written to
|
|
2135
|
+
* it: it can only fire where a dataset DOES exist, and it is the one refusal
|
|
2136
|
+
* here whose member no request key named — so it carries `cube` and no
|
|
2137
|
+
* `param`, and says in its own words which document to go and edit. It stays
|
|
2138
|
+
* `INVALID_FIELD` rather than becoming `DATASET_INVALID` because the verdict
|
|
2139
|
+
* is the same physical one as its neighbour — this engine cannot join this
|
|
2140
|
+
* member — and splitting the code by PROVENANCE would make a caller branch on
|
|
2141
|
+
* two wire shapes for one capability limit.
|
|
1916
2142
|
*
|
|
1917
2143
|
* Detection is on RESOLVED field names, so a dotted dimension the cube
|
|
1918
2144
|
* flattens to a real column is treated as base, not cross-object.
|
|
@@ -1934,7 +2160,7 @@ var ObjectQLStrategy = class {
|
|
|
1934
2160
|
member: m,
|
|
1935
2161
|
field: this.resolveMeasureAggregation(cube, m).field
|
|
1936
2162
|
})),
|
|
1937
|
-
...Object.
|
|
2163
|
+
...Object.entries(filter).filter(([, origin]) => origin === "where").map(([f]) => ({ where: "filter", member: f, field: f }))
|
|
1938
2164
|
].filter((r) => this.isCrossObjectField(cube, r.field, baseObject));
|
|
1939
2165
|
if (nonDim.length > 0) {
|
|
1940
2166
|
throw invalidMemberError(
|
|
@@ -1948,6 +2174,13 @@ var ObjectQLStrategy = class {
|
|
|
1948
2174
|
}
|
|
1949
2175
|
);
|
|
1950
2176
|
}
|
|
2177
|
+
const scopeCross = Object.entries(filter).filter(([field, origin]) => origin === "dataset-filter" && this.isCrossObjectField(cube, field, baseObject)).map(([field]) => field);
|
|
2178
|
+
if (scopeCross.length > 0) {
|
|
2179
|
+
throw invalidMemberError(
|
|
2180
|
+
`[Analytics] ObjectQLStrategy cannot evaluate the cross-object filter ("${scopeCross[0]}") that dataset "${cube.name}" declares at its definition level \u2014 the engine cannot join in an aggregate, so this predicate matches nothing and the answer would be neither the scoped number nor the unscoped one. Nothing in the request names it: remove the cross-object leaf from the dataset's own \`filter\`, or serve this dataset on a native-SQL driver, where the same definition is valid.`,
|
|
2181
|
+
{ member: scopeCross[0], cube: cube.name }
|
|
2182
|
+
);
|
|
2183
|
+
}
|
|
1951
2184
|
const crossDims = [];
|
|
1952
2185
|
for (const dim of query.dimensions ?? []) {
|
|
1953
2186
|
const field = this.resolveFieldName(cube, dim, "dimension");
|
|
@@ -2051,7 +2284,7 @@ var ObjectQLStrategy = class {
|
|
|
2051
2284
|
if (fkValues.length === 0 || typeof ctx.executeAggregate !== "function") return map;
|
|
2052
2285
|
const idFilter = { id: { $in: fkValues } };
|
|
2053
2286
|
const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
|
|
2054
|
-
if (scope != null) (0,
|
|
2287
|
+
if (scope != null) (0, import_data3.markFilterSubtreeProvenance)(scope, "policy");
|
|
2055
2288
|
const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
|
|
2056
2289
|
const rows = await ctx.executeAggregate(refObject, {
|
|
2057
2290
|
groupBy: ["id", attr],
|
|
@@ -2543,10 +2776,10 @@ var ObjectQLStrategy = class {
|
|
|
2543
2776
|
};
|
|
2544
2777
|
|
|
2545
2778
|
// src/dataset-compiler.ts
|
|
2546
|
-
var
|
|
2779
|
+
var import_data4 = require("@objectstack/spec/data");
|
|
2547
2780
|
var import_ui = require("@objectstack/spec/ui");
|
|
2548
2781
|
var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set();
|
|
2549
|
-
var SUPPORTED_AGGREGATES =
|
|
2782
|
+
var SUPPORTED_AGGREGATES = import_data4.AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
|
|
2550
2783
|
function aggregateToMetricType(m) {
|
|
2551
2784
|
if (!m.aggregate) {
|
|
2552
2785
|
throw new Error(`[dataset-compiler] non-derived measure "${m.name}" has no aggregate`);
|
|
@@ -2708,11 +2941,11 @@ function compileDataset(dataset, resolver, options) {
|
|
|
2708
2941
|
}
|
|
2709
2942
|
|
|
2710
2943
|
// src/dataset-executor.ts
|
|
2711
|
-
var
|
|
2712
|
-
var
|
|
2944
|
+
var import_data5 = require("@objectstack/spec/data");
|
|
2945
|
+
var import_core4 = require("@objectstack/core");
|
|
2713
2946
|
function resolveSelectionTokens(compiled, selection, context) {
|
|
2714
|
-
const tokenCtx = (0,
|
|
2715
|
-
const resolve = (v) => (0,
|
|
2947
|
+
const tokenCtx = (0, import_core4.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
|
|
2948
|
+
const resolve = (v) => (0, import_core4.resolveFilterTokens)(v, tokenCtx);
|
|
2716
2949
|
const filter = resolve(compiled.filter);
|
|
2717
2950
|
const measureFilters = resolve(compiled.measureFilters);
|
|
2718
2951
|
const runtimeFilter = resolve(selection.runtimeFilter);
|
|
@@ -2748,7 +2981,7 @@ function evaluateDerivedMeasures(rows, derived) {
|
|
|
2748
2981
|
}
|
|
2749
2982
|
function fillEmptyGroups(rows, columnAggregates) {
|
|
2750
2983
|
for (const [column, aggregate2] of Object.entries(columnAggregates)) {
|
|
2751
|
-
const empty = (0,
|
|
2984
|
+
const empty = (0, import_data5.emptyGroupValueFor)(aggregate2);
|
|
2752
2985
|
if (empty === void 0) continue;
|
|
2753
2986
|
for (const row of rows) if (row[column] == null) row[column] = empty;
|
|
2754
2987
|
}
|
|
@@ -2948,7 +3181,7 @@ function bucketKeyAtOrdinal(ordinal, granularity) {
|
|
|
2948
3181
|
}
|
|
2949
3182
|
function alignedCompareBucketKey(key, granularity, kind, currentRange, shiftedRange) {
|
|
2950
3183
|
if (typeof key !== "string" || key.length === 0) return null;
|
|
2951
|
-
const span = (0,
|
|
3184
|
+
const span = (0, import_core4.bucketKeyToCalendarRange)(key, granularity);
|
|
2952
3185
|
if (!span) return null;
|
|
2953
3186
|
const targetOrdinal = kind === "previousYear" ? bucketOrdinalOfDay(shiftYear(span.start, 1), granularity) : bucketOrdinalOfDay(span.start, granularity) + (bucketOrdinalOfDay(currentRange[0], granularity) - bucketOrdinalOfDay(shiftedRange[0], granularity));
|
|
2954
3187
|
const first = bucketOrdinalOfDay(currentRange[0], granularity);
|
|
@@ -3412,18 +3645,18 @@ function pickDisplayField(fields) {
|
|
|
3412
3645
|
}
|
|
3413
3646
|
|
|
3414
3647
|
// src/preview-evaluator.ts
|
|
3415
|
-
var
|
|
3648
|
+
var import_core5 = require("@objectstack/core");
|
|
3416
3649
|
function compare(a, b) {
|
|
3417
3650
|
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
3418
3651
|
if (a instanceof Date || b instanceof Date) {
|
|
3419
|
-
const ai = (0,
|
|
3420
|
-
const bi = (0,
|
|
3652
|
+
const ai = (0, import_core5.utcInstantMs)(a);
|
|
3653
|
+
const bi = (0, import_core5.utcInstantMs)(b);
|
|
3421
3654
|
if (ai !== null && bi !== null) return ai - bi;
|
|
3422
3655
|
}
|
|
3423
3656
|
return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0;
|
|
3424
3657
|
}
|
|
3425
3658
|
function lteBound(value, bound) {
|
|
3426
|
-
const nextDay = (0,
|
|
3659
|
+
const nextDay = (0, import_core5.nextUtcCalendarDay)(bound);
|
|
3427
3660
|
if (nextDay != null) return compare(value, nextDay) < 0;
|
|
3428
3661
|
return compare(value, bound) <= 0;
|
|
3429
3662
|
}
|
|
@@ -3481,7 +3714,7 @@ function matchesWhere(row, where) {
|
|
|
3481
3714
|
function bucketDate(value, granularity, timezone) {
|
|
3482
3715
|
const d = new Date(String(value));
|
|
3483
3716
|
if (Number.isNaN(d.getTime())) return null;
|
|
3484
|
-
const { year: y, month, day: dayNum } = (0,
|
|
3717
|
+
const { year: y, month, day: dayNum } = (0, import_core5.calendarPartsInTzOrUtc)(d, timezone);
|
|
3485
3718
|
const m = `${month}`.padStart(2, "0");
|
|
3486
3719
|
const day = `${dayNum}`.padStart(2, "0");
|
|
3487
3720
|
switch (granularity) {
|
|
@@ -3535,7 +3768,7 @@ function evaluateAnalyticsQueryOverRows(query, cube, rows) {
|
|
|
3535
3768
|
const [start, end] = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
|
|
3536
3769
|
filtered = filtered.filter((r) => {
|
|
3537
3770
|
const v = String(r[field] ?? "");
|
|
3538
|
-
const nextDay = (0,
|
|
3771
|
+
const nextDay = (0, import_core5.nextUtcCalendarDay)(end);
|
|
3539
3772
|
const inUpper = nextDay != null ? v < nextDay : v <= `${end}~`;
|
|
3540
3773
|
return v >= String(start) && inUpper;
|
|
3541
3774
|
});
|
|
@@ -3669,7 +3902,7 @@ var AnalyticsService = class {
|
|
|
3669
3902
|
this.datasetRegistry = /* @__PURE__ */ new Map();
|
|
3670
3903
|
/** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */
|
|
3671
3904
|
this.warnedNoObjectRegistry = false;
|
|
3672
|
-
this.logger = config.logger || (0,
|
|
3905
|
+
this.logger = config.logger || (0, import_core6.createLogger)({ level: "info", format: "pretty" });
|
|
3673
3906
|
this.cubeRegistry = new CubeRegistry();
|
|
3674
3907
|
if (config.cubes) {
|
|
3675
3908
|
this.cubeRegistry.registerAll(config.cubes);
|
|
@@ -3683,7 +3916,7 @@ var AnalyticsService = class {
|
|
|
3683
3916
|
this.getObjectFieldNames = config.getObjectFieldNames;
|
|
3684
3917
|
this.getObjectDatasource = config.getObjectDatasource;
|
|
3685
3918
|
this.isExternalObject = config.isExternalObject;
|
|
3686
|
-
this.debugSql = config.debugSql ?? (0,
|
|
3919
|
+
this.debugSql = config.debugSql ?? (0, import_core6.getEnv)("NODE_ENV") === "development";
|
|
3687
3920
|
if (config.datasets) {
|
|
3688
3921
|
for (const ds of config.datasets) {
|
|
3689
3922
|
try {
|
|
@@ -3702,6 +3935,16 @@ var AnalyticsService = class {
|
|
|
3702
3935
|
// Prefer a compiled dataset's declared relationships (D-C join allowlist);
|
|
3703
3936
|
// fall back to any explicitly-configured provider for legacy cubes.
|
|
3704
3937
|
getAllowedRelationships: (cubeName) => this.datasetRegistry.get(cubeName)?.allowedRelationships ?? config.getAllowedRelationships?.(cubeName),
|
|
3938
|
+
// [#10298] The compiled dataset's definition-level filter and its
|
|
3939
|
+
// per-measure filters — the half of the declaration the Cube model has
|
|
3940
|
+
// no room for. Same shape and same registry as `getAllowedRelationships`
|
|
3941
|
+
// directly above: answered for a cube that IS a compiled dataset,
|
|
3942
|
+
// `undefined` for every other cube.
|
|
3943
|
+
getDatasetScope: (cubeName) => {
|
|
3944
|
+
const compiled = this.datasetRegistry.get(cubeName);
|
|
3945
|
+
if (!compiled) return void 0;
|
|
3946
|
+
return { filter: compiled.filter, measureFilters: compiled.measureFilters };
|
|
3947
|
+
},
|
|
3705
3948
|
coerceTemporalFilterValue: config.coerceTemporalFilterValue,
|
|
3706
3949
|
coerceTemporalFilterColumn: config.coerceTemporalFilterColumn,
|
|
3707
3950
|
isExternalObject: config.isExternalObject
|
|
@@ -3948,11 +4191,11 @@ var AnalyticsService = class {
|
|
|
3948
4191
|
else if (rangeTz === "UTC") rangeDims.push({ d, granularity, instant: false });
|
|
3949
4192
|
}
|
|
3950
4193
|
if (rangeDims.length && result.rows.length) {
|
|
3951
|
-
const bound = (ymd, instant) => instant ? new Date((0,
|
|
4194
|
+
const bound = (ymd, instant) => instant ? new Date((0, import_core6.zonedDateStartToUtcMs)(ymd, rangeTz)).toISOString() : ymd;
|
|
3952
4195
|
result.drillRanges = result.rows.map((row) => {
|
|
3953
4196
|
const ranges = {};
|
|
3954
4197
|
for (const { d, granularity, instant } of rangeDims) {
|
|
3955
|
-
const cal = (0,
|
|
4198
|
+
const cal = (0, import_core6.bucketKeyToCalendarRange)(row[d.name], granularity);
|
|
3956
4199
|
if (cal) {
|
|
3957
4200
|
ranges[d.name] = { field: d.field, gte: bound(cal.start, instant), lt: bound(cal.end, instant) };
|
|
3958
4201
|
}
|
|
@@ -4003,7 +4246,7 @@ var AnalyticsService = class {
|
|
|
4003
4246
|
}
|
|
4004
4247
|
}
|
|
4005
4248
|
if (f.percentScale == null) {
|
|
4006
|
-
f.percentScale = m.derived?.op === "ratio" ? "fraction" : (0,
|
|
4249
|
+
f.percentScale = m.derived?.op === "ratio" ? "fraction" : (0, import_data6.percentScaleOf)(meta);
|
|
4007
4250
|
}
|
|
4008
4251
|
}
|
|
4009
4252
|
}
|