Skip to content

Commit 8d251e1

Browse files
committed
feat: Add support for dashboard filters on Raw SQL Charts
1 parent e99cc39 commit 8d251e1

9 files changed

Lines changed: 238 additions & 43 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@hyperdx/common-utils": patch
3+
"@hyperdx/app": patch
4+
---
5+
6+
feat: Add support for dashboard filters on Raw SQL Charts

packages/app/src/DBDashboardPage.tsx

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import Head from 'next/head';
1212
import { useRouter } from 'next/router';
1313
import { formatRelative } from 'date-fns';
1414
import produce from 'immer';
15-
import { parseAsJson, parseAsString, useQueryState } from 'nuqs';
15+
import { parseAsString, useQueryState } from 'nuqs';
1616
import { ErrorBoundary } from 'react-error-boundary';
1717
import RGL, { WidthProvider } from 'react-grid-layout';
1818
import { useForm, useWatch } from 'react-hook-form';
@@ -21,6 +21,7 @@ import { convertToDashboardTemplate } from '@hyperdx/common-utils/dist/core/util
2121
import {
2222
isBuilderChartConfig,
2323
isBuilderSavedChartConfig,
24+
isRawSqlChartConfig,
2425
isRawSqlSavedChartConfig,
2526
} from '@hyperdx/common-utils/dist/guards';
2627
import {
@@ -67,6 +68,7 @@ import {
6768
IconTrash,
6869
IconUpload,
6970
IconX,
71+
IconZoomExclamation,
7072
} from '@tabler/icons-react';
7173

7274
import { ContactSupportText } from '@/components/ContactSupportText';
@@ -198,7 +200,7 @@ const Tile = forwardRef(
198200

199201
useEffect(() => {
200202
if (isRawSqlSavedChartConfig(chart.config)) {
201-
setQueriedConfig({ ...chart.config, dateRange, granularity });
203+
setQueriedConfig({ ...chart.config, dateRange, granularity, filters });
202204
return;
203205
}
204206

@@ -261,6 +263,28 @@ const Tile = forwardRef(
261263
return tooltip;
262264
}, [alert]);
263265

266+
const skippingLuceneFilterIcon = useMemo(() => {
267+
const isSkippingLuceneGlobalFilter =
268+
!!queriedConfig &&
269+
isRawSqlChartConfig(queriedConfig) &&
270+
queriedConfig.sqlTemplate.includes('$__filters') &&
271+
queriedConfig.filters?.some(
272+
f => f.type === 'lucene' && !!f.condition.trim(),
273+
);
274+
275+
if (!isSkippingLuceneGlobalFilter) return null;
276+
277+
return (
278+
<Tooltip
279+
multiline
280+
maw={500}
281+
label="Lucene-based filter cannot be applied to this SQL-based chart"
282+
>
283+
<IconZoomExclamation size={16} color="var(--color-text-danger)" />
284+
</Tooltip>
285+
);
286+
}, [queriedConfig]);
287+
264288
const hoverToolbar = useMemo(() => {
265289
return (
266290
<Flex
@@ -358,7 +382,9 @@ const Tile = forwardRef(
358382
// Render chart content (used in both tile and fullscreen views)
359383
const renderChartContent = useCallback(
360384
(hideToolbar: boolean = false, isFullscreenView: boolean = false) => {
361-
const toolbar = hideToolbar ? [] : [hoverToolbar];
385+
const toolbar = hideToolbar
386+
? [skippingLuceneFilterIcon]
387+
: [hoverToolbar, skippingLuceneFilterIcon];
362388
const keyPrefix = isFullscreenView ? 'fullscreen' : 'tile';
363389

364390
// Markdown charts may not have queriedConfig, if config.source is not set
@@ -493,6 +519,7 @@ const Tile = forwardRef(
493519
onUpdateChart,
494520
source,
495521
dateRange,
522+
skippingLuceneFilterIcon,
496523
],
497524
);
498525

packages/app/src/components/ChartEditor/RawSqlChartInstructions.tsx

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ export function RawSqlChartInstructions({
9696
{DISPLAY_TYPE_INSTRUCTIONS[displayType]}
9797

9898
<Text size="xs" fw="bold">
99-
The following parameters can be referenced in this chart's SQL:
99+
The following parameters and macros can be used in this chart:
100100
</Text>
101101
<List size="xs" withPadding spacing={3}>
102102
{availableParams.map(({ name, type, description }) => (
@@ -107,6 +107,23 @@ export function RawSqlChartInstructions({
107107
/>
108108
</List.Item>
109109
))}
110+
<List.Item>
111+
<ParamSnippet
112+
value={`$__filters`}
113+
description="Applies the selected dashboard filter conditions to the chart"
114+
/>
115+
</List.Item>
116+
<List.Item>
117+
<Text size="xs">
118+
Macros from the{' '}
119+
<Anchor
120+
href="https://github.com/grafana/clickhouse-datasource?tab=readme-ov-file#macros"
121+
target="_blank"
122+
>
123+
ClickHouse Datasource Grafana Plugin
124+
</Anchor>
125+
</Text>
126+
</List.Item>
110127
</List>
111128

112129
<Text size="xs" fw="bold">
@@ -141,16 +158,6 @@ export function RawSqlChartInstructions({
141158
<Code fz="xs" block>
142159
{QUERY_PARAM_EXAMPLES[displayType]}
143160
</Code>
144-
<Text size="xs" mt="xs">
145-
Macros from the{' '}
146-
<Anchor
147-
href="https://github.com/grafana/clickhouse-datasource?tab=readme-ov-file#macros"
148-
target="_blank"
149-
>
150-
ClickHouse Datasource Grafana Plugin
151-
</Anchor>{' '}
152-
may also be used.
153-
</Text>
154161
</div>
155162
</Stack>
156163
</Collapse>
Lines changed: 99 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,104 +1,185 @@
1-
import { replaceMacros } from '../macros';
1+
import { renderFiltersToSql, replaceMacros } from '../macros';
2+
import type { RawSqlChartConfig } from '../types';
3+
4+
/** Helper to create a minimal RawSqlChartConfig for testing */
5+
function config(
6+
sqlTemplate: string,
7+
overrides?: Partial<RawSqlChartConfig>,
8+
): RawSqlChartConfig {
9+
return {
10+
configType: 'sql',
11+
sqlTemplate,
12+
connection: 'test',
13+
...overrides,
14+
};
15+
}
16+
17+
describe('renderFiltersToSql', () => {
18+
it('should render sql_ast filters', () => {
19+
expect(
20+
renderFiltersToSql([
21+
{ type: 'sql_ast', operator: '=', left: 'col', right: "'val'" },
22+
]),
23+
).toBe("(col = 'val')");
24+
});
25+
26+
it('should render sql filters', () => {
27+
expect(
28+
renderFiltersToSql([{ type: 'sql', condition: "name = 'test'" }]),
29+
).toBe("(name = 'test')");
30+
});
31+
32+
it('should join multiple filters with AND', () => {
33+
expect(
34+
renderFiltersToSql([
35+
{ type: 'sql', condition: 'a = 1' },
36+
{ type: 'sql_ast', operator: '>', left: 'b', right: '2' },
37+
]),
38+
).toBe('(a = 1) AND (b > 2)');
39+
});
40+
41+
it('should skip empty sql conditions', () => {
42+
expect(renderFiltersToSql([{ type: 'sql', condition: '' }])).toBe(
43+
'(1=1 /** no filters applied */)',
44+
);
45+
});
46+
47+
it('should skip lucene filters', () => {
48+
expect(
49+
renderFiltersToSql([{ type: 'lucene', condition: 'field:value' }]),
50+
).toBe('(1=1 /** no filters applied */)');
51+
});
52+
53+
it('should return fallback for empty array', () => {
54+
expect(renderFiltersToSql([])).toBe('(1=1 /** no filters applied */)');
55+
});
56+
});
257

358
describe('replaceMacros', () => {
459
it('should replace $__fromTime with seconds-precision DateTime', () => {
5-
expect(replaceMacros('SELECT $__fromTime')).toBe(
60+
expect(replaceMacros(config('SELECT $__fromTime'))).toBe(
661
'SELECT toDateTime(fromUnixTimestamp64Milli({startDateMilliseconds:Int64}))',
762
);
863
});
964

1065
it('should replace $__toTime with seconds-precision DateTime', () => {
11-
expect(replaceMacros('SELECT $__toTime')).toBe(
66+
expect(replaceMacros(config('SELECT $__toTime'))).toBe(
1267
'SELECT toDateTime(fromUnixTimestamp64Milli({endDateMilliseconds:Int64}))',
1368
);
1469
});
1570

1671
it('should replace $__fromTime_ms with millisecond-precision DateTime64', () => {
17-
expect(replaceMacros('SELECT $__fromTime_ms')).toBe(
72+
expect(replaceMacros(config('SELECT $__fromTime_ms'))).toBe(
1873
'SELECT fromUnixTimestamp64Milli({startDateMilliseconds:Int64})',
1974
);
2075
});
2176

2277
it('should replace $__toTime_ms with millisecond-precision DateTime64', () => {
23-
expect(replaceMacros('SELECT $__toTime_ms')).toBe(
78+
expect(replaceMacros(config('SELECT $__toTime_ms'))).toBe(
2479
'SELECT fromUnixTimestamp64Milli({endDateMilliseconds:Int64})',
2580
);
2681
});
2782

2883
it('should replace $__timeFilter with seconds-precision range filter', () => {
29-
const result = replaceMacros('WHERE $__timeFilter(ts)');
84+
const result = replaceMacros(config('WHERE $__timeFilter(ts)'));
3085
expect(result).toBe(
3186
'WHERE ts >= toDateTime(fromUnixTimestamp64Milli({startDateMilliseconds:Int64})) AND ts <= toDateTime(fromUnixTimestamp64Milli({endDateMilliseconds:Int64}))',
3287
);
3388
});
3489

3590
it('should replace $__timeFilter_ms with millisecond-precision range filter', () => {
36-
const result = replaceMacros('WHERE $__timeFilter_ms(ts)');
91+
const result = replaceMacros(config('WHERE $__timeFilter_ms(ts)'));
3792
expect(result).toBe(
3893
'WHERE ts >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64}) AND ts <= fromUnixTimestamp64Milli({endDateMilliseconds:Int64})',
3994
);
4095
});
4196

4297
it('should replace $__dateFilter with date-only range filter', () => {
43-
const result = replaceMacros('WHERE $__dateFilter(d)');
98+
const result = replaceMacros(config('WHERE $__dateFilter(d)'));
4499
expect(result).toBe(
45100
'WHERE d >= toDate(fromUnixTimestamp64Milli({startDateMilliseconds:Int64})) AND d <= toDate(fromUnixTimestamp64Milli({endDateMilliseconds:Int64}))',
46101
);
47102
});
48103

49104
it('should replace $__dateTimeFilter with combined date and time filter', () => {
50-
const result = replaceMacros('WHERE $__dateTimeFilter(d, ts)');
105+
const result = replaceMacros(config('WHERE $__dateTimeFilter(d, ts)'));
51106
expect(result).toBe(
52107
'WHERE (d >= toDate(fromUnixTimestamp64Milli({startDateMilliseconds:Int64})) AND d <= toDate(fromUnixTimestamp64Milli({endDateMilliseconds:Int64}))) AND (ts >= toDateTime(fromUnixTimestamp64Milli({startDateMilliseconds:Int64})) AND ts <= toDateTime(fromUnixTimestamp64Milli({endDateMilliseconds:Int64})))',
53108
);
54109
});
55110

56111
it('should replace $__dt as an alias for dateTimeFilter', () => {
57-
const result = replaceMacros('WHERE $__dt(d, ts)');
112+
const result = replaceMacros(config('WHERE $__dt(d, ts)'));
58113
expect(result).toBe(
59114
'WHERE (d >= toDate(fromUnixTimestamp64Milli({startDateMilliseconds:Int64})) AND d <= toDate(fromUnixTimestamp64Milli({endDateMilliseconds:Int64}))) AND (ts >= toDateTime(fromUnixTimestamp64Milli({startDateMilliseconds:Int64})) AND ts <= toDateTime(fromUnixTimestamp64Milli({endDateMilliseconds:Int64})))',
60115
);
61116
});
62117

63118
it('should replace $__timeInterval with interval bucketing expression', () => {
64-
const result = replaceMacros('SELECT $__timeInterval(ts)');
119+
const result = replaceMacros(config('SELECT $__timeInterval(ts)'));
65120
expect(result).toBe(
66121
'SELECT toStartOfInterval(toDateTime(ts), INTERVAL {intervalSeconds:Int64} second)',
67122
);
68123
});
69124

70125
it('should replace $__timeInterval_ms with millisecond interval bucketing', () => {
71-
const result = replaceMacros('SELECT $__timeInterval_ms(ts)');
126+
const result = replaceMacros(config('SELECT $__timeInterval_ms(ts)'));
72127
expect(result).toBe(
73128
'SELECT toStartOfInterval(toDateTime64(ts, 3), INTERVAL {intervalMilliseconds:Int64} millisecond)',
74129
);
75130
});
76131

77132
it('should replace $__interval_s with interval seconds param', () => {
78-
expect(replaceMacros('INTERVAL $__interval_s second')).toBe(
133+
expect(replaceMacros(config('INTERVAL $__interval_s second'))).toBe(
79134
'INTERVAL {intervalSeconds:Int64} second',
80135
);
81136
});
82137

83138
it('should replace multiple macros in one query', () => {
84-
const sql =
85-
'SELECT $__timeInterval(ts), count() FROM t WHERE $__timeFilter(ts) GROUP BY 1';
86-
const result = replaceMacros(sql);
139+
const result = replaceMacros(
140+
config(
141+
'SELECT $__timeInterval(ts), count() FROM t WHERE $__timeFilter(ts) GROUP BY 1',
142+
),
143+
);
87144
expect(result).toContain('toStartOfInterval');
88145
expect(result).toContain(
89146
'ts >= toDateTime(fromUnixTimestamp64Milli({startDateMilliseconds:Int64}))',
90147
);
91148
});
92149

93150
it('should throw on wrong argument count', () => {
94-
expect(() => replaceMacros('$__timeFilter(a, b)')).toThrow(
151+
expect(() => replaceMacros(config('$__timeFilter(a, b)'))).toThrow(
95152
'expects 1 argument(s), but got 2',
96153
);
97154
});
98155

99156
it('should throw on missing close bracket', () => {
100-
expect(() => replaceMacros('$__timeFilter(col')).toThrow(
157+
expect(() => replaceMacros(config('$__timeFilter(col'))).toThrow(
101158
'Failed to parse macro arguments',
102159
);
103160
});
161+
162+
it('should replace $__filters with rendered filter conditions', () => {
163+
const result = replaceMacros(
164+
config('WHERE $__filters', {
165+
filters: [
166+
{ type: 'sql', condition: "col = 'val'" },
167+
{ type: 'sql_ast', operator: '>', left: 'x', right: '1' },
168+
],
169+
}),
170+
);
171+
expect(result).toBe("WHERE (col = 'val') AND (x > 1)");
172+
});
173+
174+
it('should replace $__filters with fallback when no filters provided', () => {
175+
expect(replaceMacros(config('WHERE $__filters'))).toBe(
176+
'WHERE (1=1 /** no filters applied */)',
177+
);
178+
});
179+
180+
it('should replace $__filters with fallback when filters is empty', () => {
181+
expect(replaceMacros(config('WHERE $__filters', { filters: [] }))).toBe(
182+
'WHERE (1=1 /** no filters applied */)',
183+
);
184+
});
104185
});

packages/common-utils/src/__tests__/renderChartConfig.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1678,5 +1678,40 @@ describe('renderChartConfig', () => {
16781678
);
16791679
expect(result.sql).toBe(sql);
16801680
});
1681+
1682+
it('replaces $__filters macro with rendered filter conditions', async () => {
1683+
const result = await renderChartConfig(
1684+
{
1685+
configType: 'sql',
1686+
sqlTemplate:
1687+
'SELECT * FROM logs WHERE $__timeFilter(ts) AND $__filters',
1688+
connection: 'conn-1',
1689+
dateRange: [start, end],
1690+
filters: [
1691+
{ type: 'sql', condition: "ServiceName = 'api'" },
1692+
{ type: 'sql_ast', operator: '>', left: 'duration', right: '100' },
1693+
],
1694+
},
1695+
mockMetadata,
1696+
undefined,
1697+
);
1698+
expect(result.sql).toContain(
1699+
"AND (ServiceName = 'api') AND (duration > 100)",
1700+
);
1701+
});
1702+
1703+
it('replaces $__filters with 1 = 1 when no filters provided', async () => {
1704+
const result = await renderChartConfig(
1705+
{
1706+
configType: 'sql',
1707+
sqlTemplate: 'SELECT * FROM logs WHERE $__filters',
1708+
connection: 'conn-1',
1709+
dateRange: [start, end],
1710+
},
1711+
mockMetadata,
1712+
undefined,
1713+
);
1714+
expect(result.sql).toBe('SELECT * FROM logs WHERE 1 = 1');
1715+
});
16811716
});
16821717
});

packages/common-utils/src/core/renderChartConfig.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1409,7 +1409,7 @@ export function renderRawSqlChartConfig(
14091409
): ChSql {
14101410
const displayType = chartConfig.displayType ?? DisplayType.Table;
14111411

1412-
const sqlWithMacrosReplaced = replaceMacros(chartConfig.sqlTemplate);
1412+
const sqlWithMacrosReplaced = replaceMacros(chartConfig);
14131413

14141414
// eslint-disable-next-line security/detect-object-injection
14151415
const queryParams = QUERY_PARAMS_BY_DISPLAY_TYPE[displayType];

0 commit comments

Comments
 (0)