diff --git a/packages/services/service-analytics/src/__tests__/analytics-service.test.ts b/packages/services/service-analytics/src/__tests__/analytics-service.test.ts index 2e73b0218d..8a6bb5626d 100644 --- a/packages/services/service-analytics/src/__tests__/analytics-service.test.ts +++ b/packages/services/service-analytics/src/__tests__/analytics-service.test.ts @@ -641,3 +641,66 @@ describe('AnalyticsService', () => { expect(JSON.stringify(filterObj)).toContain('open'); }); }); + +// ───────────────────────────────────────────────────────────────── +// Runtime strategy fallback — RAW_SQL_UNSUPPORTED (in-memory driver) +// ───────────────────────────────────────────────────────────────── +// +// A driver that cannot run SQL (the in-memory driver) returns null from +// execute(); the plugin's raw-SQL bridge surfaces that as a typed +// RAW_SQL_UNSUPPORTED error. The orchestrator must fall back to the next +// capable strategy (aggregate bridge) instead of failing — and must NEVER +// fabricate an empty result (the "every dashboard reads No rows" bug). + +describe('AnalyticsService — RAW_SQL_UNSUPPORTED fallback', () => { + const rawUnsupported = () => + Object.assign(new Error('driver returned null for raw SQL'), { code: 'RAW_SQL_UNSUPPORTED' }); + + it('falls back from NativeSQL to the aggregate strategy and returns its rows', async () => { + const executeRawSql = vi.fn().mockRejectedValue(rawUnsupported()); + const executeAggregate = vi.fn().mockResolvedValue([ + { status: 'completed', count: 5 }, + ]); + const service = new AnalyticsService({ + cubes: [ordersCube], + logger: silentLogger, + executeRawSql, + executeAggregate, + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }), + }); + + const result = await service.query({ cube: 'orders', measures: ['count'], dimensions: ['status'] }); + + expect(executeRawSql).toHaveBeenCalledTimes(1); + expect(executeAggregate).toHaveBeenCalledTimes(1); + expect(Array.isArray(result.rows)).toBe(true); + expect(result.rows.length).toBeGreaterThan(0); + }); + + it('propagates any OTHER raw-SQL error untouched (no fallback masking)', async () => { + const executeRawSql = vi.fn().mockRejectedValue(new Error('no such column: bogus')); + const executeAggregate = vi.fn(); + const service = new AnalyticsService({ + cubes: [ordersCube], + logger: silentLogger, + executeRawSql, + executeAggregate, + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }), + }); + + await expect(service.query({ cube: 'orders', measures: ['count'] })).rejects.toThrow('no such column'); + expect(executeAggregate).not.toHaveBeenCalled(); + }); + + it('reports a clear error (never silent empty rows) when no fallback strategy exists', async () => { + const executeRawSql = vi.fn().mockRejectedValue(rawUnsupported()); + const service = new AnalyticsService({ + cubes: [ordersCube], + logger: silentLogger, + executeRawSql, + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + }); + + await expect(service.query({ cube: 'orders', measures: ['count'] })).rejects.toThrow('No strategy can handle'); + }); +}); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index ca5c909394..a4774a32ca 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -284,6 +284,14 @@ export class AnalyticsService implements IAnalyticsService { /** * Execute an analytical query by delegating to the first capable strategy. + * + * A strategy can discover only AT EXECUTION TIME that the underlying driver + * cannot serve it — the canonical case is NativeSQLStrategy on an in-memory + * driver, whose `execute()` returns null for raw SQL (the auto-bridge throws + * `RAW_SQL_UNSUPPORTED`). That is a capability miss, not a query error: fall + * back to the next capable strategy (e.g. ObjectQLStrategy over the + * aggregate bridge) instead of failing — or worse, fabricating empty rows. + * Any other error propagates untouched. */ async query(query: AnalyticsQuery, context?: ExecutionContext): Promise { if (!query.cube) { @@ -292,10 +300,23 @@ export class AnalyticsService implements IAnalyticsService { this.ensureCube(query); const ctx = await this.callCtx(query, context); - const strategy = this.resolveStrategy(query, ctx); - this.logger.debug(`[Analytics] Query on cube "${query.cube}" → ${strategy.name}`); - - return strategy.execute(query, ctx); + let skip: Set | undefined; + for (;;) { + const strategy = this.resolveStrategy(query, ctx, skip); + this.logger.debug(`[Analytics] Query on cube "${query.cube}" → ${strategy.name}`); + try { + return await strategy.execute(query, ctx); + } catch (e) { + if ((e as { code?: string })?.code === 'RAW_SQL_UNSUPPORTED') { + this.logger.warn( + `[Analytics] ${strategy.name} cannot run on this driver (raw SQL unsupported) — falling back to the next strategy.`, + ); + (skip ??= new Set()).add(strategy); + continue; + } + throw e; + } + } } /** @@ -508,17 +529,24 @@ export class AnalyticsService implements IAnalyticsService { } /** - * Walk the strategy chain and return the first strategy that can handle the query. + * Walk the strategy chain and return the first strategy that can handle the + * query. `skip` excludes strategies that already proved incapable at + * execution time (see {@link query}'s RAW_SQL_UNSUPPORTED fallback). */ - private resolveStrategy(query: AnalyticsQuery, ctx: StrategyContext): AnalyticsStrategy { + private resolveStrategy( + query: AnalyticsQuery, + ctx: StrategyContext, + skip?: Set, + ): AnalyticsStrategy { for (const strategy of this.strategies) { + if (skip?.has(strategy)) continue; if (strategy.canHandle(query, ctx)) { return strategy; } } throw new Error( `[Analytics] No strategy can handle query for cube "${query.cube}". ` + - `Checked: ${this.strategies.map(s => s.name).join(', ')}. ` + + `Checked: ${this.strategies.map(s => s.name).join(', ')}${skip?.size ? ` (skipped at runtime: ${[...skip].map((s) => s.name).join(', ')})` : ''}. ` + 'Ensure a compatible driver is configured or a fallback service is registered.', ); } diff --git a/packages/services/service-analytics/src/plugin.ts b/packages/services/service-analytics/src/plugin.ts index 86bc514c05..659869039c 100644 --- a/packages/services/service-analytics/src/plugin.ts +++ b/packages/services/service-analytics/src/plugin.ts @@ -211,8 +211,23 @@ export class AnalyticsServicePlugin implements Plugin { // driver-sql) speaks `?` placeholders, so translate. const knexSql = sql.replace(/\$(\d+)/g, '?'); const result = await engine.execute(knexSql, { args: params }); + // A driver that cannot run SQL (e.g. the in-memory driver) returns + // null from execute(). Silently mapping that to [] made EVERY dataset + // query on such environments report "No rows" while looking healthy + // (HTTP 200, compiled SQL attached). Throw a TYPED error instead so + // the orchestrator can fall back to an aggregate-based strategy — + // never fabricate an empty result. + if (result === null || result === undefined) { + const err = new Error( + '[Analytics] The "data" engine\'s driver returned null for raw SQL — ' + + 'this driver does not support SQL execution. The query will fall back ' + + 'to an aggregate-based strategy when one is available.', + ) as Error & { code: string }; + err.code = 'RAW_SQL_UNSUPPORTED'; + throw err; + } if (Array.isArray(result)) return result as Record[]; - if (result && typeof result === 'object' && 'rows' in (result as Record)) { + if (typeof result === 'object' && 'rows' in (result as Record)) { return (result as { rows: Record[] }).rows; } return [];