@objectstack/service-analytics 8.0.0 → 9.0.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/dist/index.cjs +215 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +99 -3
- package/dist/index.d.ts +99 -3
- package/dist/index.js +213 -9
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.cjs
CHANGED
|
@@ -31,6 +31,8 @@ __export(index_exports, {
|
|
|
31
31
|
compileScopedFilterToSql: () => compileScopedFilterToSql,
|
|
32
32
|
evaluateDerivedMeasures: () => evaluateDerivedMeasures,
|
|
33
33
|
mergeByDimensions: () => mergeByDimensions,
|
|
34
|
+
pickDisplayField: () => pickDisplayField,
|
|
35
|
+
resolveDimensionLabels: () => resolveDimensionLabels,
|
|
34
36
|
shiftRange: () => shiftRange
|
|
35
37
|
});
|
|
36
38
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -633,12 +635,22 @@ var ObjectQLStrategy = class {
|
|
|
633
635
|
async execute(query, ctx) {
|
|
634
636
|
const cube = ctx.getCube(query.cube);
|
|
635
637
|
const objectName = this.extractObjectName(cube);
|
|
638
|
+
const granByDim = /* @__PURE__ */ new Map();
|
|
639
|
+
for (const td of query.timeDimensions ?? []) {
|
|
640
|
+
if (td.granularity) granByDim.set(td.dimension, td.granularity);
|
|
641
|
+
}
|
|
636
642
|
const groupBy = [];
|
|
637
643
|
if (query.dimensions && query.dimensions.length > 0) {
|
|
638
644
|
for (const dim of query.dimensions) {
|
|
639
|
-
|
|
645
|
+
const field = this.resolveFieldName(cube, dim, "dimension");
|
|
646
|
+
const gran = granByDim.get(dim);
|
|
647
|
+
groupBy.push(gran ? { field, dateGranularity: gran } : field);
|
|
648
|
+
granByDim.delete(dim);
|
|
640
649
|
}
|
|
641
650
|
}
|
|
651
|
+
for (const [dim, gran] of granByDim) {
|
|
652
|
+
groupBy.push({ field: this.resolveFieldName(cube, dim, "dimension"), dateGranularity: gran });
|
|
653
|
+
}
|
|
642
654
|
const aggregations = [];
|
|
643
655
|
if (query.measures && query.measures.length > 0) {
|
|
644
656
|
for (const measure of query.measures) {
|
|
@@ -651,10 +663,16 @@ var ObjectQLStrategy = class {
|
|
|
651
663
|
if (normalizedFilters.length > 0) {
|
|
652
664
|
for (const f of normalizedFilters) {
|
|
653
665
|
const fieldName = this.resolveFieldName(cube, f.member, "any");
|
|
654
|
-
|
|
666
|
+
const converted = this.convertFilter(f.operator, f.values);
|
|
667
|
+
const existing = filter[fieldName];
|
|
668
|
+
const mergeable = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
669
|
+
filter[fieldName] = mergeable(existing) && mergeable(converted) ? { ...existing, ...converted } : converted;
|
|
655
670
|
}
|
|
656
671
|
}
|
|
657
672
|
const rows = await ctx.executeAggregate(objectName, {
|
|
673
|
+
// Structured groupBy items ({field, dateGranularity}) pass through the
|
|
674
|
+
// executeAggregate bridge to engine.aggregate, which buckets them. The
|
|
675
|
+
// contract types groupBy as string[]; the cast carries the richer shape.
|
|
658
676
|
groupBy: groupBy.length > 0 ? groupBy : void 0,
|
|
659
677
|
aggregations: aggregations.length > 0 ? aggregations : void 0,
|
|
660
678
|
filter: Object.keys(filter).length > 0 ? filter : void 0
|
|
@@ -1067,7 +1085,17 @@ var DatasetExecutor = class {
|
|
|
1067
1085
|
timezone: opts.selection.timezone ?? "UTC"
|
|
1068
1086
|
};
|
|
1069
1087
|
if (opts.where) q.where = opts.where;
|
|
1070
|
-
|
|
1088
|
+
const selTimeDims = opts.selection.timeDimensions ?? [];
|
|
1089
|
+
const selDims = new Set(selTimeDims.map((t) => t.dimension));
|
|
1090
|
+
const explicitTimeDims = [];
|
|
1091
|
+
for (const name of opts.dimensions) {
|
|
1092
|
+
const cd = compiled.cube.dimensions[name];
|
|
1093
|
+
if (cd?.type === "time" && cd.granularities?.length === 1 && !selDims.has(name)) {
|
|
1094
|
+
explicitTimeDims.push({ dimension: name, granularity: String(cd.granularities[0]) });
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
const mergedTimeDims = [...selTimeDims, ...explicitTimeDims];
|
|
1098
|
+
if (mergedTimeDims.length > 0) q.timeDimensions = mergedTimeDims;
|
|
1071
1099
|
if (opts.selection.order) q.order = opts.selection.order;
|
|
1072
1100
|
if (opts.selection.limit != null) q.limit = opts.selection.limit;
|
|
1073
1101
|
if (opts.selection.offset != null) q.offset = opts.selection.offset;
|
|
@@ -1122,6 +1150,88 @@ function mergeByDimensions(base, extra, dimensions, valueColumns) {
|
|
|
1122
1150
|
return base;
|
|
1123
1151
|
}
|
|
1124
1152
|
|
|
1153
|
+
// src/dimension-labels.ts
|
|
1154
|
+
var LOOKUP_TYPES = /* @__PURE__ */ new Set(["lookup", "master_detail"]);
|
|
1155
|
+
var pad = (n) => String(n).padStart(2, "0");
|
|
1156
|
+
function formatDateBucket(value, granularity) {
|
|
1157
|
+
if (value == null || value instanceof Date === false) {
|
|
1158
|
+
if (typeof value !== "number" && typeof value !== "string") return value;
|
|
1159
|
+
}
|
|
1160
|
+
let d;
|
|
1161
|
+
if (value instanceof Date) d = value;
|
|
1162
|
+
else if (typeof value === "number") d = new Date(value);
|
|
1163
|
+
else {
|
|
1164
|
+
const s = String(value).trim();
|
|
1165
|
+
d = /^\d+$/.test(s) ? new Date(Number(s) < 1e12 ? Number(s) * 1e3 : Number(s)) : new Date(s);
|
|
1166
|
+
}
|
|
1167
|
+
if (Number.isNaN(d.getTime())) return value;
|
|
1168
|
+
const y = d.getUTCFullYear();
|
|
1169
|
+
const m = d.getUTCMonth();
|
|
1170
|
+
switch (granularity) {
|
|
1171
|
+
case "year":
|
|
1172
|
+
return String(y);
|
|
1173
|
+
case "quarter":
|
|
1174
|
+
return `${y}-Q${Math.floor(m / 3) + 1}`;
|
|
1175
|
+
case "month":
|
|
1176
|
+
return `${y}-${pad(m + 1)}`;
|
|
1177
|
+
case "week":
|
|
1178
|
+
case "day":
|
|
1179
|
+
default:
|
|
1180
|
+
return `${y}-${pad(m + 1)}-${pad(d.getUTCDate())}`;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
async function resolveDimensionLabels(baseObject, dims, rows, deps) {
|
|
1184
|
+
if (!rows.length || !dims.length) return;
|
|
1185
|
+
const fields = deps.getObjectFields(baseObject);
|
|
1186
|
+
if (!fields) return;
|
|
1187
|
+
for (const dim of dims) {
|
|
1188
|
+
const meta = fields[dim.field];
|
|
1189
|
+
if (dim.type === "date" || meta && meta.type === "date") {
|
|
1190
|
+
for (const row of rows) {
|
|
1191
|
+
const formatted = formatDateBucket(row[dim.name], dim.dateGranularity);
|
|
1192
|
+
if (formatted != null) row[dim.name] = formatted;
|
|
1193
|
+
}
|
|
1194
|
+
continue;
|
|
1195
|
+
}
|
|
1196
|
+
if (!meta) continue;
|
|
1197
|
+
if (Array.isArray(meta.options) && meta.options.length > 0) {
|
|
1198
|
+
const labelByValue = /* @__PURE__ */ new Map();
|
|
1199
|
+
for (const opt of meta.options) {
|
|
1200
|
+
if (opt && opt.label != null) labelByValue.set(opt.value, String(opt.label));
|
|
1201
|
+
}
|
|
1202
|
+
if (labelByValue.size === 0) continue;
|
|
1203
|
+
for (const row of rows) {
|
|
1204
|
+
const raw = row[dim.name];
|
|
1205
|
+
const label = labelByValue.get(raw);
|
|
1206
|
+
if (label != null) row[dim.name] = label;
|
|
1207
|
+
}
|
|
1208
|
+
continue;
|
|
1209
|
+
}
|
|
1210
|
+
if (meta.type && LOOKUP_TYPES.has(meta.type) && meta.reference) {
|
|
1211
|
+
const ids = Array.from(
|
|
1212
|
+
new Set(rows.map((r) => r[dim.name]).filter((v) => v != null))
|
|
1213
|
+
);
|
|
1214
|
+
if (ids.length === 0) continue;
|
|
1215
|
+
const labelById = await deps.fetchRecordLabels(meta.reference, ids);
|
|
1216
|
+
if (!labelById || labelById.size === 0) continue;
|
|
1217
|
+
for (const row of rows) {
|
|
1218
|
+
const label = labelById.get(row[dim.name]);
|
|
1219
|
+
if (label != null) row[dim.name] = label;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
function pickDisplayField(fields) {
|
|
1225
|
+
if (!fields) return void 0;
|
|
1226
|
+
for (const preferred of ["name", "title", "label"]) {
|
|
1227
|
+
if (fields[preferred]) return preferred;
|
|
1228
|
+
}
|
|
1229
|
+
for (const [name, meta] of Object.entries(fields)) {
|
|
1230
|
+
if (meta.type === "text" || meta.type === "string") return name;
|
|
1231
|
+
}
|
|
1232
|
+
return void 0;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1125
1235
|
// src/analytics-service.ts
|
|
1126
1236
|
var DEFAULT_CAPABILITIES = {
|
|
1127
1237
|
nativeSql: false,
|
|
@@ -1139,6 +1249,7 @@ var AnalyticsService = class {
|
|
|
1139
1249
|
}
|
|
1140
1250
|
this.readScopeProvider = config.getReadScope;
|
|
1141
1251
|
this.relationshipResolver = config.relationshipResolver;
|
|
1252
|
+
this.labelResolver = config.labelResolver;
|
|
1142
1253
|
if (config.datasets) {
|
|
1143
1254
|
for (const ds of config.datasets) {
|
|
1144
1255
|
try {
|
|
@@ -1176,13 +1287,60 @@ var AnalyticsService = class {
|
|
|
1176
1287
|
* current request's ExecutionContext (ADR-0021 D-C). The strategy then sees a
|
|
1177
1288
|
* `getReadScope(objectName)` that already knows the active tenant.
|
|
1178
1289
|
*/
|
|
1179
|
-
callCtx(context) {
|
|
1290
|
+
async callCtx(query, context) {
|
|
1180
1291
|
if (!this.readScopeProvider) return this.baseCtx;
|
|
1292
|
+
const scopes = await this.resolveReadScopes(query, context);
|
|
1181
1293
|
return {
|
|
1182
1294
|
...this.baseCtx,
|
|
1183
|
-
getReadScope: (objectName) =>
|
|
1295
|
+
getReadScope: (objectName) => scopes.get(objectName) ?? null
|
|
1184
1296
|
};
|
|
1185
1297
|
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
|
|
1300
|
+
* AND every joined object of the query's cube, keyed by object name. This is
|
|
1301
|
+
* the async pre-pass that lets the synchronous strategy enforce scoping even
|
|
1302
|
+
* when the provider (security `getReadFilter`) resolves asynchronously.
|
|
1303
|
+
*
|
|
1304
|
+
* The object set is `cube.sql` (base) plus every `cube.joins[*].name` — a
|
|
1305
|
+
* SUPERSET of what the strategy actually scans (the strategy only joins along
|
|
1306
|
+
* declared relationships), so no scanned object is ever left unscoped.
|
|
1307
|
+
*
|
|
1308
|
+
* Fail-closed: if the provider throws for an object, the whole query is
|
|
1309
|
+
* rejected rather than emitting SQL with that object unscoped.
|
|
1310
|
+
*/
|
|
1311
|
+
async resolveReadScopes(query, context) {
|
|
1312
|
+
const map = /* @__PURE__ */ new Map();
|
|
1313
|
+
const provider = this.readScopeProvider;
|
|
1314
|
+
if (!provider || !query.cube) return map;
|
|
1315
|
+
const cube = this.cubeRegistry.get(query.cube);
|
|
1316
|
+
if (!cube) return map;
|
|
1317
|
+
const objects = /* @__PURE__ */ new Set();
|
|
1318
|
+
if (typeof cube.sql === "string" && cube.sql.trim()) {
|
|
1319
|
+
objects.add(cube.sql.trim());
|
|
1320
|
+
}
|
|
1321
|
+
const joins = cube.joins;
|
|
1322
|
+
if (joins) {
|
|
1323
|
+
for (const [alias, j] of Object.entries(joins)) {
|
|
1324
|
+
objects.add(j?.name ?? alias);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
for (const object of objects) {
|
|
1328
|
+
let filter;
|
|
1329
|
+
try {
|
|
1330
|
+
filter = await provider(object, context);
|
|
1331
|
+
} catch (e) {
|
|
1332
|
+
this.logger.error?.(
|
|
1333
|
+
`[Analytics] read-scope resolution failed for object "${object}" \u2014 rejecting query (fail-closed, ADR-0021 D-C)`,
|
|
1334
|
+
e instanceof Error ? e : new Error(String(e))
|
|
1335
|
+
);
|
|
1336
|
+
throw new Error(
|
|
1337
|
+
`[Analytics] read-scope resolution failed for "${object}"; query denied (fail-closed).`
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
1340
|
+
if (filter != null) map.set(object, filter);
|
|
1341
|
+
}
|
|
1342
|
+
return map;
|
|
1343
|
+
}
|
|
1186
1344
|
/**
|
|
1187
1345
|
* Execute an analytical query by delegating to the first capable strategy.
|
|
1188
1346
|
*/
|
|
@@ -1191,7 +1349,7 @@ var AnalyticsService = class {
|
|
|
1191
1349
|
throw new Error("Cube name is required in analytics query");
|
|
1192
1350
|
}
|
|
1193
1351
|
this.ensureCube(query);
|
|
1194
|
-
const ctx = this.callCtx(context);
|
|
1352
|
+
const ctx = await this.callCtx(query, context);
|
|
1195
1353
|
const strategy = this.resolveStrategy(query, ctx);
|
|
1196
1354
|
this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
|
|
1197
1355
|
return strategy.execute(query, ctx);
|
|
@@ -1216,7 +1374,27 @@ var AnalyticsService = class {
|
|
|
1216
1374
|
async queryDataset(dataset, selection, context) {
|
|
1217
1375
|
const compiled = this.registerDataset(dataset);
|
|
1218
1376
|
this.logger.debug(`[Analytics] queryDataset "${dataset.name}" (object=${dataset.object}, include=${(dataset.include ?? []).join(",") || "\u2014"})`);
|
|
1219
|
-
|
|
1377
|
+
const result = await new DatasetExecutor(this).execute(compiled, selection, context);
|
|
1378
|
+
if (this.labelResolver && selection.dimensions?.length) {
|
|
1379
|
+
const dims = selection.dimensions.map((name) => dataset.dimensions?.find((d) => d.name === name)).filter((d) => !!d?.field).map((d) => ({ name: d.name, field: d.field, type: d.type, dateGranularity: d.dateGranularity }));
|
|
1380
|
+
if (dims.length) {
|
|
1381
|
+
try {
|
|
1382
|
+
await resolveDimensionLabels(dataset.object, dims, result.rows, this.labelResolver);
|
|
1383
|
+
} catch (e) {
|
|
1384
|
+
this.logger?.warn?.(`[Analytics] dimension label resolution failed for "${dataset.name}": ${String(e?.message ?? e)}`);
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
if (result.fields?.length && dataset.measures?.length) {
|
|
1389
|
+
const measureByName = new Map(dataset.measures.map((m) => [m.name, m]));
|
|
1390
|
+
for (const f of result.fields) {
|
|
1391
|
+
const m = measureByName.get(f.name) ?? measureByName.get(f.name.replace(/__compare$/, ""));
|
|
1392
|
+
if (!m) continue;
|
|
1393
|
+
if (f.label == null && typeof m.label === "string") f.label = m.label;
|
|
1394
|
+
if (f.format == null && m.format) f.format = m.format;
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
return result;
|
|
1220
1398
|
}
|
|
1221
1399
|
/**
|
|
1222
1400
|
* Get cube metadata for discovery.
|
|
@@ -1246,7 +1424,7 @@ var AnalyticsService = class {
|
|
|
1246
1424
|
throw new Error("Cube name is required for SQL generation");
|
|
1247
1425
|
}
|
|
1248
1426
|
this.ensureCube(query);
|
|
1249
|
-
const ctx = this.callCtx(context);
|
|
1427
|
+
const ctx = await this.callCtx(query, context);
|
|
1250
1428
|
const strategy = this.resolveStrategy(query, ctx);
|
|
1251
1429
|
this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" \u2192 ${strategy.name}`);
|
|
1252
1430
|
return strategy.generateSql(query, ctx);
|
|
@@ -1516,6 +1694,31 @@ var AnalyticsServicePlugin = class {
|
|
|
1516
1694
|
}
|
|
1517
1695
|
return engine ? void 0 : relationshipName;
|
|
1518
1696
|
};
|
|
1697
|
+
const dataEngine = () => {
|
|
1698
|
+
try {
|
|
1699
|
+
const svc = ctx.getService("data");
|
|
1700
|
+
return svc && typeof svc.getObject === "function" ? svc : void 0;
|
|
1701
|
+
} catch {
|
|
1702
|
+
return void 0;
|
|
1703
|
+
}
|
|
1704
|
+
};
|
|
1705
|
+
const labelResolver = {
|
|
1706
|
+
getObjectFields: (objectName) => dataEngine()?.getObject?.(objectName)?.fields,
|
|
1707
|
+
fetchRecordLabels: async (targetObject, ids) => {
|
|
1708
|
+
const map = /* @__PURE__ */ new Map();
|
|
1709
|
+
const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
|
|
1710
|
+
if (!displayField || !executeAggregate || ids.length === 0) return map;
|
|
1711
|
+
const rows = await executeAggregate(targetObject, {
|
|
1712
|
+
groupBy: ["id", displayField],
|
|
1713
|
+
aggregations: [{ field: "id", method: "count", alias: "_c" }],
|
|
1714
|
+
filter: { id: { $in: ids } }
|
|
1715
|
+
});
|
|
1716
|
+
for (const r of rows) {
|
|
1717
|
+
if (r.id != null && r[displayField] != null) map.set(r.id, String(r[displayField]));
|
|
1718
|
+
}
|
|
1719
|
+
return map;
|
|
1720
|
+
}
|
|
1721
|
+
};
|
|
1519
1722
|
const config = {
|
|
1520
1723
|
cubes: this.options.cubes,
|
|
1521
1724
|
logger: ctx.logger,
|
|
@@ -1525,7 +1728,8 @@ var AnalyticsServicePlugin = class {
|
|
|
1525
1728
|
fallbackService,
|
|
1526
1729
|
getReadScope,
|
|
1527
1730
|
getAllowedRelationships: this.options.getAllowedRelationships,
|
|
1528
|
-
relationshipResolver
|
|
1731
|
+
relationshipResolver,
|
|
1732
|
+
labelResolver
|
|
1529
1733
|
};
|
|
1530
1734
|
if (autoBridgedReadScope) {
|
|
1531
1735
|
ctx.logger.info('[Analytics] Auto-bridged getReadScope \u2192 "security" service (getReadFilter)');
|
|
@@ -1577,6 +1781,8 @@ var AnalyticsServicePlugin = class {
|
|
|
1577
1781
|
compileScopedFilterToSql,
|
|
1578
1782
|
evaluateDerivedMeasures,
|
|
1579
1783
|
mergeByDimensions,
|
|
1784
|
+
pickDisplayField,
|
|
1785
|
+
resolveDimensionLabels,
|
|
1580
1786
|
shiftRange
|
|
1581
1787
|
});
|
|
1582
1788
|
//# sourceMappingURL=index.cjs.map
|