@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/.turbo/turbo-build.log +22 -0
- package/CHANGELOG.md +19 -0
- package/LICENSE +202 -0
- package/dist/index.cjs +631 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +260 -0
- package/dist/index.d.ts +260 -0
- package/dist/index.js +600 -0
- package/dist/index.js.map +1 -0
- package/package.json +29 -0
- package/src/__tests__/analytics-service.test.ts +469 -0
- package/src/analytics-service.ts +231 -0
- package/src/cube-registry.ts +147 -0
- package/src/index.ts +19 -0
- package/src/plugin.ts +133 -0
- package/src/strategies/native-sql-strategy.ts +184 -0
- package/src/strategies/objectql-strategy.ts +178 -0
- package/src/strategies/types.ts +11 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import type { AnalyticsQuery, AnalyticsResult } from '@objectstack/spec/contracts';
|
|
4
|
+
import type { Cube } from '@objectstack/spec/data';
|
|
5
|
+
import type { AnalyticsStrategy, StrategyContext } from './types.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* NativeSQLStrategy — Priority 1
|
|
9
|
+
*
|
|
10
|
+
* Pushes the analytics query down to the database as a native SQL statement.
|
|
11
|
+
* This is the most efficient path and is preferred whenever the backing driver
|
|
12
|
+
* supports raw SQL execution (e.g. Postgres, MySQL, SQLite).
|
|
13
|
+
*/
|
|
14
|
+
export class NativeSQLStrategy implements AnalyticsStrategy {
|
|
15
|
+
readonly name = 'NativeSQLStrategy';
|
|
16
|
+
readonly priority = 10;
|
|
17
|
+
|
|
18
|
+
canHandle(query: AnalyticsQuery, ctx: StrategyContext): boolean {
|
|
19
|
+
if (!query.cube) return false;
|
|
20
|
+
const caps = ctx.queryCapabilities(query.cube);
|
|
21
|
+
return caps.nativeSql && typeof ctx.executeRawSql === 'function';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async execute(query: AnalyticsQuery, ctx: StrategyContext): Promise<AnalyticsResult> {
|
|
25
|
+
const { sql, params } = await this.generateSql(query, ctx);
|
|
26
|
+
const cube = ctx.getCube(query.cube!)!;
|
|
27
|
+
const objectName = this.extractObjectName(cube);
|
|
28
|
+
|
|
29
|
+
const rows = await ctx.executeRawSql!(objectName, sql, params);
|
|
30
|
+
|
|
31
|
+
// Build field metadata
|
|
32
|
+
const fields = this.buildFieldMeta(query, cube);
|
|
33
|
+
|
|
34
|
+
return { rows, fields, sql };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async generateSql(query: AnalyticsQuery, ctx: StrategyContext): Promise<{ sql: string; params: unknown[] }> {
|
|
38
|
+
const cube = ctx.getCube(query.cube!);
|
|
39
|
+
if (!cube) {
|
|
40
|
+
throw new Error(`Cube not found: ${query.cube}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const params: unknown[] = [];
|
|
44
|
+
const selectClauses: string[] = [];
|
|
45
|
+
const groupByClauses: string[] = [];
|
|
46
|
+
|
|
47
|
+
// Build SELECT for dimensions
|
|
48
|
+
if (query.dimensions && query.dimensions.length > 0) {
|
|
49
|
+
for (const dim of query.dimensions) {
|
|
50
|
+
const colExpr = this.resolveDimensionSql(cube, dim);
|
|
51
|
+
selectClauses.push(`${colExpr} AS "${dim}"`);
|
|
52
|
+
groupByClauses.push(colExpr);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Build SELECT for measures
|
|
57
|
+
if (query.measures && query.measures.length > 0) {
|
|
58
|
+
for (const measure of query.measures) {
|
|
59
|
+
const aggExpr = this.resolveMeasureSql(cube, measure);
|
|
60
|
+
selectClauses.push(`${aggExpr} AS "${measure}"`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Build WHERE clause
|
|
65
|
+
const whereClauses: string[] = [];
|
|
66
|
+
if (query.filters && query.filters.length > 0) {
|
|
67
|
+
for (const filter of query.filters) {
|
|
68
|
+
const colExpr = this.resolveFieldSql(cube, filter.member);
|
|
69
|
+
const clause = this.buildFilterClause(colExpr, filter.operator, filter.values, params);
|
|
70
|
+
if (clause) whereClauses.push(clause);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Build time dimension filters
|
|
75
|
+
if (query.timeDimensions && query.timeDimensions.length > 0) {
|
|
76
|
+
for (const td of query.timeDimensions) {
|
|
77
|
+
const colExpr = this.resolveFieldSql(cube, td.dimension);
|
|
78
|
+
if (td.dateRange) {
|
|
79
|
+
const range = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
|
|
80
|
+
if (range.length === 2) {
|
|
81
|
+
params.push(range[0], range[1]);
|
|
82
|
+
whereClauses.push(`${colExpr} BETWEEN $${params.length - 1} AND $${params.length}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const tableName = this.extractObjectName(cube);
|
|
89
|
+
let sql = `SELECT ${selectClauses.join(', ')} FROM "${tableName}"`;
|
|
90
|
+
if (whereClauses.length > 0) {
|
|
91
|
+
sql += ` WHERE ${whereClauses.join(' AND ')}`;
|
|
92
|
+
}
|
|
93
|
+
if (groupByClauses.length > 0) {
|
|
94
|
+
sql += ` GROUP BY ${groupByClauses.join(', ')}`;
|
|
95
|
+
}
|
|
96
|
+
if (query.order && Object.keys(query.order).length > 0) {
|
|
97
|
+
const orderClauses = Object.entries(query.order).map(([f, d]) => `"${f}" ${d.toUpperCase()}`);
|
|
98
|
+
sql += ` ORDER BY ${orderClauses.join(', ')}`;
|
|
99
|
+
}
|
|
100
|
+
if (query.limit != null) {
|
|
101
|
+
sql += ` LIMIT ${query.limit}`;
|
|
102
|
+
}
|
|
103
|
+
if (query.offset != null) {
|
|
104
|
+
sql += ` OFFSET ${query.offset}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { sql, params };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
private resolveDimensionSql(cube: Cube, member: string): string {
|
|
113
|
+
const fieldName = member.includes('.') ? member.split('.')[1] : member;
|
|
114
|
+
const dim = cube.dimensions[fieldName];
|
|
115
|
+
return dim ? dim.sql : fieldName;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private resolveMeasureSql(cube: Cube, member: string): string {
|
|
119
|
+
const fieldName = member.includes('.') ? member.split('.')[1] : member;
|
|
120
|
+
const measure = cube.measures[fieldName];
|
|
121
|
+
if (!measure) return `COUNT(*)`;
|
|
122
|
+
|
|
123
|
+
const col = measure.sql;
|
|
124
|
+
switch (measure.type) {
|
|
125
|
+
case 'count': return 'COUNT(*)';
|
|
126
|
+
case 'sum': return `SUM(${col})`;
|
|
127
|
+
case 'avg': return `AVG(${col})`;
|
|
128
|
+
case 'min': return `MIN(${col})`;
|
|
129
|
+
case 'max': return `MAX(${col})`;
|
|
130
|
+
case 'count_distinct': return `COUNT(DISTINCT ${col})`;
|
|
131
|
+
default: return `COUNT(*)`;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private resolveFieldSql(cube: Cube, member: string): string {
|
|
136
|
+
const fieldName = member.includes('.') ? member.split('.')[1] : member;
|
|
137
|
+
const dim = cube.dimensions[fieldName];
|
|
138
|
+
if (dim) return dim.sql;
|
|
139
|
+
const measure = cube.measures[fieldName];
|
|
140
|
+
if (measure) return measure.sql;
|
|
141
|
+
return fieldName;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private buildFilterClause(col: string, operator: string, values: string[] | undefined, params: unknown[]): string | null {
|
|
145
|
+
const opMap: Record<string, string> = {
|
|
146
|
+
equals: '=', notEquals: '!=', gt: '>', gte: '>=', lt: '<', lte: '<=',
|
|
147
|
+
contains: 'LIKE', notContains: 'NOT LIKE',
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
if (operator === 'set') return `${col} IS NOT NULL`;
|
|
151
|
+
if (operator === 'notSet') return `${col} IS NULL`;
|
|
152
|
+
|
|
153
|
+
const sqlOp = opMap[operator];
|
|
154
|
+
if (!sqlOp || !values || values.length === 0) return null;
|
|
155
|
+
|
|
156
|
+
if (operator === 'contains' || operator === 'notContains') {
|
|
157
|
+
params.push(`%${values[0]}%`);
|
|
158
|
+
} else {
|
|
159
|
+
params.push(values[0]);
|
|
160
|
+
}
|
|
161
|
+
return `${col} ${sqlOp} $${params.length}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private extractObjectName(cube: Cube): string {
|
|
165
|
+
return cube.sql.trim();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private buildFieldMeta(query: AnalyticsQuery, cube: Cube): Array<{ name: string; type: string }> {
|
|
169
|
+
const fields: Array<{ name: string; type: string }> = [];
|
|
170
|
+
if (query.dimensions) {
|
|
171
|
+
for (const dim of query.dimensions) {
|
|
172
|
+
const fieldName = dim.includes('.') ? dim.split('.')[1] : dim;
|
|
173
|
+
const d = cube.dimensions[fieldName];
|
|
174
|
+
fields.push({ name: dim, type: d?.type || 'string' });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (query.measures) {
|
|
178
|
+
for (const m of query.measures) {
|
|
179
|
+
fields.push({ name: m, type: 'number' });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return fields;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import type { AnalyticsQuery, AnalyticsResult } from '@objectstack/spec/contracts';
|
|
4
|
+
import type { Cube } from '@objectstack/spec/data';
|
|
5
|
+
import type { AnalyticsStrategy, StrategyContext } from './types.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* ObjectQLStrategy — Priority 2
|
|
9
|
+
*
|
|
10
|
+
* Translates an analytics query into an ObjectQL `engine.aggregate()` call.
|
|
11
|
+
* This path works with any driver that supports the ObjectQL aggregate AST
|
|
12
|
+
* (Postgres, Mongo, SQLite, etc.) without requiring raw SQL access.
|
|
13
|
+
*/
|
|
14
|
+
export class ObjectQLStrategy implements AnalyticsStrategy {
|
|
15
|
+
readonly name = 'ObjectQLStrategy';
|
|
16
|
+
readonly priority = 20;
|
|
17
|
+
|
|
18
|
+
canHandle(query: AnalyticsQuery, ctx: StrategyContext): boolean {
|
|
19
|
+
if (!query.cube) return false;
|
|
20
|
+
const caps = ctx.queryCapabilities(query.cube);
|
|
21
|
+
return caps.objectqlAggregate && typeof ctx.executeAggregate === 'function';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async execute(query: AnalyticsQuery, ctx: StrategyContext): Promise<AnalyticsResult> {
|
|
25
|
+
const cube = ctx.getCube(query.cube!)!;
|
|
26
|
+
const objectName = this.extractObjectName(cube);
|
|
27
|
+
|
|
28
|
+
// Build groupBy from dimensions
|
|
29
|
+
const groupBy: string[] = [];
|
|
30
|
+
if (query.dimensions && query.dimensions.length > 0) {
|
|
31
|
+
for (const dim of query.dimensions) {
|
|
32
|
+
groupBy.push(this.resolveFieldName(cube, dim, 'dimension'));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Build aggregations from measures
|
|
37
|
+
const aggregations: Array<{ field: string; method: string; alias: string }> = [];
|
|
38
|
+
if (query.measures && query.measures.length > 0) {
|
|
39
|
+
for (const measure of query.measures) {
|
|
40
|
+
const { field, method } = this.resolveMeasureAggregation(cube, measure);
|
|
41
|
+
aggregations.push({ field, method, alias: measure });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Build filter from query filters
|
|
46
|
+
const filter: Record<string, unknown> = {};
|
|
47
|
+
if (query.filters && query.filters.length > 0) {
|
|
48
|
+
for (const f of query.filters) {
|
|
49
|
+
const fieldName = this.resolveFieldName(cube, f.member, 'any');
|
|
50
|
+
filter[fieldName] = this.convertFilter(f.operator, f.values);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const rows = await ctx.executeAggregate!(objectName, {
|
|
55
|
+
groupBy: groupBy.length > 0 ? groupBy : undefined,
|
|
56
|
+
aggregations: aggregations.length > 0 ? aggregations : undefined,
|
|
57
|
+
filter: Object.keys(filter).length > 0 ? filter : undefined,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Remap short field names back to cube-qualified names
|
|
61
|
+
const mappedRows = rows.map(row => {
|
|
62
|
+
const mapped: Record<string, unknown> = {};
|
|
63
|
+
if (query.dimensions) {
|
|
64
|
+
for (const dim of query.dimensions) {
|
|
65
|
+
const shortName = this.resolveFieldName(cube, dim, 'dimension');
|
|
66
|
+
if (shortName in row) mapped[dim] = row[shortName];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (query.measures) {
|
|
70
|
+
for (const m of query.measures) {
|
|
71
|
+
// Alias was set to the full measure name
|
|
72
|
+
if (m in row) mapped[m] = row[m];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return mapped;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const fields = this.buildFieldMeta(query, cube);
|
|
79
|
+
return { rows: mappedRows, fields };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async generateSql(query: AnalyticsQuery, ctx: StrategyContext): Promise<{ sql: string; params: unknown[] }> {
|
|
83
|
+
const cube = ctx.getCube(query.cube!);
|
|
84
|
+
if (!cube) {
|
|
85
|
+
throw new Error(`Cube not found: ${query.cube}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Generate a representative SQL even though ObjectQL uses AST internally
|
|
89
|
+
const selectParts: string[] = [];
|
|
90
|
+
const groupByParts: string[] = [];
|
|
91
|
+
|
|
92
|
+
if (query.dimensions) {
|
|
93
|
+
for (const dim of query.dimensions) {
|
|
94
|
+
const col = this.resolveFieldName(cube, dim, 'dimension');
|
|
95
|
+
selectParts.push(`${col} AS "${dim}"`);
|
|
96
|
+
groupByParts.push(col);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (query.measures) {
|
|
100
|
+
for (const m of query.measures) {
|
|
101
|
+
const { field, method } = this.resolveMeasureAggregation(cube, m);
|
|
102
|
+
const aggSql = method === 'count' ? 'COUNT(*)' : `${method.toUpperCase()}(${field})`;
|
|
103
|
+
selectParts.push(`${aggSql} AS "${m}"`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const tableName = this.extractObjectName(cube);
|
|
108
|
+
let sql = `SELECT ${selectParts.join(', ')} FROM "${tableName}"`;
|
|
109
|
+
if (groupByParts.length > 0) {
|
|
110
|
+
sql += ` GROUP BY ${groupByParts.join(', ')}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { sql, params: [] };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
private resolveFieldName(cube: Cube, member: string, kind: 'dimension' | 'measure' | 'any'): string {
|
|
119
|
+
const fieldName = member.includes('.') ? member.split('.')[1] : member;
|
|
120
|
+
if (kind === 'dimension' || kind === 'any') {
|
|
121
|
+
const dim = cube.dimensions[fieldName];
|
|
122
|
+
if (dim) return dim.sql.replace(/^\$/, '');
|
|
123
|
+
}
|
|
124
|
+
if (kind === 'measure' || kind === 'any') {
|
|
125
|
+
const measure = cube.measures[fieldName];
|
|
126
|
+
if (measure) return measure.sql.replace(/^\$/, '');
|
|
127
|
+
}
|
|
128
|
+
return fieldName;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private resolveMeasureAggregation(cube: Cube, measureName: string): { field: string; method: string } {
|
|
132
|
+
const fieldName = measureName.includes('.') ? measureName.split('.')[1] : measureName;
|
|
133
|
+
const measure = cube.measures[fieldName];
|
|
134
|
+
if (!measure) return { field: '*', method: 'count' };
|
|
135
|
+
return {
|
|
136
|
+
field: measure.sql.replace(/^\$/, ''),
|
|
137
|
+
method: measure.type === 'count_distinct' ? 'count_distinct' : measure.type,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private convertFilter(operator: string, values?: string[]): unknown {
|
|
142
|
+
if (operator === 'set') return { $ne: null };
|
|
143
|
+
if (operator === 'notSet') return null;
|
|
144
|
+
if (!values || values.length === 0) return undefined;
|
|
145
|
+
|
|
146
|
+
switch (operator) {
|
|
147
|
+
case 'equals': return values[0];
|
|
148
|
+
case 'notEquals': return { $ne: values[0] };
|
|
149
|
+
case 'gt': return { $gt: values[0] };
|
|
150
|
+
case 'gte': return { $gte: values[0] };
|
|
151
|
+
case 'lt': return { $lt: values[0] };
|
|
152
|
+
case 'lte': return { $lte: values[0] };
|
|
153
|
+
case 'contains': return { $regex: values[0] };
|
|
154
|
+
default: return values[0];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private extractObjectName(cube: Cube): string {
|
|
159
|
+
return cube.sql.trim();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private buildFieldMeta(query: AnalyticsQuery, cube: Cube): Array<{ name: string; type: string }> {
|
|
163
|
+
const fields: Array<{ name: string; type: string }> = [];
|
|
164
|
+
if (query.dimensions) {
|
|
165
|
+
for (const dim of query.dimensions) {
|
|
166
|
+
const fieldName = dim.includes('.') ? dim.split('.')[1] : dim;
|
|
167
|
+
const d = cube.dimensions[fieldName];
|
|
168
|
+
fields.push({ name: dim, type: d?.type || 'string' });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (query.measures) {
|
|
172
|
+
for (const m of query.measures) {
|
|
173
|
+
fields.push({ name: m, type: 'number' });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return fields;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Strategy pattern types — re-exported from @objectstack/spec/contracts
|
|
5
|
+
* for convenience. The canonical definitions live in the spec package.
|
|
6
|
+
*/
|
|
7
|
+
export type {
|
|
8
|
+
AnalyticsStrategy,
|
|
9
|
+
StrategyContext,
|
|
10
|
+
DriverCapabilities,
|
|
11
|
+
} from '@objectstack/spec/contracts';
|