Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cspell.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"sqlutil",
"stagename",
"stretchr",
"subkey",
"subquery",
"subqueries",
"subresource",
Expand Down
8 changes: 7 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,13 @@ services:
- -c
- |
set -e
clickhouse-client --host clickhouse-server --multiquery < /data/seed.sql
# Load every *.sql fixture under /data in lexicographic order so new
# feature-specific fixtures can live alongside seed.sql without
# creating merge conflicts when multiple PRs add fixtures at once.
for f in /data/*.sql; do
echo "Loading fixture: $$f"
clickhouse-client --host clickhouse-server --multiquery < "$$f"
done
depends_on:
clickhouse-server:
condition: service_healthy
Expand Down
182 changes: 170 additions & 12 deletions src/data/CHDatasource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@ import { DataQuery } from '@grafana/schema';
import { mockDatasource } from '__mocks__/datasource';
import { cloneDeep } from 'lodash';
import { of } from 'rxjs';
import { BuilderMode, ColumnHint, FilterOperator, OrderByDirection, QueryBuilderOptions, QueryType } from 'types/queryBuilder';
import {
BuilderMode,
ColumnHint,
FilterOperator,
OrderByDirection,
QueryBuilderOptions,
QueryType,
} from 'types/queryBuilder';
import { CHBuilderQuery, CHQuery, CHSqlQuery, EditorType } from 'types/sql';
import { AdHocFilter } from './adHocFilter';
import { Datasource } from './CHDatasource';
Expand Down Expand Up @@ -124,7 +131,7 @@ describe('ClickHouseDatasource', () => {
// Setup ad-hoc filters
const adHocFilters = [
{ key: 'column', operator: '=', value: 'value' },
{ key: 'column.nested', operator: '=', value: 'value2' }
{ key: 'column.nested', operator: '=', value: 'value2' },
];

// Mock getAdhocFilters to return our test filters
Expand Down Expand Up @@ -169,12 +176,14 @@ describe('ClickHouseDatasource', () => {

// Mock the template variable resolution
const spyOnReplace = jest.spyOn(templateSrvMock, 'replace').mockImplementation(() => resolvedSql);
const spyOnGetVars = jest.spyOn(templateSrvMock, 'getVariables').mockImplementation(() => [{name: 'clickhouse_adhoc_use_json'}]);
const spyOnGetVars = jest
.spyOn(templateSrvMock, 'getVariables')
.mockImplementation(() => [{ name: 'clickhouse_adhoc_use_json' }]);

// Setup ad-hoc filters
const adHocFilters = [
{ key: 'column', operator: '=', value: 'value' },
{ key: 'column.nested', operator: '=', value: 'value2' }
{ key: 'column.nested', operator: '=', value: 'value2' },
];

// Mock getAdhocFilters to return our test filters
Expand All @@ -201,7 +210,6 @@ describe('ClickHouseDatasource', () => {
expect(result.rawSql).toEqual(sqlWithAdHocFilters);
});


it('should expand $__adHocFilters macro with single quotes', async () => {
const query = {
rawSql: "SELECT * FROM complex_table settings $__adHocFilters('my_table')",
Expand Down Expand Up @@ -529,6 +537,107 @@ describe('ClickHouseDatasource', () => {
});
});

describe('Map type ad-hoc filters (#1434)', () => {
it('expands Map-typed columns into one tag key per discovered map key', async () => {
jest.spyOn(templateSrvMock, 'replace').mockImplementation(() => 'db.events');
const ds = cloneDeep(mockDatasource);
ds.settings.jsonData.defaultDatabase = 'db';
ds.settings.jsonData.defaultTable = 'events';
// system.columns → two columns, one Map-typed.
const columnsFrame = arrayToDataFrame([
{ name: 'level', type: 'String', table: 'events' },
{ name: 'labels', type: 'Map(String, String)', table: 'events' },
]);
// fetchUniqueMapKeys → distinct map keys for `labels`.
const mapKeysFrame = arrayToDataFrame([{ keys: 'http.method' }, { keys: 'http.status' }]);
jest.spyOn(ds, 'query').mockImplementation((request) => {
const sql = request.targets[0].rawSql ?? '';
if (sql.includes('arrayJoin(labels.keys)')) {
return of({ data: [mapKeysFrame] });
}
return of({ data: [columnsFrame] });
});

const keys = await ds.getTagKeys();
expect(keys).toEqual([
{ text: 'events.level' },
{ text: 'events.labels.http.method' },
{ text: 'events.labels.http.status' },
]);
});

it('falls back to a flat Map column entry when no table context is available', async () => {
jest.spyOn(templateSrvMock, 'replace').mockImplementation(() => 'db');
const ds = cloneDeep(mockDatasource);
ds.settings.jsonData.defaultDatabase = 'db';
const columnsFrame = arrayToDataFrame([{ name: 'labels', type: 'Map(String, String)', table: 'events' }]);
jest.spyOn(ds, 'query').mockImplementation(() => of({ data: [columnsFrame] }));

const keys = await ds.getTagKeys();
// No `db.table` context → we don't fan out mapKeys probes; show the
// raw Map column entry instead of crashing or emitting `[object Object]`.
expect(keys).toEqual([{ text: 'events.labels' }]);
});

it('rewrites dotted Map keys into bracket access in fetchTagValuesFromSchema', async () => {
jest.spyOn(templateSrvMock, 'replace').mockImplementation(() => 'db.events');
const ds = cloneDeep(mockDatasource);
ds.settings.jsonData.defaultDatabase = 'db';
const columnsFrame = arrayToDataFrame([{ name: 'labels', type: 'Map(String, String)', table: 'events' }]);
const mapKeysFrame = arrayToDataFrame([{ keys: 'http.method' }]);
const valuesFrame = arrayToDataFrame([{ val: 'GET' }, { val: 'POST' }]);

const spyOnQuery = jest.spyOn(ds, 'query').mockImplementation((request) => {
const sql = request.targets[0].rawSql ?? '';
if (sql.includes('arrayJoin(labels.keys)')) {
return of({ data: [mapKeysFrame] });
}
if (sql.includes("labels['http.method']")) {
return of({ data: [valuesFrame] });
}
return of({ data: [columnsFrame] });
});

await ds.getTagKeys(); // populates the mapColumnsByTable cache
const values = await ds.getTagValues({ key: 'events.labels.http.method' });
expect(values).toEqual([{ text: 'GET' }, { text: 'POST' }]);
expect(spyOnQuery).toHaveBeenCalledWith(
expect.objectContaining({
targets: expect.arrayContaining([
expect.objectContaining({
rawSql: "select distinct labels['http.method'] from db.events limit 1000",
}),
]),
})
);
});

it('publishes the Map-column set to the AdHocFilter for escapeKey rewriting', async () => {
jest.spyOn(templateSrvMock, 'replace').mockImplementation(() => 'db.events');
const ds = cloneDeep(mockDatasource);
ds.settings.jsonData.defaultDatabase = 'db';
const columnsFrame = arrayToDataFrame([
{ name: 'labels', type: 'Map(String, String)', table: 'events' },
{ name: 'other_map', type: 'Nullable(Map(String, String))', table: 'events' },
]);
const mapKeysFrame = arrayToDataFrame([{ keys: 'a' }]);
jest.spyOn(ds, 'query').mockImplementation((request) => {
const sql = request.targets[0].rawSql ?? '';
if (sql.includes('.keys)')) {
return of({ data: [mapKeysFrame] });
}
return of({ data: [columnsFrame] });
});

await ds.getTagKeys();
const published = ds.adHocFilter.getMapColumns();
expect(published.has('labels')).toBe(true);
expect(published.has('other_map')).toBe(true);
// OTel fallback names remain in the set for back-compat.
expect(published.has('LogAttributes')).toBe(true);
});
});

describe('Conditional All', () => {
it('should replace $__conditionalAll with 1=1 when all is selected', async () => {
const rawSql = 'select stuff from table where $__conditionalAll(fieldVal in ($fieldVal), $fieldVal);';
Expand Down Expand Up @@ -1005,7 +1114,7 @@ describe('ClickHouseDatasource', () => {
value: 'error',
type: 'string',
filterType: 'custom',
condition: 'AND'
condition: 'AND',
},
],
},
Expand Down Expand Up @@ -1147,7 +1256,16 @@ describe('ClickHouseDatasource', () => {
...query,
builderOptions: {
...query.builderOptions,
filters: [{ condition: 'AND', key: 'level', type: 'string', filterType: 'custom', operator: FilterOperator.Equals, value: 'debug' }],
filters: [
{
condition: 'AND',
key: 'level',
type: 'string',
filterType: 'custom',
operator: FilterOperator.Equals,
value: 'debug',
},
],
},
};

Expand Down Expand Up @@ -1191,7 +1309,16 @@ describe('ClickHouseDatasource', () => {
...query,
builderOptions: {
...query.builderOptions,
filters: [{ condition: 'AND', key: 'level', type: 'string', filterType: 'custom', operator: FilterOperator.Equals, value: 'info' }],
filters: [
{
condition: 'AND',
key: 'level',
type: 'string',
filterType: 'custom',
operator: FilterOperator.Equals,
value: 'info',
},
],
},
};

Expand All @@ -1218,7 +1345,16 @@ describe('ClickHouseDatasource', () => {
...query,
builderOptions: {
...query.builderOptions,
filters: [{ condition: 'AND', key: 'level', type: 'string', filterType: 'custom', operator: FilterOperator.NotEquals, value: 'info' }],
filters: [
{
condition: 'AND',
key: 'level',
type: 'string',
filterType: 'custom',
operator: FilterOperator.NotEquals,
value: 'info',
},
],
},
};

Expand All @@ -1238,7 +1374,16 @@ describe('ClickHouseDatasource', () => {
...query,
builderOptions: {
...query.builderOptions,
filters: [{ condition: 'AND', key: 'level', type: 'string', filterType: 'custom', operator: FilterOperator.NotEquals, value: 'error' }],
filters: [
{
condition: 'AND',
key: 'level',
type: 'string',
filterType: 'custom',
operator: FilterOperator.NotEquals,
value: 'error',
},
],
},
};

Expand Down Expand Up @@ -1378,7 +1523,17 @@ describe('ClickHouseDatasource', () => {
builderOptions: {
...query.builderOptions,
columns: [{ name: 'SeverityText', hint: ColumnHint.LogLevel, type: 'string' }],
filters: [{ condition: 'AND', key: '', hint: ColumnHint.LogLevel, type: 'string', filterType: 'custom', operator: FilterOperator.Equals, value: 'debug' }],
filters: [
{
condition: 'AND',
key: '',
hint: ColumnHint.LogLevel,
type: 'string',
filterType: 'custom',
operator: FilterOperator.Equals,
value: 'debug',
},
],
},
};

Expand Down Expand Up @@ -1487,7 +1642,10 @@ describe('ClickHouseDatasource', () => {

it('returns query unchanged for non-Builder editorType', () => {
const sqlQuery: CHSqlQuery = { pluginVersion: '', refId: 'A', editorType: EditorType.SQL, rawSql: 'SELECT 1' };
const result = datasource.modifyQuery(sqlQuery, { type: 'ADD_FILTER', options: { key: 'level', value: 'info' } } as any);
const result = datasource.modifyQuery(sqlQuery, {
type: 'ADD_FILTER',
options: { key: 'level', value: 'info' },
} as any);
expect(result).toBe(sqlQuery);
});

Expand Down
Loading
Loading