Skip to content

Commit 3825d19

Browse files
authored
feat: add support hide table name in adhoc filter (#1493)
1 parent ca6e861 commit 3825d19

File tree

5 files changed

+210
-18
lines changed

5 files changed

+210
-18
lines changed

src/data/CHDatasource.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,115 @@ describe('ClickHouseDatasource', () => {
367367
});
368368
});
369369

370+
describe('Hide Table Name In AdHoc Filters', () => {
371+
it('should return only column names when hideTableNameInAdhocFilters is true', async () => {
372+
jest.spyOn(templateSrvMock, 'replace').mockImplementation(() => 'foo');
373+
const ds = cloneDeep(mockDatasource);
374+
ds.settings.jsonData.hideTableNameInAdhocFilters = true;
375+
const frame = arrayToDataFrame([{ name: 'foo', type: 'String', table: 'table' }]);
376+
jest.spyOn(ds, 'query').mockImplementation((_request) => of({ data: [frame] }));
377+
378+
const keys = await ds.getTagKeys();
379+
expect(keys).toEqual([{ text: 'foo' }]);
380+
});
381+
382+
it('should return table.column when hideTableNameInAdhocFilters is false', async () => {
383+
jest.spyOn(templateSrvMock, 'replace').mockImplementation(() => 'foo');
384+
const ds = cloneDeep(mockDatasource);
385+
ds.settings.jsonData.hideTableNameInAdhocFilters = false;
386+
const frame = arrayToDataFrame([{ name: 'foo', type: 'String', table: 'table' }]);
387+
jest.spyOn(ds, 'query').mockImplementation((_request) => of({ data: [frame] }));
388+
389+
const keys = await ds.getTagKeys();
390+
expect(keys).toEqual([{ text: 'table.foo' }]);
391+
});
392+
393+
it('should return table.column when hideTableNameInAdhocFilters is undefined (default)', async () => {
394+
jest.spyOn(templateSrvMock, 'replace').mockImplementation(() => 'foo');
395+
const ds = cloneDeep(mockDatasource);
396+
ds.settings.jsonData.hideTableNameInAdhocFilters = undefined;
397+
const frame = arrayToDataFrame([{ name: 'foo', type: 'String', table: 'table' }]);
398+
jest.spyOn(ds, 'query').mockImplementation((_request) => of({ data: [frame] }));
399+
400+
const keys = await ds.getTagKeys();
401+
expect(keys).toEqual([{ text: 'table.foo' }]);
402+
});
403+
404+
it('should fetch tag values with column name when hideTableNameInAdhocFilters is true', async () => {
405+
const spyOnReplace = jest.spyOn(templateSrvMock, 'replace').mockImplementation(() => 'foo');
406+
const ds = cloneDeep(mockDatasource);
407+
ds.settings.jsonData.hideTableNameInAdhocFilters = true;
408+
const frame = arrayToDataFrame([{ bar: 'value1' }, { bar: 'value2' }]);
409+
const spyOnQuery = jest.spyOn(ds, 'query').mockImplementation((_request) => of({ data: [frame] }));
410+
411+
const values = await ds.getTagValues({ key: 'bar' });
412+
expect(spyOnReplace).toHaveBeenCalled();
413+
const expected = { rawSql: 'select distinct bar from foo limit 1000' };
414+
415+
expect(spyOnQuery).toHaveBeenCalledWith(
416+
expect.objectContaining({ targets: expect.arrayContaining([expect.objectContaining(expected)]) })
417+
);
418+
419+
expect(values).toEqual([{ text: 'value1' }, { text: 'value2' }]);
420+
});
421+
422+
it('should fetch tag values with table.column format when hideTableNameInAdhocFilters is false', async () => {
423+
const spyOnReplace = jest.spyOn(templateSrvMock, 'replace').mockImplementation(() => '$clickhouse_adhoc_query');
424+
const ds = cloneDeep(mockDatasource);
425+
ds.settings.jsonData.defaultDatabase = undefined;
426+
ds.settings.jsonData.hideTableNameInAdhocFilters = false;
427+
const frame = arrayToDataFrame([{ bar: 'value1' }, { bar: 'value2' }]);
428+
const spyOnQuery = jest.spyOn(ds, 'query').mockImplementation((_request) => of({ data: [frame] }));
429+
430+
const values = await ds.getTagValues({ key: 'foo.bar' });
431+
expect(spyOnReplace).toHaveBeenCalled();
432+
const expected = { rawSql: 'select distinct bar from foo limit 1000' };
433+
434+
expect(spyOnQuery).toHaveBeenCalledWith(
435+
expect.objectContaining({ targets: expect.arrayContaining([expect.objectContaining(expected)]) })
436+
);
437+
438+
expect(values).toEqual([{ text: 'value1' }, { text: 'value2' }]);
439+
});
440+
441+
it('should handle nested column names with dots when hideTableNameInAdhocFilters is true', async () => {
442+
const spyOnReplace = jest.spyOn(templateSrvMock, 'replace').mockImplementation(() => 'foo');
443+
const ds = cloneDeep(mockDatasource);
444+
ds.settings.jsonData.hideTableNameInAdhocFilters = true;
445+
const frame = arrayToDataFrame([{ 'nested.field': 'value1' }, { 'nested.field': 'value2' }]);
446+
const spyOnQuery = jest.spyOn(ds, 'query').mockImplementation((_request) => of({ data: [frame] }));
447+
448+
const values = await ds.getTagValues({ key: 'nested.field' });
449+
expect(spyOnReplace).toHaveBeenCalled();
450+
const expected = { rawSql: 'select distinct nested.field from foo limit 1000' };
451+
452+
expect(spyOnQuery).toHaveBeenCalledWith(
453+
expect.objectContaining({ targets: expect.arrayContaining([expect.objectContaining(expected)]) })
454+
);
455+
456+
expect(values).toEqual([{ text: 'value1' }, { text: 'value2' }]);
457+
});
458+
459+
it('should handle nested column names with dots when hideTableNameInAdhocFilters is false', async () => {
460+
const spyOnReplace = jest.spyOn(templateSrvMock, 'replace').mockImplementation(() => '$clickhouse_adhoc_query');
461+
const ds = cloneDeep(mockDatasource);
462+
ds.settings.jsonData.defaultDatabase = undefined;
463+
ds.settings.jsonData.hideTableNameInAdhocFilters = false;
464+
const frame = arrayToDataFrame([{ 'nested.field': 'value1' }, { 'nested.field': 'value2' }]);
465+
const spyOnQuery = jest.spyOn(ds, 'query').mockImplementation((_request) => of({ data: [frame] }));
466+
467+
const values = await ds.getTagValues({ key: 'foo.nested.field' });
468+
expect(spyOnReplace).toHaveBeenCalled();
469+
const expected = { rawSql: 'select distinct nested.field from foo limit 1000' };
470+
471+
expect(spyOnQuery).toHaveBeenCalledWith(
472+
expect.objectContaining({ targets: expect.arrayContaining([expect.objectContaining(expected)]) })
473+
);
474+
475+
expect(values).toEqual([{ text: 'value1' }, { text: 'value2' }]);
476+
});
477+
});
478+
370479
describe('Conditional All', () => {
371480
it('should replace $__conditionalAll with 1=1 when all is selected', async () => {
372481
const rawSql = 'select stuff from table where $__conditionalAll(fieldVal in ($fieldVal), $fieldVal);';

src/data/CHDatasource.ts

Lines changed: 82 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
DataSourceInstanceSettings,
88
DataSourceWithLogsContextSupport,
99
DataSourceWithSupplementaryQueriesSupport,
10+
Field,
1011
getTimeZone,
1112
getTimeZoneInfo,
1213
LogRowContextOptions,
@@ -861,8 +862,9 @@ export class Datasource
861862
return frame.fields.map((f) => ({ text: f.name }));
862863
}
863864
const view = new DataFrameView(frame);
865+
const hideTableName = this.settings.jsonData.hideTableNameInAdhocFilters || false;
864866
return view.map((item) => ({
865-
text: `${item[2]}.${item[0]}`,
867+
text: hideTableName ? item[0] : `${item[2]}.${item[0]}`,
866868
}));
867869
}
868870

@@ -875,35 +877,65 @@ export class Datasource
875877
return this.fetchTagValuesFromSchema(key);
876878
}
877879

880+
private fieldValuesToMetricFindValues(field: Field): MetricFindValue[] {
881+
// Convert to string to avoid https://github.com/grafana/grafana/issues/12209
882+
return field.values
883+
.filter((value) => value !== null)
884+
.map((value) => {
885+
return { text: String(value) };
886+
});
887+
}
888+
878889
private async fetchTagValuesFromSchema(key: string): Promise<MetricFindValue[]> {
879890
const { from } = this.getTagSource();
880-
const [table, ...colParts] = key.split('.');
881-
const col = colParts.join('.');
882-
const source = from?.includes('.') ? `${from.split('.')[0]}.${table}` : table;
891+
const hideTableName = this.settings.jsonData.hideTableNameInAdhocFilters || false;
892+
893+
let col: string;
894+
let source: string;
895+
896+
if (hideTableName && from) {
897+
// When hideTableNameInAdhocFilters is true, key is just the column name (e.g., 'bar')
898+
col = key;
899+
source = from;
900+
} else {
901+
// When hideTableNameInAdhocFilters is false, key is 'table.column' format (e.g., 'foo.bar')
902+
const [table, ...colParts] = key.split('.');
903+
col = colParts.join('.');
904+
source = from?.includes('.') ? `${from.split('.')[0]}.${table}` : table;
905+
}
906+
883907
const rawSql = `select distinct ${col} from ${source} limit 1000`;
884908
const frame = await this.runQuery({ rawSql });
885909
if (frame.fields?.length === 0) {
886910
return [];
887911
}
888912
const field = frame.fields[0];
889-
// Convert to string to avoid https://github.com/grafana/grafana/issues/12209
890-
return field.values
891-
.filter((value) => value !== null)
892-
.map((value) => {
893-
return { text: String(value) };
894-
});
913+
return this.fieldValuesToMetricFindValues(field);
895914
}
896915

897916
private async fetchTagValuesFromQuery(key: string): Promise<MetricFindValue[]> {
917+
const tagSource = this.getTagSource();
918+
919+
// Check if the query contains the $__adhoc_column macro
920+
if (tagSource.source && tagSource.source.includes('$__adhoc_column')) {
921+
// Replace the macro with the actual column name
922+
const queryWithColumn = tagSource.source.replace(/\$__adhoc_column/g, key);
923+
this.skipAdHocFilter = true;
924+
const frame = await this.runQuery({ rawSql: queryWithColumn });
925+
926+
if (frame.fields?.length === 0) {
927+
return [];
928+
}
929+
930+
const field = frame.fields[0];
931+
return this.fieldValuesToMetricFindValues(field);
932+
}
933+
934+
// Fallback to the original behavior
898935
const { frame } = await this.fetchTags();
899936
const field = frame.fields.find((f) => f.name === key);
900937
if (field) {
901-
// Convert to string to avoid https://github.com/grafana/grafana/issues/12209
902-
return field.values
903-
.filter((value) => value !== null)
904-
.map((value) => {
905-
return { text: String(value) };
906-
});
938+
return this.fieldValuesToMetricFindValues(field);
907939
}
908940
return [];
909941
}
@@ -919,13 +951,46 @@ export class Datasource
919951
}
920952

