@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.js CHANGED
@@ -116,6 +116,92 @@ var CubeRegistry = class {
116
116
  }
117
117
  };
118
118
 
119
+ // src/strategies/filter-normalizer.ts
120
+ var MONGO_TO_CUBE_OP = {
121
+ $eq: "equals",
122
+ $ne: "notEquals",
123
+ $gt: "gt",
124
+ $gte: "gte",
125
+ $lt: "lt",
126
+ $lte: "lte",
127
+ $in: "in",
128
+ $nin: "notIn",
129
+ $contains: "contains",
130
+ $notContains: "notContains",
131
+ $exists: "set"
132
+ };
133
+ function stringifyForCube(v) {
134
+ if (v == null) return "";
135
+ if (typeof v === "boolean") return v ? "1" : "0";
136
+ if (v instanceof Date) return v.toISOString();
137
+ if (typeof v === "object") return JSON.stringify(v);
138
+ return String(v);
139
+ }
140
+ function flattenCondition(cond, out) {
141
+ for (const [key, raw] of Object.entries(cond)) {
142
+ if (raw === void 0) continue;
143
+ if (key === "$and" && Array.isArray(raw)) {
144
+ for (const sub of raw) {
145
+ if (sub && typeof sub === "object") {
146
+ flattenCondition(sub, out);
147
+ }
148
+ }
149
+ continue;
150
+ }
151
+ if (key === "$or" || key === "$not") continue;
152
+ if (raw === null) {
153
+ out.push({ member: key, operator: "notSet", values: [] });
154
+ continue;
155
+ }
156
+ if (typeof raw === "object" && !Array.isArray(raw) && !(raw instanceof Date)) {
157
+ const wrapper = raw;
158
+ const opKeys = Object.keys(wrapper).filter((k) => k.startsWith("$"));
159
+ if (opKeys.length > 0) {
160
+ for (const opKey of opKeys) {
161
+ const cubeOp = MONGO_TO_CUBE_OP[opKey];
162
+ if (!cubeOp) continue;
163
+ const v = wrapper[opKey];
164
+ const values = Array.isArray(v) ? v.map(stringifyForCube) : [stringifyForCube(v)];
165
+ out.push({ member: key, operator: cubeOp, values });
166
+ }
167
+ continue;
168
+ }
169
+ for (const [nestedKey, nestedVal] of Object.entries(wrapper)) {
170
+ flattenCondition({ [`${key}.${nestedKey}`]: nestedVal }, out);
171
+ }
172
+ continue;
173
+ }
174
+ if (Array.isArray(raw)) {
175
+ out.push({ member: key, operator: "in", values: raw.map(stringifyForCube) });
176
+ } else {
177
+ out.push({ member: key, operator: "equals", values: [stringifyForCube(raw)] });
178
+ }
179
+ }
180
+ }
181
+ function normalizeAnalyticsFilters(query) {
182
+ if (!query || typeof query !== "object") return [];
183
+ const out = [];
184
+ const where = query.where;
185
+ if (where && typeof where === "object" && !Array.isArray(where)) {
186
+ flattenCondition(where, out);
187
+ }
188
+ return out;
189
+ }
190
+ function coerceFilterValueForSql(s) {
191
+ if (s === "true") return 1;
192
+ if (s === "false") return 0;
193
+ if (s === "null") return null;
194
+ if (/^-?\d+$/.test(s)) {
195
+ const n = Number(s);
196
+ if (Number.isFinite(n)) return n;
197
+ }
198
+ if (/^-?\d+\.\d+$/.test(s)) {
199
+ const n = Number(s);
200
+ if (Number.isFinite(n)) return n;
201
+ }
202
+ return s;
203
+ }
204
+
119
205
  // src/strategies/native-sql-strategy.ts
