@objectstack/service-analytics 3.2.9
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 +22 -0
- package/CHANGELOG.md +19 -0
- package/LICENSE +202 -0
- package/dist/index.cjs +631 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +260 -0
- package/dist/index.d.ts +260 -0
- package/dist/index.js +600 -0
- package/dist/index.js.map +1 -0
- package/package.json +29 -0
- package/src/__tests__/analytics-service.test.ts +469 -0
- package/src/analytics-service.ts +231 -0
- package/src/cube-registry.ts +147 -0
- package/src/index.ts +19 -0
- package/src/plugin.ts +133 -0
- package/src/strategies/native-sql-strategy.ts +184 -0
- package/src/strategies/objectql-strategy.ts +178 -0
- package/src/strategies/types.ts +11 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/analytics-service.ts","../src/cube-registry.ts","../src/strategies/native-sql-strategy.ts","../src/strategies/objectql-strategy.ts","../src/plugin.ts"],"sourcesContent":["// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type {\n IAnalyticsService,\n AnalyticsQuery,\n AnalyticsResult,\n CubeMeta,\n} from '@objectstack/spec/contracts';\nimport type { Cube } from '@objectstack/spec/data';\nimport type { Logger } from '@objectstack/spec/contracts';\nimport { createLogger } from '@objectstack/core';\nimport { CubeRegistry } from './cube-registry.js';\nimport type { AnalyticsStrategy, DriverCapabilities, StrategyContext } from './strategies/types.js';\nimport { NativeSQLStrategy } from './strategies/native-sql-strategy.js';\nimport { ObjectQLStrategy } from './strategies/objectql-strategy.js';\n\n/**\n * Configuration for AnalyticsService.\n */\nexport interface AnalyticsServiceConfig {\n /** Pre-defined cube definitions (from manifest). */\n cubes?: Cube[];\n /** Logger instance. */\n logger?: Logger;\n /**\n * Probe driver capabilities for the object that backs a cube.\n * The service calls this function to decide which strategy can handle a query.\n */\n queryCapabilities?: (cubeName: string) => DriverCapabilities;\n /**\n * Execute raw SQL on the driver for a given object.\n * Required for NativeSQLStrategy.\n */\n executeRawSql?: (objectName: string, sql: string, params: unknown[]) => Promise<Record<string, unknown>[]>;\n /**\n * Execute an ObjectQL aggregate query.\n * Required for ObjectQLStrategy.\n */\n executeAggregate?: (objectName: string, options: {\n groupBy?: string[];\n aggregations?: Array<{ field: string; method: string; alias: string }>;\n filter?: Record<string, unknown>;\n }) => Promise<Record<string, unknown>[]>;\n /**\n * Fallback IAnalyticsService (e.g. MemoryAnalyticsService).\n * Used by InMemoryStrategy.\n */\n fallbackService?: IAnalyticsService;\n /**\n * Custom strategies to add/replace the defaults.\n * They are merged with the built-in strategies and sorted by priority.\n */\n strategies?: AnalyticsStrategy[];\n}\n\n/**\n * Default capabilities when probing is not configured — assumes in-memory only.\n */\nconst DEFAULT_CAPABILITIES: DriverCapabilities = {\n nativeSql: false,\n objectqlAggregate: false,\n inMemory: true,\n};\n\n/**\n * AnalyticsService — Multi-driver analytics orchestrator.\n *\n * Implements `IAnalyticsService` by delegating to a priority-ordered\n * strategy chain:\n *\n * | Priority | Strategy | Condition |\n * |:---:|:---|:---|\n * | P1 (10) | NativeSQLStrategy | Driver supports raw SQL |\n * | P2 (20) | ObjectQLStrategy | Driver supports aggregate AST |\n * | P3 (30) | (custom / InMemoryStrategy from driver-memory) | Injected by user |\n *\n * When `fallbackService` is configured, an internal delegate strategy\n * is automatically appended at priority 30 as a safety net.\n *\n * The service also owns a `CubeRegistry` for metadata discovery and\n * auto-inference from object schemas.\n */\nexport class AnalyticsService implements IAnalyticsService {\n private readonly strategies: AnalyticsStrategy[];\n private readonly strategyCtx: StrategyContext;\n readonly cubeRegistry: CubeRegistry;\n private readonly logger: Logger;\n\n constructor(config: AnalyticsServiceConfig = {}) {\n this.logger = config.logger || createLogger({ level: 'info', format: 'pretty' });\n this.cubeRegistry = new CubeRegistry();\n\n // Register pre-defined cubes\n if (config.cubes) {\n this.cubeRegistry.registerAll(config.cubes);\n }\n\n // Build strategy context\n this.strategyCtx = {\n getCube: (name) => this.cubeRegistry.get(name),\n queryCapabilities: config.queryCapabilities || (() => DEFAULT_CAPABILITIES),\n executeRawSql: config.executeRawSql,\n executeAggregate: config.executeAggregate,\n fallbackService: config.fallbackService,\n };\n\n // Build strategy chain (built-in + custom, sorted by priority)\n // InMemoryStrategy is NOT built-in — it lives in @objectstack/driver-memory\n // and should be passed via config.strategies when needed.\n // When fallbackService is configured, an internal delegate is added at P3.\n const builtIn: AnalyticsStrategy[] = [\n new NativeSQLStrategy(),\n new ObjectQLStrategy(),\n ];\n\n // Auto-add fallback delegate when fallbackService is provided\n if (config.fallbackService) {\n builtIn.push(new FallbackDelegateStrategy());\n }\n\n const custom = config.strategies || [];\n this.strategies = [...builtIn, ...custom].sort((a, b) => a.priority - b.priority);\n\n this.logger.info(\n `[Analytics] Initialized with ${this.cubeRegistry.size} cubes, ` +\n `${this.strategies.length} strategies: ${this.strategies.map(s => s.name).join(' → ')}`,\n );\n }\n\n /**\n * Execute an analytical query by delegating to the first capable strategy.\n */\n async query(query: AnalyticsQuery): Promise<AnalyticsResult> {\n if (!query.cube) {\n throw new Error('Cube name is required in analytics query');\n }\n\n const strategy = this.resolveStrategy(query);\n this.logger.debug(`[Analytics] Query on cube \"${query.cube}\" → ${strategy.name}`);\n\n return strategy.execute(query, this.strategyCtx);\n }\n\n /**\n * Get cube metadata for discovery.\n */\n async getMeta(cubeName?: string): Promise<CubeMeta[]> {\n // If a fallback service is configured, merge its metadata with the registry\n const cubes = cubeName\n ? [this.cubeRegistry.get(cubeName)].filter(Boolean) as Cube[]\n : this.cubeRegistry.getAll();\n\n return cubes.map(cube => ({\n name: cube.name,\n title: cube.title,\n measures: Object.entries(cube.measures).map(([key, measure]) => ({\n name: `${cube.name}.${key}`,\n type: measure.type,\n title: measure.label,\n })),\n dimensions: Object.entries(cube.dimensions).map(([key, dimension]) => ({\n name: `${cube.name}.${key}`,\n type: dimension.type,\n title: dimension.label,\n })),\n }));\n }\n\n /**\n * Generate SQL for a query without executing it (dry-run).\n */\n async generateSql(query: AnalyticsQuery): Promise<{ sql: string; params: unknown[] }> {\n if (!query.cube) {\n throw new Error('Cube name is required for SQL generation');\n }\n\n const strategy = this.resolveStrategy(query);\n this.logger.debug(`[Analytics] generateSql on cube \"${query.cube}\" → ${strategy.name}`);\n\n return strategy.generateSql(query, this.strategyCtx);\n }\n\n // ── Internal ─────────────────────────────────────────────────────\n\n /**\n * Walk the strategy chain and return the first strategy that can handle the query.\n */\n private resolveStrategy(query: AnalyticsQuery): AnalyticsStrategy {\n for (const strategy of this.strategies) {\n if (strategy.canHandle(query, this.strategyCtx)) {\n return strategy;\n }\n }\n throw new Error(\n `[Analytics] No strategy can handle query for cube \"${query.cube}\". ` +\n `Checked: ${this.strategies.map(s => s.name).join(', ')}. ` +\n 'Ensure a compatible driver is configured or a fallback service is registered.',\n );\n }\n}\n\n/**\n * FallbackDelegateStrategy — Internal strategy for fallback service delegation.\n *\n * Automatically added to the strategy chain when `fallbackService` is configured.\n * Not exported — consumers who need explicit in-memory support should use\n * `InMemoryStrategy` from `@objectstack/driver-memory`.\n */\nclass FallbackDelegateStrategy implements AnalyticsStrategy {\n readonly name = 'FallbackDelegateStrategy';\n readonly priority = 30;\n\n canHandle(query: AnalyticsQuery, ctx: StrategyContext): boolean {\n if (!query.cube) return false;\n return !!ctx.fallbackService;\n }\n\n async execute(query: AnalyticsQuery, ctx: StrategyContext): Promise<AnalyticsResult> {\n return ctx.fallbackService!.query(query);\n }\n\n async generateSql(query: AnalyticsQuery, ctx: StrategyContext): Promise<{ sql: string; params: unknown[] }> {\n if (ctx.fallbackService?.generateSql) {\n return ctx.fallbackService.generateSql(query);\n }\n return {\n sql: `-- FallbackDelegateStrategy: SQL generation not supported for cube \"${query.cube}\"`,\n params: [],\n };\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { Cube } from '@objectstack/spec/data';\n\n/**\n * CubeRegistry — Central registry for analytics cube definitions.\n *\n * Cubes can be registered from two sources:\n * 1. **Manifest definitions** — Explicit cube definitions in `objectstack.config.ts`.\n * 2. **Object schema inference** — Auto-generated cubes from ObjectQL object schemas.\n *\n * The registry is the single source of truth for cube metadata discovery\n * (used by `getMeta()` and the strategy chain).\n */\nexport class CubeRegistry {\n private cubes = new Map<string, Cube>();\n\n /** Register a single cube definition. Overwrites if name already exists. */\n register(cube: Cube): void {\n this.cubes.set(cube.name, cube);\n }\n\n /** Register multiple cube definitions at once. */\n registerAll(cubes: Cube[]): void {\n for (const cube of cubes) {\n this.register(cube);\n }\n }\n\n /** Get a cube definition by name. */\n get(name: string): Cube | undefined {\n return this.cubes.get(name);\n }\n\n /** Check if a cube is registered. */\n has(name: string): boolean {\n return this.cubes.has(name);\n }\n\n /** Return all registered cubes. */\n getAll(): Cube[] {\n return Array.from(this.cubes.values());\n }\n\n /** Return all cube names. */\n names(): string[] {\n return Array.from(this.cubes.keys());\n }\n\n /** Number of registered cubes. */\n get size(): number {\n return this.cubes.size;\n }\n\n /** Remove all cubes. */\n clear(): void {\n this.cubes.clear();\n }\n\n /**\n * Auto-generate a cube definition from an object schema.\n *\n * Heuristic rules:\n * - `number` fields → `sum`, `avg`, `min`, `max` measures\n * - `boolean` fields → `count` measure (count where true)\n * - All non-computed fields → dimensions\n * - `date`/`datetime` fields → time dimensions with standard granularities\n * - A default `count` measure is always added\n *\n * @param objectName - The snake_case object name (used as table/cube name)\n * @param fields - Array of field descriptors `{ name, type, label? }`\n */\n inferFromObject(\n objectName: string,\n fields: Array<{ name: string; type: string; label?: string }>,\n ): Cube {\n const measures: Record<string, any> = {\n count: {\n name: 'count',\n label: 'Count',\n type: 'count',\n sql: '*',\n },\n };\n const dimensions: Record<string, any> = {};\n\n for (const field of fields) {\n const label = field.label || field.name;\n\n // All fields become dimensions\n const dimType = this.fieldTypeToDimensionType(field.type);\n dimensions[field.name] = {\n name: field.name,\n label,\n type: dimType,\n sql: field.name,\n ...(dimType === 'time'\n ? { granularities: ['day', 'week', 'month', 'quarter', 'year'] }\n : {}),\n };\n\n // Numeric fields also become aggregation measures\n if (field.type === 'number' || field.type === 'currency' || field.type === 'percent') {\n measures[`${field.name}_sum`] = {\n name: `${field.name}_sum`,\n label: `${label} (Sum)`,\n type: 'sum',\n sql: field.name,\n };\n measures[`${field.name}_avg`] = {\n name: `${field.name}_avg`,\n label: `${label} (Avg)`,\n type: 'avg',\n sql: field.name,\n };\n }\n }\n\n const cube: Cube = {\n name: objectName,\n title: objectName,\n sql: objectName,\n measures,\n dimensions,\n public: false,\n };\n\n this.register(cube);\n return cube;\n }\n\n private fieldTypeToDimensionType(fieldType: string): string {\n switch (fieldType) {\n case 'number':\n case 'currency':\n case 'percent':\n return 'number';\n case 'boolean':\n return 'boolean';\n case 'date':\n case 'datetime':\n return 'time';\n default:\n return 'string';\n }\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { AnalyticsQuery, AnalyticsResult } from '@objectstack/spec/contracts';\nimport type { Cube } from '@objectstack/spec/data';\nimport type { AnalyticsStrategy, StrategyContext } from './types.js';\n\n/**\n * NativeSQLStrategy — Priority 1\n *\n * Pushes the analytics query down to the database as a native SQL statement.\n * This is the most efficient path and is preferred whenever the backing driver\n * supports raw SQL execution (e.g. Postgres, MySQL, SQLite).\n */\nexport class NativeSQLStrategy implements AnalyticsStrategy {\n readonly name = 'NativeSQLStrategy';\n readonly priority = 10;\n\n canHandle(query: AnalyticsQuery, ctx: StrategyContext): boolean {\n if (!query.cube) return false;\n const caps = ctx.queryCapabilities(query.cube);\n return caps.nativeSql && typeof ctx.executeRawSql === 'function';\n }\n\n async execute(query: AnalyticsQuery, ctx: StrategyContext): Promise<AnalyticsResult> {\n const { sql, params } = await this.generateSql(query, ctx);\n const cube = ctx.getCube(query.cube!)!;\n const objectName = this.extractObjectName(cube);\n\n const rows = await ctx.executeRawSql!(objectName, sql, params);\n\n // Build field metadata\n const fields = this.buildFieldMeta(query, cube);\n\n return { rows, fields, sql };\n }\n\n async generateSql(query: AnalyticsQuery, ctx: StrategyContext): Promise<{ sql: string; params: unknown[] }> {\n const cube = ctx.getCube(query.cube!);\n if (!cube) {\n throw new Error(`Cube not found: ${query.cube}`);\n }\n\n const params: unknown[] = [];\n const selectClauses: string[] = [];\n const groupByClauses: string[] = [];\n\n // Build SELECT for dimensions\n if (query.dimensions && query.dimensions.length > 0) {\n for (const dim of query.dimensions) {\n const colExpr = this.resolveDimensionSql(cube, dim);\n selectClauses.push(`${colExpr} AS \"${dim}\"`);\n groupByClauses.push(colExpr);\n }\n }\n\n // Build SELECT for measures\n if (query.measures && query.measures.length > 0) {\n for (const measure of query.measures) {\n const aggExpr = this.resolveMeasureSql(cube, measure);\n selectClauses.push(`${aggExpr} AS \"${measure}\"`);\n }\n }\n\n // Build WHERE clause\n const whereClauses: string[] = [];\n if (query.filters && query.filters.length > 0) {\n for (const filter of query.filters) {\n const colExpr = this.resolveFieldSql(cube, filter.member);\n const clause = this.buildFilterClause(colExpr, filter.operator, filter.values, params);\n if (clause) whereClauses.push(clause);\n }\n }\n\n // Build time dimension filters\n if (query.timeDimensions && query.timeDimensions.length > 0) {\n for (const td of query.timeDimensions) {\n const colExpr = this.resolveFieldSql(cube, td.dimension);\n if (td.dateRange) {\n const range = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];\n if (range.length === 2) {\n params.push(range[0], range[1]);\n whereClauses.push(`${colExpr} BETWEEN $${params.length - 1} AND $${params.length}`);\n }\n }\n }\n }\n\n const tableName = this.extractObjectName(cube);\n let sql = `SELECT ${selectClauses.join(', ')} FROM \"${tableName}\"`;\n if (whereClauses.length > 0) {\n sql += ` WHERE ${whereClauses.join(' AND ')}`;\n }\n if (groupByClauses.length > 0) {\n sql += ` GROUP BY ${groupByClauses.join(', ')}`;\n }\n if (query.order && Object.keys(query.order).length > 0) {\n const orderClauses = Object.entries(query.order).map(([f, d]) => `\"${f}\" ${d.toUpperCase()}`);\n sql += ` ORDER BY ${orderClauses.join(', ')}`;\n }\n if (query.limit != null) {\n sql += ` LIMIT ${query.limit}`;\n }\n if (query.offset != null) {\n sql += ` OFFSET ${query.offset}`;\n }\n\n return { sql, params };\n }\n\n // ── Helpers ──────────────────────────────────────────────────────\n\n private resolveDimensionSql(cube: Cube, member: string): string {\n const fieldName = member.includes('.') ? member.split('.')[1] : member;\n const dim = cube.dimensions[fieldName];\n return dim ? dim.sql : fieldName;\n }\n\n private resolveMeasureSql(cube: Cube, member: string): string {\n const fieldName = member.includes('.') ? member.split('.')[1] : member;\n const measure = cube.measures[fieldName];\n if (!measure) return `COUNT(*)`;\n\n const col = measure.sql;\n switch (measure.type) {\n case 'count': return 'COUNT(*)';\n case 'sum': return `SUM(${col})`;\n case 'avg': return `AVG(${col})`;\n case 'min': return `MIN(${col})`;\n case 'max': return `MAX(${col})`;\n case 'count_distinct': return `COUNT(DISTINCT ${col})`;\n default: return `COUNT(*)`;\n }\n }\n\n private resolveFieldSql(cube: Cube, member: string): string {\n const fieldName = member.includes('.') ? member.split('.')[1] : member;\n const dim = cube.dimensions[fieldName];\n if (dim) return dim.sql;\n const measure = cube.measures[fieldName];\n if (measure) return measure.sql;\n return fieldName;\n }\n\n private buildFilterClause(col: string, operator: string, values: string[] | undefined, params: unknown[]): string | null {\n const opMap: Record<string, string> = {\n equals: '=', notEquals: '!=', gt: '>', gte: '>=', lt: '<', lte: '<=',\n contains: 'LIKE', notContains: 'NOT LIKE',\n };\n\n if (operator === 'set') return `${col} IS NOT NULL`;\n if (operator === 'notSet') return `${col} IS NULL`;\n\n const sqlOp = opMap[operator];\n if (!sqlOp || !values || values.length === 0) return null;\n\n if (operator === 'contains' || operator === 'notContains') {\n params.push(`%${values[0]}%`);\n } else {\n params.push(values[0]);\n }\n return `${col} ${sqlOp} $${params.length}`;\n }\n\n private extractObjectName(cube: Cube): string {\n return cube.sql.trim();\n }\n\n private buildFieldMeta(query: AnalyticsQuery, cube: Cube): Array<{ name: string; type: string }> {\n const fields: Array<{ name: string; type: string }> = [];\n if (query.dimensions) {\n for (const dim of query.dimensions) {\n const fieldName = dim.includes('.') ? dim.split('.')[1] : dim;\n const d = cube.dimensions[fieldName];\n fields.push({ name: dim, type: d?.type || 'string' });\n }\n }\n if (query.measures) {\n for (const m of query.measures) {\n fields.push({ name: m, type: 'number' });\n }\n }\n return fields;\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { AnalyticsQuery, AnalyticsResult } from '@objectstack/spec/contracts';\nimport type { Cube } from '@objectstack/spec/data';\nimport type { AnalyticsStrategy, StrategyContext } from './types.js';\n\n/**\n * ObjectQLStrategy — Priority 2\n *\n * Translates an analytics query into an ObjectQL `engine.aggregate()` call.\n * This path works with any driver that supports the ObjectQL aggregate AST\n * (Postgres, Mongo, SQLite, etc.) without requiring raw SQL access.\n */\nexport class ObjectQLStrategy implements AnalyticsStrategy {\n readonly name = 'ObjectQLStrategy';\n readonly priority = 20;\n\n canHandle(query: AnalyticsQuery, ctx: StrategyContext): boolean {\n if (!query.cube) return false;\n const caps = ctx.queryCapabilities(query.cube);\n return caps.objectqlAggregate && typeof ctx.executeAggregate === 'function';\n }\n\n async execute(query: AnalyticsQuery, ctx: StrategyContext): Promise<AnalyticsResult> {\n const cube = ctx.getCube(query.cube!)!;\n const objectName = this.extractObjectName(cube);\n\n // Build groupBy from dimensions\n const groupBy: string[] = [];\n if (query.dimensions && query.dimensions.length > 0) {\n for (const dim of query.dimensions) {\n groupBy.push(this.resolveFieldName(cube, dim, 'dimension'));\n }\n }\n\n // Build aggregations from measures\n const aggregations: Array<{ field: string; method: string; alias: string }> = [];\n if (query.measures && query.measures.length > 0) {\n for (const measure of query.measures) {\n const { field, method } = this.resolveMeasureAggregation(cube, measure);\n aggregations.push({ field, method, alias: measure });\n }\n }\n\n // Build filter from query filters\n const filter: Record<string, unknown> = {};\n if (query.filters && query.filters.length > 0) {\n for (const f of query.filters) {\n const fieldName = this.resolveFieldName(cube, f.member, 'any');\n filter[fieldName] = this.convertFilter(f.operator, f.values);\n }\n }\n\n const rows = await ctx.executeAggregate!(objectName, {\n groupBy: groupBy.length > 0 ? groupBy : undefined,\n aggregations: aggregations.length > 0 ? aggregations : undefined,\n filter: Object.keys(filter).length > 0 ? filter : undefined,\n });\n\n // Remap short field names back to cube-qualified names\n const mappedRows = rows.map(row => {\n const mapped: Record<string, unknown> = {};\n if (query.dimensions) {\n for (const dim of query.dimensions) {\n const shortName = this.resolveFieldName(cube, dim, 'dimension');\n if (shortName in row) mapped[dim] = row[shortName];\n }\n }\n if (query.measures) {\n for (const m of query.measures) {\n // Alias was set to the full measure name\n if (m in row) mapped[m] = row[m];\n }\n }\n return mapped;\n });\n\n const fields = this.buildFieldMeta(query, cube);\n return { rows: mappedRows, fields };\n }\n\n async generateSql(query: AnalyticsQuery, ctx: StrategyContext): Promise<{ sql: string; params: unknown[] }> {\n const cube = ctx.getCube(query.cube!);\n if (!cube) {\n throw new Error(`Cube not found: ${query.cube}`);\n }\n\n // Generate a representative SQL even though ObjectQL uses AST internally\n const selectParts: string[] = [];\n const groupByParts: string[] = [];\n\n if (query.dimensions) {\n for (const dim of query.dimensions) {\n const col = this.resolveFieldName(cube, dim, 'dimension');\n selectParts.push(`${col} AS \"${dim}\"`);\n groupByParts.push(col);\n }\n }\n if (query.measures) {\n for (const m of query.measures) {\n const { field, method } = this.resolveMeasureAggregation(cube, m);\n const aggSql = method === 'count' ? 'COUNT(*)' : `${method.toUpperCase()}(${field})`;\n selectParts.push(`${aggSql} AS \"${m}\"`);\n }\n }\n\n const tableName = this.extractObjectName(cube);\n let sql = `SELECT ${selectParts.join(', ')} FROM \"${tableName}\"`;\n if (groupByParts.length > 0) {\n sql += ` GROUP BY ${groupByParts.join(', ')}`;\n }\n\n return { sql, params: [] };\n }\n\n // ── Helpers ──────────────────────────────────────────────────────\n\n private resolveFieldName(cube: Cube, member: string, kind: 'dimension' | 'measure' | 'any'): string {\n const fieldName = member.includes('.') ? member.split('.')[1] : member;\n if (kind === 'dimension' || kind === 'any') {\n const dim = cube.dimensions[fieldName];\n if (dim) return dim.sql.replace(/^\\$/, '');\n }\n if (kind === 'measure' || kind === 'any') {\n const measure = cube.measures[fieldName];\n if (measure) return measure.sql.replace(/^\\$/, '');\n }\n return fieldName;\n }\n\n private resolveMeasureAggregation(cube: Cube, measureName: string): { field: string; method: string } {\n const fieldName = measureName.includes('.') ? measureName.split('.')[1] : measureName;\n const measure = cube.measures[fieldName];\n if (!measure) return { field: '*', method: 'count' };\n return {\n field: measure.sql.replace(/^\\$/, ''),\n method: measure.type === 'count_distinct' ? 'count_distinct' : measure.type,\n };\n }\n\n private convertFilter(operator: string, values?: string[]): unknown {\n if (operator === 'set') return { $ne: null };\n if (operator === 'notSet') return null;\n if (!values || values.length === 0) return undefined;\n\n switch (operator) {\n case 'equals': return values[0];\n case 'notEquals': return { $ne: values[0] };\n case 'gt': return { $gt: values[0] };\n case 'gte': return { $gte: values[0] };\n case 'lt': return { $lt: values[0] };\n case 'lte': return { $lte: values[0] };\n case 'contains': return { $regex: values[0] };\n default: return values[0];\n }\n }\n\n private extractObjectName(cube: Cube): string {\n return cube.sql.trim();\n }\n\n private buildFieldMeta(query: AnalyticsQuery, cube: Cube): Array<{ name: string; type: string }> {\n const fields: Array<{ name: string; type: string }> = [];\n if (query.dimensions) {\n for (const dim of query.dimensions) {\n const fieldName = dim.includes('.') ? dim.split('.')[1] : dim;\n const d = cube.dimensions[fieldName];\n fields.push({ name: dim, type: d?.type || 'string' });\n }\n }\n if (query.measures) {\n for (const m of query.measures) {\n fields.push({ name: m, type: 'number' });\n }\n }\n return fields;\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport type { Cube } from '@objectstack/spec/data';\nimport type { IAnalyticsService } from '@objectstack/spec/contracts';\nimport { AnalyticsService } from './analytics-service.js';\nimport type { AnalyticsServiceConfig } from './analytics-service.js';\nimport type { DriverCapabilities } from './strategies/types.js';\n\n/**\n * Configuration for AnalyticsServicePlugin.\n */\nexport interface AnalyticsServicePluginOptions {\n /** Pre-defined cube definitions (from manifest). */\n cubes?: Cube[];\n /**\n * Probe driver capabilities for a given cube.\n * When omitted, defaults to in-memory only.\n */\n queryCapabilities?: (cubeName: string) => DriverCapabilities;\n /**\n * Execute raw SQL on a driver. Enables NativeSQLStrategy.\n */\n executeRawSql?: (objectName: string, sql: string, params: unknown[]) => Promise<Record<string, unknown>[]>;\n /**\n * Execute ObjectQL aggregate. Enables ObjectQLStrategy.\n */\n executeAggregate?: (objectName: string, options: {\n groupBy?: string[];\n aggregations?: Array<{ field: string; method: string; alias: string }>;\n filter?: Record<string, unknown>;\n }) => Promise<Record<string, unknown>[]>;\n /** Enable debug logging. */\n debug?: boolean;\n}\n\n/**\n * AnalyticsServicePlugin — Kernel plugin for multi-driver analytics.\n *\n * Lifecycle:\n * 1. **init** — Creates `AnalyticsService`, registers as `'analytics'` service.\n * If an existing analytics service is already registered (e.g. MemoryAnalyticsService\n * from dev-plugin), it is captured as the `fallbackService`.\n * 2. **start** — Triggers `'analytics:ready'` hook so other plugins can\n * register cubes or extend the service.\n * 3. **destroy** — Cleans up references.\n *\n * @example\n * ```ts\n * import { LiteKernel } from '@objectstack/core';\n * import { AnalyticsServicePlugin } from '@objectstack/service-analytics';\n *\n * const kernel = new LiteKernel();\n * kernel.use(new AnalyticsServicePlugin({\n * cubes: [ordersCube],\n * queryCapabilities: (cube) => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }),\n * executeRawSql: async (obj, sql, params) => pgPool.query(sql, params).then(r => r.rows),\n * }));\n * await kernel.bootstrap();\n *\n * const analytics = kernel.getService<IAnalyticsService>('analytics');\n * const result = await analytics.query({ cube: 'orders', measures: ['orders.count'] });\n * ```\n */\nexport class AnalyticsServicePlugin implements Plugin {\n name = 'com.objectstack.service-analytics';\n version = '1.0.0';\n type = 'standard' as const;\n dependencies: string[] = [];\n\n private service?: AnalyticsService;\n private readonly options: AnalyticsServicePluginOptions;\n\n constructor(options: AnalyticsServicePluginOptions = {}) {\n this.options = options;\n }\n\n async init(ctx: PluginContext): Promise<void> {\n // Check if there is an existing analytics service (e.g. from dev-plugin)\n let fallbackService: IAnalyticsService | undefined;\n try {\n const existing = ctx.getService<IAnalyticsService>('analytics');\n if (existing && typeof existing.query === 'function') {\n fallbackService = existing;\n ctx.logger.debug('[Analytics] Found existing analytics service, using as fallback');\n }\n } catch {\n // No existing service — that's fine\n }\n\n const config: AnalyticsServiceConfig = {\n cubes: this.options.cubes,\n logger: ctx.logger,\n queryCapabilities: this.options.queryCapabilities,\n executeRawSql: this.options.executeRawSql,\n executeAggregate: this.options.executeAggregate,\n fallbackService,\n };\n\n this.service = new AnalyticsService(config);\n\n // Register or replace the analytics service\n if (fallbackService) {\n ctx.replaceService('analytics', this.service);\n } else {\n ctx.registerService('analytics', this.service);\n }\n\n if (this.options.debug) {\n ctx.hook('analytics:beforeQuery', async (query: unknown) => {\n ctx.logger.debug('[Analytics] Before query', { query });\n });\n }\n\n ctx.logger.info('[Analytics] Service initialized');\n }\n\n async start(ctx: PluginContext): Promise<void> {\n if (!this.service) return;\n\n // Notify other plugins that analytics is ready\n await ctx.trigger('analytics:ready', this.service);\n\n ctx.logger.info(\n `[Analytics] Service started with ${this.service.cubeRegistry.size} cubes: ` +\n `${this.service.cubeRegistry.names().join(', ') || '(none)'}`,\n );\n }\n\n async destroy(): Promise<void> {\n this.service = undefined;\n }\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@objectstack/service-analytics",
|
|
3
|
+
"version": "3.2.9",
|
|
4
|
+
"license": "Apache-2.0",
|
|
5
|
+
"description": "Analytics Service for ObjectStack — implements IAnalyticsService with multi-driver strategy pattern (NativeSQL, ObjectQL, InMemory)",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.mjs",
|
|
13
|
+
"require": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@objectstack/core": "3.2.9",
|
|
18
|
+
"@objectstack/spec": "3.2.9"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"typescript": "^5.0.0",
|
|
22
|
+
"vitest": "^4.1.0",
|
|
23
|
+
"@types/node": "^25.5.0"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsup --config ../../../tsup.config.ts",
|
|
27
|
+
"test": "vitest run"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
4
|
+
import type { Cube } from '@objectstack/spec/data';
|
|
5
|
+
import type { AnalyticsQuery, AnalyticsResult, IAnalyticsService } from '@objectstack/spec/contracts';
|
|
6
|
+
import { AnalyticsService } from '../analytics-service.js';
|
|
7
|
+
import { CubeRegistry } from '../cube-registry.js';
|
|
8
|
+
import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js';
|
|
9
|
+
import { ObjectQLStrategy } from '../strategies/objectql-strategy.js';
|
|
10
|
+
import type { DriverCapabilities } from '../strategies/types.js';
|
|
11
|
+
|
|
12
|
+
// ─────────────────────────────────────────────────────────────────
|
|
13
|
+
// Test fixtures
|
|
14
|
+
// ─────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
const ordersCube: Cube = {
|
|
17
|
+
name: 'orders',
|
|
18
|
+
title: 'Orders',
|
|
19
|
+
sql: 'orders',
|
|
20
|
+
measures: {
|
|
21
|
+
count: { name: 'count', label: 'Count', type: 'count', sql: '*' },
|
|
22
|
+
total_amount: { name: 'total_amount', label: 'Total Amount', type: 'sum', sql: 'amount' },
|
|
23
|
+
avg_amount: { name: 'avg_amount', label: 'Avg Amount', type: 'avg', sql: 'amount' },
|
|
24
|
+
},
|
|
25
|
+
dimensions: {
|
|
26
|
+
status: { name: 'status', label: 'Status', type: 'string', sql: 'status' },
|
|
27
|
+
created_at: {
|
|
28
|
+
name: 'created_at',
|
|
29
|
+
label: 'Created At',
|
|
30
|
+
type: 'time',
|
|
31
|
+
sql: 'created_at',
|
|
32
|
+
granularities: ['day', 'week', 'month'],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
public: false,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const baseQuery: AnalyticsQuery = {
|
|
39
|
+
cube: 'orders',
|
|
40
|
+
measures: ['orders.count', 'orders.total_amount'],
|
|
41
|
+
dimensions: ['orders.status'],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Suppress logger output in tests
|
|
45
|
+
const silentLogger = {
|
|
46
|
+
info: vi.fn(),
|
|
47
|
+
debug: vi.fn(),
|
|
48
|
+
warn: vi.fn(),
|
|
49
|
+
error: vi.fn(),
|
|
50
|
+
child: vi.fn().mockReturnThis(),
|
|
51
|
+
} as any;
|
|
52
|
+
|
|
53
|
+
// ─────────────────────────────────────────────────────────────────
|
|
54
|
+
// CubeRegistry
|
|
55
|
+
// ─────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
describe('CubeRegistry', () => {
|
|
58
|
+
let registry: CubeRegistry;
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
registry = new CubeRegistry();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should register and retrieve a cube', () => {
|
|
65
|
+
registry.register(ordersCube);
|
|
66
|
+
expect(registry.get('orders')).toEqual(ordersCube);
|
|
67
|
+
expect(registry.has('orders')).toBe(true);
|
|
68
|
+
expect(registry.size).toBe(1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should register multiple cubes at once', () => {
|
|
72
|
+
const cube2: Cube = { ...ordersCube, name: 'products', sql: 'products' };
|
|
73
|
+
registry.registerAll([ordersCube, cube2]);
|
|
74
|
+
expect(registry.size).toBe(2);
|
|
75
|
+
expect(registry.names()).toEqual(['orders', 'products']);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should return undefined for unknown cube', () => {
|
|
79
|
+
expect(registry.get('nonexistent')).toBeUndefined();
|
|
80
|
+
expect(registry.has('nonexistent')).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should clear all cubes', () => {
|
|
84
|
+
registry.register(ordersCube);
|
|
85
|
+
registry.clear();
|
|
86
|
+
expect(registry.size).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should infer a cube from object fields', () => {
|
|
90
|
+
const cube = registry.inferFromObject('tasks', [
|
|
91
|
+
{ name: 'title', type: 'text', label: 'Title' },
|
|
92
|
+
{ name: 'hours', type: 'number', label: 'Hours' },
|
|
93
|
+
{ name: 'due_date', type: 'date', label: 'Due Date' },
|
|
94
|
+
{ name: 'active', type: 'boolean', label: 'Active' },
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
expect(cube.name).toBe('tasks');
|
|
98
|
+
expect(cube.measures.count).toBeDefined();
|
|
99
|
+
expect(cube.measures.hours_sum).toBeDefined();
|
|
100
|
+
expect(cube.measures.hours_avg).toBeDefined();
|
|
101
|
+
expect(cube.dimensions.title.type).toBe('string');
|
|
102
|
+
expect(cube.dimensions.hours.type).toBe('number');
|
|
103
|
+
expect(cube.dimensions.due_date.type).toBe('time');
|
|
104
|
+
expect(cube.dimensions.active.type).toBe('boolean');
|
|
105
|
+
|
|
106
|
+
// Should also be registered automatically
|
|
107
|
+
expect(registry.get('tasks')).toBe(cube);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ─────────────────────────────────────────────────────────────────
|
|
112
|
+
// NativeSQLStrategy
|
|
113
|
+
// ─────────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
describe('NativeSQLStrategy', () => {
|
|
116
|
+
const strategy = new NativeSQLStrategy();
|
|
117
|
+
|
|
118
|
+
it('should have correct name and priority', () => {
|
|
119
|
+
expect(strategy.name).toBe('NativeSQLStrategy');
|
|
120
|
+
expect(strategy.priority).toBe(10);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should handle when nativeSql capability is true', () => {
|
|
124
|
+
const ctx = {
|
|
125
|
+
getCube: () => ordersCube,
|
|
126
|
+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
|
|
127
|
+
executeRawSql: vi.fn(),
|
|
128
|
+
};
|
|
129
|
+
expect(strategy.canHandle(baseQuery, ctx)).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should not handle when nativeSql is false', () => {
|
|
133
|
+
const ctx = {
|
|
134
|
+
getCube: () => ordersCube,
|
|
135
|
+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
|
|
136
|
+
};
|
|
137
|
+
expect(strategy.canHandle(baseQuery, ctx)).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should not handle when cube is missing', () => {
|
|
141
|
+
const ctx = {
|
|
142
|
+
getCube: () => ordersCube,
|
|
143
|
+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
|
|
144
|
+
executeRawSql: vi.fn(),
|
|
145
|
+
};
|
|
146
|
+
expect(strategy.canHandle({ measures: ['count'] }, ctx)).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should generate SQL with dimensions, measures, and filters', async () => {
|
|
150
|
+
const ctx = {
|
|
151
|
+
getCube: () => ordersCube,
|
|
152
|
+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
|
|
153
|
+
executeRawSql: vi.fn(),
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const query: AnalyticsQuery = {
|
|
157
|
+
cube: 'orders',
|
|
158
|
+
measures: ['orders.count', 'orders.total_amount'],
|
|
159
|
+
dimensions: ['orders.status'],
|
|
160
|
+
filters: [{ member: 'orders.status', operator: 'equals', values: ['completed'] }],
|
|
161
|
+
limit: 10,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const { sql, params } = await strategy.generateSql(query, ctx);
|
|
165
|
+
|
|
166
|
+
expect(sql).toContain('SELECT');
|
|
167
|
+
expect(sql).toContain('COUNT(*)');
|
|
168
|
+
expect(sql).toContain('SUM(amount)');
|
|
169
|
+
expect(sql).toContain('GROUP BY');
|
|
170
|
+
expect(sql).toContain('LIMIT 10');
|
|
171
|
+
expect(params).toContain('completed');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should execute query and return structured result', async () => {
|
|
175
|
+
const mockRows = [
|
|
176
|
+
{ 'orders.status': 'completed', 'orders.count': 5, 'orders.total_amount': 500 },
|
|
177
|
+
];
|
|
178
|
+
const executeRawSql = vi.fn().mockResolvedValue(mockRows);
|
|
179
|
+
|
|
180
|
+
const ctx = {
|
|
181
|
+
getCube: () => ordersCube,
|
|
182
|
+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
|
|
183
|
+
executeRawSql,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const result = await strategy.execute(baseQuery, ctx);
|
|
187
|
+
|
|
188
|
+
expect(executeRawSql).toHaveBeenCalled();
|
|
189
|
+
expect(result.rows).toEqual(mockRows);
|
|
190
|
+
expect(result.fields).toHaveLength(3); // 1 dimension + 2 measures
|
|
191
|
+
expect(result.sql).toBeDefined();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ─────────────────────────────────────────────────────────────────
|
|
196
|
+
// ObjectQLStrategy
|
|
197
|
+
// ─────────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
describe('ObjectQLStrategy', () => {
|
|
200
|
+
const strategy = new ObjectQLStrategy();
|
|
201
|
+
|
|
202
|
+
it('should have correct name and priority', () => {
|
|
203
|
+
expect(strategy.name).toBe('ObjectQLStrategy');
|
|
204
|
+
expect(strategy.priority).toBe(20);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should handle when objectqlAggregate capability is true', () => {
|
|
208
|
+
const ctx = {
|
|
209
|
+
getCube: () => ordersCube,
|
|
210
|
+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
|
|
211
|
+
executeAggregate: vi.fn(),
|
|
212
|
+
};
|
|
213
|
+
expect(strategy.canHandle(baseQuery, ctx)).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should not handle without executeAggregate', () => {
|
|
217
|
+
const ctx = {
|
|
218
|
+
getCube: () => ordersCube,
|
|
219
|
+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
|
|
220
|
+
};
|
|
221
|
+
expect(strategy.canHandle(baseQuery, ctx)).toBe(false);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('should execute an aggregate query', async () => {
|
|
225
|
+
const mockRows = [
|
|
226
|
+
{ status: 'pending', 'orders.count': 3, 'orders.total_amount': 150 },
|
|
227
|
+
];
|
|
228
|
+
const executeAggregate = vi.fn().mockResolvedValue(mockRows);
|
|
229
|
+
|
|
230
|
+
const ctx = {
|
|
231
|
+
getCube: () => ordersCube,
|
|
232
|
+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
|
|
233
|
+
executeAggregate,
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const result = await strategy.execute(baseQuery, ctx);
|
|
237
|
+
|
|
238
|
+
expect(executeAggregate).toHaveBeenCalledWith('orders', expect.objectContaining({
|
|
239
|
+
groupBy: ['status'],
|
|
240
|
+
aggregations: expect.arrayContaining([
|
|
241
|
+
expect.objectContaining({ method: 'count' }),
|
|
242
|
+
expect.objectContaining({ method: 'sum' }),
|
|
243
|
+
]),
|
|
244
|
+
}));
|
|
245
|
+
expect(result.rows).toHaveLength(1);
|
|
246
|
+
expect(result.fields).toHaveLength(3);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('should generate representative SQL', async () => {
|
|
250
|
+
const ctx = {
|
|
251
|
+
getCube: () => ordersCube,
|
|
252
|
+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
|
|
253
|
+
executeAggregate: vi.fn(),
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const { sql } = await strategy.generateSql(baseQuery, ctx);
|
|
257
|
+
expect(sql).toContain('SELECT');
|
|
258
|
+
expect(sql).toContain('COUNT(*)');
|
|
259
|
+
expect(sql).toContain('GROUP BY');
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// ─────────────────────────────────────────────────────────────────
|
|
264
|
+
// FallbackDelegateStrategy (internal, tested via AnalyticsService)
|
|
265
|
+
// ─────────────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
describe('FallbackDelegateStrategy (via AnalyticsService)', () => {
|
|
268
|
+
it('should auto-add FallbackDelegateStrategy when fallbackService is configured', async () => {
|
|
269
|
+
const mockResult: AnalyticsResult = { rows: [{ count: 10 }], fields: [{ name: 'count', type: 'number' }] };
|
|
270
|
+
const fallback: IAnalyticsService = {
|
|
271
|
+
query: vi.fn().mockResolvedValue(mockResult),
|
|
272
|
+
getMeta: vi.fn().mockResolvedValue([]),
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const service = new AnalyticsService({
|
|
276
|
+
cubes: [ordersCube],
|
|
277
|
+
logger: silentLogger,
|
|
278
|
+
fallbackService: fallback,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const result = await service.query(baseQuery);
|
|
282
|
+
expect(fallback.query).toHaveBeenCalledWith(baseQuery);
|
|
283
|
+
expect(result).toEqual(mockResult);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('should NOT add FallbackDelegateStrategy when no fallbackService', async () => {
|
|
287
|
+
const service = new AnalyticsService({
|
|
288
|
+
cubes: [ordersCube],
|
|
289
|
+
logger: silentLogger,
|
|
290
|
+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: false, inMemory: false }),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
await expect(service.query(baseQuery)).rejects.toThrow('No strategy can handle');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('should delegate generateSql to fallback service', async () => {
|
|
297
|
+
const fallback: IAnalyticsService = {
|
|
298
|
+
query: vi.fn(),
|
|
299
|
+
getMeta: vi.fn(),
|
|
300
|
+
generateSql: vi.fn().mockResolvedValue({ sql: 'SELECT 1', params: [] }),
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const service = new AnalyticsService({
|
|
304
|
+
cubes: [ordersCube],
|
|
305
|
+
logger: silentLogger,
|
|
306
|
+
fallbackService: fallback,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const { sql } = await service.generateSql(baseQuery);
|
|
310
|
+
expect(sql).toBe('SELECT 1');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('should return placeholder SQL when fallback has no generateSql', async () => {
|
|
314
|
+
const fallback: IAnalyticsService = {
|
|
315
|
+
query: vi.fn().mockResolvedValue({ rows: [], fields: [] }),
|
|
316
|
+
getMeta: vi.fn(),
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const service = new AnalyticsService({
|
|
320
|
+
cubes: [ordersCube],
|
|
321
|
+
logger: silentLogger,
|
|
322
|
+
fallbackService: fallback,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const { sql } = await service.generateSql(baseQuery);
|
|
326
|
+
expect(sql).toContain('FallbackDelegateStrategy');
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// ─────────────────────────────────────────────────────────────────
|
|
331
|
+
// AnalyticsService (Orchestrator)
|
|
332
|
+
// ─────────────────────────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
describe('AnalyticsService', () => {
|
|
335
|
+
it('should use NativeSQLStrategy when driver supports native SQL', async () => {
|
|
336
|
+
const mockRows = [{ count: 42 }];
|
|
337
|
+
const service = new AnalyticsService({
|
|
338
|
+
cubes: [ordersCube],
|
|
339
|
+
logger: silentLogger,
|
|
340
|
+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }),
|
|
341
|
+
executeRawSql: vi.fn().mockResolvedValue(mockRows),
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const result = await service.query(baseQuery);
|
|
345
|
+
expect(result.rows).toEqual(mockRows);
|
|
346
|
+
expect(result.sql).toBeDefined(); // NativeSQL always includes sql
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('should fall back to ObjectQLStrategy when nativeSql is false', async () => {
|
|
350
|
+
const mockRows = [{ status: 'pending', 'orders.count': 3, 'orders.total_amount': 100 }];
|
|
351
|
+
const service = new AnalyticsService({
|
|
352
|
+
cubes: [ordersCube],
|
|
353
|
+
logger: silentLogger,
|
|
354
|
+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
|
|
355
|
+
executeAggregate: vi.fn().mockResolvedValue(mockRows),
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const result = await service.query(baseQuery);
|
|
359
|
+
expect(result.rows).toHaveLength(1);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('should fall back to FallbackDelegateStrategy with fallback service', async () => {
|
|
363
|
+
const mockResult: AnalyticsResult = {
|
|
364
|
+
rows: [{ count: 100 }],
|
|
365
|
+
fields: [{ name: 'count', type: 'number' }],
|
|
366
|
+
};
|
|
367
|
+
const fallback: IAnalyticsService = {
|
|
368
|
+
query: vi.fn().mockResolvedValue(mockResult),
|
|
369
|
+
getMeta: vi.fn().mockResolvedValue([]),
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const service = new AnalyticsService({
|
|
373
|
+
cubes: [ordersCube],
|
|
374
|
+
logger: silentLogger,
|
|
375
|
+
fallbackService: fallback,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const result = await service.query(baseQuery);
|
|
379
|
+
expect(result).toEqual(mockResult);
|
|
380
|
+
expect(fallback.query).toHaveBeenCalled();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('should throw when no strategy can handle the query', async () => {
|
|
384
|
+
const service = new AnalyticsService({
|
|
385
|
+
cubes: [ordersCube],
|
|
386
|
+
logger: silentLogger,
|
|
387
|
+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: false, inMemory: false }),
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
await expect(service.query(baseQuery)).rejects.toThrow('No strategy can handle');
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('should throw when cube name is missing', async () => {
|
|
394
|
+
const service = new AnalyticsService({ logger: silentLogger });
|
|
395
|
+
await expect(service.query({ measures: ['count'] })).rejects.toThrow('Cube name is required');
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('should return cube metadata via getMeta()', async () => {
|
|
399
|
+
const service = new AnalyticsService({
|
|
400
|
+
cubes: [ordersCube],
|
|
401
|
+
logger: silentLogger,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const meta = await service.getMeta();
|
|
405
|
+
expect(meta).toHaveLength(1);
|
|
406
|
+
expect(meta[0].name).toBe('orders');
|
|
407
|
+
expect(meta[0].measures.length).toBeGreaterThan(0);
|
|
408
|
+
expect(meta[0].dimensions.length).toBeGreaterThan(0);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('should filter getMeta() by cube name', async () => {
|
|
412
|
+
const service = new AnalyticsService({
|
|
413
|
+
cubes: [ordersCube],
|
|
414
|
+
logger: silentLogger,
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
const meta = await service.getMeta('orders');
|
|
418
|
+
expect(meta).toHaveLength(1);
|
|
419
|
+
expect(meta[0].name).toBe('orders');
|
|
420
|
+
|
|
421
|
+
const empty = await service.getMeta('nonexistent');
|
|
422
|
+
expect(empty).toHaveLength(0);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('should generate SQL via generateSql()', async () => {
|
|
426
|
+
const service = new AnalyticsService({
|
|
427
|
+
cubes: [ordersCube],
|
|
428
|
+
logger: silentLogger,
|
|
429
|
+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
|
|
430
|
+
executeRawSql: vi.fn(),
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const { sql } = await service.generateSql(baseQuery);
|
|
434
|
+
expect(sql).toContain('SELECT');
|
|
435
|
+
expect(sql).toContain('COUNT(*)');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('should expose cubeRegistry for external cube registration', () => {
|
|
439
|
+
const service = new AnalyticsService({ logger: silentLogger });
|
|
440
|
+
expect(service.cubeRegistry).toBeInstanceOf(CubeRegistry);
|
|
441
|
+
expect(service.cubeRegistry.size).toBe(0);
|
|
442
|
+
|
|
443
|
+
service.cubeRegistry.register(ordersCube);
|
|
444
|
+
expect(service.cubeRegistry.size).toBe(1);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('should support strategy priority ordering', async () => {
|
|
448
|
+
// Custom strategy at priority 5 (before NativeSQL at 10)
|
|
449
|
+
const customStrategy = {
|
|
450
|
+
name: 'CustomStrategy',
|
|
451
|
+
priority: 5,
|
|
452
|
+
canHandle: () => true,
|
|
453
|
+
execute: vi.fn().mockResolvedValue({ rows: [{ custom: true }], fields: [] }),
|
|
454
|
+
generateSql: vi.fn().mockResolvedValue({ sql: 'CUSTOM', params: [] }),
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const service = new AnalyticsService({
|
|
458
|
+
cubes: [ordersCube],
|
|
459
|
+
logger: silentLogger,
|
|
460
|
+
strategies: [customStrategy],
|
|
461
|
+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }),
|
|
462
|
+
executeRawSql: vi.fn(),
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
const result = await service.query(baseQuery);
|
|
466
|
+
expect(customStrategy.execute).toHaveBeenCalled();
|
|
467
|
+
expect(result.rows[0]).toEqual({ custom: true });
|
|
468
|
+
});
|
|
469
|
+
});
|