From 07de4476ac684f3fe3844f9398d5c3e5f1111c65 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 11 Apr 2026 01:10:36 +0000 Subject: [PATCH] Fix heatmap delta query config inheritance Co-authored-by: Alex Fedotyev --- .../__tests__/ChartPreviewPanel.test.tsx | 7 +- .../components/DBHeatmapWithDeltasChart.tsx | 91 ++++++++++++++++++- .../DBHeatmapWithDeltasChart.test.tsx | 87 ++++++++++++++++++ 3 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 packages/app/src/components/__tests__/DBHeatmapWithDeltasChart.test.tsx diff --git a/packages/app/src/components/DBEditTimeChartForm/__tests__/ChartPreviewPanel.test.tsx b/packages/app/src/components/DBEditTimeChartForm/__tests__/ChartPreviewPanel.test.tsx index fff2e8d6..67e5e2d6 100644 --- a/packages/app/src/components/DBEditTimeChartForm/__tests__/ChartPreviewPanel.test.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/__tests__/ChartPreviewPanel.test.tsx @@ -23,9 +23,13 @@ jest.mock('@/components/DBNumberChart', () => ({ default: () =>
Number Chart
, })); +const mockDBHeatmapWithDeltasChart = jest.fn(() => ( +
Heatmap Chart
+)); + jest.mock('@/components/DBHeatmapWithDeltasChart', () => ({ __esModule: true, - default: () =>
Heatmap Chart
, + default: (props: unknown) => mockDBHeatmapWithDeltasChart(props), })); jest.mock('@/components/DBPieChart', () => ({ @@ -88,6 +92,7 @@ const renderPanel = ( describe('ChartPreviewPanel', () => { beforeEach(() => { jest.clearAllMocks(); + mockDBHeatmapWithDeltasChart.mockClear(); }); describe('when no query has been run', () => { diff --git a/packages/app/src/components/DBHeatmapWithDeltasChart.tsx b/packages/app/src/components/DBHeatmapWithDeltasChart.tsx index 0f622a65..87e68385 100644 --- a/packages/app/src/components/DBHeatmapWithDeltasChart.tsx +++ b/packages/app/src/components/DBHeatmapWithDeltasChart.tsx @@ -42,6 +42,74 @@ import DBHeatmapChart, { lightPalette, } from './DBHeatmapChart'; +function stripTrailingAlias(expression: string): string { + const normalized = expression.trim(); + if (!normalized) return normalized; + + let parenDepth = 0; + let bracketDepth = 0; + let inSingleQuote = false; + let inDoubleQuote = false; + let inBacktick = false; + let aliasStartIndex: number | undefined; + + for (let i = 0; i < normalized.length; i++) { + const char = normalized[i]; + const prev = i > 0 ? normalized[i - 1] : ''; + + if (char === "'" && !inDoubleQuote && !inBacktick && prev !== '\\') { + inSingleQuote = !inSingleQuote; + continue; + } + if (char === '"' && !inSingleQuote && !inBacktick && prev !== '\\') { + inDoubleQuote = !inDoubleQuote; + continue; + } + if (char === '`' && !inSingleQuote && !inDoubleQuote && prev !== '\\') { + inBacktick = !inBacktick; + continue; + } + if (inSingleQuote || inDoubleQuote || inBacktick) continue; + + if (char === '(') { + parenDepth++; + continue; + } + if (char === ')') { + parenDepth--; + continue; + } + if (char === '[') { + bracketDepth++; + continue; + } + if (char === ']') { + bracketDepth--; + continue; + } + + if (parenDepth === 0 && bracketDepth === 0 && /\s/.test(char)) { + let j = i; + while (j < normalized.length && /\s/.test(normalized[j])) j++; + if ( + normalized.slice(j, j + 2).toUpperCase() === 'AS' && + j + 2 < normalized.length && + /\s/.test(normalized[j + 2]) + ) { + aliasStartIndex = i; + } + } + } + + return aliasStartIndex == null + ? normalized + : normalized.slice(0, aliasStartIndex).trim(); +} + +function sanitizeTimestampExpression(timestampValueExpression: string): string { + return stripTrailingAlias(timestampValueExpression); +} + const Schema = z.object({ value: z.string().trim().min(1), count: z.string().trim().optional(), @@ -155,6 +223,24 @@ export default function DBHeatmapWithDeltasChart({ const showHeatmapSetupHint = !resolvedValueExpression.trim(); const spanIdExpression = 'spanIdExpression' in source ? source.spanIdExpression : undefined; + const deltaChartConfig = useMemo(() => { + // DBDeltaChart builds ad-hoc grouped/ordered queries from timestampValueExpression. + // If the expression carries a trailing alias (e.g. "toStartOfInterval(...) AS __hdx_time_bucket"), + // ClickHouse can fail in generated GROUP BY clauses. Use alias-free timestamp expressions there. + const sanitizedTimestampValueExpression = sanitizeTimestampExpression( + chartConfig.timestampValueExpression, + ); + + return { + ...chartConfig, + with: undefined, // Avoid colliding with DBDeltaChart's internal CTE names + select: 'tuple(_part, _part_offset)', // Keep base config minimal for DBDeltaChart's custom SELECTs + groupBy: undefined, // Prevent invalid "SELECT * ... GROUP BY" queries in delta queries + having: undefined, // DBDeltaChart applies HAVING only for aggregate selection paths + granularity: undefined, // Let DBDeltaChart control grouping via timestamp expression when needed + timestampValueExpression: sanitizedTimestampValueExpression, + }; + }, [chartConfig]); return ( ) : ( ( +
Delta chart
+)); +const mockDBHeatmapChart = jest.fn(() => ( +
Heatmap chart
+)); + +jest.mock('../DBDeltaChart', () => ({ + __esModule: true, + default: (props: unknown) => mockDBDeltaChart(props), +})); + +jest.mock('../DBHeatmapChart', () => ({ + __esModule: true, + default: (props: unknown) => mockDBHeatmapChart(props), + ColorLegend: () =>
, + darkPalette: ['#000000'], + lightPalette: ['#ffffff'], +})); + +jest.mock('@/components/SQLEditor/SQLInlineEditor', () => ({ + SQLInlineEditorControlled: () => ( +
SQL editor
+ ), +})); + +const baseChartConfig = { + timestampValueExpression: + 'toStartOfInterval(toDateTime(TimestampTime), INTERVAL 1 minute) AS `__hdx_time_bucket`', + connection: 'default', + from: { databaseName: 'default', tableName: 'otel_logs' }, + select: [{ aggFn: 'count' as const, valueExpression: '' }], + where: '', + granularity: 'auto' as const, + dateRange: [ + new Date('2026-04-10T23:00:00Z'), + new Date('2026-04-11T00:00:00Z'), + ], +}; + +const mockTraceSource = { + id: 'trace-source', + kind: SourceKind.Trace, + name: 'Demo Traces', + connection: 'default', + from: { databaseName: 'default', tableName: 'otel_traces' }, + timestampValueExpression: 'TimestampTime', + durationExpression: 'Duration', + durationPrecision: 9, + spanIdExpression: 'SpanId', +} as const; + +describe('DBHeatmapWithDeltasChart', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('sanitizes timestamp expression aliases before passing config to DBDeltaChart', () => { + renderWithMantine( + , + ); + + expect(screen.getByTestId('db-delta-chart')).toBeInTheDocument(); + expect(mockDBDeltaChart).toHaveBeenCalled(); + + const firstCallProps = mockDBDeltaChart.mock.calls[0]?.[0] as { + config?: { timestampValueExpression?: string }; + }; + + expect(firstCallProps.config?.timestampValueExpression).toBe( + 'toStartOfInterval(toDateTime(TimestampTime), INTERVAL 1 minute)', + ); + }); +});