diff --git a/packages/plugins/driver-memory/src/index.ts b/packages/plugins/driver-memory/src/index.ts index af7df95664..1e0adc13c0 100644 --- a/packages/plugins/driver-memory/src/index.ts +++ b/packages/plugins/driver-memory/src/index.ts @@ -5,6 +5,9 @@ import { InMemoryDriver } from './memory-driver.js'; export { InMemoryDriver }; // Export class for direct usage export type { InMemoryDriverConfig } from './memory-driver.js'; +export { MemoryAnalyticsService } from './memory-analytics.js'; +export type { MemoryAnalyticsConfig } from './memory-analytics.js'; + export default { id: 'com.objectstack.driver.memory', version: '1.0.0', diff --git a/packages/plugins/driver-memory/src/memory-analytics.test.ts b/packages/plugins/driver-memory/src/memory-analytics.test.ts new file mode 100644 index 0000000000..a3b9e83758 --- /dev/null +++ b/packages/plugins/driver-memory/src/memory-analytics.test.ts @@ -0,0 +1,346 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { InMemoryDriver } from './memory-driver.js'; +import { MemoryAnalyticsService } from './memory-analytics.js'; +import type { Cube } from '@objectstack/spec/data'; + +describe('MemoryAnalyticsService', () => { + let driver: InMemoryDriver; + let service: MemoryAnalyticsService; + + beforeEach(async () => { + // Initialize driver with sample data + driver = new InMemoryDriver({ + initialData: { + orders: [ + { id: 1, customer: 'Alice', status: 'completed', amount: 100, created_at: new Date('2024-01-15') }, + { id: 2, customer: 'Bob', status: 'completed', amount: 200, created_at: new Date('2024-01-16') }, + { id: 3, customer: 'Alice', status: 'pending', amount: 150, created_at: new Date('2024-01-17') }, + { id: 4, customer: 'Charlie', status: 'completed', amount: 300, created_at: new Date('2024-01-18') }, + { id: 5, customer: 'Bob', status: 'cancelled', amount: 50, created_at: new Date('2024-01-19') }, + ], + products: [ + { id: 1, name: 'Laptop', category: 'electronics', price: 999, stock: 10 }, + { id: 2, name: 'Mouse', category: 'electronics', price: 25, stock: 100 }, + { id: 3, name: 'Desk', category: 'furniture', price: 299, stock: 5 }, + { id: 4, name: 'Chair', category: 'furniture', price: 199, stock: 8 }, + ] + } + }); + + // Connect the driver to load initial data + await driver.connect(); + + // Define cubes + const cubes: Cube[] = [ + { + name: 'orders', + title: 'Orders', + sql: 'orders', + measures: { + count: { + name: 'count', + label: 'Order Count', + type: 'count', + sql: 'id' + }, + totalAmount: { + name: 'total_amount', + label: 'Total Amount', + type: 'sum', + sql: 'amount' + }, + avgAmount: { + name: 'avg_amount', + label: 'Average Amount', + type: 'avg', + sql: 'amount' + } + }, + dimensions: { + customer: { + name: 'customer', + label: 'Customer', + type: 'string', + sql: 'customer' + }, + status: { + name: 'status', + label: 'Status', + type: 'string', + sql: 'status' + }, + createdAt: { + name: 'created_at', + label: 'Created At', + type: 'time', + sql: 'created_at', + granularities: ['day', 'week', 'month'] + } + }, + public: true + }, + { + name: 'products', + title: 'Products', + sql: 'products', + measures: { + count: { + name: 'count', + label: 'Product Count', + type: 'count', + sql: 'id' + }, + avgPrice: { + name: 'avg_price', + label: 'Average Price', + type: 'avg', + sql: 'price' + }, + totalStock: { + name: 'total_stock', + label: 'Total Stock', + type: 'sum', + sql: 'stock' + } + }, + dimensions: { + category: { + name: 'category', + label: 'Category', + type: 'string', + sql: 'category' + }, + name: { + name: 'name', + label: 'Product Name', + type: 'string', + sql: 'name' + } + }, + public: true + } + ]; + + service = new MemoryAnalyticsService({ driver, cubes }); + }); + + describe('getMeta', () => { + it('should return metadata for all cubes', async () => { + const meta = await service.getMeta(); + + expect(meta).toHaveLength(2); + expect(meta[0].name).toBe('orders'); + expect(meta[1].name).toBe('products'); + }); + + it('should return metadata for a specific cube', async () => { + const meta = await service.getMeta('orders'); + + expect(meta).toHaveLength(1); + expect(meta[0].name).toBe('orders'); + expect(meta[0].measures).toHaveLength(3); + expect(meta[0].dimensions).toHaveLength(3); + }); + + it('should include measure and dimension details', async () => { + const meta = await service.getMeta('orders'); + const cube = meta[0]; + + const countMeasure = cube.measures.find(m => m.name === 'orders.count'); + expect(countMeasure).toBeDefined(); + expect(countMeasure?.type).toBe('count'); + + const statusDim = cube.dimensions.find(d => d.name === 'orders.status'); + expect(statusDim).toBeDefined(); + expect(statusDim?.type).toBe('string'); + }); + }); + + describe('query', () => { + it('should execute a simple count query', async () => { + const result = await service.query({ + cube: 'orders', + measures: ['orders.count'] + }); + + expect(result.rows).toHaveLength(1); + expect(result.rows[0]['orders.count']).toBe(5); + expect(result.fields).toHaveLength(1); + expect(result.fields[0].name).toBe('orders.count'); + expect(result.fields[0].type).toBe('number'); + }); + + it('should group by a dimension', async () => { + const result = await service.query({ + cube: 'orders', + measures: ['orders.count'], + dimensions: ['orders.status'] + }); + + expect(result.rows).toHaveLength(3); // completed, pending, cancelled + + const completedRow = result.rows.find(r => r['orders.status'] === 'completed'); + expect(completedRow).toBeDefined(); + expect(completedRow!['orders.count']).toBe(3); + }); + + it('should calculate sum aggregation', async () => { + const result = await service.query({ + cube: 'orders', + measures: ['orders.totalAmount'], + dimensions: ['orders.customer'] + }); + + const aliceRow = result.rows.find(r => r['orders.customer'] === 'Alice'); + expect(aliceRow).toBeDefined(); + expect(aliceRow!['orders.totalAmount']).toBe(250); // 100 + 150 + }); + + it('should calculate average aggregation', async () => { + const result = await service.query({ + cube: 'products', + measures: ['products.avgPrice'], + dimensions: ['products.category'] + }); + + const electronicsRow = result.rows.find(r => r['products.category'] === 'electronics'); + expect(electronicsRow).toBeDefined(); + expect(electronicsRow!['products.avgPrice']).toBe(512); // (999 + 25) / 2 + }); + + it('should support multiple measures', async () => { + const result = await service.query({ + cube: 'orders', + measures: ['orders.count', 'orders.totalAmount', 'orders.avgAmount'] + }); + + expect(result.rows).toHaveLength(1); + expect(result.rows[0]['orders.count']).toBe(5); + expect(result.rows[0]['orders.totalAmount']).toBe(800); // 100+200+150+300+50 + expect(result.rows[0]['orders.avgAmount']).toBe(160); // 800/5 + }); + + it('should apply filters', async () => { + const result = await service.query({ + cube: 'orders', + measures: ['orders.count', 'orders.totalAmount'], + filters: [ + { member: 'orders.status', operator: 'equals', values: ['completed'] } + ] + }); + + expect(result.rows).toHaveLength(1); + expect(result.rows[0]['orders.count']).toBe(3); + expect(result.rows[0]['orders.totalAmount']).toBe(600); // 100+200+300 + }); + + it('should support sorting', async () => { + const result = await service.query({ + cube: 'orders', + measures: ['orders.totalAmount'], + dimensions: ['orders.customer'], + order: { 'orders.totalAmount': 'desc' } + }); + + expect(result.rows[0]['orders.customer']).toBe('Charlie'); // 300 + expect(result.rows[1]['orders.customer']).toBe('Alice'); // 250 + expect(result.rows[2]['orders.customer']).toBe('Bob'); // 250 + }); + + it('should support limit and offset', async () => { + const result = await service.query({ + cube: 'orders', + measures: ['orders.count'], + dimensions: ['orders.customer'], + order: { 'orders.customer': 'asc' }, + limit: 2, + offset: 1 + }); + + expect(result.rows).toHaveLength(2); + expect(result.rows[0]['orders.customer']).toBe('Bob'); + expect(result.rows[1]['orders.customer']).toBe('Charlie'); + }); + + it('should throw error for unknown cube', async () => { + await expect(async () => { + await service.query({ + cube: 'unknown', + measures: ['unknown.count'] + }); + }).rejects.toThrow('Cube not found: unknown'); + }); + + it('should include SQL in result for debugging', async () => { + const result = await service.query({ + cube: 'orders', + measures: ['orders.count'] + }); + + expect(result.sql).toBeDefined(); + expect(result.sql).toContain('orders'); + }); + }); + + describe('generateSql', () => { + it('should generate SQL for a simple query', async () => { + const result = await service.generateSql({ + cube: 'orders', + measures: ['orders.count'] + }); + + expect(result.sql).toContain('SELECT'); + expect(result.sql).toContain('COUNT(*)'); + expect(result.sql).toContain('FROM orders'); + }); + + it('should generate SQL with GROUP BY', async () => { + const result = await service.generateSql({ + cube: 'orders', + measures: ['orders.count'], + dimensions: ['orders.status'] + }); + + expect(result.sql).toContain('GROUP BY status'); + }); + + it('should generate SQL with WHERE clause', async () => { + const result = await service.generateSql({ + cube: 'orders', + measures: ['orders.count'], + filters: [ + { member: 'orders.status', operator: 'equals', values: ['completed'] } + ] + }); + + expect(result.sql).toContain('WHERE'); + expect(result.sql).toContain('status'); + }); + + it('should generate SQL with ORDER BY', async () => { + const result = await service.generateSql({ + cube: 'orders', + measures: ['orders.count'], + dimensions: ['orders.status'], + order: { 'orders.status': 'asc' } + }); + + expect(result.sql).toContain('ORDER BY'); + expect(result.sql).toContain('ASC'); + }); + + it('should generate SQL with LIMIT and OFFSET', async () => { + const result = await service.generateSql({ + cube: 'orders', + measures: ['orders.count'], + limit: 10, + offset: 5 + }); + + expect(result.sql).toContain('LIMIT 10'); + expect(result.sql).toContain('OFFSET 5'); + }); + }); +}); diff --git a/packages/plugins/driver-memory/src/memory-analytics.ts b/packages/plugins/driver-memory/src/memory-analytics.ts new file mode 100644 index 0000000000..8312684584 --- /dev/null +++ b/packages/plugins/driver-memory/src/memory-analytics.ts @@ -0,0 +1,512 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { IAnalyticsService, AnalyticsResult, CubeMeta } from '@objectstack/spec/contracts'; +import type { Cube, AnalyticsQuery } from '@objectstack/spec/data'; +import type { InMemoryDriver } from './memory-driver.js'; +import { Logger, createLogger } from '@objectstack/core'; + +/** + * Configuration for MemoryAnalyticsService + */ +export interface MemoryAnalyticsConfig { + /** The data driver instance to use for queries */ + driver: InMemoryDriver; + /** Cube definitions for the semantic layer */ + cubes: Cube[]; + /** Optional logger */ + logger?: Logger; +} + +/** + * Memory-Based Analytics Service + * + * Implements IAnalyticsService using InMemoryDriver's aggregation capabilities. + * Provides a semantic layer (Cubes, Metrics, Dimensions) on top of in-memory data. + * + * Features: + * - Cube-based semantic modeling + * - Measure calculations (count, sum, avg, min, max, count_distinct) + * - Dimension grouping + * - Filter support + * - Time dimension handling + * - SQL generation (for debugging/transparency) + * + * This implementation is suitable for: + * - Development and testing + * - Local-first analytics + * - Small to medium datasets + * - Prototyping BI applications + */ +export class MemoryAnalyticsService implements IAnalyticsService { + private driver: InMemoryDriver; + private cubes: Map; + private logger: Logger; + + constructor(config: MemoryAnalyticsConfig) { + this.driver = config.driver; + this.cubes = new Map(config.cubes.map(c => [c.name, c])); + this.logger = config.logger || createLogger({ level: 'info', format: 'pretty' }); + this.logger.debug('MemoryAnalyticsService initialized', { cubeCount: this.cubes.size }); + } + + /** + * Execute an analytical query using the memory driver's aggregation pipeline + */ + async query(query: AnalyticsQuery): Promise { + this.logger.debug('Executing analytics query', { cube: query.cube, measures: query.measures }); + + // Get cube definition + const cube = this.cubes.get(query.cube); + if (!cube) { + throw new Error(`Cube not found: ${query.cube}`); + } + + // Build MongoDB aggregation pipeline + const pipeline: Record[] = []; + + // Stage 1: $match for filters + if (query.filters && query.filters.length > 0) { + const matchStage: Record = {}; + for (const filter of query.filters) { + const mongoOp = this.convertOperatorToMongo(filter.operator); + const fieldPath = this.resolveFieldPath(cube, filter.member); + + if (filter.values && filter.values.length > 0) { + if (mongoOp === '$in') { + matchStage[fieldPath] = { $in: filter.values }; + } else if (mongoOp === '$nin') { + matchStage[fieldPath] = { $nin: filter.values }; + } else { + matchStage[fieldPath] = { [mongoOp]: filter.values[0] }; + } + } else if (mongoOp === '$exists') { + matchStage[fieldPath] = { $exists: filter.operator === 'set' }; + } + } + if (Object.keys(matchStage).length > 0) { + pipeline.push({ $match: matchStage }); + } + } + + // Stage 2: Time dimension filters + if (query.timeDimensions && query.timeDimensions.length > 0) { + for (const timeDim of query.timeDimensions) { + const fieldPath = this.resolveFieldPath(cube, timeDim.dimension); + if (timeDim.dateRange) { + const range = Array.isArray(timeDim.dateRange) + ? timeDim.dateRange + : this.parseDateRangeString(timeDim.dateRange); + + if (range.length === 2) { + pipeline.push({ + $match: { + [fieldPath]: { + $gte: new Date(range[0]), + $lte: new Date(range[1]) + } + } + }); + } + } + } + } + + // Stage 3: $group for measures and dimensions + const groupStage: Record = { _id: {} }; + + // Add dimensions to _id + if (query.dimensions && query.dimensions.length > 0) { + for (const dim of query.dimensions) { + const fieldPath = this.resolveFieldPath(cube, dim); + const dimName = this.getShortName(dim); + groupStage._id[dimName] = `$${fieldPath}`; + } + } else { + groupStage._id = null; // No grouping, aggregate all + } + + // Add measures as computed fields + if (query.measures && query.measures.length > 0) { + for (const measure of query.measures) { + const measureDef = this.resolveMeasure(cube, measure); + const measureName = this.getShortName(measure); + + if (measureDef) { + const aggregator = this.buildAggregator(measureDef); + groupStage[measureName] = aggregator; + } + } + } + + pipeline.push({ $group: groupStage }); + + // Stage 4: $project to reshape results (use short names, we'll fix them later) + const projectStage: Record = { _id: 0 }; + if (query.dimensions && query.dimensions.length > 0) { + for (const dim of query.dimensions) { + const dimName = this.getShortName(dim); + projectStage[dimName] = `$_id.${dimName}`; + } + } + if (query.measures && query.measures.length > 0) { + for (const measure of query.measures) { + const measureName = this.getShortName(measure); + projectStage[measureName] = `$${measureName}`; + } + } + pipeline.push({ $project: projectStage }); + + // Stage 5: $sort (use short names) + if (query.order && Object.keys(query.order).length > 0) { + const sortStage: Record = {}; + for (const [field, direction] of Object.entries(query.order)) { + const shortName = this.getShortName(field); + sortStage[shortName] = direction === 'asc' ? 1 : -1; + } + pipeline.push({ $sort: sortStage }); + } + + // Stage 6: $limit and $skip + if (query.offset) { + pipeline.push({ $skip: query.offset }); + } + if (query.limit) { + pipeline.push({ $limit: query.limit }); + } + + // Execute the aggregation pipeline + const tableName = this.extractTableName(cube.sql); + const rawRows = await this.driver.aggregate(tableName, pipeline); + + // Rename fields from short names to full cube.field names + const rows = rawRows.map(row => { + const renamedRow: Record = {}; + + // Rename dimensions + if (query.dimensions) { + for (const dim of query.dimensions) { + const shortName = this.getShortName(dim); + if (shortName in row) { + renamedRow[dim] = row[shortName]; + } + } + } + + // Rename measures + if (query.measures) { + for (const measure of query.measures) { + const shortName = this.getShortName(measure); + if (shortName in row) { + renamedRow[measure] = row[shortName]; + } + } + } + + return renamedRow; + }); + + // Build field metadata + const fields: Array<{ name: string; type: string }> = []; + + if (query.dimensions) { + for (const dim of query.dimensions) { + const dimension = this.resolveDimension(cube, dim); + fields.push({ + name: dim, + type: dimension?.type || 'string' + }); + } + } + + if (query.measures) { + for (const measure of query.measures) { + const measureDef = this.resolveMeasure(cube, measure); + fields.push({ + name: measure, + type: this.measureTypeToFieldType(measureDef?.type || 'count') + }); + } + } + + this.logger.debug('Analytics query completed', { rowCount: rows.length }); + + return { + rows, + fields, + sql: this.generateSqlFromPipeline(tableName, pipeline) // For debugging + }; + } + + /** + * Get available cube metadata for discovery + */ + async getMeta(cubeName?: string): Promise { + const cubes = cubeName + ? [this.cubes.get(cubeName)].filter(Boolean) as Cube[] + : Array.from(this.cubes.values()); + + return cubes.map(cube => ({ + name: cube.name, + title: cube.title, + measures: Object.entries(cube.measures).map(([key, measure]) => ({ + name: `${cube.name}.${key}`, + type: measure.type, + title: measure.label + })), + dimensions: Object.entries(cube.dimensions).map(([key, dimension]) => ({ + name: `${cube.name}.${key}`, + type: dimension.type, + title: dimension.label + })) + })); + } + + /** + * Generate SQL representation for debugging/transparency + */ + async generateSql(query: AnalyticsQuery): Promise<{ sql: string; params: unknown[] }> { + const cube = this.cubes.get(query.cube); + if (!cube) { + throw new Error(`Cube not found: ${query.cube}`); + } + + const tableName = this.extractTableName(cube.sql); + const selectClauses: string[] = []; + const groupByClauses: string[] = []; + + // Build SELECT for dimensions + if (query.dimensions && query.dimensions.length > 0) { + for (const dim of query.dimensions) { + const fieldPath = this.resolveFieldPath(cube, dim); + selectClauses.push(`${fieldPath} AS "${dim}"`); + groupByClauses.push(fieldPath); + } + } + + // Build SELECT for measures + if (query.measures && query.measures.length > 0) { + for (const measure of query.measures) { + const measureDef = this.resolveMeasure(cube, measure); + if (measureDef) { + const aggSql = this.measureToSql(measureDef); + selectClauses.push(`${aggSql} AS "${measure}"`); + } + } + } + + // Build WHERE clause + const whereClauses: string[] = []; + if (query.filters && query.filters.length > 0) { + for (const filter of query.filters) { + const fieldPath = this.resolveFieldPath(cube, filter.member); + const sqlOp = this.operatorToSql(filter.operator); + if (filter.values && filter.values.length > 0) { + whereClauses.push(`${fieldPath} ${sqlOp} '${filter.values[0]}'`); + } + } + } + + let sql = `SELECT ${selectClauses.join(', ')} FROM ${tableName}`; + if (whereClauses.length > 0) { + sql += ` WHERE ${whereClauses.join(' AND ')}`; + } + if (groupByClauses.length > 0) { + sql += ` GROUP BY ${groupByClauses.join(', ')}`; + } + if (query.order) { + const orderClauses = Object.entries(query.order).map(([field, dir]) => + `"${field}" ${dir.toUpperCase()}` + ); + sql += ` ORDER BY ${orderClauses.join(', ')}`; + } + if (query.limit) { + sql += ` LIMIT ${query.limit}`; + } + if (query.offset) { + sql += ` OFFSET ${query.offset}`; + } + + return { sql, params: [] }; + } + + // =================================== + // Helper Methods + // =================================== + + private resolveFieldPath(cube: Cube, member: string): string { + // Handle both "cube.field" and "field" formats + const parts = member.split('.'); + const fieldName = parts.length > 1 ? parts[1] : parts[0]; + + // Check if it's a dimension + const dimension = cube.dimensions[fieldName]; + if (dimension) { + // Extract field path from SQL expression + return dimension.sql.replace(/^\$/, ''); // Remove $ prefix if present + } + + // Check if it's a measure (for filters) + const measure = cube.measures[fieldName]; + if (measure) { + return measure.sql.replace(/^\$/, ''); + } + + return fieldName; + } + + private resolveMeasure(cube: Cube, measureName: string) { + const parts = measureName.split('.'); + const fieldName = parts.length > 1 ? parts[1] : parts[0]; + return cube.measures[fieldName]; + } + + private resolveDimension(cube: Cube, dimensionName: string) { + const parts = dimensionName.split('.'); + const fieldName = parts.length > 1 ? parts[1] : parts[0]; + return cube.dimensions[fieldName]; + } + + private getShortName(fullName: string): string { + const parts = fullName.split('.'); + return parts.length > 1 ? parts[1] : parts[0]; + } + + private buildAggregator(measure: { type: string; sql: string; filters?: any[] }): any { + const fieldPath = measure.sql.replace(/^\$/, ''); + + switch (measure.type) { + case 'count': + return { $sum: 1 }; + case 'sum': + return { $sum: `$${fieldPath}` }; + case 'avg': + return { $avg: `$${fieldPath}` }; + case 'min': + return { $min: `$${fieldPath}` }; + case 'max': + return { $max: `$${fieldPath}` }; + case 'count_distinct': + return { $addToSet: `$${fieldPath}` }; // Will need post-processing for count + default: + return { $sum: 1 }; // Default to count + } + } + + private measureTypeToFieldType(measureType: string): string { + switch (measureType) { + case 'count': + case 'sum': + case 'count_distinct': + return 'number'; + case 'avg': + case 'min': + case 'max': + return 'number'; + case 'string': + return 'string'; + case 'boolean': + return 'boolean'; + default: + return 'number'; + } + } + + private convertOperatorToMongo(operator: string): string { + const opMap: Record = { + 'equals': '$eq', + 'notEquals': '$ne', + 'contains': '$regex', + 'notContains': '$not', + 'gt': '$gt', + 'gte': '$gte', + 'lt': '$lt', + 'lte': '$lte', + 'set': '$exists', + 'notSet': '$exists', + 'inDateRange': '$gte', // Will need special handling + }; + return opMap[operator] || '$eq'; + } + + private operatorToSql(operator: string): string { + const opMap: Record = { + 'equals': '=', + 'notEquals': '!=', + 'contains': 'LIKE', + 'notContains': 'NOT LIKE', + 'gt': '>', + 'gte': '>=', + 'lt': '<', + 'lte': '<=', + }; + return opMap[operator] || '='; + } + + private measureToSql(measure: { type: string; sql: string }): string { + const fieldPath = measure.sql.replace(/^\$/, ''); + + switch (measure.type) { + case 'count': + return 'COUNT(*)'; + case 'sum': + return `SUM(${fieldPath})`; + case 'avg': + return `AVG(${fieldPath})`; + case 'min': + return `MIN(${fieldPath})`; + case 'max': + return `MAX(${fieldPath})`; + case 'count_distinct': + return `COUNT(DISTINCT ${fieldPath})`; + default: + return 'COUNT(*)'; + } + } + + private extractTableName(sql: string): string { + // For simple table names, return as-is + // For complex SQL, this would need more sophisticated parsing + return sql.trim(); + } + + private parseDateRangeString(range: string): string[] { + // Simple parser for common date range strings + // In production, this would use a proper date range parser + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + if (range === 'today') { + return [today.toISOString(), new Date(today.getTime() + 86400000).toISOString()]; + } else if (range.startsWith('last ')) { + const parts = range.split(' '); + const num = parseInt(parts[1]); + const unit = parts[2]; + const start = new Date(today); + + if (unit.startsWith('day')) { + start.setDate(start.getDate() - num); + } else if (unit.startsWith('week')) { + start.setDate(start.getDate() - num * 7); + } else if (unit.startsWith('month')) { + start.setMonth(start.getMonth() - num); + } else if (unit.startsWith('year')) { + start.setFullYear(start.getFullYear() - num); + } + + return [start.toISOString(), now.toISOString()]; + } + + return [range, range]; // Fallback + } + + private generateSqlFromPipeline(table: string, pipeline: Record[]): string { + // Simplified SQL generation for debugging + // This is a basic representation of the aggregation pipeline + const stages = pipeline.map((stage, idx) => { + const op = Object.keys(stage)[0]; + return `/* Stage ${idx + 1}: ${op} */ ${JSON.stringify(stage[op])}`; + }).join('\n'); + + return `-- MongoDB Aggregation Pipeline on table: ${table}\n${stages}`; + } +} diff --git a/packages/plugins/driver-memory/src/memory-driver.ts b/packages/plugins/driver-memory/src/memory-driver.ts index 7a6bf10a7f..d84e60808e 100644 --- a/packages/plugins/driver-memory/src/memory-driver.ts +++ b/packages/plugins/driver-memory/src/memory-driver.ts @@ -1,8 +1,8 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { QueryAST, QueryInput } from '@objectstack/spec/data'; -import { DriverOptions } from '@objectstack/spec/data'; -import { DriverInterface, Logger, createLogger } from '@objectstack/core'; +import type { QueryAST, QueryInput, DriverOptions } from '@objectstack/spec/data'; +import type { DriverInterface } from '@objectstack/core'; +import { Logger, createLogger } from '@objectstack/core'; import { Query, Aggregator } from 'mingo'; import { getValueByPath } from './memory-matcher.js'; diff --git a/packages/spec/src/api/analytics.zod.ts b/packages/spec/src/api/analytics.zod.ts index de128c48d3..240185ec6c 100644 --- a/packages/spec/src/api/analytics.zod.ts +++ b/packages/spec/src/api/analytics.zod.ts @@ -29,8 +29,7 @@ export const AnalyticsEndpoint = z.enum([ * Query Request Body */ export const AnalyticsQueryRequestSchema = z.object({ - query: AnalyticsQuerySchema.describe(' The analytic query definition'), - cube: z.string().describe('Target cube name'), + query: AnalyticsQuerySchema.describe('The analytic query definition'), format: z.enum(['json', 'csv', 'xlsx']).default('json').describe('Response format'), }); diff --git a/packages/spec/src/contracts/analytics-service.ts b/packages/spec/src/contracts/analytics-service.ts index e93df29dc1..41a9d0f3e6 100644 --- a/packages/spec/src/contracts/analytics-service.ts +++ b/packages/spec/src/contracts/analytics-service.ts @@ -35,10 +35,14 @@ export interface AnalyticsQuery { granularity?: string; dateRange?: string | string[]; }>; + /** Sort order for results */ + order?: Record; /** Result limit */ limit?: number; /** Result offset */ offset?: number; + /** Timezone for date/time calculations */ + timezone?: string; } /** diff --git a/packages/spec/src/data/analytics.zod.ts b/packages/spec/src/data/analytics.zod.ts index 0f985cc62b..3f332ed40c 100644 --- a/packages/spec/src/data/analytics.zod.ts +++ b/packages/spec/src/data/analytics.zod.ts @@ -133,6 +133,7 @@ export const CubeSchema = z.object({ * The request format for the Analytics API. */ export const AnalyticsQuerySchema = z.object({ + cube: z.string().describe('Target cube name'), measures: z.array(z.string()).describe('List of metrics to calculate'), dimensions: z.array(z.string()).optional().describe('List of dimensions to group by'), @@ -156,7 +157,7 @@ export const AnalyticsQuerySchema = z.object({ limit: z.number().optional(), offset: z.number().optional(), - timezone: z.string().default('UTC'), + timezone: z.string().optional().default('UTC'), }); export type Metric = z.infer;