Skip to content

Commit 69d1774

Browse files
authored
Allow manual placement of ad-hoc filters (#1488)
1 parent c55d83b commit 69d1774

File tree

6 files changed

+222
-18
lines changed

6 files changed

+222
-18
lines changed

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,41 @@ For example, if `clickhouse_adhoc_query` is set to `SELECT DISTINCT
268268
machine_name FROM mgbench.logs1` you would be able to select which machine
269269
names are filtered for in the dashboard.
270270

271+
#### Manual Ad Hoc Filter Placement with `$__adHocFilters`
272+
273+
By default, ad-hoc filters are automatically applied to queries by detecting the
274+
target table using SQL parsing. However, for queries that use CTEs or ClickHouse-specific
275+
syntax like `INTERVAL` or aggregate functions with parameters, the automatic
276+
detection may fail. In these cases, you can manually specify where to apply
277+
ad-hoc filters using the `$__adHocFilters('table_name')` macro.
278+
279+
This macro expands to the ClickHouse `additional_table_filters` setting with the
280+
currently active ad-hoc filters. It should be placed in the `SETTINGS` clause of
281+
your query.
282+
283+
Example:
284+
285+
```sql
286+
SELECT *
287+
FROM (
288+
SELECT * FROM my_complex_table
289+
WHERE complicated_condition
290+
) AS result
291+
SETTINGS $__adHocFilters('my_complex_table')
292+
```
293+
294+
When ad-hoc filters are active (e.g., `status = 'active'` and `region = 'us-west'`),
295+
this expands to:
296+
297+
```sql
298+
SELECT *
299+
FROM (
300+
SELECT * FROM my_complex_table
301+
WHERE complicated_condition
302+
) AS result
303+
SETTINGS additional_table_filters={'my_complex_table': 'status = \'active\' AND region = \'us-west\''}
304+
```
305+
271306
## Learn more
272307

273308
- Add [Annotations](https://grafana.com/docs/grafana/latest/dashboards/annotations/).

src/ch-parser/pluginMacros.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,11 @@ export const pluginMacros: PluginMacro[] = [
9292
'Replaced by the first parameter when the template variable in the second parameter does not select every value. Replaced by 1=1 when the template variable selects every value',
9393
example: 'condition or 1=1',
9494
},
95+
{
96+
name: '$__adHocFilters',
97+
isFunction: true,
98+
documentation:
99+
'Manually applies ad-hoc filters to a specific table. Useful for complex queries where automatic filter detection fails. Use in SETTINGS clause to specify the target table for ad-hoc filters',
100+
example: "additional_table_filters={'table_name': 'column = \\'value\\' AND column2 = \\'value2\\'}",
101+
},
95102
];

src/data/CHDatasource.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,85 @@ describe('ClickHouseDatasource', () => {
148148
// Verify that the final query contains the ad-hoc filters
149149
expect(result.rawSql).toEqual(sqlWithAdHocFilters);
150150
});
151+
152+
it('should expand $__adHocFilters macro with single quotes', async () => {
153+
const query = {
154+
rawSql: "SELECT * FROM complex_table settings $__adHocFilters('my_table')",
155+
editorType: EditorType.SQL,
156+
} as CHQuery;
157+
158+
const adHocFilters = [
159+
{ key: 'key', operator: '=', value: 'val' },
160+
{ key: 'keyNum', operator: '=', value: '123' },
161+
];
162+
163+
const spyOnReplace = jest.spyOn(templateSrvMock, 'replace').mockImplementation((x) => x);
164+
const spyOnGetVars = jest.spyOn(templateSrvMock, 'getVariables').mockImplementation(() => []);
165+
166+
const result = createInstance({}).applyTemplateVariables(query, {}, adHocFilters);
167+
168+
expect(spyOnReplace).toHaveBeenCalled();
169+
expect(spyOnGetVars).toHaveBeenCalled();
170+
expect(result.rawSql).toEqual(
171+
"SELECT * FROM complex_table settings additional_table_filters={'my_table': ' key = \\'val\\' AND keyNum = \\'123\\' '}"
172+
);
173+
});
174+
175+
it('should expand $__adHocFilters macro with double quotes', async () => {
176+
const query = {
177+
rawSql: 'SELECT * FROM complex_table settings $__adHocFilters("my_table")',
178+
editorType: EditorType.SQL,
179+
} as CHQuery;
180+
181+
const adHocFilters = [{ key: 'key', operator: '=', value: 'val' }];
182+
183+
const spyOnReplace = jest.spyOn(templateSrvMock, 'replace').mockImplementation((x) => x);
184+
const spyOnGetVars = jest.spyOn(templateSrvMock, 'getVariables').mockImplementation(() => []);
185+
186+
const result = createInstance({}).applyTemplateVariables(query, {}, adHocFilters);
187+
188+
expect(spyOnReplace).toHaveBeenCalled();
189+
expect(spyOnGetVars).toHaveBeenCalled();
190+
expect(result.rawSql).toEqual(
191+
"SELECT * FROM complex_table settings additional_table_filters={'my_table': ' key = \\'val\\' '}"
192+
);
193+
});
194+
195+
it('should expand $__adHocFilters macro to empty object when no filters are present', async () => {
196+
const query = {
197+
rawSql: "SELECT * FROM complex_table settings $__adHocFilters('my_table')",
198+
editorType: EditorType.SQL,
199+
} as CHQuery;
200+
201+
const spyOnReplace = jest.spyOn(templateSrvMock, 'replace').mockImplementation((x) => x);
202+
const spyOnGetVars = jest.spyOn(templateSrvMock, 'getVariables').mockImplementation(() => []);
203+
204+
const result = createInstance({}).applyTemplateVariables(query, {}, []);
205+
206+
expect(spyOnReplace).toHaveBeenCalled();
207+
expect(spyOnGetVars).toHaveBeenCalled();
208+
expect(result.rawSql).toEqual('SELECT * FROM complex_table settings additional_table_filters={}');
209+
});
210+
211+
it('should handle $__adHocFilters macro with spaces', async () => {
212+
const query = {
213+
rawSql: "SELECT * FROM complex_table settings $__adHocFilters( 'my_table' )",
214+
editorType: EditorType.SQL,
215+
} as CHQuery;
216+
217+
const adHocFilters = [{ key: 'key', operator: '=', value: 'val' }];
218+
219+
const spyOnReplace = jest.spyOn(templateSrvMock, 'replace').mockImplementation((x) => x);
220+
const spyOnGetVars = jest.spyOn(templateSrvMock, 'getVariables').mockImplementation(() => []);
221+
222+
const result = createInstance({}).applyTemplateVariables(query, {}, adHocFilters);
223+
224+
expect(spyOnReplace).toHaveBeenCalled();
225+
expect(spyOnGetVars).toHaveBeenCalled();
226+
expect(result.rawSql).toEqual(
227+
"SELECT * FROM complex_table settings additional_table_filters={'my_table': ' key = \\'val\\' '}"
228+
);
229+
});
151230
});
152231

