Skip to content

Commit 187e7ae

Browse files
authored
Merge pull request #625 from objectstack-ai/copilot/evaluate-memory-driver-analytics
2 parents 9da8e3e + 8426265 commit 187e7ae

7 files changed

Lines changed: 871 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: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

0 commit comments

Comments
 (0)