@objectstack/driver-memory 2.0.7 → 3.0.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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +12 -0
- package/dist/index.d.mts +68 -2
- package/dist/index.d.ts +68 -2
- package/dist/index.js +396 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +394 -0
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +3 -0
- package/src/memory-analytics.test.ts +346 -0
- package/src/memory-analytics.ts +518 -0
- package/src/memory-driver.ts +3 -3
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
4
|
+
import { InMemoryDriver } from './memory-driver.js';
|
|
5
|
+
import { MemoryAnalyticsService } from './memory-analytics.js';
|
|
6
|
+
import type { Cube } from '@objectstack/spec/data';
|
|
7
|
+
|
|
8
|
+
describe('MemoryAnalyticsService', () => {
|
|
9
|
+
let driver: InMemoryDriver;
|
|
10
|
+
let service: MemoryAnalyticsService;
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
// Initialize driver with sample data
|
|
14
|
+
driver = new InMemoryDriver({
|
|
15
|
+
initialData: {
|
|
16
|
+
orders: [
|
|
17
|
+
{ id: 1, customer: 'Alice', status: 'completed', amount: 100, created_at: new Date('2024-01-15') },
|
|
18
|
+
{ id: 2, customer: 'Bob', status: 'completed', amount: 200, created_at: new Date('2024-01-16') },
|
|
19
|
+
{ id: 3, customer: 'Alice', status: 'pending', amount: 150, created_at: new Date('2024-01-17') },
|
|
20
|
+
{ id: 4, customer: 'Charlie', status: 'completed', amount: 300, created_at: new Date('2024-01-18') },
|
|
21
|
+
{ id: 5, customer: 'Bob', status: 'cancelled', amount: 50, created_at: new Date('2024-01-19') },
|
|
22
|
+
],
|
|
23
|
+
products: [
|
|
24
|
+
{ id: 1, name: 'Laptop', category: 'electronics', price: 999, stock: 10 },
|
|
25
|
+
{ id: 2, name: 'Mouse', category: 'electronics', price: 25, stock: 100 },
|
|
26
|
+
{ id: 3, name: 'Desk', category: 'furniture', price: 299, stock: 5 },
|
|
27
|
+
{ id: 4, name: 'Chair', category: 'furniture', price: 199, stock: 8 },
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Connect the driver to load initial data
|
|
33
|
+
await driver.connect();
|
|
34
|
+
|
|
35
|
+
// Define cubes
|
|
36
|
+
const cubes: Cube[] = [
|
|
37
|
+
{
|
|
38
|
+
name: 'orders',
|
|
39
|
+
title: 'Orders',
|
|
40
|
+
sql: 'orders',
|
|
41
|
+
measures: {
|
|
42
|
+
count: {
|
|
43
|
+
name: 'count',
|
|
44
|
+
label: 'Order Count',
|
|
45
|
+
type: 'count',
|
|
46
|
+
sql: 'id'
|
|
47
|
+
},
|
|
48
|
+
totalAmount: {
|
|
49
|
+
name: 'total_amount',
|
|
50
|
+
label: 'Total Amount',
|
|
51
|
+
type: 'sum',
|
|
52
|
+
sql: 'amount'
|
|
53
|
+
},
|
|
54
|
+
avgAmount: {
|
|
55
|
+
name: 'avg_amount',
|
|
56
|
+
label: 'Average Amount',
|
|
57
|
+
type: 'avg',
|
|
58
|
+
sql: 'amount'
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
dimensions: {
|
|
62
|
+
customer: {
|
|
63
|
+
name: 'customer',
|
|
64
|
+
label: 'Customer',
|
|
65
|
+
type: 'string',
|
|
66
|
+
sql: 'customer'
|
|
67
|
+
},
|
|
68
|
+
status: {
|
|
69
|
+
name: 'status',
|
|
70
|
+
label: 'Status',
|
|
71
|
+
type: 'string',
|
|
72
|
+
sql: 'status'
|
|
73
|
+
},
|
|
74
|
+
createdAt: {
|
|
75
|
+
name: 'created_at',
|
|
76
|
+
label: 'Created At',
|
|
77
|
+
type: 'time',
|
|
78
|
+
sql: 'created_at',
|
|
79
|
+
granularities: ['day', 'week', 'month']
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
public: true
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'products',
|
|
86
|
+
title: 'Products',
|
|
87
|
+
sql: 'products',
|
|
88
|
+
measures: {
|
|
89
|
+
count: {
|
|
90
|
+
name: 'count',
|
|
91
|
+
label: 'Product Count',
|
|
92
|
+
type: 'count',
|
|
93
|
+
sql: 'id'
|
|
94
|
+
},
|
|
95
|
+
avgPrice: {
|
|
96
|
+
name: 'avg_price',
|
|
97
|
+
label: 'Average Price',
|
|
98
|
+
type: 'avg',
|
|
99
|
+
sql: 'price'
|
|
100
|
+
},
|
|
101
|
+
totalStock: {
|
|
102
|
+
name: 'total_stock',
|
|
103
|
+
label: 'Total Stock',
|
|
104
|
+
type: 'sum',
|
|
105
|
+
sql: 'stock'
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
dimensions: {
|
|
109
|
+
category: {
|
|
110
|
+
name: 'category',
|
|
111
|
+
label: 'Category',
|
|
112
|
+
type: 'string',
|
|
113
|
+
sql: 'category'
|
|
114
|
+
},
|
|
115
|
+
name: {
|
|
116
|
+
name: 'name',
|
|
117
|
+
label: 'Product Name',
|
|
118
|
+
type: 'string',
|
|
119
|
+
sql: 'name'
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
public: true
|
|
123
|
+
}
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
service = new MemoryAnalyticsService({ driver, cubes });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('getMeta', () => {
|
|
130
|
+
it('should return metadata for all cubes', async () => {
|
|
131
|
+
const meta = await service.getMeta();
|
|
132
|
+
|
|
133
|
+
expect(meta).toHaveLength(2);
|
|
134
|
+
expect(meta[0].name).toBe('orders');
|
|
135
|
+
expect(meta[1].name).toBe('products');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should return metadata for a specific cube', async () => {
|
|
139
|
+
const meta = await service.getMeta('orders');
|
|
140
|
+
|
|
141
|
+
expect(meta).toHaveLength(1);
|
|
142
|
+
expect(meta[0].name).toBe('orders');
|
|
143
|
+
expect(meta[0].measures).toHaveLength(3);
|
|
144
|
+
expect(meta[0].dimensions).toHaveLength(3);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should include measure and dimension details', async () => {
|
|
148
|
+
const meta = await service.getMeta('orders');
|
|
149
|
+
const cube = meta[0];
|
|
150
|
+
|
|
151
|
+
const countMeasure = cube.measures.find(m => m.name === 'orders.count');
|
|
152
|
+
expect(countMeasure).toBeDefined();
|
|
153
|
+
expect(countMeasure?.type).toBe('count');
|
|
154
|
+
|
|
155
|
+
const statusDim = cube.dimensions.find(d => d.name === 'orders.status');
|
|
156
|
+
expect(statusDim).toBeDefined();
|
|
157
|
+
expect(statusDim?.type).toBe('string');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('query', () => {
|
|
162
|
+
it('should execute a simple count query', async () => {
|
|
163
|
+
const result = await service.query({
|
|
164
|
+
cube: 'orders',
|
|
165
|
+
measures: ['orders.count']
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(result.rows).toHaveLength(1);
|
|
169
|
+
expect(result.rows[0]['orders.count']).toBe(5);
|
|
170
|
+
expect(result.fields).toHaveLength(1);
|
|
171
|
+
expect(result.fields[0].name).toBe('orders.count');
|
|
172
|
+
expect(result.fields[0].type).toBe('number');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should group by a dimension', async () => {
|
|
176
|
+
const result = await service.query({
|
|
177
|
+
cube: 'orders',
|
|
178
|
+
measures: ['orders.count'],
|
|
179
|
+
dimensions: ['orders.status']
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(result.rows).toHaveLength(3); // completed, pending, cancelled
|
|
183
|
+
|
|
184
|
+
const completedRow = result.rows.find(r => r['orders.status'] === 'completed');
|
|
185
|
+
expect(completedRow).toBeDefined();
|
|
186
|
+
expect(completedRow!['orders.count']).toBe(3);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('should calculate sum aggregation', async () => {
|
|
190
|
+
const result = await service.query({
|
|
191
|
+
cube: 'orders',
|
|
192
|
+
measures: ['orders.totalAmount'],
|
|
193
|
+
dimensions: ['orders.customer']
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const aliceRow = result.rows.find(r => r['orders.customer'] === 'Alice');
|
|
197
|
+
expect(aliceRow).toBeDefined();
|
|
198
|
+
expect(aliceRow!['orders.totalAmount']).toBe(250); // 100 + 150
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should calculate average aggregation', async () => {
|
|
202
|
+
const result = await service.query({
|
|
203
|
+
cube: 'products',
|
|
204
|
+
measures: ['products.avgPrice'],
|
|
205
|
+
dimensions: ['products.category']
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const electronicsRow = result.rows.find(r => r['products.category'] === 'electronics');
|
|
209
|
+
expect(electronicsRow).toBeDefined();
|
|
210
|
+
expect(electronicsRow!['products.avgPrice']).toBe(512); // (999 + 25) / 2
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should support multiple measures', async () => {
|
|
214
|
+
const result = await service.query({
|
|
215
|
+
cube: 'orders',
|
|
216
|
+
measures: ['orders.count', 'orders.totalAmount', 'orders.avgAmount']
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(result.rows).toHaveLength(1);
|
|
220
|
+
expect(result.rows[0]['orders.count']).toBe(5);
|
|
221
|
+
expect(result.rows[0]['orders.totalAmount']).toBe(800); // 100+200+150+300+50
|
|
222
|
+
expect(result.rows[0]['orders.avgAmount']).toBe(160); // 800/5
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should apply filters', async () => {
|
|
226
|
+
const result = await service.query({
|
|
227
|
+
cube: 'orders',
|
|
228
|
+
measures: ['orders.count', 'orders.totalAmount'],
|
|
229
|
+
filters: [
|
|
230
|
+
{ member: 'orders.status', operator: 'equals', values: ['completed'] }
|
|
231
|
+
]
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
expect(result.rows).toHaveLength(1);
|
|
235
|
+
expect(result.rows[0]['orders.count']).toBe(3);
|
|
236
|
+
expect(result.rows[0]['orders.totalAmount']).toBe(600); // 100+200+300
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should support sorting', async () => {
|
|
240
|
+
const result = await service.query({
|
|
241
|
+
cube: 'orders',
|
|
242
|
+
measures: ['orders.totalAmount'],
|
|
243
|
+
dimensions: ['orders.customer'],
|
|
244
|
+
order: { 'orders.totalAmount': 'desc' }
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
expect(result.rows[0]['orders.customer']).toBe('Charlie'); // 300
|
|
248
|
+
expect(result.rows[1]['orders.customer']).toBe('Alice'); // 250
|
|
249
|
+
expect(result.rows[2]['orders.customer']).toBe('Bob'); // 250
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('should support limit and offset', async () => {
|
|
253
|
+
const result = await service.query({
|
|
254
|
+
cube: 'orders',
|
|
255
|
+
measures: ['orders.count'],
|
|
256
|
+
dimensions: ['orders.customer'],
|
|
257
|
+
order: { 'orders.customer': 'asc' },
|
|
258
|
+
limit: 2,
|
|
259
|
+
offset: 1
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
expect(result.rows).toHaveLength(2);
|
|
263
|
+
expect(result.rows[0]['orders.customer']).toBe('Bob');
|
|
264
|
+
expect(result.rows[1]['orders.customer']).toBe('Charlie');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('should throw error for unknown cube', async () => {
|
|
268
|
+
await expect(async () => {
|
|
269
|
+
await service.query({
|
|
270
|
+
cube: 'unknown',
|
|
271
|
+
measures: ['unknown.count']
|
|
272
|
+
});
|
|
273
|
+
}).rejects.toThrow('Cube not found: unknown');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('should include SQL in result for debugging', async () => {
|
|
277
|
+
const result = await service.query({
|
|
278
|
+
cube: 'orders',
|
|
279
|
+
measures: ['orders.count']
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
expect(result.sql).toBeDefined();
|
|
283
|
+
expect(result.sql).toContain('orders');
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe('generateSql', () => {
|
|
288
|
+
it('should generate SQL for a simple query', async () => {
|
|
289
|
+
const result = await service.generateSql({
|
|
290
|
+
cube: 'orders',
|
|
291
|
+
measures: ['orders.count']
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
expect(result.sql).toContain('SELECT');
|
|
295
|
+
expect(result.sql).toContain('COUNT(*)');
|
|
296
|
+
expect(result.sql).toContain('FROM orders');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('should generate SQL with GROUP BY', async () => {
|
|
300
|
+
const result = await service.generateSql({
|
|
301
|
+
cube: 'orders',
|
|
302
|
+
measures: ['orders.count'],
|
|
303
|
+
dimensions: ['orders.status']
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
expect(result.sql).toContain('GROUP BY status');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('should generate SQL with WHERE clause', async () => {
|
|
310
|
+
const result = await service.generateSql({
|
|
311
|
+
cube: 'orders',
|
|
312
|
+
measures: ['orders.count'],
|
|
313
|
+
filters: [
|
|
314
|
+
{ member: 'orders.status', operator: 'equals', values: ['completed'] }
|
|
315
|
+
]
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
expect(result.sql).toContain('WHERE');
|
|
319
|
+
expect(result.sql).toContain('status');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('should generate SQL with ORDER BY', async () => {
|
|
323
|
+
const result = await service.generateSql({
|
|
324
|
+
cube: 'orders',
|
|
325
|
+
measures: ['orders.count'],
|
|
326
|
+
dimensions: ['orders.status'],
|
|
327
|
+
order: { 'orders.status': 'asc' }
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(result.sql).toContain('ORDER BY');
|
|
331
|
+
expect(result.sql).toContain('ASC');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('should generate SQL with LIMIT and OFFSET', async () => {
|
|
335
|
+
const result = await service.generateSql({
|
|
336
|
+
cube: 'orders',
|
|
337
|
+
measures: ['orders.count'],
|
|
338
|
+
limit: 10,
|
|
339
|
+
offset: 5
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
expect(result.sql).toContain('LIMIT 10');
|
|
343
|
+
expect(result.sql).toContain('OFFSET 5');
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
});
|