@objectstack/service-analytics 4.0.4 → 4.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 CHANGED
@@ -146,6 +146,92 @@ var CubeRegistry = class {
146
146
  }
147
147
  };
148
148
 
149
+ // src/strategies/filter-normalizer.ts
150
+ var MONGO_TO_CUBE_OP = {
151
+ $eq: "equals",
152
+ $ne: "notEquals",
153
+ $gt: "gt",
154
+ $gte: "gte",
155
+ $lt: "lt",
156
+ $lte: "lte",
157
+ $in: "in",
158
+ $nin: "notIn",
159
+ $contains: "contains",
160
+ $notContains: "notContains",
161
+ $exists: "set"
162
+ };
163
+ function stringifyForCube(v) {
164
+ if (v == null) return "";
165
+ if (typeof v === "boolean") return v ? "1" : "0";
166
+ if (v instanceof Date) return v.toISOString();
167
+ if (typeof v === "object") return JSON.stringify(v);
168
+ return String(v);
169
+ }
170
+ function flattenCondition(cond, out) {
171
+ for (const [key, raw] of Object.entries(cond)) {
172
+ if (raw === void 0) continue;
173
+ if (key === "$and" && Array.isArray(raw)) {
174
+ for (const sub of raw) {
175
+ if (sub && typeof sub === "object") {
176
+ flattenCondition(sub, out);
177
+ }
178
+ }
179
+ continue;
180
+ }
181
+ if (key === "$or" || key === "$not") continue;
182
+ if (raw === null) {
183
+ out.push({ member: key, operator: "notSet", values: [] });
184
+ continue;
185
+ }
186
+ if (typeof raw === "object" && !Array.isArray(raw) && !(raw instanceof Date)) {
187
+ const wrapper = raw;
188
+ const opKeys = Object.keys(wrapper).filter((k) => k.startsWith("$"));
189
+ if (opKeys.length > 0) {
190
+ for (const opKey of opKeys) {
191
+ const cubeOp = MONGO_TO_CUBE_OP[opKey];
192
+ if (!cubeOp) continue;
193
+ const v = wrapper[opKey];
194
+ const values = Array.isArray(v) ? v.map(stringifyForCube) : [stringifyForCube(v)];
195
+ out.push({ member: key, operator: cubeOp, values });
196
+ }
197
+ continue;
198
+ }
199
+ for (const [nestedKey, nestedVal] of Object.entries(wrapper)) {
200
+ flattenCondition({ [`${key}.${nestedKey}`]: nestedVal }, out);
201
+ }
202
+ continue;
203
+ }
204
+ if (Array.isArray(raw)) {
205
+ out.push({ member: key, operator: "in", values: raw.map(stringifyForCube) });
206
+ } else {
207
+ out.push({ member: key, operator: "equals", values: [stringifyForCube(raw)] });
208
+ }
209
+ }
210
+ }
211
+ function normalizeAnalyticsFilters(query) {
212
+ if (!query || typeof query !== "object") return [];
213
+ const out = [];
214
+ const where = query.where;
215
+ if (where && typeof where === "object" && !Array.isArray(where)) {
216
+ flattenCondition(where, out);
217
+ }
218
+ return out;
219
+ }
220
+ function coerceFilterValueForSql(s) {
221
+ if (s === "true") return 1;
222
+ if (s === "false") return 0;
223
+ if (s === "null") return null;
224
+ if (/^-?\d+$/.test(s)) {
225
+ const n = Number(s);
226
+ if (Number.isFinite(n)) return n;
227
+ }
228
+ if (/^-?\d+\.\d+$/.test(s)) {
229
+ const n = Number(s);
230
+ if (Number.isFinite(n)) return n;
231
+ }
232
+ return s;
233
+ }
234
+
149
235
  // src/strategies/native-sql-strategy.ts
