Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
42 changes: 35 additions & 7 deletions packages/services/service-analytics/src/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnalyticsResult> {
if (!query.cube) {
Expand All @@ -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<AnalyticsStrategy> | 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;
}
}
}

/**
Expand Down Expand Up @@ -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>,
): 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.',
);
}
Expand Down
17 changes: 16 additions & 1 deletion packages/services/service-analytics/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>[];
if (result && typeof result === 'object' && 'rows' in (result as Record<string, unknown>)) {
if (typeof result === 'object' && 'rows' in (result as Record<string, unknown>)) {
return (result as { rows: Record<string, unknown>[] }).rows;
}
return [];
Expand Down
Loading