Fix heatmap delta query config inheritance

Co-authored-by: Alex Fedotyev <alex-fedotyev@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-11 01:10:36 +00:00
parent 8e75a0a07d
commit 07de4476ac
No known key found for this signature in database
3 changed files with 180 additions and 5 deletions

View file

@ -23,9 +23,13 @@ jest.mock('@/components/DBNumberChart', () => ({
default: () => <div data-testid="db-number-chart">Number Chart</div>,
}));
const mockDBHeatmapWithDeltasChart = jest.fn(() => (
<div data-testid="db-heatmap-with-deltas">Heatmap Chart</div>
));
jest.mock('@/components/DBHeatmapWithDeltasChart', () => ({
__esModule: true,
default: () => <div data-testid="db-heatmap-with-deltas">Heatmap Chart</div>,
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', () => {

View file

@ -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 (
<Flex
@ -257,10 +343,7 @@ export default function DBHeatmapWithDeltasChart({
</Flex>
) : (
<DBDeltaChart
config={{
...chartConfig,
with: undefined,
}}
config={deltaChartConfig}
valueExpr={resolvedValueExpression}
xMin={resolvedSelection.xMin}
xMax={resolvedSelection.xMax}

View file

@ -0,0 +1,87 @@
import React from 'react';
import { SourceKind } from '@hyperdx/common-utils/dist/types';
import { screen } from '@testing-library/react';
import DBHeatmapWithDeltasChart from '../DBHeatmapWithDeltasChart';
const mockDBDeltaChart = jest.fn(() => (
<div data-testid="db-delta-chart">Delta chart</div>
));
const mockDBHeatmapChart = jest.fn(() => (
<div data-testid="db-heatmap-chart">Heatmap chart</div>
));
jest.mock('../DBDeltaChart', () => ({
__esModule: true,
default: (props: unknown) => mockDBDeltaChart(props),
}));
jest.mock('../DBHeatmapChart', () => ({
__esModule: true,
default: (props: unknown) => mockDBHeatmapChart(props),
ColorLegend: () => <div data-testid="color-legend" />,
darkPalette: ['#000000'],
lightPalette: ['#ffffff'],
}));
jest.mock('@/components/SQLEditor/SQLInlineEditor', () => ({
SQLInlineEditorControlled: () => (
<div data-testid="sql-inline-editor-controlled">SQL editor</div>
),
}));
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(
<DBHeatmapWithDeltasChart
chartConfig={baseChartConfig}
source={mockTraceSource}
isReady
valueExpression="Duration"
countExpression="count()"
scaleType="log"
/>,
);
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)',
);
});
});