@objectstack/service-analytics 9.0.0 → 9.1.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 +238 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +26 -2
- package/dist/index.d.ts +26 -2
- package/dist/index.js +238 -9
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -1232,6 +1232,160 @@ function pickDisplayField(fields) {
|
|
|
1232
1232
|
return void 0;
|
|
1233
1233
|
}
|
|
1234
1234
|
|
|
1235
|
+
// src/preview-evaluator.ts
|
|
1236
|
+
function compare(a, b) {
|
|
1237
|
+
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
1238
|
+
return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0;
|
|
1239
|
+
}
|
|
1240
|
+
function matchOp(value, op, expected) {
|
|
1241
|
+
switch (op) {
|
|
1242
|
+
case "$eq":
|
|
1243
|
+
return value === expected || String(value) === String(expected);
|
|
1244
|
+
case "$ne":
|
|
1245
|
+
return !(value === expected || String(value) === String(expected));
|
|
1246
|
+
case "$gt":
|
|
1247
|
+
return value != null && compare(value, expected) > 0;
|
|
1248
|
+
case "$gte":
|
|
1249
|
+
return value != null && compare(value, expected) >= 0;
|
|
1250
|
+
case "$lt":
|
|
1251
|
+
return value != null && compare(value, expected) < 0;
|
|
1252
|
+
case "$lte":
|
|
1253
|
+
return value != null && compare(value, expected) <= 0;
|
|
1254
|
+
case "$in":
|
|
1255
|
+
return Array.isArray(expected) && expected.some((e) => value === e || String(value) === String(e));
|
|
1256
|
+
case "$nin":
|
|
1257
|
+
return Array.isArray(expected) && !expected.some((e) => value === e || String(value) === String(e));
|
|
1258
|
+
case "$contains":
|
|
1259
|
+
return String(value ?? "").toLowerCase().includes(String(expected ?? "").toLowerCase());
|
|
1260
|
+
default:
|
|
1261
|
+
return true;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
function matchesWhere(row, where) {
|
|
1265
|
+
if (!where) return true;
|
|
1266
|
+
for (const [key, cond] of Object.entries(where)) {
|
|
1267
|
+
if (key === "$and") {
|
|
1268
|
+
if (!cond.every((c) => matchesWhere(row, c))) return false;
|
|
1269
|
+
} else if (key === "$or") {
|
|
1270
|
+
if (!cond.some((c) => matchesWhere(row, c))) return false;
|
|
1271
|
+
} else if (key === "$not") {
|
|
1272
|
+
if (matchesWhere(row, cond)) return false;
|
|
1273
|
+
} else if (cond !== null && typeof cond === "object" && !Array.isArray(cond)) {
|
|
1274
|
+
for (const [op, expected] of Object.entries(cond)) {
|
|
1275
|
+
if (!matchOp(row[key], op, expected)) return false;
|
|
1276
|
+
}
|
|
1277
|
+
} else if (!(row[key] === cond || String(row[key]) === String(cond))) {
|
|
1278
|
+
return false;
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
return true;
|
|
1282
|
+
}
|
|
1283
|
+
function bucketDate(value, granularity) {
|
|
1284
|
+
const d = new Date(String(value));
|
|
1285
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
1286
|
+
const y = d.getUTCFullYear();
|
|
1287
|
+
const m = `${d.getUTCMonth() + 1}`.padStart(2, "0");
|
|
1288
|
+
const day = `${d.getUTCDate()}`.padStart(2, "0");
|
|
1289
|
+
switch (granularity) {
|
|
1290
|
+
case "year":
|
|
1291
|
+
return `${y}`;
|
|
1292
|
+
case "quarter":
|
|
1293
|
+
return `${y}-Q${Math.floor(d.getUTCMonth() / 3) + 1}`;
|
|
1294
|
+
case "month":
|
|
1295
|
+
return `${y}-${m}`;
|
|
1296
|
+
case "week": {
|
|
1297
|
+
const monday = new Date(d);
|
|
1298
|
+
const dow = (d.getUTCDay() + 6) % 7;
|
|
1299
|
+
monday.setUTCDate(d.getUTCDate() - dow);
|
|
1300
|
+
return monday.toISOString().slice(0, 10);
|
|
1301
|
+
}
|
|
1302
|
+
case "day":
|
|
1303
|
+
default:
|
|
1304
|
+
return `${y}-${m}-${day}`;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
function aggregate(rows, metricType, field) {
|
|
1308
|
+
if (metricType === "count" || field === "*") {
|
|
1309
|
+
if (metricType === "countDistinct") {
|
|
1310
|
+
return new Set(rows.map((r) => r[field]).filter((v) => v != null)).size;
|
|
1311
|
+
}
|
|
1312
|
+
return rows.length;
|
|
1313
|
+
}
|
|
1314
|
+
const nums = rows.map((r) => Number(r[field])).filter((n) => Number.isFinite(n));
|
|
1315
|
+
switch (metricType) {
|
|
1316
|
+
case "countDistinct":
|
|
1317
|
+
return new Set(rows.map((r) => r[field]).filter((v) => v != null)).size;
|
|
1318
|
+
case "sum":
|
|
1319
|
+
return nums.reduce((a, b) => a + b, 0);
|
|
1320
|
+
case "avg":
|
|
1321
|
+
return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : 0;
|
|
1322
|
+
case "min":
|
|
1323
|
+
return nums.length ? Math.min(...nums) : 0;
|
|
1324
|
+
case "max":
|
|
1325
|
+
return nums.length ? Math.max(...nums) : 0;
|
|
1326
|
+
default:
|
|
1327
|
+
return nums.length ? nums.reduce((a, b) => a + b, 0) : rows.length;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
function evaluateAnalyticsQueryOverRows(query, cube, rows) {
|
|
1331
|
+
let filtered = rows.filter((r) => matchesWhere(r, query.where));
|
|
1332
|
+
const timeDims = query.timeDimensions ?? [];
|
|
1333
|
+
for (const td of timeDims) {
|
|
1334
|
+
const dim = cube.dimensions?.[td.dimension];
|
|
1335
|
+
const field = String(dim?.sql ?? td.dimension);
|
|
1336
|
+
if (!td.dateRange) continue;
|
|
1337
|
+
const [start, end] = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
|
|
1338
|
+
filtered = filtered.filter((r) => {
|
|
1339
|
+
const v = String(r[field] ?? "");
|
|
1340
|
+
return v >= String(start) && v <= `${end}~`;
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
const dimensions = query.dimensions ?? [];
|
|
1344
|
+
const granByDim = new Map(timeDims.filter((t) => t.granularity).map((t) => [t.dimension, t.granularity]));
|
|
1345
|
+
const keyOf = (r) => {
|
|
1346
|
+
const values = {};
|
|
1347
|
+
for (const name of dimensions) {
|
|
1348
|
+
const dim = cube.dimensions?.[name];
|
|
1349
|
+
const field = String(dim?.sql ?? name);
|
|
1350
|
+
const raw = r[field];
|
|
1351
|
+
const gran = granByDim.get(name) ?? (dim?.type === "time" && dim.granularities?.length === 1 ? String(dim.granularities[0]) : void 0);
|
|
1352
|
+
values[name] = gran ? bucketDate(raw, gran) : raw ?? null;
|
|
1353
|
+
}
|
|
1354
|
+
return { key: JSON.stringify(values), values };
|
|
1355
|
+
};
|
|
1356
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1357
|
+
for (const r of filtered) {
|
|
1358
|
+
const { key, values } = keyOf(r);
|
|
1359
|
+
const g = groups.get(key) ?? { values, rows: [] };
|
|
1360
|
+
g.rows.push(r);
|
|
1361
|
+
groups.set(key, g);
|
|
1362
|
+
}
|
|
1363
|
+
if (dimensions.length === 0 && groups.size === 0) {
|
|
1364
|
+
groups.set("{}", { values: {}, rows: [] });
|
|
1365
|
+
}
|
|
1366
|
+
const out = [];
|
|
1367
|
+
for (const g of groups.values()) {
|
|
1368
|
+
const row = { ...g.values };
|
|
1369
|
+
for (const m of query.measures) {
|
|
1370
|
+
const metric = cube.measures?.[m];
|
|
1371
|
+
row[m] = aggregate(g.rows, String(metric?.type ?? "count"), String(metric?.sql ?? "*"));
|
|
1372
|
+
}
|
|
1373
|
+
out.push(row);
|
|
1374
|
+
}
|
|
1375
|
+
for (const [col, dir] of Object.entries(query.order ?? {}).reverse()) {
|
|
1376
|
+
out.sort((a, b) => (dir === "desc" ? -1 : 1) * compare(a[col], b[col]));
|
|
1377
|
+
}
|
|
1378
|
+
const offset = query.offset ?? 0;
|
|
1379
|
+
const limited = out.slice(offset, query.limit != null ? offset + query.limit : void 0);
|
|
1380
|
+
return {
|
|
1381
|
+
rows: limited,
|
|
1382
|
+
fields: [
|
|
1383
|
+
...dimensions.map((d) => ({ name: d, type: "string" })),
|
|
1384
|
+
...query.measures.map((m) => ({ name: m, type: "number" }))
|
|
1385
|
+
]
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1235
1389
|
// src/analytics-service.ts
|
|
1236
1390
|
var DEFAULT_CAPABILITIES = {
|
|
1237
1391
|
nativeSql: false,
|
|
@@ -1250,6 +1404,7 @@ var AnalyticsService = class {
|
|
|
1250
1404
|
this.readScopeProvider = config.getReadScope;
|
|
1251
1405
|
this.relationshipResolver = config.relationshipResolver;
|
|
1252
1406
|
this.labelResolver = config.labelResolver;
|
|
1407
|
+
this.draftRowsResolver = config.draftRowsResolver;
|
|
1253
1408
|
if (config.datasets) {
|
|
1254
1409
|
for (const ds of config.datasets) {
|
|
1255
1410
|
try {
|
|
@@ -1343,6 +1498,14 @@ var AnalyticsService = class {
|
|
|
1343
1498
|
}
|
|
1344
1499
|
/**
|
|
1345
1500
|
* Execute an analytical query by delegating to the first capable strategy.
|
|
1501
|
+
*
|
|
1502
|
+
* A strategy can discover only AT EXECUTION TIME that the underlying driver
|
|
1503
|
+
* cannot serve it — the canonical case is NativeSQLStrategy on an in-memory
|
|
1504
|
+
* driver, whose `execute()` returns null for raw SQL (the auto-bridge throws
|
|
1505
|
+
* `RAW_SQL_UNSUPPORTED`). That is a capability miss, not a query error: fall
|
|
1506
|
+
* back to the next capable strategy (e.g. ObjectQLStrategy over the
|
|
1507
|
+
* aggregate bridge) instead of failing — or worse, fabricating empty rows.
|
|
1508
|
+
* Any other error propagates untouched.
|
|
1346
1509
|
*/
|
|
1347
1510
|
async query(query, context) {
|
|
1348
1511
|
if (!query.cube) {
|
|
@@ -1350,9 +1513,23 @@ var AnalyticsService = class {
|
|
|
1350
1513
|
}
|
|
1351
1514
|
this.ensureCube(query);
|
|
1352
1515
|
const ctx = await this.callCtx(query, context);
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1516
|
+
let skip;
|
|
1517
|
+
for (; ; ) {
|
|
1518
|
+
const strategy = this.resolveStrategy(query, ctx, skip);
|
|
1519
|
+
this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
|
|
1520
|
+
try {
|
|
1521
|
+
return await strategy.execute(query, ctx);
|
|
1522
|
+
} catch (e) {
|
|
1523
|
+
if (e?.code === "RAW_SQL_UNSUPPORTED") {
|
|
1524
|
+
this.logger.warn(
|
|
1525
|
+
`[Analytics] ${strategy.name} cannot run on this driver (raw SQL unsupported) \u2014 falling back to the next strategy.`
|
|
1526
|
+
);
|
|
1527
|
+
(skip ?? (skip = /* @__PURE__ */ new Set())).add(strategy);
|
|
1528
|
+
continue;
|
|
1529
|
+
}
|
|
1530
|
+
throw e;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1356
1533
|
}
|
|
1357
1534
|
/**
|
|
1358
1535
|
* Compile a `dataset` (ADR-0021) and register its Cube + join allowlist so it
|
|
@@ -1371,9 +1548,25 @@ var AnalyticsService = class {
|
|
|
1371
1548
|
* runs the selection through the `DatasetExecutor` with the request context so
|
|
1372
1549
|
* tenant/RLS scoping (D-C) is applied. See {@link IAnalyticsService.queryDataset}.
|
|
1373
1550
|
*/
|
|
1374
|
-
async queryDataset(dataset, selection, context) {
|
|
1551
|
+
async queryDataset(dataset, selection, context, options) {
|
|
1375
1552
|
const compiled = this.registerDataset(dataset);
|
|
1376
1553
|
this.logger.debug(`[Analytics] queryDataset "${dataset.name}" (object=${dataset.object}, include=${(dataset.include ?? []).join(",") || "\u2014"})`);
|
|
1554
|
+
if (options?.previewDrafts && this.draftRowsResolver) {
|
|
1555
|
+
let seedRows = null;
|
|
1556
|
+
try {
|
|
1557
|
+
seedRows = await this.draftRowsResolver(dataset.object, context);
|
|
1558
|
+
} catch (e) {
|
|
1559
|
+
this.logger.warn(`[Analytics] draft preview resolver failed for "${dataset.object}" \u2014 falling back to live data: ${String(e?.message ?? e)}`);
|
|
1560
|
+
}
|
|
1561
|
+
if (seedRows) {
|
|
1562
|
+
this.logger.debug(`[Analytics] queryDataset "${dataset.name}" \u2192 preview over ${seedRows.length} drafted seed row(s)`);
|
|
1563
|
+
const previewService = {
|
|
1564
|
+
query: async (q) => evaluateAnalyticsQueryOverRows(q, compiled.cube, seedRows)
|
|
1565
|
+
};
|
|
1566
|
+
const previewResult = await new DatasetExecutor(previewService).execute(compiled, selection, context);
|
|
1567
|
+
return previewResult;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1377
1570
|
const result = await new DatasetExecutor(this).execute(compiled, selection, context);
|
|
1378
1571
|
if (this.labelResolver && selection.dimensions?.length) {
|
|
1379
1572
|
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 }));
|
|
@@ -1518,16 +1711,19 @@ var AnalyticsService = class {
|
|
|
1518
1711
|
};
|
|
1519
1712
|
}
|
|
1520
1713
|
/**
|
|
1521
|
-
* Walk the strategy chain and return the first strategy that can handle the
|
|
1714
|
+
* Walk the strategy chain and return the first strategy that can handle the
|
|
1715
|
+
* query. `skip` excludes strategies that already proved incapable at
|
|
1716
|
+
* execution time (see {@link query}'s RAW_SQL_UNSUPPORTED fallback).
|
|
1522
1717
|
*/
|
|
1523
|
-
resolveStrategy(query, ctx) {
|
|
1718
|
+
resolveStrategy(query, ctx, skip) {
|
|
1524
1719
|
for (const strategy of this.strategies) {
|
|
1720
|
+
if (skip?.has(strategy)) continue;
|
|
1525
1721
|
if (strategy.canHandle(query, ctx)) {
|
|
1526
1722
|
return strategy;
|
|
1527
1723
|
}
|
|
1528
1724
|
}
|
|
1529
1725
|
throw new Error(
|
|
1530
|
-
`[Analytics] No strategy can handle query for cube "${query.cube}". Checked: ${this.strategies.map((s) => s.name).join(", ")}. Ensure a compatible driver is configured or a fallback service is registered.`
|
|
1726
|
+
`[Analytics] No strategy can handle query for cube "${query.cube}". Checked: ${this.strategies.map((s) => s.name).join(", ")}${skip?.size ? ` (skipped at runtime: ${[...skip].map((s) => s.name).join(", ")})` : ""}. Ensure a compatible driver is configured or a fallback service is registered.`
|
|
1531
1727
|
);
|
|
1532
1728
|
}
|
|
1533
1729
|
};
|
|
@@ -1649,8 +1845,15 @@ var AnalyticsServicePlugin = class {
|
|
|
1649
1845
|
}
|
|
1650
1846
|
const knexSql = sql.replace(/\$(\d+)/g, "?");
|
|
1651
1847
|
const result = await engine.execute(knexSql, { args: params });
|
|
1848
|
+
if (result === null || result === void 0) {
|
|
1849
|
+
const err = new Error(
|
|
1850
|
+
`[Analytics] The "data" engine's driver returned null for raw SQL \u2014 this driver does not support SQL execution. The query will fall back to an aggregate-based strategy when one is available.`
|
|
1851
|
+
);
|
|
1852
|
+
err.code = "RAW_SQL_UNSUPPORTED";
|
|
1853
|
+
throw err;
|
|
1854
|
+
}
|
|
1652
1855
|
if (Array.isArray(result)) return result;
|
|
1653
|
-
if (
|
|
1856
|
+
if (typeof result === "object" && "rows" in result) {
|
|
1654
1857
|
return result.rows;
|
|
1655
1858
|
}
|
|
1656
1859
|
return [];
|
|
@@ -1719,6 +1922,31 @@ var AnalyticsServicePlugin = class {
|
|
|
1719
1922
|
return map;
|
|
1720
1923
|
}
|
|
1721
1924
|
};
|
|
1925
|
+
const draftRowsResolver = async (objectName) => {
|
|
1926
|
+
let protocol;
|
|
1927
|
+
try {
|
|
1928
|
+
protocol = ctx.getService("protocol");
|
|
1929
|
+
} catch {
|
|
1930
|
+
return null;
|
|
1931
|
+
}
|
|
1932
|
+
if (!protocol?.getMetaItems || !protocol.getMetaItem) return null;
|
|
1933
|
+
const res = await protocol.getMetaItems({ type: "seed", previewDrafts: true }).catch(() => null);
|
|
1934
|
+
const list = Array.isArray(res) ? res : res && typeof res === "object" && Array.isArray(res.items) ? res.items : [];
|
|
1935
|
+
const rows = [];
|
|
1936
|
+
let pending = false;
|
|
1937
|
+
for (const entry of list) {
|
|
1938
|
+
const body = entry?.item ?? entry;
|
|
1939
|
+
if (!body?.name || body.object !== objectName) continue;
|
|
1940
|
+
const draft = await protocol.getMetaItem({ type: "seed", name: body.name, state: "draft" }).catch(() => null);
|
|
1941
|
+
const draftBody = draft?.item;
|
|
1942
|
+
if (!draftBody) continue;
|
|
1943
|
+
pending = true;
|
|
1944
|
+
for (const r of Array.isArray(draftBody.records) ? draftBody.records : []) {
|
|
1945
|
+
if (r && typeof r === "object") rows.push(r);
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
return pending ? rows : null;
|
|
1949
|
+
};
|
|
1722
1950
|
const config = {
|
|
1723
1951
|
cubes: this.options.cubes,
|
|
1724
1952
|
logger: ctx.logger,
|
|
@@ -1729,7 +1957,8 @@ var AnalyticsServicePlugin = class {
|
|
|
1729
1957
|
getReadScope,
|
|
1730
1958
|
getAllowedRelationships: this.options.getAllowedRelationships,
|
|
1731
1959
|
relationshipResolver,
|
|
1732
|
-
labelResolver
|
|
1960
|
+
labelResolver,
|
|
1961
|
+
draftRowsResolver
|
|
1733
1962
|
};
|
|
1734
1963
|
if (autoBridgedReadScope) {
|
|
1735
1964
|
ctx.logger.info('[Analytics] Auto-bridged getReadScope \u2192 "security" service (getReadFilter)');
|