@objectstack/service-analytics 8.0.1 → 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 {
@@ -1263,7 +1374,27 @@ var AnalyticsService = class {
1263
1374
  async queryDataset(dataset, selection, context) {
1264
1375
  const compiled = this.registerDataset(dataset);
1265
1376
  this.logger.debug(`[Analytics] queryDataset "${dataset.name}" (object=${dataset.object}, include=${(dataset.include ?? []).join(",") || "\u2014"})`);
1266
- 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;
1267
1398
  }
1268
1399
  /**
1269
1400
  * Get cube metadata for discovery.
@@ -1563,6 +1694,31 @@ var AnalyticsServicePlugin = class {
1563
1694
  }
1564
1695
  return engine ? void 0 : relationshipName;
1565
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
+ };
1566
1722
  const config = {
1567
1723
  cubes: this.options.cubes,
1568
1724
  logger: ctx.logger,
@@ -1572,7 +1728,8 @@ var AnalyticsServicePlugin = class {
1572
1728
  fallbackService,
1573
1729
  getReadScope,
1574
1730
  getAllowedRelationships: this.options.getAllowedRelationships,
1575
- relationshipResolver
1731
+ relationshipResolver,
1732
+ labelResolver
1576
1733
  };
1577
1734
  if (autoBridgedReadScope) {
1578
1735
  ctx.logger.info('[Analytics] Auto-bridged getReadScope \u2192 "security" service (getReadFilter)');
@@ -1624,6 +1781,8 @@ var AnalyticsServicePlugin = class {
1624
1781
  compileScopedFilterToSql,
1625
1782
  evaluateDerivedMeasures,
1626
1783
  mergeByDimensions,
1784
+ pickDisplayField,
1785
+ resolveDimensionLabels,
1627
1786
  shiftRange
1628
1787
  });
1629
1788
  //# sourceMappingURL=index.cjs.map