120
206
  var NativeSQLStrategy = class {
121
207
  constructor() {
@@ -143,30 +229,33 @@ var NativeSQLStrategy = class {
143
229
  const params = [];
144
230
  const selectClauses = [];
145
231
  const groupByClauses = [];
232
+ const tableName = this.extractObjectName(cube);
233
+ const joins = /* @__PURE__ */ new Map();
146
234
  if (query.dimensions && query.dimensions.length > 0) {
147
235
  for (const dim of query.dimensions) {
148
- const colExpr = this.resolveDimensionSql(cube, dim);
236
+ const colExpr = this.resolveDimensionSql(cube, dim, tableName, joins);
149
237
  selectClauses.push(`${colExpr} AS "${dim}"`);
150
238
  groupByClauses.push(colExpr);
151
239
  }
152
240
  }
153
241
  if (query.measures && query.measures.length > 0) {
154
242
  for (const measure of query.measures) {
155
- const aggExpr = this.resolveMeasureSql(cube, measure);
243
+ const aggExpr = this.resolveMeasureSql(cube, measure, tableName, joins);
156
244
  selectClauses.push(`${aggExpr} AS "${measure}"`);
157
245
  }
158
246
  }
159
247
  const whereClauses = [];
160
- if (query.filters && query.filters.length > 0) {
161
- for (const filter of query.filters) {
162
- const colExpr = this.resolveFieldSql(cube, filter.member);
248
+ const normalizedFilters = normalizeAnalyticsFilters(query);
249
+ if (normalizedFilters.length > 0) {
250
+ for (const filter of normalizedFilters) {
251
+ const colExpr = this.resolveFieldSql(cube, filter.member, tableName, joins);
163
252
  const clause = this.buildFilterClause(colExpr, filter.operator, filter.values, params);
164
253
  if (clause) whereClauses.push(clause);
165
254
  }
166
255
  }
167
256
  if (query.timeDimensions && query.timeDimensions.length > 0) {
168
257
  for (const td of query.timeDimensions) {
169
- const colExpr = this.resolveFieldSql(cube, td.dimension);
258
+ const colExpr = this.resolveFieldSql(cube, td.dimension, tableName, joins);
170
259
  if (td.dateRange) {
171
260
  const range = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
172
261
  if (range.length === 2) {
@@ -176,8 +265,10 @@ var NativeSQLStrategy = class {
176
265
  }
177
266
  }
178
267
  }
179
- const tableName = this.extractObjectName(cube);
180
268
  let sql = `SELECT ${selectClauses.join(", ")} FROM "${tableName}"`;
269
+ if (joins.size > 0) {
270
+ sql += " " + Array.from(joins.values()).join(" ");
271
+ }
181
272
  if (whereClauses.length > 0) {
182
273
  sql += ` WHERE ${whereClauses.join(" AND ")}`;
183
274
  }
@@ -197,16 +288,77 @@ var NativeSQLStrategy = class {
197
288
  return { sql, params };
198
289
  }
199
290
  // ── Helpers ──────────────────────────────────────────────────────
200
- resolveDimensionSql(cube, member) {
201
- const fieldName = member.includes(".") ? member.split(".")[1] : member;
202
- const dim = cube.dimensions[fieldName];
203
- return dim ? dim.sql : fieldName;
291
+ /**
292
+ * Resolve a dimension/measure/filter SQL expression that may reference a
293
+ * related table via dot notation (e.g. `account.industry`).
294
+ *
295
+ * When the resolved `sql` contains a dot, treat the prefix as a lookup
296
+ * field on the cube's table and synthesise a `LEFT JOIN` against the
297
+ * related table. The convention (matching the auto-cube generator and
298
+ * ObjectStack object schemas) is:
299
+ *
300
+ * <parentTable>.<lookupField> = <lookupField>.id
301
+ *
302
+ * i.e. the lookup field name on the parent table equals the related
303
+ * table name. This holds for all `Field.lookup({ object: '...' })`
304
+ * declarations where the field is named after its target object.
305
+ *
306
+ * Returns the qualified SQL reference (e.g. `"account"."industry"`).
307
+ * Pure column references (no dot) are returned as-is.
308
+ */
309
+ qualifyAndRegisterJoin(rawSql, parentTable, joins) {
310
+ if (!rawSql.includes(".")) return rawSql;
311
+ const [alias, ...rest] = rawSql.split(".");
312
+ if (!alias || rest.length === 0) return rawSql;
313
+ const column = rest.join(".");
314
+ if (!joins.has(alias)) {
315
+ joins.set(
316
+ alias,
317
+ `LEFT JOIN "${alias}" ON "${parentTable}"."${alias}" = "${alias}"."id"`
318
+ );
319
+ }
320
+ return `"${alias}"."${column}"`;
204
321
  }
205
- resolveMeasureSql(cube, member) {
206
- const fieldName = member.includes(".") ? member.split(".")[1] : member;
207
- const measure = cube.measures[fieldName];
322
+ /**
323
+ * Resolve a member reference (dimension, measure, or filter field) to its
324
+ * cube definition.
325
+ *
326
+ * Accepts three naming conventions:
327
+ * 1. `<cube>.<field>` — the canonical analytics qualifier (stripped to `<field>`).
328
+ * 2. `<lookup>.<field>` — a relation traversal (e.g. `account.industry`).
329
+ * First tried as the literal key, then as the underscore-flattened
330
+ * key (`account_industry`), and finally returned as a synthetic
331
+ * definition whose `sql` is the dotted reference so the JOIN
332
+ * machinery can pick it up.
333
+ * 3. `<field>` — a bare field name on the cube's table.
334
+ */
335
+ lookupMember(cube, member, kind) {
336
+ const bag = kind === "dimension" ? cube.dimensions : cube.measures;
337
+ if (bag[member]) return bag[member];
338
+ if (member.includes(".")) {
339
+ const [first, ...rest] = member.split(".");
340
+ const tail = rest.join(".");
341
+ if (first === cube.name && bag[tail]) return bag[tail];
342
+ if (bag[tail]) return bag[tail];
343
+ const flat = member.replace(/\./g, "_");
344
+ if (bag[flat]) return bag[flat];
345
+ if (kind === "dimension") {
346
+ return { sql: member, type: "string" };
347
+ }
348
+ } else if (bag[member]) {
349
+ return bag[member];
350
+ }
351
+ return void 0;
352
+ }
353
+ resolveDimensionSql(cube, member, parentTable, joins) {
354
+ const dim = this.lookupMember(cube, member, "dimension");
355
+ const raw = dim ? dim.sql : member.includes(".") ? member.split(".")[1] : member;
356
+ return this.qualifyAndRegisterJoin(raw, parentTable, joins);
357
+ }
358
+ resolveMeasureSql(cube, member, parentTable, joins) {
359
+ const measure = this.lookupMember(cube, member, "measure");
208
360
  if (!measure) return `COUNT(*)`;
209
- const col = measure.sql;
361
+ const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins);
210
362
  switch (measure.type) {
211
363
  case "count":
212
364
  return "COUNT(*)";
@@ -224,12 +376,12 @@ var NativeSQLStrategy = class {
224
376
  return `COUNT(*)`;
225
377
  }
226
378
  }
227
- resolveFieldSql(cube, member) {
379
+ resolveFieldSql(cube, member, parentTable, joins) {
380
+ const dim = this.lookupMember(cube, member, "dimension");
381
+ if (dim) return this.qualifyAndRegisterJoin(dim.sql, parentTable, joins);
382
+ const measure = this.lookupMember(cube, member, "measure");
383
+ if (measure) return this.qualifyAndRegisterJoin(measure.sql, parentTable, joins);
228
384
  const fieldName = member.includes(".") ? member.split(".")[1] : member;
229
- const dim = cube.dimensions[fieldName];
230
- if (dim) return dim.sql;
231
- const measure = cube.measures[fieldName];
232
- if (measure) return measure.sql;
233
385
  return fieldName;
234
386
  }
235
387
  buildFilterClause(col, operator, values, params) {
@@ -245,12 +397,20 @@ var NativeSQLStrategy = class {
245
397
  };
246
398
  if (operator === "set") return `${col} IS NOT NULL`;
247
399
  if (operator === "notSet") return `${col} IS NULL`;
400
+ if (operator === "in" || operator === "notIn") {
401
+ if (!values || values.length === 0) return null;
402
+ const placeholders = values.map((v) => {
403
+ params.push(coerceFilterValueForSql(v));
404
+ return `$${params.length}`;
405
+ }).join(", ");
406
+ return `${col} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
407
+ }
248
408
  const sqlOp = opMap[operator];
249
409
  if (!sqlOp || !values || values.length === 0) return null;
250
410
  if (operator === "contains" || operator === "notContains") {
251
411
  params.push(`%${values[0]}%`);
252
412
  } else {
253
- params.push(values[0]);
413
+ params.push(coerceFilterValueForSql(values[0]));
254
414
  }
255
415
  return `${col} ${sqlOp} $${params.length}`;
256
416
  }
@@ -261,8 +421,7 @@ var NativeSQLStrategy = class {
261
421
  const fields = [];
262
422
  if (query.dimensions) {
263
423
  for (const dim of query.dimensions) {
264
- const fieldName = dim.includes(".") ? dim.split(".")[1] : dim;
265
- const d = cube.dimensions[fieldName];
424
+ const d = this.lookupMember(cube, dim, "dimension");
266
425
  fields.push({ name: dim, type: d?.type || "string" });
267
426
  }
268
427
  }
@@ -303,8 +462,9 @@ var ObjectQLStrategy = class {
303
462
  }
304
463
  }
305
464
  const filter = {};
306
- if (query.filters && query.filters.length > 0) {
307
- for (const f of query.filters) {
465
+ const normalizedFilters = normalizeAnalyticsFilters(query);
466
+ if (normalizedFilters.length > 0) {
467
+ for (const f of normalizedFilters) {
308
468
  const fieldName = this.resolveFieldName(cube, f.member, "any");
309
469
  filter[fieldName] = this.convertFilter(f.operator, f.values);
310
470
  }
@@ -361,48 +521,97 @@ var ObjectQLStrategy = class {
361
521
  return { sql, params: [] };
362
522
  }
363
523
  // ── Helpers ──────────────────────────────────────────────────────
524
+ /**
525
+ * Resolve a member ref to a `{ sql, type? }` definition.
526
+ *
527
+ * Mirrors `NativeSQLStrategy.lookupMember` so the two strategies
528
+ * accept the same naming conventions:
529
+ * 1. `<cube>.<field>` — canonical analytics qualifier.
530
+ * 2. `<lookup>.<field>` — relation traversal (e.g. `account.industry`).
531
+ * Tries literal key, then underscore-flattened key, then falls
532
+ * back to a synthetic dim whose `sql` is the dotted path so the
533
+ * ObjectQL aggregate engine can traverse it via the lookup field.
534
+ * 3. `<field>` — bare column on the cube's table.
535
+ */
536
+ lookupMember(cube, member, kind) {
537
+ const bag = kind === "dimension" ? cube.dimensions : cube.measures;
538
+ if (bag[member]) return bag[member];
539
+ if (member.includes(".")) {
540
+ const [first, ...rest] = member.split(".");
541
+ const tail = rest.join(".");
542
+ if (first === cube.name && bag[tail]) return bag[tail];
543
+ if (bag[tail]) return bag[tail];
544
+ const flat = member.replace(/\./g, "_");
545
+ if (bag[flat]) return bag[flat];
546
+ if (kind === "dimension") return { sql: member, type: "string" };
547
+ } else if (bag[member]) {
548
+ return bag[member];
549
+ }
550
+ return void 0;
551
+ }
364
552
  resolveFieldName(cube, member, kind) {
365
- const fieldName = member.includes(".") ? member.split(".")[1] : member;
366
553
  if (kind === "dimension" || kind === "any") {
367
- const dim = cube.dimensions[fieldName];
554
+ const dim = this.lookupMember(cube, member, "dimension");
368
555
  if (dim) return dim.sql.replace(/^\$/, "");
369
556
  }
370
557
  if (kind === "measure" || kind === "any") {
371
- const measure = cube.measures[fieldName];
558
+ const measure = this.lookupMember(cube, member, "measure");
372
559
  if (measure) return measure.sql.replace(/^\$/, "");
373
560
  }
374
- return fieldName;
561
+ return member.includes(".") ? member.split(".")[1] : member;
375
562
  }
376
563
  resolveMeasureAggregation(cube, measureName) {
564
+ const direct = this.lookupMember(cube, measureName, "measure");
565
+ if (direct) {
566
+ return {
567
+ field: direct.sql.replace(/^\$/, ""),
568
+ method: direct.type === "count_distinct" ? "count_distinct" : direct.type
569
+ };
570
+ }
377
571
  const fieldName = measureName.includes(".") ? measureName.split(".")[1] : measureName;
378
- const measure = cube.measures[fieldName];
379
- if (!measure) return { field: "*", method: "count" };
380
- return {
381
- field: measure.sql.replace(/^\$/, ""),
382
- method: measure.type === "count_distinct" ? "count_distinct" : measure.type
383
- };
572
+ const aggTypes = ["count", "sum", "avg", "min", "max", "count_distinct"];
573
+ for (const type of aggTypes) {
574
+ const suffix = `_${type}`;
575
+ if (fieldName.endsWith(suffix)) {
576
+ const baseField = fieldName.slice(0, -suffix.length);
577
+ const candidate = cube.measures[baseField];
578
+ if (candidate && candidate.type === type) {
579
+ return {
580
+ field: candidate.sql.replace(/^\$/, ""),
581
+ method: candidate.type === "count_distinct" ? "count_distinct" : candidate.type
582
+ };
583
+ }
584
+ }
585
+ }
586
+ return { field: "*", method: "count" };
384
587
  }
385
588
  convertFilter(operator, values) {
386
589
  if (operator === "set") return { $ne: null };
387
590
  if (operator === "notSet") return null;
388
591
  if (!values || values.length === 0) return void 0;
592
+ const v0 = coerceFilterValueForSql(values[0]);
593
+ const all = values.map(coerceFilterValueForSql);
389
594
  switch (operator) {
390
595
  case "equals":
391
- return values[0];
596
+ return v0;
392
597
  case "notEquals":
393
- return { $ne: values[0] };
598
+ return { $ne: v0 };
394
599
  case "gt":
395
- return { $gt: values[0] };
600
+ return { $gt: v0 };
396
601
  case "gte":
397
- return { $gte: values[0] };
602
+ return { $gte: v0 };
398
603
  case "lt":
399
- return { $lt: values[0] };
604
+ return { $lt: v0 };
400
605
  case "lte":
401
- return { $lte: values[0] };
606
+ return { $lte: v0 };
402
607
  case "contains":
403
608
  return { $regex: values[0] };
609
+ case "in":
610
+ return { $in: all };
611
+ case "notIn":
612
+ return { $nin: all };
404
613
  default:
405
- return values[0];
614
+ return v0;
406
615
  }
407
616
  }
408
617
  extractObjectName(cube) {
@@ -412,8 +621,7 @@ var ObjectQLStrategy = class {
412
621
  const fields = [];
413
622
  if (query.dimensions) {
414
623
  for (const dim of query.dimensions) {
415
- const fieldName = dim.includes(".") ? dim.split(".")[1] : dim;
416
- const d = cube.dimensions[fieldName];
624
+ const d = this.lookupMember(cube, dim, "dimension");
417
625
  fields.push({ name: dim, type: d?.type || "string" });
418
626
  }
419
627
  }
@@ -466,6 +674,7 @@ var AnalyticsService = class {
466
674
  if (!query.cube) {
467
675
  throw new Error("Cube name is required in analytics query");
468
676
  }
677
+ this.ensureCube(query);
469
678
  const strategy = this.resolveStrategy(query);
470
679
  this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
471
680
  return strategy.execute(query, this.strategyCtx);
@@ -497,11 +706,99 @@ var AnalyticsService = class {
497
706
  if (!query.cube) {
498
707
  throw new Error("Cube name is required for SQL generation");
499
708
  }
709
+ this.ensureCube(query);
500
710
  const strategy = this.resolveStrategy(query);
501
711
  this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" \u2192 ${strategy.name}`);
502
712
  return strategy.generateSql(query, this.strategyCtx);
503
713
  }
504
714
  // ── Internal ─────────────────────────────────────────────────────
715
+ /**
716
+ * Ensure a cube exists for the given query and that it knows about every
717
+ * measure referenced by the query.
718
+ *
719
+ * - If no cube is registered for `query.cube`, infer a minimal cube from
720
+ * the query so downstream strategies (which assume `cube.sql` exists)
721
+ * don't crash.
722
+ * - If a cube exists but the query references measures that aren't in
723
+ * `cube.measures` (e.g. `amount_sum`, `amount_avg` emitted by dashboard
724
+ * widget translators), inject suffix-inferred Metric entries so the
725
+ * strategies pick the right aggregation function and field.
726
+ */
727
+ ensureCube(query) {
728
+ const name = query.cube;
729
+ let cube = this.cubeRegistry.get(name);
730
+ if (!cube) {
731
+ cube = this.inferCubeFromQuery(query);
732
+ this.cubeRegistry.register(cube);
733
+ this.logger.warn(
734
+ `[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.`
735
+ );
736
+ return;
737
+ }
738
+ const stripPrefix = (m) => m.includes(".") ? m.split(".").slice(1).join(".") : m;
739
+ const extraMeasures = {};
740
+ for (const m of query.measures || []) {
741
+ const key = stripPrefix(m);
742
+ if (cube.measures[key] || extraMeasures[key]) continue;
743
+ extraMeasures[key] = inferMeasure(key);
744
+ }
745
+ if (Object.keys(extraMeasures).length > 0) {
746
+ const augmented = {
747
+ ...cube,
748
+ measures: { ...cube.measures, ...extraMeasures }
749
+ };
750
+ this.cubeRegistry.register(augmented);
751
+ this.logger.debug(
752
+ `[Analytics] Augmented cube "${name}" with inferred measures: ${Object.keys(extraMeasures).join(",")}`
753
+ );
754
+ }
755
+ }
756
+ /** Build a minimal Cube from the fields referenced by an AnalyticsQuery. */
757
+ inferCubeFromQuery(query) {
758
+ const cubeName = query.cube;
759
+ const measures = {};
760
+ const dimensions = {};
761
+ const stripPrefix = (m) => m.includes(".") ? m.split(".").slice(1).join(".") : m;
762
+ measures.count = { name: "count", label: "Count", type: "count", sql: "*" };
763
+ for (const m of query.measures || []) {
764
+ const key = stripPrefix(m);
765
+ if (measures[key]) continue;
766
+ const inferred = inferMeasure(key);
767
+ measures[key] = inferred;
768
+ }
769
+ for (const d of query.dimensions || []) {
770
+ const key = stripPrefix(d);
771
+ if (dimensions[key]) continue;
772
+ dimensions[key] = { name: key, label: key, type: "string", sql: key };
773
+ }
774
+ if (query.where && typeof query.where === "object" && !Array.isArray(query.where)) {
775
+ for (const key of Object.keys(query.where)) {
776
+ if (key.startsWith("$")) continue;
777
+ const stripped = stripPrefix(key);
778
+ if (dimensions[stripped] || measures[stripped]) continue;
779
+ dimensions[stripped] = { name: stripped, label: stripped, type: "string", sql: stripped };
780
+ }
781
+ }
782
+ for (const td of query.timeDimensions || []) {
783
+ const key = stripPrefix(td.dimension);
784
+ if (dimensions[key]) continue;
785
+ dimensions[key] = {
786
+ name: key,
787
+ label: key,
788
+ type: "time",
789
+ sql: key,
790
+ granularities: ["day", "week", "month", "quarter", "year"]
791
+ };
792
+ }
793
+ return {
794
+ name: cubeName,
795
+ title: cubeName,
796
+ sql: cubeName,
797
+ measures,
798
+ dimensions,
799
+ public: false
800
+ };
801
+ }
505
802
  /**
506
803
  * Walk the strategy chain and return the first strategy that can handle the query.
507
804
  */
@@ -516,6 +813,26 @@ var AnalyticsService = class {
516
813
  );
517
814
  }
518
815
  };
816
+ function inferMeasure(key) {
817
+ if (key === "count") {
818
+ return { name: "count", label: "Count", type: "count", sql: "*" };
819
+ }
820
+ const suffixes = [
821
+ ["_count_distinct", "count_distinct"],
822
+ ["_sum", "sum"],
823
+ ["_avg", "avg"],
824
+ ["_average", "avg"],
825
+ ["_min", "min"],
826
+ ["_max", "max"]
827
+ ];
828
+ for (const [suffix, type] of suffixes) {
829
+ if (key.endsWith(suffix)) {
830
+ const field = key.slice(0, -suffix.length) || "*";
831
+ return { name: key, label: key, type, sql: field };
832
+ }
833
+ }
834
+ return { name: key, label: key, type: "sum", sql: key };
835
+ }
519
836
  var FallbackDelegateStrategy = class {
520
837
  constructor() {
521
838
  this.name = "FallbackDelegateStrategy";
@@ -558,14 +875,89 @@ var AnalyticsServicePlugin = class {
558
875
  }
559
876
  } catch {
560
877
  }
878
+ let executeAggregate = this.options.executeAggregate;
879
+ let autoBridged = false;
880
+ if (!executeAggregate) {
881
+ const tryGetDataEngine = () => {
882
+ try {
883
+ const svc = ctx.getService("data");
884
+ return svc && typeof svc.aggregate === "function" ? svc : void 0;
885
+ } catch {
886
+ return void 0;
887
+ }
888
+ };
889
+ if (!tryGetDataEngine()) {
890
+ ctx.logger.warn(
891
+ '[Analytics] No "data" service registered yet at init; will retry per-query. Register ObjectQLPlugin or pass executeAggregate.'
892
+ );
893
+ }
894
+ executeAggregate = async (objectName, { groupBy, aggregations, filter }) => {
895
+ const engine = tryGetDataEngine();
896
+ if (!engine) {
897
+ throw new Error(
898
+ '[Analytics] Cannot execute aggregate: no IDataEngine ("data") service is registered. Add ObjectQLPlugin to the kernel or supply AnalyticsServicePlugin({ executeAggregate }).'
899
+ );
900
+ }
901
+ const rows = await engine.aggregate(objectName, {
902
+ where: filter,
903
+ groupBy,
904
+ aggregations: aggregations?.map((a) => ({
905
+ function: a.method,
906
+ field: a.field,
907
+ alias: a.alias
908
+ }))
909
+ });
910
+ return rows;
911
+ };
912
+ autoBridged = true;
913
+ }
914
+ let executeRawSql = this.options.executeRawSql;
915
+ let autoBridgedRawSql = false;
916
+ if (!executeRawSql) {
917
+ const tryGetExecutor = () => {
918
+ try {
919
+ const svc = ctx.getService("data");
920
+ return svc && typeof svc.execute === "function" ? svc : void 0;
921
+ } catch {
922
+ return void 0;
923
+ }
924
+ };
925
+ executeRawSql = async (_objectName, sql, params) => {
926
+ const engine = tryGetExecutor();
927
+ if (!engine || !engine.execute) {
928
+ throw new Error(
929
+ '[Analytics] Cannot execute raw SQL: no IDataEngine ("data") service with execute() is registered.'
930
+ );
931
+ }
932
+ const knexSql = sql.replace(/\$(\d+)/g, "?");
933
+ const result = await engine.execute(knexSql, { args: params });
934
+ if (Array.isArray(result)) return result;
935
+ if (result && typeof result === "object" && "rows" in result) {
936
+ return result.rows;
937
+ }
938
+ return [];
939
+ };
940
+ autoBridgedRawSql = true;
941
+ }
942
+ const queryCapabilities = this.options.queryCapabilities ?? (() => ({
943
+ nativeSql: !!executeRawSql,
944
+ objectqlAggregate: !!executeAggregate,
945
+ inMemory: false
946
+ }));
561
947
  const config = {
562
948
  cubes: this.options.cubes,
563
949
  logger: ctx.logger,
564
- queryCapabilities: this.options.queryCapabilities,
565
- executeRawSql: this.options.executeRawSql,
566
- executeAggregate: this.options.executeAggregate,
950
+ queryCapabilities,
951
+ executeRawSql,
952
+ executeAggregate,
567
953
  fallbackService
568
954
  };
955
+ if (autoBridged) {
956
+ ctx.logger.info('[Analytics] Auto-bridged executeAggregate \u2192 "data" service (IDataEngine)');
957
+ }
958
+ if (autoBridgedRawSql) {
959
+ ctx.logger.info('[Analytics] Auto-bridged executeRawSql \u2192 "data" service (IDataEngine.execute)');
960
+ }
569
961
  this.service = new AnalyticsService(config);
570
962
  if (fallbackService) {
571
963
  ctx.replaceService("analytics", this.service);