@objectstack/service-analytics 4.0.3 → 4.0.5
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/README.md +395 -0
- package/dist/index.cjs +257 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +26 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +257 -14
- package/dist/index.js.map +1 -1
- package/package.json +31 -6
- package/.turbo/turbo-build.log +0 -22
- package/CHANGELOG.md +0 -57
- package/src/__tests__/analytics-service.test.ts +0 -469
- package/src/analytics-service.ts +0 -231
- package/src/cube-registry.ts +0 -147
- package/src/index.ts +0 -19
- package/src/plugin.ts +0 -133
- package/src/strategies/native-sql-strategy.ts +0 -184
- package/src/strategies/objectql-strategy.ts +0 -178
- package/src/strategies/types.ts +0 -11
- package/tsconfig.json +0 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/service-analytics",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.5",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Analytics Service for ObjectStack — implements IAnalyticsService with multi-driver strategy pattern (NativeSQL, ObjectQL, InMemory)",
|
|
6
6
|
"type": "module",
|
|
@@ -14,13 +14,38 @@
|
|
|
14
14
|
}
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@objectstack/core": "4.0.
|
|
18
|
-
"@objectstack/spec": "4.0.
|
|
17
|
+
"@objectstack/core": "4.0.5",
|
|
18
|
+
"@objectstack/spec": "4.0.5"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
|
-
"@types/node": "^25.6.
|
|
22
|
-
"typescript": "^6.0.
|
|
23
|
-
"vitest": "^4.1.
|
|
21
|
+
"@types/node": "^25.6.2",
|
|
22
|
+
"typescript": "^6.0.3",
|
|
23
|
+
"vitest": "^4.1.5"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"objectstack",
|
|
27
|
+
"service",
|
|
28
|
+
"analytics",
|
|
29
|
+
"metrics",
|
|
30
|
+
"events"
|
|
31
|
+
],
|
|
32
|
+
"author": "ObjectStack",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/objectstack-ai/framework.git",
|
|
36
|
+
"directory": "packages/services/service-analytics"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://objectstack.ai/docs",
|
|
39
|
+
"bugs": "https://github.com/objectstack-ai/framework/issues",
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"dist",
|
|
45
|
+
"README.md"
|
|
46
|
+
],
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=18.0.0"
|
|
24
49
|
},
|
|
25
50
|
"scripts": {
|
|
26
51
|
"build": "tsup --config ../../../tsup.config.ts",
|
package/.turbo/turbo-build.log
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
> @objectstack/service-analytics@4.0.3 build /home/runner/work/framework/framework/packages/services/service-analytics
|
|
3
|
-
> tsup --config ../../../tsup.config.ts
|
|
4
|
-
|
|
5
|
-
[34mCLI[39m Building entry: src/index.ts
|
|
6
|
-
[34mCLI[39m Using tsconfig: tsconfig.json
|
|
7
|
-
[34mCLI[39m tsup v8.5.1
|
|
8
|
-
[34mCLI[39m Using tsup config: /home/runner/work/framework/framework/tsup.config.ts
|
|
9
|
-
[34mCLI[39m Target: es2020
|
|
10
|
-
[34mCLI[39m Cleaning output folder
|
|
11
|
-
[34mESM[39m Build start
|
|
12
|
-
[34mCJS[39m Build start
|
|
13
|
-
[32mESM[39m [1mdist/index.js [22m[32m18.81 KB[39m
|
|
14
|
-
[32mESM[39m [1mdist/index.js.map [22m[32m43.38 KB[39m
|
|
15
|
-
[32mESM[39m ⚡️ Build success in 89ms
|
|
16
|
-
[32mCJS[39m [1mdist/index.cjs [22m[32m20.02 KB[39m
|
|
17
|
-
[32mCJS[39m [1mdist/index.cjs.map [22m[32m44.22 KB[39m
|
|
18
|
-
[32mCJS[39m ⚡️ Build success in 93ms
|
|
19
|
-
[34mDTS[39m Build start
|
|
20
|
-
[32mDTS[39m ⚡️ Build success in 13138ms
|
|
21
|
-
[32mDTS[39m [1mdist/index.d.ts [22m[32m9.26 KB[39m
|
|
22
|
-
[32mDTS[39m [1mdist/index.d.cts [22m[32m9.26 KB[39m
|
package/CHANGELOG.md
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
# Changelog — @objectstack/service-analytics
|
|
2
|
-
|
|
3
|
-
## 4.0.3
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
- @objectstack/spec@4.0.3
|
|
8
|
-
- @objectstack/core@4.0.3
|
|
9
|
-
|
|
10
|
-
## 4.0.2
|
|
11
|
-
|
|
12
|
-
### Patch Changes
|
|
13
|
-
|
|
14
|
-
- Updated dependencies [5f659e9]
|
|
15
|
-
- @objectstack/spec@4.0.2
|
|
16
|
-
- @objectstack/core@4.0.2
|
|
17
|
-
|
|
18
|
-
## 4.0.0
|
|
19
|
-
|
|
20
|
-
### Patch Changes
|
|
21
|
-
|
|
22
|
-
- Updated dependencies [f08ffc3]
|
|
23
|
-
- Updated dependencies [e0b0a78]
|
|
24
|
-
- @objectstack/spec@4.0.0
|
|
25
|
-
- @objectstack/core@4.0.0
|
|
26
|
-
|
|
27
|
-
## 3.3.1
|
|
28
|
-
|
|
29
|
-
### Patch Changes
|
|
30
|
-
|
|
31
|
-
- @objectstack/spec@3.3.1
|
|
32
|
-
- @objectstack/core@3.3.1
|
|
33
|
-
|
|
34
|
-
## 3.2.10
|
|
35
|
-
|
|
36
|
-
### Patch Changes
|
|
37
|
-
|
|
38
|
-
- @objectstack/spec@3.3.0
|
|
39
|
-
- @objectstack/core@3.3.0
|
|
40
|
-
|
|
41
|
-
All notable changes to this package will be documented in this file.
|
|
42
|
-
|
|
43
|
-
## [3.2.9] — 2026-03-22
|
|
44
|
-
|
|
45
|
-
### Added
|
|
46
|
-
|
|
47
|
-
- Initial implementation of `@objectstack/service-analytics`
|
|
48
|
-
- `AnalyticsService` orchestrator implementing `IAnalyticsService`
|
|
49
|
-
- Strategy pattern with priority chain:
|
|
50
|
-
- **P1 — NativeSQLStrategy**: Pushes queries as native SQL to SQL-capable drivers (Postgres, MySQL, etc.)
|
|
51
|
-
- **P2 — ObjectQLStrategy**: Translates analytics queries into ObjectQL `engine.aggregate()` calls
|
|
52
|
-
- **P3 — InMemoryStrategy**: Delegates to any registered `IAnalyticsService` (e.g., `MemoryAnalyticsService`)
|
|
53
|
-
- `CubeRegistry` for auto-discovery and registration of cubes from manifest definitions and object schema inference
|
|
54
|
-
- `AnalyticsServicePlugin` for kernel plugin lifecycle integration
|
|
55
|
-
- `queryCapabilities()` driver capability probing for strategy selection
|
|
56
|
-
- `generateSql()` dry-run SQL generation across all strategies
|
|
57
|
-
- Unit tests covering all strategy branches
|
|
@@ -1,469 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
4
|
-
import type { Cube } from '@objectstack/spec/data';
|
|
5
|
-
import type { AnalyticsQuery, AnalyticsResult, IAnalyticsService } from '@objectstack/spec/contracts';
|
|
6
|
-
import { AnalyticsService } from '../analytics-service.js';
|
|
7
|
-
import { CubeRegistry } from '../cube-registry.js';
|
|
8
|
-
import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js';
|
|
9
|
-
import { ObjectQLStrategy } from '../strategies/objectql-strategy.js';
|
|
10
|
-
import type { DriverCapabilities } from '../strategies/types.js';
|
|
11
|
-
|
|
12
|
-
// ─────────────────────────────────────────────────────────────────
|
|
13
|
-
// Test fixtures
|
|
14
|
-
// ─────────────────────────────────────────────────────────────────
|
|
15
|
-
|
|
16
|
-
const ordersCube: Cube = {
|
|
17
|
-
name: 'orders',
|
|
18
|
-
title: 'Orders',
|
|
19
|
-
sql: 'orders',
|
|
20
|
-
measures: {
|
|
21
|
-
count: { name: 'count', label: 'Count', type: 'count', sql: '*' },
|
|
22
|
-
total_amount: { name: 'total_amount', label: 'Total Amount', type: 'sum', sql: 'amount' },
|
|
23
|
-
avg_amount: { name: 'avg_amount', label: 'Avg Amount', type: 'avg', sql: 'amount' },
|
|
24
|
-
},
|
|
25
|
-
dimensions: {
|
|
26
|
-
status: { name: 'status', label: 'Status', type: 'string', sql: 'status' },
|
|
27
|
-
created_at: {
|
|
28
|
-
name: 'created_at',
|
|
29
|
-
label: 'Created At',
|
|
30
|
-
type: 'time',
|
|
31
|
-
sql: 'created_at',
|
|
32
|
-
granularities: ['day', 'week', 'month'],
|
|
33
|
-
},
|
|
34
|
-
},
|
|
35
|
-
public: false,
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
const baseQuery: AnalyticsQuery = {
|
|
39
|
-
cube: 'orders',
|
|
40
|
-
measures: ['orders.count', 'orders.total_amount'],
|
|
41
|
-
dimensions: ['orders.status'],
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
// Suppress logger output in tests
|
|
45
|
-
const silentLogger = {
|
|
46
|
-
info: vi.fn(),
|
|
47
|
-
debug: vi.fn(),
|
|
48
|
-
warn: vi.fn(),
|
|
49
|
-
error: vi.fn(),
|
|
50
|
-
child: vi.fn().mockReturnThis(),
|
|
51
|
-
} as any;
|
|
52
|
-
|
|
53
|
-
// ─────────────────────────────────────────────────────────────────
|
|
54
|
-
// CubeRegistry
|
|
55
|
-
// ─────────────────────────────────────────────────────────────────
|
|
56
|
-
|
|
57
|
-
describe('CubeRegistry', () => {
|
|
58
|
-
let registry: CubeRegistry;
|
|
59
|
-
|
|
60
|
-
beforeEach(() => {
|
|
61
|
-
registry = new CubeRegistry();
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('should register and retrieve a cube', () => {
|
|
65
|
-
registry.register(ordersCube);
|
|
66
|
-
expect(registry.get('orders')).toEqual(ordersCube);
|
|
67
|
-
expect(registry.has('orders')).toBe(true);
|
|
68
|
-
expect(registry.size).toBe(1);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('should register multiple cubes at once', () => {
|
|
72
|
-
const cube2: Cube = { ...ordersCube, name: 'products', sql: 'products' };
|
|
73
|
-
registry.registerAll([ordersCube, cube2]);
|
|
74
|
-
expect(registry.size).toBe(2);
|
|
75
|
-
expect(registry.names()).toEqual(['orders', 'products']);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('should return undefined for unknown cube', () => {
|
|
79
|
-
expect(registry.get('nonexistent')).toBeUndefined();
|
|
80
|
-
expect(registry.has('nonexistent')).toBe(false);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it('should clear all cubes', () => {
|
|
84
|
-
registry.register(ordersCube);
|
|
85
|
-
registry.clear();
|
|
86
|
-
expect(registry.size).toBe(0);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('should infer a cube from object fields', () => {
|
|
90
|
-
const cube = registry.inferFromObject('tasks', [
|
|
91
|
-
{ name: 'title', type: 'text', label: 'Title' },
|
|
92
|
-
{ name: 'hours', type: 'number', label: 'Hours' },
|
|
93
|
-
{ name: 'due_date', type: 'date', label: 'Due Date' },
|
|
94
|
-
{ name: 'active', type: 'boolean', label: 'Active' },
|
|
95
|
-
]);
|
|
96
|
-
|
|
97
|
-
expect(cube.name).toBe('tasks');
|
|
98
|
-
expect(cube.measures.count).toBeDefined();
|
|
99
|
-
expect(cube.measures.hours_sum).toBeDefined();
|
|
100
|
-
expect(cube.measures.hours_avg).toBeDefined();
|
|
101
|
-
expect(cube.dimensions.title.type).toBe('string');
|
|
102
|
-
expect(cube.dimensions.hours.type).toBe('number');
|
|
103
|
-
expect(cube.dimensions.due_date.type).toBe('time');
|
|
104
|
-
expect(cube.dimensions.active.type).toBe('boolean');
|
|
105
|
-
|
|
106
|
-
// Should also be registered automatically
|
|
107
|
-
expect(registry.get('tasks')).toBe(cube);
|
|
108
|
-
});
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
// ─────────────────────────────────────────────────────────────────
|
|
112
|
-
// NativeSQLStrategy
|
|
113
|
-
// ─────────────────────────────────────────────────────────────────
|
|
114
|
-
|
|
115
|
-
describe('NativeSQLStrategy', () => {
|
|
116
|
-
const strategy = new NativeSQLStrategy();
|
|
117
|
-
|
|
118
|
-
it('should have correct name and priority', () => {
|
|
119
|
-
expect(strategy.name).toBe('NativeSQLStrategy');
|
|
120
|
-
expect(strategy.priority).toBe(10);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it('should handle when nativeSql capability is true', () => {
|
|
124
|
-
const ctx = {
|
|
125
|
-
getCube: () => ordersCube,
|
|
126
|
-
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
|
|
127
|
-
executeRawSql: vi.fn(),
|
|
128
|
-
};
|
|
129
|
-
expect(strategy.canHandle(baseQuery, ctx)).toBe(true);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it('should not handle when nativeSql is false', () => {
|
|
133
|
-
const ctx = {
|
|
134
|
-
getCube: () => ordersCube,
|
|
135
|
-
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
|
|
136
|
-
};
|
|
137
|
-
expect(strategy.canHandle(baseQuery, ctx)).toBe(false);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it('should not handle when cube is missing', () => {
|
|
141
|
-
const ctx = {
|
|
142
|
-
getCube: () => ordersCube,
|
|
143
|
-
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
|
|
144
|
-
executeRawSql: vi.fn(),
|
|
145
|
-
};
|
|
146
|
-
expect(strategy.canHandle({ measures: ['count'] }, ctx)).toBe(false);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it('should generate SQL with dimensions, measures, and filters', async () => {
|
|
150
|
-
const ctx = {
|
|
151
|
-
getCube: () => ordersCube,
|
|
152
|
-
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
|
|
153
|
-
executeRawSql: vi.fn(),
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
const query: AnalyticsQuery = {
|
|
157
|
-
cube: 'orders',
|
|
158
|
-
measures: ['orders.count', 'orders.total_amount'],
|
|
159
|
-
dimensions: ['orders.status'],
|
|
160
|
-
filters: [{ member: 'orders.status', operator: 'equals', values: ['completed'] }],
|
|
161
|
-
limit: 10,
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
const { sql, params } = await strategy.generateSql(query, ctx);
|
|
165
|
-
|
|
166
|
-
expect(sql).toContain('SELECT');
|
|
167
|
-
expect(sql).toContain('COUNT(*)');
|
|
168
|
-
expect(sql).toContain('SUM(amount)');
|
|
169
|
-
expect(sql).toContain('GROUP BY');
|
|
170
|
-
expect(sql).toContain('LIMIT 10');
|
|
171
|
-
expect(params).toContain('completed');
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
it('should execute query and return structured result', async () => {
|
|
175
|
-
const mockRows = [
|
|
176
|
-
{ 'orders.status': 'completed', 'orders.count': 5, 'orders.total_amount': 500 },
|
|
177
|
-
];
|
|
178
|
-
const executeRawSql = vi.fn().mockResolvedValue(mockRows);
|
|
179
|
-
|
|
180
|
-
const ctx = {
|
|
181
|
-
getCube: () => ordersCube,
|
|
182
|
-
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
|
|
183
|
-
executeRawSql,
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
const result = await strategy.execute(baseQuery, ctx);
|
|
187
|
-
|
|
188
|
-
expect(executeRawSql).toHaveBeenCalled();
|
|
189
|
-
expect(result.rows).toEqual(mockRows);
|
|
190
|
-
expect(result.fields).toHaveLength(3); // 1 dimension + 2 measures
|
|
191
|
-
expect(result.sql).toBeDefined();
|
|
192
|
-
});
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
// ─────────────────────────────────────────────────────────────────
|
|
196
|
-
// ObjectQLStrategy
|
|
197
|
-
// ─────────────────────────────────────────────────────────────────
|
|
198
|
-
|
|
199
|
-
describe('ObjectQLStrategy', () => {
|
|
200
|
-
const strategy = new ObjectQLStrategy();
|
|
201
|
-
|
|
202
|
-
it('should have correct name and priority', () => {
|
|
203
|
-
expect(strategy.name).toBe('ObjectQLStrategy');
|
|
204
|
-
expect(strategy.priority).toBe(20);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it('should handle when objectqlAggregate capability is true', () => {
|
|
208
|
-
const ctx = {
|
|
209
|
-
getCube: () => ordersCube,
|
|
210
|
-
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
|
|
211
|
-
executeAggregate: vi.fn(),
|
|
212
|
-
};
|
|
213
|
-
expect(strategy.canHandle(baseQuery, ctx)).toBe(true);
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
it('should not handle without executeAggregate', () => {
|
|
217
|
-
const ctx = {
|
|
218
|
-
getCube: () => ordersCube,
|
|
219
|
-
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
|
|
220
|
-
};
|
|
221
|
-
expect(strategy.canHandle(baseQuery, ctx)).toBe(false);
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
it('should execute an aggregate query', async () => {
|
|
225
|
-
const mockRows = [
|
|
226
|
-
{ status: 'pending', 'orders.count': 3, 'orders.total_amount': 150 },
|
|
227
|
-
];
|
|
228
|
-
const executeAggregate = vi.fn().mockResolvedValue(mockRows);
|
|
229
|
-
|
|
230
|
-
const ctx = {
|
|
231
|
-
getCube: () => ordersCube,
|
|
232
|
-
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
|
|
233
|
-
executeAggregate,
|
|
234
|
-
};
|
|
235
|
-
|
|
236
|
-
const result = await strategy.execute(baseQuery, ctx);
|
|
237
|
-
|
|
238
|
-
expect(executeAggregate).toHaveBeenCalledWith('orders', expect.objectContaining({
|
|
239
|
-
groupBy: ['status'],
|
|
240
|
-
aggregations: expect.arrayContaining([
|
|
241
|
-
expect.objectContaining({ method: 'count' }),
|
|
242
|
-
expect.objectContaining({ method: 'sum' }),
|
|
243
|
-
]),
|
|
244
|
-
}));
|
|
245
|
-
expect(result.rows).toHaveLength(1);
|
|
246
|
-
expect(result.fields).toHaveLength(3);
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
it('should generate representative SQL', async () => {
|
|
250
|
-
const ctx = {
|
|
251
|
-
getCube: () => ordersCube,
|
|
252
|
-
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
|
|
253
|
-
executeAggregate: vi.fn(),
|
|
254
|
-
};
|
|
255
|
-
|
|
256
|
-
const { sql } = await strategy.generateSql(baseQuery, ctx);
|
|
257
|
-
expect(sql).toContain('SELECT');
|
|
258
|
-
expect(sql).toContain('COUNT(*)');
|
|
259
|
-
expect(sql).toContain('GROUP BY');
|
|
260
|
-
});
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
// ─────────────────────────────────────────────────────────────────
|
|
264
|
-
// FallbackDelegateStrategy (internal, tested via AnalyticsService)
|
|
265
|
-
// ─────────────────────────────────────────────────────────────────
|
|
266
|
-
|
|
267
|
-
describe('FallbackDelegateStrategy (via AnalyticsService)', () => {
|
|
268
|
-
it('should auto-add FallbackDelegateStrategy when fallbackService is configured', async () => {
|
|
269
|
-
const mockResult: AnalyticsResult = { rows: [{ count: 10 }], fields: [{ name: 'count', type: 'number' }] };
|
|
270
|
-
const fallback: IAnalyticsService = {
|
|
271
|
-
query: vi.fn().mockResolvedValue(mockResult),
|
|
272
|
-
getMeta: vi.fn().mockResolvedValue([]),
|
|
273
|
-
};
|
|
274
|
-
|
|
275
|
-
const service = new AnalyticsService({
|
|
276
|
-
cubes: [ordersCube],
|
|
277
|
-
logger: silentLogger,
|
|
278
|
-
fallbackService: fallback,
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
const result = await service.query(baseQuery);
|
|
282
|
-
expect(fallback.query).toHaveBeenCalledWith(baseQuery);
|
|
283
|
-
expect(result).toEqual(mockResult);
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
it('should NOT add FallbackDelegateStrategy when no fallbackService', async () => {
|
|
287
|
-
const service = new AnalyticsService({
|
|
288
|
-
cubes: [ordersCube],
|
|
289
|
-
logger: silentLogger,
|
|
290
|
-
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: false, inMemory: false }),
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
await expect(service.query(baseQuery)).rejects.toThrow('No strategy can handle');
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
it('should delegate generateSql to fallback service', async () => {
|
|
297
|
-
const fallback: IAnalyticsService = {
|
|
298
|
-
query: vi.fn(),
|
|
299
|
-
getMeta: vi.fn(),
|
|
300
|
-
generateSql: vi.fn().mockResolvedValue({ sql: 'SELECT 1', params: [] }),
|
|
301
|
-
};
|
|
302
|
-
|
|
303
|
-
const service = new AnalyticsService({
|
|
304
|
-
cubes: [ordersCube],
|
|
305
|
-
logger: silentLogger,
|
|
306
|
-
fallbackService: fallback,
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
const { sql } = await service.generateSql(baseQuery);
|
|
310
|
-
expect(sql).toBe('SELECT 1');
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
it('should return placeholder SQL when fallback has no generateSql', async () => {
|
|
314
|
-
const fallback: IAnalyticsService = {
|
|
315
|
-
query: vi.fn().mockResolvedValue({ rows: [], fields: [] }),
|
|
316
|
-
getMeta: vi.fn(),
|
|
317
|
-
};
|
|
318
|
-
|
|
319
|
-
const service = new AnalyticsService({
|
|
320
|
-
cubes: [ordersCube],
|
|
321
|
-
logger: silentLogger,
|
|
322
|
-
fallbackService: fallback,
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
const { sql } = await service.generateSql(baseQuery);
|
|
326
|
-
expect(sql).toContain('FallbackDelegateStrategy');
|
|
327
|
-
});
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
// ─────────────────────────────────────────────────────────────────
|
|
331
|
-
// AnalyticsService (Orchestrator)
|
|
332
|
-
// ─────────────────────────────────────────────────────────────────
|
|
333
|
-
|
|
334
|
-
describe('AnalyticsService', () => {
|
|
335
|
-
it('should use NativeSQLStrategy when driver supports native SQL', async () => {
|
|
336
|
-
const mockRows = [{ count: 42 }];
|
|
337
|
-
const service = new AnalyticsService({
|
|
338
|
-
cubes: [ordersCube],
|
|
339
|
-
logger: silentLogger,
|
|
340
|
-
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }),
|
|
341
|
-
executeRawSql: vi.fn().mockResolvedValue(mockRows),
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
const result = await service.query(baseQuery);
|
|
345
|
-
expect(result.rows).toEqual(mockRows);
|
|
346
|
-
expect(result.sql).toBeDefined(); // NativeSQL always includes sql
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
it('should fall back to ObjectQLStrategy when nativeSql is false', async () => {
|
|
350
|
-
const mockRows = [{ status: 'pending', 'orders.count': 3, 'orders.total_amount': 100 }];
|
|
351
|
-
const service = new AnalyticsService({
|
|
352
|
-
cubes: [ordersCube],
|
|
353
|
-
logger: silentLogger,
|
|
354
|
-
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
|
|
355
|
-
executeAggregate: vi.fn().mockResolvedValue(mockRows),
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
const result = await service.query(baseQuery);
|
|
359
|
-
expect(result.rows).toHaveLength(1);
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
it('should fall back to FallbackDelegateStrategy with fallback service', async () => {
|
|
363
|
-
const mockResult: AnalyticsResult = {
|
|
364
|
-
rows: [{ count: 100 }],
|
|
365
|
-
fields: [{ name: 'count', type: 'number' }],
|
|
366
|
-
};
|
|
367
|
-
const fallback: IAnalyticsService = {
|
|
368
|
-
query: vi.fn().mockResolvedValue(mockResult),
|
|
369
|
-
getMeta: vi.fn().mockResolvedValue([]),
|
|
370
|
-
};
|
|
371
|
-
|
|
372
|
-
const service = new AnalyticsService({
|
|
373
|
-
cubes: [ordersCube],
|
|
374
|
-
logger: silentLogger,
|
|
375
|
-
fallbackService: fallback,
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
const result = await service.query(baseQuery);
|
|
379
|
-
expect(result).toEqual(mockResult);
|
|
380
|
-
expect(fallback.query).toHaveBeenCalled();
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
it('should throw when no strategy can handle the query', async () => {
|
|
384
|
-
const service = new AnalyticsService({
|
|
385
|
-
cubes: [ordersCube],
|
|
386
|
-
logger: silentLogger,
|
|
387
|
-
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: false, inMemory: false }),
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
await expect(service.query(baseQuery)).rejects.toThrow('No strategy can handle');
|
|
391
|
-
});
|
|
392
|
-
|
|
393
|
-
it('should throw when cube name is missing', async () => {
|
|
394
|
-
const service = new AnalyticsService({ logger: silentLogger });
|
|
395
|
-
await expect(service.query({ measures: ['count'] })).rejects.toThrow('Cube name is required');
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
it('should return cube metadata via getMeta()', async () => {
|
|
399
|
-
const service = new AnalyticsService({
|
|
400
|
-
cubes: [ordersCube],
|
|
401
|
-
logger: silentLogger,
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
const meta = await service.getMeta();
|
|
405
|
-
expect(meta).toHaveLength(1);
|
|
406
|
-
expect(meta[0].name).toBe('orders');
|
|
407
|
-
expect(meta[0].measures.length).toBeGreaterThan(0);
|
|
408
|
-
expect(meta[0].dimensions.length).toBeGreaterThan(0);
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
it('should filter getMeta() by cube name', async () => {
|
|
412
|
-
const service = new AnalyticsService({
|
|
413
|
-
cubes: [ordersCube],
|
|
414
|
-
logger: silentLogger,
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
const meta = await service.getMeta('orders');
|
|
418
|
-
expect(meta).toHaveLength(1);
|
|
419
|
-
expect(meta[0].name).toBe('orders');
|
|
420
|
-
|
|
421
|
-
const empty = await service.getMeta('nonexistent');
|
|
422
|
-
expect(empty).toHaveLength(0);
|
|
423
|
-
});
|
|
424
|
-
|
|
425
|
-
it('should generate SQL via generateSql()', async () => {
|
|
426
|
-
const service = new AnalyticsService({
|
|
427
|
-
cubes: [ordersCube],
|
|
428
|
-
logger: silentLogger,
|
|
429
|
-
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
|
|
430
|
-
executeRawSql: vi.fn(),
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
const { sql } = await service.generateSql(baseQuery);
|
|
434
|
-
expect(sql).toContain('SELECT');
|
|
435
|
-
expect(sql).toContain('COUNT(*)');
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
it('should expose cubeRegistry for external cube registration', () => {
|
|
439
|
-
const service = new AnalyticsService({ logger: silentLogger });
|
|
440
|
-
expect(service.cubeRegistry).toBeInstanceOf(CubeRegistry);
|
|
441
|
-
expect(service.cubeRegistry.size).toBe(0);
|
|
442
|
-
|
|
443
|
-
service.cubeRegistry.register(ordersCube);
|
|
444
|
-
expect(service.cubeRegistry.size).toBe(1);
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
it('should support strategy priority ordering', async () => {
|
|
448
|
-
// Custom strategy at priority 5 (before NativeSQL at 10)
|
|
449
|
-
const customStrategy = {
|
|
450
|
-
name: 'CustomStrategy',
|
|
451
|
-
priority: 5,
|
|
452
|
-
canHandle: () => true,
|
|
453
|
-
execute: vi.fn().mockResolvedValue({ rows: [{ custom: true }], fields: [] }),
|
|
454
|
-
generateSql: vi.fn().mockResolvedValue({ sql: 'CUSTOM', params: [] }),
|
|
455
|
-
};
|
|
456
|
-
|
|
457
|
-
const service = new AnalyticsService({
|
|
458
|
-
cubes: [ordersCube],
|
|
459
|
-
logger: silentLogger,
|
|
460
|
-
strategies: [customStrategy],
|
|
461
|
-
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }),
|
|
462
|
-
executeRawSql: vi.fn(),
|
|
463
|
-
});
|
|
464
|
-
|
|
465
|
-
const result = await service.query(baseQuery);
|
|
466
|
-
expect(customStrategy.execute).toHaveBeenCalled();
|
|
467
|
-
expect(result.rows[0]).toEqual({ custom: true });
|
|
468
|
-
});
|
|
469
|
-
});
|