@objectstack/driver-memory 4.0.4 → 4.1.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.
@@ -1,518 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import type { IAnalyticsService, AnalyticsResult, CubeMeta } from '@objectstack/spec/contracts';
4
- import type { Cube, AnalyticsQuery } from '@objectstack/spec/data';
5
- import type { InMemoryDriver } from './memory-driver.js';
6
- import { Logger, createLogger } from '@objectstack/core';
7
-
8
- /**
9
- * Configuration for MemoryAnalyticsService
10
- */
11
- export interface MemoryAnalyticsConfig {
12
- /** The data driver instance to use for queries */
13
- driver: InMemoryDriver;
14
- /** Cube definitions for the semantic layer */
15
- cubes: Cube[];
16
- /** Optional logger */
17
- logger?: Logger;
18
- }
19
-
20
- /**
21
- * Memory-Based Analytics Service
22
- *
23
- * Implements IAnalyticsService using InMemoryDriver's aggregation capabilities.
24
- * Provides a semantic layer (Cubes, Metrics, Dimensions) on top of in-memory data.
25
- *
26
- * Features:
27
- * - Cube-based semantic modeling
28
- * - Measure calculations (count, sum, avg, min, max, count_distinct)
29
- * - Dimension grouping
30
- * - Filter support
31
- * - Time dimension handling
32
- * - SQL generation (for debugging/transparency)
33
- *
34
- * This implementation is suitable for:
35
- * - Development and testing
36
- * - Local-first analytics
37
- * - Small to medium datasets
38
- * - Prototyping BI applications
39
- */
40
- export class MemoryAnalyticsService implements IAnalyticsService {
41
- private driver: InMemoryDriver;
42
- private cubes: Map<string, Cube>;
43
- private logger: Logger;
44
-
45
- constructor(config: MemoryAnalyticsConfig) {
46
- this.driver = config.driver;
47
- this.cubes = new Map(config.cubes.map(c => [c.name, c]));
48
- this.logger = config.logger || createLogger({ level: 'info', format: 'pretty' });
49
- this.logger.debug('MemoryAnalyticsService initialized', { cubeCount: this.cubes.size });
50
- }
51
-
52
- /**
53
- * Execute an analytical query using the memory driver's aggregation pipeline
54
- */
55
- async query(query: AnalyticsQuery): Promise<AnalyticsResult> {
56
- this.logger.debug('Executing analytics query', { cube: query.cube, measures: query.measures });
57
-
58
- // Get cube definition
59
- if (!query.cube) {
60
- throw new Error('Cube name is required');
61
- }
62
- const cube = this.cubes.get(query.cube);
63
- if (!cube) {
64
- throw new Error(`Cube not found: ${query.cube}`);
65
- }
66
-
67
- // Build MongoDB aggregation pipeline
68
- const pipeline: Record<string, any>[] = [];
69
-
70
- // Stage 1: $match for filters
71
- if (query.filters && query.filters.length > 0) {
72
- const matchStage: Record<string, any> = {};
73
- for (const filter of query.filters) {
74
- const mongoOp = this.convertOperatorToMongo(filter.operator);
75
- const fieldPath = this.resolveFieldPath(cube, filter.member);
76
-
77
- if (filter.values && filter.values.length > 0) {
78
- if (mongoOp === '$in') {
79
- matchStage[fieldPath] = { $in: filter.values };
80
- } else if (mongoOp === '$nin') {
81
- matchStage[fieldPath] = { $nin: filter.values };
82
- } else {
83
- matchStage[fieldPath] = { [mongoOp]: filter.values[0] };
84
- }
85
- } else if (mongoOp === '$exists') {
86
- matchStage[fieldPath] = { $exists: filter.operator === 'set' };
87
- }
88
- }
89
- if (Object.keys(matchStage).length > 0) {
90
- pipeline.push({ $match: matchStage });
91
- }
92
- }
93
-
94
- // Stage 2: Time dimension filters
95
- if (query.timeDimensions && query.timeDimensions.length > 0) {
96
- for (const timeDim of query.timeDimensions) {
97
- const fieldPath = this.resolveFieldPath(cube, timeDim.dimension);
98
- if (timeDim.dateRange) {
99
- const range = Array.isArray(timeDim.dateRange)
100
- ? timeDim.dateRange
101
- : this.parseDateRangeString(timeDim.dateRange);
102
-
103
- if (range.length === 2) {
104
- pipeline.push({
105
- $match: {
106
- [fieldPath]: {
107
- $gte: new Date(range[0]),
108
- $lte: new Date(range[1])
109
- }
110
- }
111
- });
112
- }
113
- }
114
- }
115
- }
116
-
117
- // Stage 3: $group for measures and dimensions
118
- const groupStage: Record<string, any> = { _id: {} };
119
-
120
- // Add dimensions to _id
121
- if (query.dimensions && query.dimensions.length > 0) {
122
- for (const dim of query.dimensions) {
123
- const fieldPath = this.resolveFieldPath(cube, dim);
124
- const dimName = this.getShortName(dim);
125
- groupStage._id[dimName] = `$${fieldPath}`;
126
- }
127
- } else {
128
- groupStage._id = null; // No grouping, aggregate all
129
- }
130
-
131
- // Add measures as computed fields
132
- if (query.measures && query.measures.length > 0) {
133
- for (const measure of query.measures) {
134
- const measureDef = this.resolveMeasure(cube, measure);
135
- const measureName = this.getShortName(measure);
136
-
137
- if (measureDef) {
138
- const aggregator = this.buildAggregator(measureDef);
139
- groupStage[measureName] = aggregator;
140
- }
141
- }
142
- }
143
-
144
- pipeline.push({ $group: groupStage });
145
-
146
- // Stage 4: $project to reshape results (use short names, we'll fix them later)
147
- const projectStage: Record<string, any> = { _id: 0 };
148
- if (query.dimensions && query.dimensions.length > 0) {
149
- for (const dim of query.dimensions) {
150
- const dimName = this.getShortName(dim);
151
- projectStage[dimName] = `$_id.${dimName}`;
152
- }
153
- }
154
- if (query.measures && query.measures.length > 0) {
155
- for (const measure of query.measures) {
156
- const measureName = this.getShortName(measure);
157
- projectStage[measureName] = `$${measureName}`;
158
- }
159
- }
160
- pipeline.push({ $project: projectStage });
161
-
162
- // Stage 5: $sort (use short names)
163
- if (query.order && Object.keys(query.order).length > 0) {
164
- const sortStage: Record<string, any> = {};
165
- for (const [field, direction] of Object.entries(query.order)) {
166
- const shortName = this.getShortName(field);
167
- sortStage[shortName] = direction === 'asc' ? 1 : -1;
168
- }
169
- pipeline.push({ $sort: sortStage });
170
- }
171
-
172
- // Stage 6: $limit and $skip
173
- if (query.offset) {
174
- pipeline.push({ $skip: query.offset });
175
- }
176
- if (query.limit) {
177
- pipeline.push({ $limit: query.limit });
178
- }
179
-
180
- // Execute the aggregation pipeline
181
- const tableName = this.extractTableName(cube.sql);
182
- const rawRows = await this.driver.aggregate(tableName, pipeline);
183
-
184
- // Rename fields from short names to full cube.field names
185
- const rows = rawRows.map(row => {
186
- const renamedRow: Record<string, unknown> = {};
187
-
188
- // Rename dimensions
189
- if (query.dimensions) {
190
- for (const dim of query.dimensions) {
191
- const shortName = this.getShortName(dim);
192
- if (shortName in row) {
193
- renamedRow[dim] = row[shortName];
194
- }
195
- }
196
- }
197
-
198
- // Rename measures
199
- if (query.measures) {
200
- for (const measure of query.measures) {
201
- const shortName = this.getShortName(measure);
202
- if (shortName in row) {
203
- renamedRow[measure] = row[shortName];
204
- }
205
- }
206
- }
207
-
208
- return renamedRow;
209
- });
210
-
211
- // Build field metadata
212
- const fields: Array<{ name: string; type: string }> = [];
213
-
214
- if (query.dimensions) {
215
- for (const dim of query.dimensions) {
216
- const dimension = this.resolveDimension(cube, dim);
217
- fields.push({
218
- name: dim,
219
- type: dimension?.type || 'string'
220
- });
221
- }
222
- }
223
-
224
- if (query.measures) {
225
- for (const measure of query.measures) {
226
- const measureDef = this.resolveMeasure(cube, measure);
227
- fields.push({
228
- name: measure,
229
- type: this.measureTypeToFieldType(measureDef?.type || 'count')
230
- });
231
- }
232
- }
233
-
234
- this.logger.debug('Analytics query completed', { rowCount: rows.length });
235
-
236
- return {
237
- rows,
238
- fields,
239
- sql: this.generateSqlFromPipeline(tableName, pipeline) // For debugging
240
- };
241
- }
242
-
243
- /**
244
- * Get available cube metadata for discovery
245
- */
246
- async getMeta(cubeName?: string): Promise<CubeMeta[]> {
247
- const cubes = cubeName
248
- ? [this.cubes.get(cubeName)].filter(Boolean) as Cube[]
249
- : Array.from(this.cubes.values());
250
-
251
- return cubes.map(cube => ({
252
- name: cube.name,
253
- title: cube.title,
254
- measures: Object.entries(cube.measures).map(([key, measure]) => ({
255
- name: `${cube.name}.${key}`,
256
- type: measure.type,
257
- title: measure.label
258
- })),
259
- dimensions: Object.entries(cube.dimensions).map(([key, dimension]) => ({
260
- name: `${cube.name}.${key}`,
261
- type: dimension.type,
262
- title: dimension.label
263
- }))
264
- }));
265
- }
266
-
267
- /**
268
- * Generate SQL representation for debugging/transparency
269
- */
270
- async generateSql(query: AnalyticsQuery): Promise<{ sql: string; params: unknown[] }> {
271
- if (!query.cube) {
272
- throw new Error('Cube name is required');
273
- }
274
- const cube = this.cubes.get(query.cube);
275
- if (!cube) {
276
- throw new Error(`Cube not found: ${query.cube}`);
277
- }
278
-
279
- const tableName = this.extractTableName(cube.sql);
280
- const selectClauses: string[] = [];
281
- const groupByClauses: string[] = [];
282
-
283
- // Build SELECT for dimensions
284
- if (query.dimensions && query.dimensions.length > 0) {
285
- for (const dim of query.dimensions) {
286
- const fieldPath = this.resolveFieldPath(cube, dim);
287
- selectClauses.push(`${fieldPath} AS "${dim}"`);
288
- groupByClauses.push(fieldPath);
289
- }
290
- }
291
-
292
- // Build SELECT for measures
293
- if (query.measures && query.measures.length > 0) {
294
- for (const measure of query.measures) {
295
- const measureDef = this.resolveMeasure(cube, measure);
296
- if (measureDef) {
297
- const aggSql = this.measureToSql(measureDef);
298
- selectClauses.push(`${aggSql} AS "${measure}"`);
299
- }
300
- }
301
- }
302
-
303
- // Build WHERE clause
304
- const whereClauses: string[] = [];
305
- if (query.filters && query.filters.length > 0) {
306
- for (const filter of query.filters) {
307
- const fieldPath = this.resolveFieldPath(cube, filter.member);
308
- const sqlOp = this.operatorToSql(filter.operator);
309
- if (filter.values && filter.values.length > 0) {
310
- whereClauses.push(`${fieldPath} ${sqlOp} '${filter.values[0]}'`);
311
- }
312
- }
313
- }
314
-
315
- let sql = `SELECT ${selectClauses.join(', ')} FROM ${tableName}`;
316
- if (whereClauses.length > 0) {
317
- sql += ` WHERE ${whereClauses.join(' AND ')}`;
318
- }
319
- if (groupByClauses.length > 0) {
320
- sql += ` GROUP BY ${groupByClauses.join(', ')}`;
321
- }
322
- if (query.order) {
323
- const orderClauses = Object.entries(query.order).map(([field, dir]) =>
324
- `"${field}" ${dir.toUpperCase()}`
325
- );
326
- sql += ` ORDER BY ${orderClauses.join(', ')}`;
327
- }
328
- if (query.limit) {
329
- sql += ` LIMIT ${query.limit}`;
330
- }
331
- if (query.offset) {
332
- sql += ` OFFSET ${query.offset}`;
333
- }
334
-
335
- return { sql, params: [] };
336
- }
337
-
338
- // ===================================
339
- // Helper Methods
340
- // ===================================
341
-
342
- private resolveFieldPath(cube: Cube, member: string): string {
343
- // Handle both "cube.field" and "field" formats
344
- const parts = member.split('.');
345
- const fieldName = parts.length > 1 ? parts[1] : parts[0];
346
-
347
- // Check if it's a dimension
348
- const dimension = cube.dimensions[fieldName];
349
- if (dimension) {
350
- // Extract field path from SQL expression
351
- return dimension.sql.replace(/^\$/, ''); // Remove $ prefix if present
352
- }
353
-
354
- // Check if it's a measure (for filters)
355
- const measure = cube.measures[fieldName];
356
- if (measure) {
357
- return measure.sql.replace(/^\$/, '');
358
- }
359
-
360
- return fieldName;
361
- }
362
-
363
- private resolveMeasure(cube: Cube, measureName: string) {
364
- const parts = measureName.split('.');
365
- const fieldName = parts.length > 1 ? parts[1] : parts[0];
366
- return cube.measures[fieldName];
367
- }
368
-
369
- private resolveDimension(cube: Cube, dimensionName: string) {
370
- const parts = dimensionName.split('.');
371
- const fieldName = parts.length > 1 ? parts[1] : parts[0];
372
- return cube.dimensions[fieldName];
373
- }
374
-
375
- private getShortName(fullName: string): string {
376
- const parts = fullName.split('.');
377
- return parts.length > 1 ? parts[1] : parts[0];
378
- }
379
-
380
- private buildAggregator(measure: { type: string; sql: string; filters?: any[] }): any {
381
- const fieldPath = measure.sql.replace(/^\$/, '');
382
-
383
- switch (measure.type) {
384
- case 'count':
385
- return { $sum: 1 };
386
- case 'sum':
387
- return { $sum: `$${fieldPath}` };
388
- case 'avg':
389
- return { $avg: `$${fieldPath}` };
390
- case 'min':
391
- return { $min: `$${fieldPath}` };
392
- case 'max':
393
- return { $max: `$${fieldPath}` };
394
- case 'count_distinct':
395
- return { $addToSet: `$${fieldPath}` }; // Will need post-processing for count
396
- default:
397
- return { $sum: 1 }; // Default to count
398
- }
399
- }
400
-
401
- private measureTypeToFieldType(measureType: string): string {
402
- switch (measureType) {
403
- case 'count':
404
- case 'sum':
405
- case 'count_distinct':
406
- return 'number';
407
- case 'avg':
408
- case 'min':
409
- case 'max':
410
- return 'number';
411
- case 'string':
412
- return 'string';
413
- case 'boolean':
414
- return 'boolean';
415
- default:
416
- return 'number';
417
- }
418
- }
419
-
420
- private convertOperatorToMongo(operator: string): string {
421
- const opMap: Record<string, string> = {
422
- 'equals': '$eq',
423
- 'notEquals': '$ne',
424
- 'contains': '$regex',
425
- 'notContains': '$not',
426
- 'gt': '$gt',
427
- 'gte': '$gte',
428
- 'lt': '$lt',
429
- 'lte': '$lte',
430
- 'set': '$exists',
431
- 'notSet': '$exists',
432
- 'inDateRange': '$gte', // Will need special handling
433
- };
434
- return opMap[operator] || '$eq';
435
- }
436
-
437
- private operatorToSql(operator: string): string {
438
- const opMap: Record<string, string> = {
439
- 'equals': '=',
440
- 'notEquals': '!=',
441
- 'contains': 'LIKE',
442
- 'notContains': 'NOT LIKE',
443
- 'gt': '>',
444
- 'gte': '>=',
445
- 'lt': '<',
446
- 'lte': '<=',
447
- };
448
- return opMap[operator] || '=';
449
- }
450
-
451
- private measureToSql(measure: { type: string; sql: string }): string {
452
- const fieldPath = measure.sql.replace(/^\$/, '');
453
-
454
- switch (measure.type) {
455
- case 'count':
456
- return 'COUNT(*)';
457
- case 'sum':
458
- return `SUM(${fieldPath})`;
459
- case 'avg':
460
- return `AVG(${fieldPath})`;
461
- case 'min':
462
- return `MIN(${fieldPath})`;
463
- case 'max':
464
- return `MAX(${fieldPath})`;
465
- case 'count_distinct':
466
- return `COUNT(DISTINCT ${fieldPath})`;
467
- default:
468
- return 'COUNT(*)';
469
- }
470
- }
471
-
472
- private extractTableName(sql: string): string {
473
- // For simple table names, return as-is
474
- // For complex SQL, this would need more sophisticated parsing
475
- return sql.trim();
476
- }
477
-
478
- private parseDateRangeString(range: string): string[] {
479
- // Simple parser for common date range strings
480
- // In production, this would use a proper date range parser
481
- const now = new Date();
482
- const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
483
-
484
- if (range === 'today') {
485
- return [today.toISOString(), new Date(today.getTime() + 86400000).toISOString()];
486
- } else if (range.startsWith('last ')) {
487
- const parts = range.split(' ');
488
- const num = parseInt(parts[1]);
489
- const unit = parts[2];
490
- const start = new Date(today);
491
-
492
- if (unit.startsWith('day')) {
493
- start.setDate(start.getDate() - num);
494
- } else if (unit.startsWith('week')) {
495
- start.setDate(start.getDate() - num * 7);
496
- } else if (unit.startsWith('month')) {
497
- start.setMonth(start.getMonth() - num);
498
- } else if (unit.startsWith('year')) {
499
- start.setFullYear(start.getFullYear() - num);
500
- }
501
-
502
- return [start.toISOString(), now.toISOString()];
503
- }
504
-
505
- return [range, range]; // Fallback
506
- }
507
-
508
- private generateSqlFromPipeline(table: string, pipeline: Record<string, any>[]): string {
509
- // Simplified SQL generation for debugging
510
- // This is a basic representation of the aggregation pipeline
511
- const stages = pipeline.map((stage, idx) => {
512
- const op = Object.keys(stage)[0];
513
- return `/* Stage ${idx + 1}: ${op} */ ${JSON.stringify(stage[op])}`;
514
- }).join('\n');
515
-
516
- return `-- MongoDB Aggregation Pipeline on table: ${table}\n${stages}`;
517
- }
518
- }