921953
if (tagSource.type === TagType.query) {
922-
this.adHocFilter.setTargetTableFromQuery(tagSource.source);
954+
// Check if the query contains the $__adhoc_column macro
955+
if (tagSource.source.includes('$__adhoc_column')) {
956+
// Extract table name from the query and get column list from system.columns
957+
const tableName = this.extractTableNameFromQuery(tagSource.source);
958+
if (tableName) {
959+
this.adHocFilter.setTargetTableFromQuery(tagSource.source.replace(/\$__adhoc_column/g, '*'));
960+
961+
// Parse database.table format
962+
const parts = tableName.split('.');
963+
let query: string;
964+
if (parts.length === 2) {
965+
const [db, table] = parts;
966+
query = `SELECT name, type, table FROM system.columns WHERE database = '${db}' AND table = '${table}'`;
967+
} else {
968+
query = `SELECT name, type, table FROM system.columns WHERE table = '${tableName}'`;
969+
}
970+
const results = await this.runQuery({ rawSql: query });
971+
return { type: TagType.schema, frame: results };
972+
}
973+
} else {
974+
this.adHocFilter.setTargetTableFromQuery(tagSource.source);
975+
}
923976
}
924977

925978
const results = await this.runQuery({ rawSql: tagSource.source });
926979
return { type: tagSource.type, frame: results };
927980
}
928981

