@objectstack/service-analytics 3.2.9

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 ADDED
@@ -0,0 +1,600 @@
1
+ // src/analytics-service.ts
2
+ import { createLogger } from "@objectstack/core";
3
+
4
+ // src/cube-registry.ts
5
+ var CubeRegistry = class {
6
+ constructor() {
7
+ this.cubes = /* @__PURE__ */ new Map();
8
+ }
9
+ /** Register a single cube definition. Overwrites if name already exists. */
10
+ register(cube) {
11
+ this.cubes.set(cube.name, cube);
12
+ }
13
+ /** Register multiple cube definitions at once. */
14
+ registerAll(cubes) {
15
+ for (const cube of cubes) {
16
+ this.register(cube);
17
+ }
18
+ }
19
+ /** Get a cube definition by name. */
20
+ get(name) {
21
+ return this.cubes.get(name);
22
+ }
23
+ /** Check if a cube is registered. */
24
+ has(name) {
25
+ return this.cubes.has(name);
26
+ }
27
+ /** Return all registered cubes. */
28
+ getAll() {
29
+ return Array.from(this.cubes.values());
30
+ }
31
+ /** Return all cube names. */
32
+ names() {
33
+ return Array.from(this.cubes.keys());
34
+ }
35
+ /** Number of registered cubes. */
36
+ get size() {
37
+ return this.cubes.size;
38
+ }
39
+ /** Remove all cubes. */
40
+ clear() {
41
+ this.cubes.clear();
42
+ }
43
+ /**
44
+ * Auto-generate a cube definition from an object schema.
45
+ *
46
+ * Heuristic rules:
47
+ * - `number` fields → `sum`, `avg`, `min`, `max` measures
48
+ * - `boolean` fields → `count` measure (count where true)
49
+ * - All non-computed fields → dimensions
50
+ * - `date`/`datetime` fields → time dimensions with standard granularities
51
+ * - A default `count` measure is always added
52
+ *
53
+ * @param objectName - The snake_case object name (used as table/cube name)
54
+ * @param fields - Array of field descriptors `{ name, type, label? }`
55
+ */
56
+ inferFromObject(objectName, fields) {
57
+ const measures = {
58
+ count: {
59
+ name: "count",
60
+ label: "Count",
61
+ type: "count",
62
+ sql: "*"
63
+ }
64
+ };
65
+ const dimensions = {};
66
+ for (const field of fields) {
67
+ const label = field.label || field.name;
68
+ const dimType = this.fieldTypeToDimensionType(field.type);
69
+ dimensions[field.name] = {
70
+ name: field.name,
71
+ label,
72
+ type: dimType,
73
+ sql: field.name,
74
+ ...dimType === "time" ? { granularities: ["day", "week", "month", "quarter", "year"] } : {}
75
+ };
76
+ if (field.type === "number" || field.type === "currency" || field.type === "percent") {
77
+ measures[`${field.name}_sum`] = {
78
+ name: `${field.name}_sum`,
79
+ label: `${label} (Sum)`,
80
+ type: "sum",
81
+ sql: field.name
82
+ };
83
+ measures[`${field.name}_avg`] = {
84
+ name: `${field.name}_avg`,
85
+ label: `${label} (Avg)`,
86
+ type: "avg",
87
+ sql: field.name
88
+ };
89
+ }
90
+ }
91
+ const cube = {
92
+ name: objectName,
93
+ title: objectName,
94
+ sql: objectName,
95
+ measures,
96
+ dimensions,
97
+ public: false
98
+ };
99
+ this.register(cube);
100
+ return cube;
101
+ }
102
+ fieldTypeToDimensionType(fieldType) {
103
+ switch (fieldType) {
104
+ case "number":
105
+ case "currency":
106
+ case "percent":
107
+ return "number";
108
+ case "boolean":
109
+ return "boolean";
110
+ case "date":
111
+ case "datetime":
112
+ return "time";
113
+ default:
114
+ return "string";
115
+ }
116
+ }
117
+ };
118
+
119
+ // src/strategies/native-sql-strategy.ts
120
+ var NativeSQLStrategy = class {
121
+ constructor() {
122
+ this.name = "NativeSQLStrategy";
123
+ this.priority = 10;
124
+ }
125
+ canHandle(query, ctx) {
126
+ if (!query.cube) return false;
127
+ const caps = ctx.queryCapabilities(query.cube);
128
+ return caps.nativeSql && typeof ctx.executeRawSql === "function";
129
+ }
130
+ async execute(query, ctx) {
131
+ const { sql, params } = await this.generateSql(query, ctx);
132
+ const cube = ctx.getCube(query.cube);
133
+ const objectName = this.extractObjectName(cube);
134
+ const rows = await ctx.executeRawSql(objectName, sql, params);
135
+ const fields = this.buildFieldMeta(query, cube);
136
+ return { rows, fields, sql };
137
+ }
138
+ async generateSql(query, ctx) {
139
+ const cube = ctx.getCube(query.cube);
140
+ if (!cube) {
141
+ throw new Error(`Cube not found: ${query.cube}`);
142
+ }
143
+ const params = [];
144
+ const selectClauses = [];
145
+ const groupByClauses = [];
146
+ if (query.dimensions && query.dimensions.length > 0) {
147
+ for (const dim of query.dimensions) {
148
+ const colExpr = this.resolveDimensionSql(cube, dim);
149
+ selectClauses.push(`${colExpr} AS "${dim}"`);
150
+ groupByClauses.push(colExpr);
151
+ }
152
+ }
153
+ if (query.measures && query.measures.length > 0) {
154
+ for (const measure of query.measures) {
155
+ const aggExpr = this.resolveMeasureSql(cube, measure);
156
+ selectClauses.push(`${aggExpr} AS "${measure}"`);
157
+ }
158
+ }
159
+ 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);
163
+ const clause = this.buildFilterClause(colExpr, filter.operator, filter.values, params);
164
+ if (clause) whereClauses.push(clause);
165
+ }
166
+ }
167
+ if (query.timeDimensions && query.timeDimensions.length > 0) {
168
+ for (const td of query.timeDimensions) {
169
+ const colExpr = this.resolveFieldSql(cube, td.dimension);
170
+ if (td.dateRange) {
171
+ const range = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
172
+ if (range.length === 2) {
173
+ params.push(range[0], range[1]);
174
+ whereClauses.push(`${colExpr} BETWEEN $${params.length - 1} AND $${params.length}`);
175
+ }
176
+ }
177
+ }
178
+ }
179
+ const tableName = this.extractObjectName(cube);
180
+ let sql = `SELECT ${selectClauses.join(", ")} FROM "${tableName}"`;
181
+ if (whereClauses.length > 0) {
182
+ sql += ` WHERE ${whereClauses.join(" AND ")}`;
183
+ }
184
+ if (groupByClauses.length > 0) {
185
+ sql += ` GROUP BY ${groupByClauses.join(", ")}`;
186
+ }
187
+ if (query.order && Object.keys(query.order).length > 0) {
188
+ const orderClauses = Object.entries(query.order).map(([f, d]) => `"${f}" ${d.toUpperCase()}`);
189
+ sql += ` ORDER BY ${orderClauses.join(", ")}`;
190
+ }
191
+ if (query.limit != null) {
192
+ sql += ` LIMIT ${query.limit}`;
193
+ }
194
+ if (query.offset != null) {
195
+ sql += ` OFFSET ${query.offset}`;
196
+ }
197
+ return { sql, params };
198
+ }
199
+ // ── 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;
204
+ }
205
+ resolveMeasureSql(cube, member) {
206
+ const fieldName = member.includes(".") ? member.split(".")[1] : member;
207
+ const measure = cube.measures[fieldName];
208
+ if (!measure) return `COUNT(*)`;
209
+ const col = measure.sql;
210
+ switch (measure.type) {
211
+ case "count":
212
+ return "COUNT(*)";
213
+ case "sum":
214
+ return `SUM(${col})`;
215
+ case "avg":
216
+ return `AVG(${col})`;
217
+ case "min":
218
+ return `MIN(${col})`;
219
+ case "max":
220
+ return `MAX(${col})`;
221
+ case "count_distinct":
222
+ return `COUNT(DISTINCT ${col})`;
223
+ default:
224
+ return `COUNT(*)`;
225
+ }
226
+ }
227
+ resolveFieldSql(cube, member) {
228
+ 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
+ return fieldName;
234
+ }
235
+ buildFilterClause(col, operator, values, params) {
236
+ const opMap = {
237
+ equals: "=",
238
+ notEquals: "!=",
239
+ gt: ">",
240
+ gte: ">=",
241
+ lt: "<",
242
+ lte: "<=",
243
+ contains: "LIKE",
244
+ notContains: "NOT LIKE"
245
+ };
246
+ if (operator === "set") return `${col} IS NOT NULL`;
247
+ if (operator === "notSet") return `${col} IS NULL`;
248
+ const sqlOp = opMap[operator];
249
+ if (!sqlOp || !values || values.length === 0) return null;
250
+ if (operator === "contains" || operator === "notContains") {
251
+ params.push(`%${values[0]}%`);
252
+ } else {
253
+ params.push(values[0]);
254
+ }
255
+ return `${col} ${sqlOp} $${params.length}`;
256
+ }
257
+ extractObjectName(cube) {
258
+ return cube.sql.trim();
259
+ }
260
+ buildFieldMeta(query, cube) {
261
+ const fields = [];
262
+ if (query.dimensions) {
263
+ for (const dim of query.dimensions) {
264
+ const fieldName = dim.includes(".") ? dim.split(".")[1] : dim;
265
+ const d = cube.dimensions[fieldName];
266
+ fields.push({ name: dim, type: d?.type || "string" });
267
+ }
268
+ }
269
+ if (query.measures) {
270
+ for (const m of query.measures) {
271
+ fields.push({ name: m, type: "number" });
272
+ }
273
+ }
274
+ return fields;
275
+ }
276
+ };
277
+
278
+ // src/strategies/objectql-strategy.ts
279
+ var ObjectQLStrategy = class {
280
+ constructor() {
281
+ this.name = "ObjectQLStrategy";
282
+ this.priority = 20;
283
+ }
284
+ canHandle(query, ctx) {
285
+ if (!query.cube) return false;
286
+ const caps = ctx.queryCapabilities(query.cube);
287
+ return caps.objectqlAggregate && typeof ctx.executeAggregate === "function";
288
+ }
289
+ async execute(query, ctx) {
290
+ const cube = ctx.getCube(query.cube);
291
+ const objectName = this.extractObjectName(cube);
292
+ const groupBy = [];
293
+ if (query.dimensions && query.dimensions.length > 0) {
294
+ for (const dim of query.dimensions) {
295
+ groupBy.push(this.resolveFieldName(cube, dim, "dimension"));
296
+ }
297
+ }
298
+ const aggregations = [];
299
+ if (query.measures && query.measures.length > 0) {
300
+ for (const measure of query.measures) {
301
+ const { field, method } = this.resolveMeasureAggregation(cube, measure);
302
+ aggregations.push({ field, method, alias: measure });
303
+ }
304
+ }
305
+ const filter = {};
306
+ if (query.filters && query.filters.length > 0) {
307
+ for (const f of query.filters) {
308
+ const fieldName = this.resolveFieldName(cube, f.member, "any");
309
+ filter[fieldName] = this.convertFilter(f.operator, f.values);
310
+ }
311
+ }
312
+ const rows = await ctx.executeAggregate(objectName, {
313
+ groupBy: groupBy.length > 0 ? groupBy : void 0,
314
+ aggregations: aggregations.length > 0 ? aggregations : void 0,
315
+ filter: Object.keys(filter).length > 0 ? filter : void 0
316
+ });
317
+ const mappedRows = rows.map((row) => {
318
+ const mapped = {};
319
+ if (query.dimensions) {
320
+ for (const dim of query.dimensions) {
321
+ const shortName = this.resolveFieldName(cube, dim, "dimension");
322
+ if (shortName in row) mapped[dim] = row[shortName];
323
+ }
324
+ }
325
+ if (query.measures) {
326
+ for (const m of query.measures) {
327
+ if (m in row) mapped[m] = row[m];
328
+ }
329
+ }
330
+ return mapped;
331
+ });
332
+ const fields = this.buildFieldMeta(query, cube);
333
+ return { rows: mappedRows, fields };
334
+ }
335
+ async generateSql(query, ctx) {
336
+ const cube = ctx.getCube(query.cube);
337
+ if (!cube) {
338
+ throw new Error(`Cube not found: ${query.cube}`);
339
+ }
340
+ const selectParts = [];
341
+ const groupByParts = [];
342
+ if (query.dimensions) {
343
+ for (const dim of query.dimensions) {
344
+ const col = this.resolveFieldName(cube, dim, "dimension");
345
+ selectParts.push(`${col} AS "${dim}"`);
346
+ groupByParts.push(col);
347
+ }
348
+ }
349
+ if (query.measures) {
350
+ for (const m of query.measures) {
351
+ const { field, method } = this.resolveMeasureAggregation(cube, m);
352
+ const aggSql = method === "count" ? "COUNT(*)" : `${method.toUpperCase()}(${field})`;
353
+ selectParts.push(`${aggSql} AS "${m}"`);
354
+ }
355
+ }
356
+ const tableName = this.extractObjectName(cube);
357
+ let sql = `SELECT ${selectParts.join(", ")} FROM "${tableName}"`;
358
+ if (groupByParts.length > 0) {
359
+ sql += ` GROUP BY ${groupByParts.join(", ")}`;
360
+ }
361
+ return { sql, params: [] };
362
+ }
363
+ // ── Helpers ──────────────────────────────────────────────────────
364
+ resolveFieldName(cube, member, kind) {
365
+ const fieldName = member.includes(".") ? member.split(".")[1] : member;
366
+ if (kind === "dimension" || kind === "any") {
367
+ const dim = cube.dimensions[fieldName];
368
+ if (dim) return dim.sql.replace(/^\$/, "");
369
+ }
370
+ if (kind === "measure" || kind === "any") {
371
+ const measure = cube.measures[fieldName];
372
+ if (measure) return measure.sql.replace(/^\$/, "");
373
+ }
374
+ return fieldName;
375
+ }
376
+ resolveMeasureAggregation(cube, measureName) {
377
+ 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
+ };
384
+ }
385
+ convertFilter(operator, values) {
386
+ if (operator === "set") return { $ne: null };
387
+ if (operator === "notSet") return null;
388
+ if (!values || values.length === 0) return void 0;
389
+ switch (operator) {
390
+ case "equals":
391
+ return values[0];
392
+ case "notEquals":
393
+ return { $ne: values[0] };
394
+ case "gt":
395
+ return { $gt: values[0] };
396
+ case "gte":
397
+ return { $gte: values[0] };
398
+ case "lt":
399
+ return { $lt: values[0] };
400
+ case "lte":
401
+ return { $lte: values[0] };
402
+ case "contains":
403
+ return { $regex: values[0] };
404
+ default:
405
+ return values[0];
406
+ }
407
+ }
408
+ extractObjectName(cube) {
409
+ return cube.sql.trim();
410
+ }
411
+ buildFieldMeta(query, cube) {
412
+ const fields = [];
413
+ if (query.dimensions) {
414
+ for (const dim of query.dimensions) {
415
+ const fieldName = dim.includes(".") ? dim.split(".")[1] : dim;
416
+ const d = cube.dimensions[fieldName];
417
+ fields.push({ name: dim, type: d?.type || "string" });
418
+ }
419
+ }
420
+ if (query.measures) {
421
+ for (const m of query.measures) {
422
+ fields.push({ name: m, type: "number" });
423
+ }
424
+ }
425
+ return fields;
426
+ }
427
+ };
428
+
429
+ // src/analytics-service.ts
430
+ var DEFAULT_CAPABILITIES = {
431
+ nativeSql: false,
432
+ objectqlAggregate: false,
433
+ inMemory: true
434
+ };
435
+ var AnalyticsService = class {
436
+ constructor(config = {}) {
437
+ this.logger = config.logger || createLogger({ level: "info", format: "pretty" });
438
+ this.cubeRegistry = new CubeRegistry();
439
+ if (config.cubes) {
440
+ this.cubeRegistry.registerAll(config.cubes);
441
+ }
442
+ this.strategyCtx = {
443
+ getCube: (name) => this.cubeRegistry.get(name),
444
+ queryCapabilities: config.queryCapabilities || (() => DEFAULT_CAPABILITIES),
445
+ executeRawSql: config.executeRawSql,
446
+ executeAggregate: config.executeAggregate,
447
+ fallbackService: config.fallbackService
448
+ };
449
+ const builtIn = [
450
+ new NativeSQLStrategy(),
451
+ new ObjectQLStrategy()
452
+ ];
453
+ if (config.fallbackService) {
454
+ builtIn.push(new FallbackDelegateStrategy());
455
+ }
456
+ const custom = config.strategies || [];
457
+ this.strategies = [...builtIn, ...custom].sort((a, b) => a.priority - b.priority);
458
+ this.logger.info(
459
+ `[Analytics] Initialized with ${this.cubeRegistry.size} cubes, ${this.strategies.length} strategies: ${this.strategies.map((s) => s.name).join(" \u2192 ")}`
460
+ );
461
+ }
462
+ /**
463
+ * Execute an analytical query by delegating to the first capable strategy.
464
+ */
465
+ async query(query) {
466
+ if (!query.cube) {
467
+ throw new Error("Cube name is required in analytics query");
468
+ }
469
+ const strategy = this.resolveStrategy(query);
470
+ this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
471
+ return strategy.execute(query, this.strategyCtx);
472
+ }
473
+ /**
474
+ * Get cube metadata for discovery.
475
+ */
476
+ async getMeta(cubeName) {
477
+ const cubes = cubeName ? [this.cubeRegistry.get(cubeName)].filter(Boolean) : this.cubeRegistry.getAll();
478
+ return cubes.map((cube) => ({
479
+ name: cube.name,
480
+ title: cube.title,
481
+ measures: Object.entries(cube.measures).map(([key, measure]) => ({
482
+ name: `${cube.name}.${key}`,
483
+ type: measure.type,
484
+ title: measure.label
485
+ })),
486
+ dimensions: Object.entries(cube.dimensions).map(([key, dimension]) => ({
487
+ name: `${cube.name}.${key}`,
488
+ type: dimension.type,
489
+ title: dimension.label
490
+ }))
491
+ }));
492
+ }
493
+ /**
494
+ * Generate SQL for a query without executing it (dry-run).
495
+ */
496
+ async generateSql(query) {
497
+ if (!query.cube) {
498
+ throw new Error("Cube name is required for SQL generation");
499
+ }
500
+ const strategy = this.resolveStrategy(query);
501
+ this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" \u2192 ${strategy.name}`);
502
+ return strategy.generateSql(query, this.strategyCtx);
503
+ }
504
+ // ── Internal ─────────────────────────────────────────────────────
505
+ /**
506
+ * Walk the strategy chain and return the first strategy that can handle the query.
507
+ */
508
+ resolveStrategy(query) {
509
+ for (const strategy of this.strategies) {
510
+ if (strategy.canHandle(query, this.strategyCtx)) {
511
+ return strategy;
512
+ }
513
+ }
514
+ throw new Error(
515
+ `[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.`
516
+ );
517
+ }
518
+ };
519
+ var FallbackDelegateStrategy = class {
520
+ constructor() {
521
+ this.name = "FallbackDelegateStrategy";
522
+ this.priority = 30;
523
+ }
524
+ canHandle(query, ctx) {
525
+ if (!query.cube) return false;
526
+ return !!ctx.fallbackService;
527
+ }
528
+ async execute(query, ctx) {
529
+ return ctx.fallbackService.query(query);
530
+ }
531
+ async generateSql(query, ctx) {
532
+ if (ctx.fallbackService?.generateSql) {
533
+ return ctx.fallbackService.generateSql(query);
534
+ }
535
+ return {
536
+ sql: `-- FallbackDelegateStrategy: SQL generation not supported for cube "${query.cube}"`,
537
+ params: []
538
+ };
539
+ }
540
+ };
541
+
542
+ // src/plugin.ts
543
+ var AnalyticsServicePlugin = class {
544
+ constructor(options = {}) {
545
+ this.name = "com.objectstack.service-analytics";
546
+ this.version = "1.0.0";
547
+ this.type = "standard";
548
+ this.dependencies = [];
549
+ this.options = options;
550
+ }
551
+ async init(ctx) {
552
+ let fallbackService;
553
+ try {
554
+ const existing = ctx.getService("analytics");
555
+ if (existing && typeof existing.query === "function") {
556
+ fallbackService = existing;
557
+ ctx.logger.debug("[Analytics] Found existing analytics service, using as fallback");
558
+ }
559
+ } catch {
560
+ }
561
+ const config = {
562
+ cubes: this.options.cubes,
563
+ logger: ctx.logger,
564
+ queryCapabilities: this.options.queryCapabilities,
565
+ executeRawSql: this.options.executeRawSql,
566
+ executeAggregate: this.options.executeAggregate,
567
+ fallbackService
568
+ };
569
+ this.service = new AnalyticsService(config);
570
+ if (fallbackService) {
571
+ ctx.replaceService("analytics", this.service);
572
+ } else {
573
+ ctx.registerService("analytics", this.service);
574
+ }
575
+ if (this.options.debug) {
576
+ ctx.hook("analytics:beforeQuery", async (query) => {
577
+ ctx.logger.debug("[Analytics] Before query", { query });
578
+ });
579
+ }
580
+ ctx.logger.info("[Analytics] Service initialized");
581
+ }
582
+ async start(ctx) {
583
+ if (!this.service) return;
584
+ await ctx.trigger("analytics:ready", this.service);
585
+ ctx.logger.info(
586
+ `[Analytics] Service started with ${this.service.cubeRegistry.size} cubes: ${this.service.cubeRegistry.names().join(", ") || "(none)"}`
587
+ );
588
+ }
589
+ async destroy() {
590
+ this.service = void 0;
591
+ }
592
+ };
593
+ export {
594
+ AnalyticsService,
595
+ AnalyticsServicePlugin,
596
+ CubeRegistry,
597
+ NativeSQLStrategy,
598
+ ObjectQLStrategy
599
+ };
600
+ //# sourceMappingURL=index.js.map