150
236
  var NativeSQLStrategy = class {
151
237
  constructor() {
@@ -173,30 +259,33 @@ var NativeSQLStrategy = class {
173
259
  const params = [];
174
260
  const selectClauses = [];
175
261
  const groupByClauses = [];
262
+ const tableName = this.extractObjectName(cube);
263
+ const joins = /* @__PURE__ */ new Map();
176
264
  if (query.dimensions && query.dimensions.length > 0) {
177
265
  for (const dim of query.dimensions) {
178
- const colExpr = this.resolveDimensionSql(cube, dim);
266
+ const colExpr = this.resolveDimensionSql(cube, dim, tableName, joins);
179
267
  selectClauses.push(`${colExpr} AS "${dim}"`);
180
268
  groupByClauses.push(colExpr);
181
269
  }
182
270
  }
183
271
  if (query.measures && query.measures.length > 0) {
184
272
  for (const measure of query.measures) {
185
- const aggExpr = this.resolveMeasureSql(cube, measure);
273
+ const aggExpr = this.resolveMeasureSql(cube, measure, tableName, joins);
186
274
  selectClauses.push(`${aggExpr} AS "${measure}"`);
187
275
  }
188
276
  }
189
277
  const whereClauses = [];
190
- if (query.filters && query.filters.length > 0) {
191
- for (const filter of query.filters) {
192
- const colExpr = this.resolveFieldSql(cube, filter.member);
278
+ const normalizedFilters = normalizeAnalyticsFilters(query);
279
+ if (normalizedFilters.length > 0) {
280
+ for (const filter of normalizedFilters) {
281
+ const colExpr = this.resolveFieldSql(cube, filter.member, tableName, joins);
193
282
  const clause = this.buildFilterClause(colExpr, filter.operator, filter.values, params);
194
283
  if (clause) whereClauses.push(clause);
195
284
  }
196
285
  }
197
286
  if (query.timeDimensions && query.timeDimensions.length > 0) {
198
287
  for (const td of query.timeDimensions) {
199
- const colExpr = this.resolveFieldSql(cube, td.dimension);
288
+ const colExpr = this.resolveFieldSql(cube, td.dimension, tableName, joins);
200
289
  if (td.dateRange) {
201
290
  const range = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
202
291
  if (range.length === 2) {
@@ -206,8 +295,10 @@ var NativeSQLStrategy = class {
206
295
  }
207
296
  }
208
297
  }
209
- const tableName = this.extractObjectName(cube);
210
298
  let sql = `SELECT ${selectClauses.join(", ")} FROM "${tableName}"`;
299
+ if (joins.size > 0) {
300
+ sql += " " + Array.from(joins.values()).join(" ");
301
+ }
211
302
  if (whereClauses.length > 0) {
212
303
  sql += ` WHERE ${whereClauses.join(" AND ")}`;
213
304
  }
@@ -227,16 +318,77 @@ var NativeSQLStrategy = class {
227
318
  return { sql, params };
228
319
  }
229
320
  // ── Helpers ──────────────────────────────────────────────────────
230
- resolveDimensionSql(cube, member) {
231
- const fieldName = member.includes(".") ? member.split(".")[1] : member;
232
- const dim = cube.dimensions[fieldName];
233
- return dim ? dim.sql : fieldName;
321
+ /**
322
+ * Resolve a dimension/measure/filter SQL expression that may reference a
323
+ * related table via dot notation (e.g. `account.industry`).
324
+ *
325
+ * When the resolved `sql` contains a dot, treat the prefix as a lookup
326
+ * field on the cube's table and synthesise a `LEFT JOIN` against the
327
+ * related table. The convention (matching the auto-cube generator and
328
+ * ObjectStack object schemas) is:
329
+ *
330
+ * <parentTable>.<lookupField> = <lookupField>.id
331
+ *
332
+ * i.e. the lookup field name on the parent table equals the related
333
+ * table name. This holds for all `Field.lookup({ object: '...' })`
334
+ * declarations where the field is named after its target object.
335
+ *
336
+ * Returns the qualified SQL reference (e.g. `"account"."industry"`).
337
+ * Pure column references (no dot) are returned as-is.
338
+ */
339
+ qualifyAndRegisterJoin(rawSql, parentTable, joins) {
340
+ if (!rawSql.includes(".")) return rawSql;
341
+ const [alias, ...rest] = rawSql.split(".");
342
+ if (!alias || rest.length === 0) return rawSql;
343
+ const column = rest.join(".");
344
+ if (!joins.has(alias)) {
345
+ joins.set(
346
+ alias,
347
+ `LEFT JOIN "${alias}" ON "${parentTable}"."${alias}" = "${alias}"."id"`
348
+ );
349
+ }
350
+ return `"${alias}"."${column}"`;
234
351
  }
235
- resolveMeasureSql(cube, member) {
236
- const fieldName = member.includes(".") ? member.split(".")[1] : member;
237
- const measure = cube.measures[fieldName];
352
+ /**
353
+ * Resolve a member reference (dimension, measure, or filter field) to its
354
+ * cube definition.
355
+ *
356
+ * Accepts three naming conventions:
357
+ * 1. `<cube>.<field>` — the canonical analytics qualifier (stripped to `<field>`).
358
+ * 2. `<lookup>.<field>` — a relation traversal (e.g. `account.industry`).
359
+ * First tried as the literal key, then as the underscore-flattened
360
+ * key (`account_industry`), and finally returned as a synthetic
361
+ * definition whose `sql` is the dotted reference so the JOIN
362
+ * machinery can pick it up.
363
+ * 3. `<field>` — a bare field name on the cube's table.
364
+ */
365
+ lookupMember(cube, member, kind) {
366
+ const bag = kind === "dimension" ? cube.dimensions : cube.measures;
367
+ if (bag[member]) return bag[member];
368
+ if (member.includes(".")) {
369
+ const [first, ...rest] = member.split(".");
370
+ const tail = rest.join(".");
371
+ if (first === cube.name && bag[tail]) return bag[tail];
372
+ if (bag[tail]) return bag[tail];
373
+ const flat = member.replace(/\./g, "_");
374
+ if (bag[flat]) return bag[flat];
375
+ if (kind === "dimension") {
376
+ return { sql: member, type: "string" };
377
+ }
378
+ } else if (bag[member]) {
379
+ return bag[member];
380
+ }
381
+ return void 0;
382
+ }
383
+ resolveDimensionSql(cube, member, parentTable, joins) {
384
+ const dim = this.lookupMember(cube, member, "dimension");
385
+ const raw = dim ? dim.sql : member.includes(".") ? member.split(".")[1] : member;
386
+ return this.qualifyAndRegisterJoin(raw, parentTable, joins);
387
+ }
388
+ resolveMeasureSql(cube, member, parentTable, joins) {
389
+ const measure = this.lookupMember(cube, member, "measure");
238
390
  if (!measure) return `COUNT(*)`;
239
- const col = measure.sql;
391
+ const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins);
240
392
  switch (measure.type) {
241
393
  case "count":
242
394
  return "COUNT(*)";
@@ -254,12 +406,12 @@ var NativeSQLStrategy = class {
254
406
  return `COUNT(*)`;
255
407
  }
256
408
  }
257
- resolveFieldSql(cube, member) {
409
+ resolveFieldSql(cube, member, parentTable, joins) {
410
+ const dim = this.lookupMember(cube, member, "dimension");
411
+ if (dim) return this.qualifyAndRegisterJoin(dim.sql, parentTable, joins);
412
+ const measure = this.lookupMember(cube, member, "measure");
413
+ if (measure) return this.qualifyAndRegisterJoin(measure.sql, parentTable, joins);
258
414
  const fieldName = member.includes(".") ? member.split(".")[1] : member;
259
- const dim = cube.dimensions[fieldName];
260
- if (dim) return dim.sql;
261
- const measure = cube.measures[fieldName];
262
- if (measure) return measure.sql;
263
415
  return fieldName;
264
416
  }
265
417
  buildFilterClause(col, operator, values, params) {
@@ -275,12 +427,20 @@ var NativeSQLStrategy = class {
275
427
  };
276
428
  if (operator === "set") return `${col} IS NOT NULL`;
277
429
  if (operator === "notSet") return `${col} IS NULL`;
430
+ if (operator === "in" || operator === "notIn") {
431
+ if (!values || values.length === 0) return null;
432
+ const placeholders = values.map((v) => {
433
+ params.push(coerceFilterValueForSql(v));
434
+ return `$${params.length}`;
435
+ }).join(", ");
436
+ return `${col} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
437
+ }
278
438
  const sqlOp = opMap[operator];
279
439
  if (!sqlOp || !values || values.length === 0) return null;
280
440
  if (operator === "contains" || operator === "notContains") {
281
441
  params.push(`%${values[0]}%`);
282
442
  } else {
283
- params.push(values[0]);
443
+ params.push(coerceFilterValueForSql(values[0]));
284
444
  }
285
445
  return `${col} ${sqlOp} $${params.length}`;
286
446
  }
@@ -291,8 +451,7 @@ var NativeSQLStrategy = class {
291
451
  const fields = [];
292
452
  if (query.dimensions) {
293
453
  for (const dim of query.dimensions) {
294
- const fieldName = dim.includes(".") ? dim.split(".")[1] : dim;
295
- const d = cube.dimensions[fieldName];
454
+ const d = this.lookupMember(cube, dim, "dimension");
296
455
  fields.push({ name: dim, type: d?.type || "string" });
297
456
  }
298
457
  }
@@ -333,8 +492,9 @@ var ObjectQLStrategy = class {
333
492
  }
334
493
  }
335
494
  const filter = {};
336
- if (query.filters && query.filters.length > 0) {
337
- for (const f of query.filters) {
495
+ const normalizedFilters = normalizeAnalyticsFilters(query);
496
+ if (normalizedFilters.length > 0) {
497
+ for (const f of normalizedFilters) {
338
498
  const fieldName = this.resolveFieldName(cube, f.member, "any");
339
499
  filter[fieldName] = this.convertFilter(f.operator, f.values);
340
500
  }
@@ -391,48 +551,97 @@ var ObjectQLStrategy = class {
391
551
  return { sql, params: [] };
392
552
  }
393
553
  // ── Helpers ──────────────────────────────────────────────────────
554
+ /**
555
+ * Resolve a member ref to a `{ sql, type? }` definition.
556
+ *
557
+ * Mirrors `NativeSQLStrategy.lookupMember` so the two strategies
558
+ * accept the same naming conventions:
559
+ * 1. `<cube>.<field>` — canonical analytics qualifier.
560
+ * 2. `<lookup>.<field>` — relation traversal (e.g. `account.industry`).
561
+ * Tries literal key, then underscore-flattened key, then falls
562
+ * back to a synthetic dim whose `sql` is the dotted path so the
563
+ * ObjectQL aggregate engine can traverse it via the lookup field.
564
+ * 3. `<field>` — bare column on the cube's table.
565
+ */
566
+ lookupMember(cube, member, kind) {
567
+ const bag = kind === "dimension" ? cube.dimensions : cube.measures;
568
+ if (bag[member]) return bag[member];
569
+ if (member.includes(".")) {
570
+ const [first, ...rest] = member.split(".");
571
+ const tail = rest.join(".");
572
+ if (first === cube.name && bag[tail]) return bag[tail];
573
+ if (bag[tail]) return bag[tail];
574
+ const flat = member.replace(/\./g, "_");
575
+ if (bag[flat]) return bag[flat];
576
+ if (kind === "dimension") return { sql: member, type: "string" };
577
+ } else if (bag[member]) {
578
+ return bag[member];
579
+ }
580
+ return void 0;
581
+ }
394
582
  resolveFieldName(cube, member, kind) {
395
- const fieldName = member.includes(".") ? member.split(".")[1] : member;
396
583
  if (kind === "dimension" || kind === "any") {
397
- const dim = cube.dimensions[fieldName];
584
+ const dim = this.lookupMember(cube, member, "dimension");
398
585
  if (dim) return dim.sql.replace(/^\$/, "");
399
586
  }
400
587
  if (kind === "measure" || kind === "any") {
401
- const measure = cube.measures[fieldName];
588
+ const measure = this.lookupMember(cube, member, "measure");
402
589
  if (measure) return measure.sql.replace(/^\$/, "");
403
590
  }
404
- return fieldName;
591
+ return member.includes(".") ? member.split(".")[1] : member;
405
592
  }
406
593
  resolveMeasureAggregation(cube, measureName) {
594
+ const direct = this.lookupMember(cube, measureName, "measure");
595
+ if (direct) {
596
+ return {
597
+ field: direct.sql.replace(/^\$/, ""),
598
+ method: direct.type === "count_distinct" ? "count_distinct" : direct.type
599
+ };
600
+ }
407
601
  const fieldName = measureName.includes(".") ? measureName.split(".")[1] : measureName;
408
- const measure = cube.measures[fieldName];
409
- if (!measure) return { field: "*", method: "count" };
410
- return {
411
- field: measure.sql.replace(/^\$/, ""),
412
- method: measure.type === "count_distinct" ? "count_distinct" : measure.type
413
- };
602
+ const aggTypes = ["count", "sum", "avg", "min", "max", "count_distinct"];
603
+ for (const type of aggTypes) {
604
+ const suffix = `_${type}`;
605
+ if (fieldName.endsWith(suffix)) {
606
+ const baseField = fieldName.slice(0, -suffix.length);
607
+ const candidate = cube.measures[baseField];
608
+ if (candidate && candidate.type === type) {
609
+ return {
610
+ field: candidate.sql.replace(/^\$/, ""),
611
+ method: candidate.type === "count_distinct" ? "count_distinct" : candidate.type
612
+ };
613
+ }
614
+ }
615
+ }
616
+ return { field: "*", method: "count" };
414
617
  }
415
618
  convertFilter(operator, values) {
416
619
  if (operator === "set") return { $ne: null };
417
620
  if (operator === "notSet") return null;
418
621
  if (!values || values.length === 0) return void 0;
622
+ const v0 = coerceFilterValueForSql(values[0]);
623
+ const all = values.map(coerceFilterValueForSql);
419
624
  switch (operator) {
420
625
  case "equals":
421
- return values[0];
626
+ return v0;
422
627
  case "notEquals":
423
- return { $ne: values[0] };
628
+ return { $ne: v0 };
424
629
  case "gt":
425
- return { $gt: values[0] };
630
+ return { $gt: v0 };
426
631
  case "gte":
427
- return { $gte: values[0] };
632
+ return { $gte: v0 };
428
633
  case "lt":
429
- return { $lt: values[0] };
634
+ return { $lt: v0 };
430
635
  case "lte":
431
- return { $lte: values[0] };
636
+ return { $lte: v0 };
432
637
  case "contains":
433
638
  return { $regex: values[0] };
639
+ case "in":
640
+ return { $in: all };
641
+ case "notIn":
642
+ return { $nin: all };
434
643
  default:
435
- return values[0];
644
+ return v0;
436
645
  }
437
646
  }
438
647
  extractObjectName(cube) {
@@ -442,8 +651,7 @@ var ObjectQLStrategy = class {
442
651
  const fields = [];
443
652
  if (query.dimensions) {
444
653
  for (const dim of query.dimensions) {
445
- const fieldName = dim.includes(".") ? dim.split(".")[1] : dim;
446
- const d = cube.dimensions[fieldName];
654
+ const d = this.lookupMember(cube, dim, "dimension");
447
655
  fields.push({ name: dim, type: d?.type || "string" });
448
656
  }
449
657
  }
@@ -496,6 +704,7 @@ var AnalyticsService = class {
496
704
  if (!query.cube) {
497
705
  throw new Error("Cube name is required in analytics query");
498
706
  }
707
+ this.ensureCube(query);
499
708
  const strategy = this.resolveStrategy(query);
500
709
  this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
501
710
  return strategy.execute(query, this.strategyCtx);
@@ -527,11 +736,99 @@ var AnalyticsService = class {
527
736
  if (!query.cube) {
528
737
  throw new Error("Cube name is required for SQL generation");
529
738
  }
739
+ this.ensureCube(query);
530
740
  const strategy = this.resolveStrategy(query);
531
741
  this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" \u2192 ${strategy.name}`);
532
742
  return strategy.generateSql(query, this.strategyCtx);
533
743
  }
534
744
  // ── Internal ─────────────────────────────────────────────────────
745
+ /**
746
+ * Ensure a cube exists for the given query and that it knows about every
747
+ * measure referenced by the query.
748
+ *
749
+ * - If no cube is registered for `query.cube`, infer a minimal cube from
750
+ * the query so downstream strategies (which assume `cube.sql` exists)
751
+ * don't crash.
752
+ * - If a cube exists but the query references measures that aren't in
753
+ * `cube.measures` (e.g. `amount_sum`, `amount_avg` emitted by dashboard
754
+ * widget translators), inject suffix-inferred Metric entries so the
755
+ * strategies pick the right aggregation function and field.
756
+ */
757
+ ensureCube(query) {
758
+ const name = query.cube;
759
+ let cube = this.cubeRegistry.get(name);
760
+ if (!cube) {
761
+ cube = this.inferCubeFromQuery(query);
762
+ this.cubeRegistry.register(cube);
763
+ this.logger.warn(
764
+ `[Analytics] No cube registered for "${name}"; auto-inferred a minimal cube (sql="${name}", measures=${Object.keys(cube.measures).join(",") || "(none)"}, dimensions=${Object.keys(cube.dimensions).join(",") || "(none)"}). Define an explicit Cube in your stack for full control.`
765
+ );
766
+ return;
767
+ }
768
+ const stripPrefix = (m) => m.includes(".") ? m.split(".").slice(1).join(".") : m;
769
+ const extraMeasures = {};
770
+ for (const m of query.measures || []) {
771
+ const key = stripPrefix(m);
772
+ if (cube.measures[key] || extraMeasures[key]) continue;
773
+ extraMeasures[key] = inferMeasure(key);
774
+ }
775
+ if (Object.keys(extraMeasures).length > 0) {
776
+ const augmented = {
777
+ ...cube,
778
+ measures: { ...cube.measures, ...extraMeasures }
779
+ };
780
+ this.cubeRegistry.register(augmented);
781
+ this.logger.debug(
782
+ `[Analytics] Augmented cube "${name}" with inferred measures: ${Object.keys(extraMeasures).join(",")}`
783
+ );
784
+ }
785
+ }
786
+ /** Build a minimal Cube from the fields referenced by an AnalyticsQuery. */
787
+ inferCubeFromQuery(query) {
788
+ const cubeName = query.cube;
789
+ const measures = {};
790
+ const dimensions = {};
791
+ const stripPrefix = (m) => m.includes(".") ? m.split(".").slice(1).join(".") : m;
792
+ measures.count = { name: "count", label: "Count", type: "count", sql: "*" };
793
+ for (const m of query.measures || []) {
794
+ const key = stripPrefix(m);
795
+ if (measures[key]) continue;
796
+ const inferred = inferMeasure(key);
797
+ measures[key] = inferred;
798
+ }
799
+ for (const d of query.dimensions || []) {
800
+ const key = stripPrefix(d);
801
+ if (dimensions[key]) continue;
802
+ dimensions[key] = { name: key, label: key, type: "string", sql: key };
803
+ }
804
+ if (query.where && typeof query.where === "object" && !Array.isArray(query.where)) {
805
+ for (const key of Object.keys(query.where)) {
806
+ if (key.startsWith("$")) continue;
807
+ const stripped = stripPrefix(key);
808
+ if (dimensions[stripped] || measures[stripped]) continue;
809
+ dimensions[stripped] = { name: stripped, label: stripped, type: "string", sql: stripped };
810
+ }
811
+ }
812
+ for (const td of query.timeDimensions || []) {
813
+ const key = stripPrefix(td.dimension);
814
+ if (dimensions[key]) continue;
815
+ dimensions[key] = {
816
+ name: key,
817
+ label: key,
818
+ type: "time",
819
+ sql: key,
820
+ granularities: ["day", "week", "month", "quarter", "year"]
821
+ };
822
+ }
823
+ return {
824
+ name: cubeName,
825
+ title: cubeName,
826
+ sql: cubeName,
827
+ measures,
828
+ dimensions,
829
+ public: false
830
+ };
831
+ }
535
832
  /**
536
833
  * Walk the strategy chain and return the first strategy that can handle the query.
537
834
  */
@@ -546,6 +843,26 @@ var AnalyticsService = class {
546
843
  );
547
844
  }
548
845
  };
846
+ function inferMeasure(key) {
847
+ if (key === "count") {
848
+ return { name: "count", label: "Count", type: "count", sql: "*" };
849
+ }
850
+ const suffixes = [
851
+ ["_count_distinct", "count_distinct"],
852
+ ["_sum", "sum"],
853
+ ["_avg", "avg"],
854
+ ["_average", "avg"],
855
+ ["_min", "min"],
856
+ ["_max", "max"]
857
+ ];
858
+ for (const [suffix, type] of suffixes) {
859
+ if (key.endsWith(suffix)) {
860
+ const field = key.slice(0, -suffix.length) || "*";
861
+ return { name: key, label: key, type, sql: field };
862
+ }
863
+ }
864
+ return { name: key, label: key, type: "sum", sql: key };
865
+ }
549
866
  var FallbackDelegateStrategy = class {
550
867
  constructor() {
551
868
  this.name = "FallbackDelegateStrategy";
@@ -588,14 +905,89 @@ var AnalyticsServicePlugin = class {
588
905
  }
589
906
  } catch {
590
907
  }
908
+ let executeAggregate = this.options.executeAggregate;
909
+ let autoBridged = false;
910
+ if (!executeAggregate) {
911
+ const tryGetDataEngine = () => {
912
+ try {
913
+ const svc = ctx.getService("data");
914
+ return svc && typeof svc.aggregate === "function" ? svc : void 0;
915
+ } catch {
916
+ return void 0;
917
+ }
918
+ };
919
+ if (!tryGetDataEngine()) {
920
+ ctx.logger.warn(
921
+ '[Analytics] No "data" service registered yet at init; will retry per-query. Register ObjectQLPlugin or pass executeAggregate.'
922
+ );
923
+ }
924
+ executeAggregate = async (objectName, { groupBy, aggregations, filter }) => {
925
+ const engine = tryGetDataEngine();
926
+ if (!engine) {
927
+ throw new Error(
928
+ '[Analytics] Cannot execute aggregate: no IDataEngine ("data") service is registered. Add ObjectQLPlugin to the kernel or supply AnalyticsServicePlugin({ executeAggregate }).'
929
+ );
930
+ }
931
+ const rows = await engine.aggregate(objectName, {
932
+ where: filter,
933
+ groupBy,
934
+ aggregations: aggregations?.map((a) => ({
935
+ function: a.method,
936
+ field: a.field,
937
+ alias: a.alias
938
+ }))
939
+ });
940
+ return rows;
941
+ };
942
+ autoBridged = true;
943
+ }
944
+ let executeRawSql = this.options.executeRawSql;
945
+ let autoBridgedRawSql = false;
946
+ if (!executeRawSql) {
947
+ const tryGetExecutor = () => {
948
+ try {
949
+ const svc = ctx.getService("data");
950
+ return svc && typeof svc.execute === "function" ? svc : void 0;
951
+ } catch {
952
+ return void 0;
953
+ }
954
+ };
955
+ executeRawSql = async (_objectName, sql, params) => {
956
+ const engine = tryGetExecutor();
957
+ if (!engine || !engine.execute) {
958
+ throw new Error(
959
+ '[Analytics] Cannot execute raw SQL: no IDataEngine ("data") service with execute() is registered.'
960
+ );
961
+ }
962
+ const knexSql = sql.replace(/\$(\d+)/g, "?");
963
+ const result = await engine.execute(knexSql, { args: params });
964
+ if (Array.isArray(result)) return result;
965
+ if (result && typeof result === "object" && "rows" in result) {
966
+ return result.rows;
967
+ }
968
+ return [];
969
+ };
970
+ autoBridgedRawSql = true;
971
+ }
972
+ const queryCapabilities = this.options.queryCapabilities ?? (() => ({
973
+ nativeSql: !!executeRawSql,
974
+ objectqlAggregate: !!executeAggregate,
975
+ inMemory: false
976
+ }));
591
977
  const config = {
592
978
  cubes: this.options.cubes,
593
979
  logger: ctx.logger,
594
- queryCapabilities: this.options.queryCapabilities,
595
- executeRawSql: this.options.executeRawSql,
596
- executeAggregate: this.options.executeAggregate,
980
+ queryCapabilities,
981
+ executeRawSql,
982
+ executeAggregate,
597
983
  fallbackService
598
984
  };
985
+ if (autoBridged) {
986
+ ctx.logger.info('[Analytics] Auto-bridged executeAggregate \u2192 "data" service (IDataEngine)');
987
+ }
988
+ if (autoBridgedRawSql) {
989
+ ctx.logger.info('[Analytics] Auto-bridged executeRawSql \u2192 "data" service (IDataEngine.execute)');
990
+ }
599
991
  this.service = new AnalyticsService(config);
600
992
  if (fallbackService) {
601
993
  ctx.replaceService("analytics", this.service);