982+
private extractTableNameFromQuery(query: string): string | null {
983+
// Try to extract table name from FROM clause
984+
// Supports formats: FROM table, FROM database.table, FROM "database"."table"
985+
const fromMatch = query.match(/FROM\s+(?:"?(\w+)"?\.)?"?(\w+)"?/i);
986+
if (fromMatch) {
987+
const database = fromMatch[1];
988+
const table = fromMatch[2];
989+
return database ? `${database}.${table}` : table;
990+
}
991+
return null;
992+
}
993+
929994
private getTagSource() {
930995
// @todo https://github.com/grafana/grafana/issues/13109
931996
const ADHOC_VAR = '$clickhouse_adhoc_query';

src/labels.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ export default {
7676
tooltip:
7777
'Enable using the Grafana row limit setting to limit the number of rows returned from Clickhouse. Ensure the appropriate permissions are set for your user. Only supported for Grafana >= 11.0.0. Defaults to false.',
7878
},
79+
hideTableNameInAdhocFilters: {
80+
label: 'Hide table name in ad hoc filters',
81+
testid: 'data-testid hide-table-name-in-adhoc-filters-switch',
82+
tooltip:
83+
'Show only column names in ad hoc filter keys instead of the full "table.column" format. This simplifies the filter interface when working with schemas that have many tables. Defaults to false.',
84+
},
7985
},
8086
HttpHeadersConfig: {
8187
title: 'HTTP Headers',

src/types/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ export interface CHConfig extends DataSourceJsonData {
4242
enableSecureSocksProxy?: boolean;
4343
enableRowLimit?: boolean;
4444

45+
hideTableNameInAdhocFilters?: boolean;
46+
4547
pdcInjected?: boolean;
4648
}
4749

src/views/CHConfigEditor.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export const ConfigEditor: React.FC<ConfigEditorProps> = (props) => {
7171
const onSwitchToggle = (
7272
key: keyof Pick<
7373
CHConfig,
74-
'secure' | 'validateSql' | 'enableSecureSocksProxy' | 'forwardGrafanaHeaders' | 'enableRowLimit'
74+
'secure' | 'validateSql' | 'enableSecureSocksProxy' | 'forwardGrafanaHeaders' | 'enableRowLimit' | 'hideTableNameInAdhocFilters'
7575
>,
7676
value: boolean
7777
) => {
@@ -622,6 +622,16 @@ export const ConfigEditor: React.FC<ConfigEditorProps> = (props) => {
622622
}}
623623
/>
624624
</Field>
625+
<Field label={labels.hideTableNameInAdhocFilters.label} description={labels.hideTableNameInAdhocFilters.tooltip}>
626+
<Switch
627+
className="gf-form"
628+
value={jsonData.hideTableNameInAdhocFilters || false}
629+
data-testid={labels.hideTableNameInAdhocFilters.testid}
630+
onChange={(e) => {
631+
onSwitchToggle('hideTableNameInAdhocFilters', e.currentTarget.checked);
632+
}}
633+
/>
634+
</Field>
625635
{config.secureSocksDSProxyEnabled && versionGte(config.buildInfo.version, '10.0.0') && (
626636
<Field label={labels.secureSocksProxy.label} description={labels.secureSocksProxy.tooltip}>
627637
<Switch

0 commit comments

Comments
 (0)