@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 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
- groupBy.push(this.resolveFieldName(cube, dim, "dimension"));
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
- filter[fieldName] = this.convertFilter(f.operator, f.values);
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
- if (opts.selection.timeDimensions) q.timeDimensions = opts.selection.timeDimensions;
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) => this.readScopeProvider(objectName, context)
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
- return new DatasetExecutor(this).execute(compiled, selection, context);
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