Skip to content

Commit 056f32a

Browse files
Copilothotlong
andcommitted
Implement memory-based analytics service
- Created MemoryAnalyticsService using InMemoryDriver's aggregation capabilities - Added comprehensive test suite for analytics service - Updated analytics contract interface to include order and timezone fields - Aligned Zod schema with TypeScript interface - Exported MemoryAnalyticsService from driver-memory package Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 46a8a9f commit 056f32a

7 files changed

Lines changed: 840 additions & 6 deletions

File tree

packages/plugins/driver-memory/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import { InMemoryDriver } from './memory-driver.js';
55
export { InMemoryDriver }; // Export class for direct usage
66
export type { InMemoryDriverConfig } from './memory-driver.js';
77

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

0 commit comments

Comments
 (0)