153232
describe('Tag Keys', () => {

src/data/CHDatasource.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,16 @@ export class Datasource
248248
`Unable to apply ad hoc filters. Upgrade ClickHouse to >=${this.adHocCHVerReq.major}.${this.adHocCHVerReq.minor} or remove ad hoc filters for the dashboard.`
249249
);
250250
}
251-
rawQuery = this.adHocFilter.apply(rawQuery, filters);
251+
// Check if query contains $__adHocFilters macro
252+
const hasMacro = /\$__adHocFilters\s*\(\s*['"](.+?)['"]\s*\)/.test(rawQuery);
253+
254+
// Apply $__adHocFilters macro before automatic filter application
255+
rawQuery = this.applyAdHocFiltersMacro(rawQuery, filters);
256+
257+
// Only apply automatic filters if the macro was not used
258+
if (!hasMacro) {
259+
rawQuery = this.adHocFilter.apply(rawQuery, filters);
260+
}
252261
}
253262
this.skipAdHocFilter = false;
254263

@@ -287,6 +296,23 @@ export class Datasource
287296
return rawQuery;
288297
}
289298

299+
applyAdHocFiltersMacro(rawQuery: string, filters: AdHocVariableFilter[]): string {
300+
if (!rawQuery) {
301+
return rawQuery;
302+
}
303+
304+
// Match $__adHocFilters('table_name') or $__adHocFilters("table_name")
305+
const regex = /\$__adHocFilters\s*\(\s*['"](.+?)['"]\s*\)/g;
306+
307+
return rawQuery.replace(regex, (match, tableName) => {
308+
const filterStr = this.adHocFilter.buildFilterString(filters);
309+
if (filterStr === '') {
310+
return 'additional_table_filters={}';
311+
}
312+
return `additional_table_filters={'${tableName}': '${filterStr}'}`;
313+
});
314+
}
315+
290316
// Support filtering by field value in Explore
291317
modifyQuery(query: CHQuery, action: QueryFixAction): CHQuery {
292318
if (query.editorType !== EditorType.Builder || !action.options || !action.options.key || !action.options.value) {

src/data/adHocFilter.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,52 @@ describe('AdHocManager', () => {
253253
expect(result).toContain('ResourceAttributes.cloud.region');
254254
});
255255

256+
describe('buildFilterString', () => {
257+
it('builds filter string with single filter', () => {
258+
const ahm = new AdHocFilter();
259+
const result = ahm.buildFilterString([{ key: 'key', operator: '=', value: 'val' }] as AdHocVariableFilter[]);
260+
expect(result).toEqual(" key = \\'val\\' ");
261+
});
262+
263+
it('builds filter string with multiple filters', () => {
264+
const ahm = new AdHocFilter();
265+
const result = ahm.buildFilterString([
266+
{ key: 'key', operator: '=', value: 'val' },
267+
{ key: 'keyNum', operator: '=', value: '123' },
268+
] as AdHocVariableFilter[]);
269+
expect(result).toEqual(" key = \\'val\\' AND keyNum = \\'123\\' ");
270+
});
271+
272+
it('returns empty string with no filters', () => {
273+
const ahm = new AdHocFilter();
274+
const result = ahm.buildFilterString([]);
275+
expect(result).toEqual('');
276+
});
277+
278+
it('builds filter string with regex operators', () => {
279+
const ahm = new AdHocFilter();
280+
const result = ahm.buildFilterString([{ key: 'key', operator: '=~', value: 'val' }] as AdHocVariableFilter[]);
281+
expect(result).toEqual(" key ILIKE \\'val\\' ");
282+
});
283+
284+
it('builds filter string with IN operator', () => {
285+
const ahm = new AdHocFilter();
286+
const result = ahm.buildFilterString([
287+
{ key: 'key', operator: 'IN', value: "'val1', 'val2'" },
288+
] as AdHocVariableFilter[]);
289+
expect(result).toEqual(" key IN (\\'val1\\', \\'val2\\') ");
290+
});
291+
292+
it('ignores invalid filters', () => {
293+
const ahm = new AdHocFilter();
294+
const result = ahm.buildFilterString([
295+
{ key: 'key', operator: '=', value: 'val' },
296+
{ key: '', operator: '=', value: 'val' } as any,
297+
{ key: 'key2', operator: '=', value: 'val2' },
298+
] as AdHocVariableFilter[]);
299+
expect(result).toEqual(" key = \\'val\\' AND key2 = \\'val2\\' ");
300+
});
301+
});
256302
it('should apply ad hoc filter with . in column name', () => {
257303
const ahm = new AdHocFilter();
258304
const val = ahm.apply('SELECT stuff FROM foo', [

src/data/adHocFilter.ts

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,32 @@ export class AdHocFilter {
1111
}
1212
}
1313

14+
buildFilterString(adHocFilters: AdHocVariableFilter[]): string {
15+
if (!adHocFilters || adHocFilters.length === 0) {
16+
return '';
17+
}
18+
19+
const validFilters = adHocFilters.filter((filter: AdHocVariableFilter) => {
20+
const valid = isValid(filter);
21+
if (!valid) {
22+
console.warn('Invalid adhoc filter will be ignored:', filter);
23+
}
24+
return valid;
25+
});
26+
27+
const filters = validFilters
28+
.map((f, i) => {
29+
const key = escapeKey(f.key);
30+
const value = escapeValueBasedOnOperator(f.value, f.operator);
31+
const condition = i !== validFilters.length - 1 ? (f.condition ? f.condition : 'AND') : '';
32+
const operator = convertOperatorToClickHouseOperator(f.operator);
33+
return ` ${key} ${operator} ${value} ${condition}`;
34+
})
35+
.join('');
36+
37+
return filters;
38+
}
39+
1440
apply(sql: string, adHocFilters: AdHocVariableFilter[]): string {
1541
if (sql === '' || !adHocFilters || adHocFilters.length === 0) {
1642
return sql;
@@ -29,22 +55,7 @@ export class AdHocFilter {
2955
return sql;
3056
}
3157

32-
const filters = adHocFilters
33-
.filter((filter: AdHocVariableFilter) => {
34-
const valid = isValid(filter);
35-
if (!valid) {
36-
console.warn('Invalid adhoc filter will be ignored:', filter);
37-
}
38-
return valid;
39-
})
40-
.map((f, i) => {
41-
const key = escapeKey(f.key);
42-
const value = escapeValueBasedOnOperator(f.value, f.operator);
43-
const condition = i !== adHocFilters.length - 1 ? (f.condition ? f.condition : 'AND') : '';
44-
const operator = convertOperatorToClickHouseOperator(f.operator);
45-
return ` ${key} ${operator} ${value} ${condition}`;
46-
})
47-
.join('');
58+
const filters = this.buildFilterString(adHocFilters);
4859

4960
if (filters === '') {
5061
return sql;
@@ -56,7 +67,7 @@ export class AdHocFilter {
5667
}
5768

5869
function isValid(filter: AdHocVariableFilter): boolean {
59-
return filter.key !== undefined && filter.operator !== undefined && filter.value !== undefined;
70+
return filter.key !== undefined && filter.key !== '' && filter.operator !== undefined && filter.value !== undefined;
6071
}
6172

6273
function escapeKey(s: string): string {

0 commit comments

Comments
 (0)