diff --git a/src/components/queryBuilder/FilterEditor.tsx b/src/components/queryBuilder/FilterEditor.tsx index ff70777c..ff2c4903 100644 --- a/src/components/queryBuilder/FilterEditor.tsx +++ b/src/components/queryBuilder/FilterEditor.tsx @@ -28,6 +28,8 @@ const filterOperators: Array> = [ { value: FilterOperator.GreaterThanOrEqual, label: '>=' }, { value: FilterOperator.Like, label: 'LIKE' }, { value: FilterOperator.NotLike, label: 'NOT LIKE' }, + { value: FilterOperator.ILike, label: 'ILIKE' }, + { value: FilterOperator.NotILike, label: 'NOT ILIKE' }, { value: FilterOperator.IsEmpty, label: 'IS EMPTY' }, { value: FilterOperator.IsNotEmpty, label: 'IS NOT EMPTY' }, { value: FilterOperator.In, label: 'IN' }, @@ -266,6 +268,8 @@ export const FilterEditor = (props: { FilterOperator.IsAnything, FilterOperator.Like, FilterOperator.NotLike, + FilterOperator.ILike, + FilterOperator.NotILike, FilterOperator.In, FilterOperator.NotIn, FilterOperator.IsNull, diff --git a/src/data/CHDatasource.test.ts b/src/data/CHDatasource.test.ts index ebaca902..e14f5e4b 100644 --- a/src/data/CHDatasource.test.ts +++ b/src/data/CHDatasource.test.ts @@ -13,7 +13,7 @@ import { DataQuery } from '@grafana/schema'; import { mockDatasource } from '__mocks__/datasource'; import { cloneDeep } from 'lodash'; import { Observable, of } from 'rxjs'; -import { BuilderMode, ColumnHint, QueryBuilderOptions, QueryType } from 'types/queryBuilder'; +import { BuilderMode, ColumnHint, FilterOperator, QueryBuilderOptions, QueryType } from 'types/queryBuilder'; import { CHBuilderQuery, CHQuery, CHSqlQuery, EditorType } from 'types/sql'; import { AdHocFilter } from './adHocFilter'; import { Datasource } from './CHDatasource'; @@ -1003,5 +1003,374 @@ describe('ClickHouseDatasource', () => { expect((result as CHBuilderQuery).builderOptions.filters![0].mapKey).toBe('service_name'); }); + + describe('ADD_FILTER', () => { + it('adds an Equals filter for the given field', () => { + const result = datasource.modifyQuery(query, { + type: 'ADD_FILTER', + options: { key: 'level', value: 'info' }, + } as any) as CHBuilderQuery; + + expect(result.builderOptions.filters).toHaveLength(1); + expect(result.builderOptions.filters![0]).toMatchObject({ + key: 'level', + operator: FilterOperator.Equals, + value: 'info', + }); + }); + + it('replaces an existing Equals filter for the same field', () => { + const queryWithFilter: CHBuilderQuery = { + ...query, + builderOptions: { + ...query.builderOptions, + filters: [{ condition: 'AND', key: 'level', type: 'string', filterType: 'custom', operator: FilterOperator.Equals, value: 'debug' }], + }, + }; + + const result = datasource.modifyQuery(queryWithFilter, { + type: 'ADD_FILTER', + options: { key: 'level', value: 'info' }, + } as any) as CHBuilderQuery; + + expect(result.builderOptions.filters).toHaveLength(1); + // @ts-expect-error not expecting `NullFilter` + expect(result.builderOptions.filters![0].value).toBe('info'); + }); + + it('returns query unchanged when key is missing', () => { + const result = datasource.modifyQuery(query, { + type: 'ADD_FILTER', + options: { value: 'info' }, + } as any); + + expect(result).toBe(query); + }); + }); + + describe('ADD_FILTER_OUT', () => { + it('adds a NotEquals filter for the given field', () => { + const result = datasource.modifyQuery(query, { + type: 'ADD_FILTER_OUT', + options: { key: 'level', value: 'error' }, + } as any) as CHBuilderQuery; + + expect(result.builderOptions.filters).toHaveLength(1); + expect(result.builderOptions.filters![0]).toMatchObject({ + key: 'level', + operator: FilterOperator.NotEquals, + value: 'error', + }); + }); + + it('removes an existing Equals filter for the same field', () => { + const queryWithFilter: CHBuilderQuery = { + ...query, + builderOptions: { + ...query.builderOptions, + filters: [{ condition: 'AND', key: 'level', type: 'string', filterType: 'custom', operator: FilterOperator.Equals, value: 'info' }], + }, + }; + + const result = datasource.modifyQuery(queryWithFilter, { + type: 'ADD_FILTER_OUT', + options: { key: 'level', value: 'error' }, + } as any) as CHBuilderQuery; + + expect(result.builderOptions.filters).toHaveLength(1); + expect(result.builderOptions.filters![0].operator).toBe(FilterOperator.NotEquals); + }); + + it('returns query unchanged when key is missing', () => { + const result = datasource.modifyQuery(query, { + type: 'ADD_FILTER_OUT', + options: { value: 'error' }, + } as any); + + expect(result).toBe(query); + }); + + it('accumulates multiple NotEquals filters for different values', () => { + const queryWithFilter: CHBuilderQuery = { + ...query, + builderOptions: { + ...query.builderOptions, + filters: [{ condition: 'AND', key: 'level', type: 'string', filterType: 'custom', operator: FilterOperator.NotEquals, value: 'info' }], + }, + }; + + const result = datasource.modifyQuery(queryWithFilter, { + type: 'ADD_FILTER_OUT', + options: { key: 'level', value: 'error' }, + } as any) as CHBuilderQuery; + + expect(result.builderOptions.filters).toHaveLength(2); + expect(result.builderOptions.filters!.map((f) => ('value' in f ? f.value : null))).toEqual( + expect.arrayContaining(['info', 'error']) + ); + }); + + it('replaces a NotEquals filter with the exact same value', () => { + const queryWithFilter: CHBuilderQuery = { + ...query, + builderOptions: { + ...query.builderOptions, + filters: [{ condition: 'AND', key: 'level', type: 'string', filterType: 'custom', operator: FilterOperator.NotEquals, value: 'error' }], + }, + }; + + const result = datasource.modifyQuery(queryWithFilter, { + type: 'ADD_FILTER_OUT', + options: { key: 'level', value: 'error' }, + } as any) as CHBuilderQuery; + + expect(result.builderOptions.filters).toHaveLength(1); + expect(result.builderOptions.filters![0].operator).toBe(FilterOperator.NotEquals); + // @ts-expect-error not expecting `NullFilter` + expect(result.builderOptions.filters![0].value).toBe('error'); + }); + }); + + describe('ADD_STRING_FILTER', () => { + it('adds an ILike filter using the provided key', () => { + const result = datasource.modifyQuery(query, { + type: 'ADD_STRING_FILTER', + options: { key: 'Body', value: 'error' }, + } as any) as CHBuilderQuery; + + expect(result.builderOptions.filters).toHaveLength(1); + expect(result.builderOptions.filters![0]).toMatchObject({ + key: 'Body', + operator: FilterOperator.ILike, + value: 'error', + }); + }); + + it('resolves column from LogMessage hint when key is absent', () => { + const queryWithLogMessage: CHBuilderQuery = { + ...query, + builderOptions: { + ...query.builderOptions, + columns: [{ name: 'Body', hint: ColumnHint.LogMessage }], + }, + }; + + const result = datasource.modifyQuery(queryWithLogMessage, { + type: 'ADD_STRING_FILTER', + options: { value: 'error' }, + } as any) as CHBuilderQuery; + + expect(result.builderOptions.filters).toHaveLength(1); + expect(result.builderOptions.filters![0]).toMatchObject({ + key: 'Body', + operator: FilterOperator.ILike, + value: 'error', + }); + }); + + it('returns query unchanged when key is absent and no LogMessage column is configured', () => { + const result = datasource.modifyQuery(query, { + type: 'ADD_STRING_FILTER', + options: { value: 'error' }, + } as any); + + expect(result).toBe(query); + }); + }); + + describe('ADD_STRING_FILTER_OUT', () => { + it('adds a NotILike filter using the provided key', () => { + const result = datasource.modifyQuery(query, { + type: 'ADD_STRING_FILTER_OUT', + options: { key: 'Body', value: 'error' }, + } as any) as CHBuilderQuery; + + expect(result.builderOptions.filters).toHaveLength(1); + expect(result.builderOptions.filters![0]).toMatchObject({ + key: 'Body', + operator: FilterOperator.NotILike, + value: 'error', + }); + }); + + it('resolves column from LogMessage hint when key is absent', () => { + const queryWithLogMessage: CHBuilderQuery = { + ...query, + builderOptions: { + ...query.builderOptions, + columns: [{ name: 'Body', hint: ColumnHint.LogMessage }], + }, + }; + + const result = datasource.modifyQuery(queryWithLogMessage, { + type: 'ADD_STRING_FILTER_OUT', + options: { value: 'error' }, + } as any) as CHBuilderQuery; + + expect(result.builderOptions.filters).toHaveLength(1); + expect(result.builderOptions.filters![0]).toMatchObject({ + key: 'Body', + operator: FilterOperator.NotILike, + value: 'error', + }); + }); + + it('returns query unchanged when key is absent and no LogMessage column is configured', () => { + const result = datasource.modifyQuery(query, { + type: 'ADD_STRING_FILTER_OUT', + options: { value: 'error' }, + } as any); + + expect(result).toBe(query); + }); + }); + + describe('hint-matched columns via logAliasToColumnHints', () => { + it('ADD_FILTER uses hint and empty key when column is resolved via log alias', () => { + const queryWithLevel: CHBuilderQuery = { + ...query, + builderOptions: { + ...query.builderOptions, + columns: [{ name: 'SeverityText', hint: ColumnHint.LogLevel, type: 'string' }], + }, + }; + + const result = datasource.modifyQuery(queryWithLevel, { + type: 'ADD_FILTER', + options: { key: 'level', value: 'info' }, + } as any) as CHBuilderQuery; + + expect(result.builderOptions.filters).toHaveLength(1); + expect(result.builderOptions.filters![0]).toMatchObject({ + key: '', + hint: ColumnHint.LogLevel, + operator: FilterOperator.Equals, + value: 'info', + }); + }); + + it('ADD_FILTER replaces existing hint-matched filter', () => { + const queryWithLevel: CHBuilderQuery = { + ...query, + 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' }], + }, + }; + + const result = datasource.modifyQuery(queryWithLevel, { + type: 'ADD_FILTER', + options: { key: 'level', value: 'info' }, + } as any) as CHBuilderQuery; + + expect(result.builderOptions.filters).toHaveLength(1); + // @ts-expect-error not expecting `NullFilter` + expect(result.builderOptions.filters![0].value).toBe('info'); + }); + }); + + describe('OTel map key splitting', () => { + it('splits ResourceAttributes key into column + mapKey', () => { + const queryWithResource: CHBuilderQuery = { + ...query, + builderOptions: { + ...query.builderOptions, + columns: [{ name: 'ResourceAttributes', type: 'Map(String, String)' }], + }, + }; + + const result = datasource.modifyQuery(queryWithResource, { + type: 'ADD_FILTER', + options: { key: 'ResourceAttributes.service.name', value: 'my-service' }, + } as any) as CHBuilderQuery; + + expect(result.builderOptions.filters![0]).toMatchObject({ + mapKey: 'service.name', + type: 'Map(String, String)', + operator: FilterOperator.Equals, + value: 'my-service', + }); + }); + + it('splits ScopeAttributes key into column + mapKey', () => { + const queryWithScope: CHBuilderQuery = { + ...query, + builderOptions: { + ...query.builderOptions, + columns: [{ name: 'ScopeAttributes', type: 'Map(String, String)' }], + }, + }; + + const result = datasource.modifyQuery(queryWithScope, { + type: 'ADD_FILTER', + options: { key: 'ScopeAttributes.version', value: '1.0' }, + } as any) as CHBuilderQuery; + + expect(result.builderOptions.filters![0]).toMatchObject({ + mapKey: 'version', + type: 'Map(String, String)', + operator: FilterOperator.Equals, + value: '1.0', + }); + }); + + it('sets type to JSON for JSON-typed map column', () => { + const queryWithJson: CHBuilderQuery = { + ...query, + builderOptions: { + ...query.builderOptions, + columns: [{ name: 'LogAttributes', type: 'JSON' }], + }, + }; + + const result = datasource.modifyQuery(queryWithJson, { + type: 'ADD_FILTER', + options: { key: 'LogAttributes.request_id', value: 'abc123' }, + } as any) as CHBuilderQuery; + + expect(result.builderOptions.filters![0]).toMatchObject({ + mapKey: 'request_id', + type: 'JSON', + operator: FilterOperator.Equals, + value: 'abc123', + }); + }); + }); + + describe('ADD_STRING_FILTER with LogMessage column alias', () => { + it('resolves LogMessage column by alias when name differs', () => { + const queryWithAlias: CHBuilderQuery = { + ...query, + builderOptions: { + ...query.builderOptions, + columns: [{ name: 'log_body', alias: 'Body', hint: ColumnHint.LogMessage }], + }, + }; + + const result = datasource.modifyQuery(queryWithAlias, { + type: 'ADD_STRING_FILTER', + options: { value: 'error' }, + } as any) as CHBuilderQuery; + + expect(result.builderOptions.filters).toHaveLength(1); + expect(result.builderOptions.filters![0]).toMatchObject({ + key: 'Body', + operator: FilterOperator.ILike, + value: 'error', + }); + }); + }); + + 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); + expect(result).toBe(sqlQuery); + }); + + it('returns query unchanged when value is missing', () => { + const result = datasource.modifyQuery(query, { type: 'ADD_FILTER', options: { key: 'level' } } as any); + expect(result).toBe(query); + }); }); }); diff --git a/src/data/CHDatasource.ts b/src/data/CHDatasource.ts index 512d8d62..0a490909 100644 --- a/src/data/CHDatasource.ts +++ b/src/data/CHDatasource.ts @@ -6,6 +6,7 @@ import { DataQueryResponse, DataSourceInstanceSettings, DataSourceWithLogsContextSupport, + DataSourceWithQueryModificationSupport, DataSourceWithSupplementaryQueriesSupport, Field, getTimeZone, @@ -58,7 +59,10 @@ import { labelsFieldName, transformQueryResponseWithTraceAndLogLinks } from './u export class Datasource extends DataSourceWithBackend - implements DataSourceWithSupplementaryQueriesSupport, DataSourceWithLogsContextSupport + implements + DataSourceWithSupplementaryQueriesSupport, + DataSourceWithLogsContextSupport, + DataSourceWithQueryModificationSupport { // This enables default annotation support for 7.2+ annotations = {}; @@ -318,13 +322,33 @@ export class Datasource }); } + getSupportedQueryModifications() { + return ['ADD_FILTER', 'ADD_FILTER_OUT', 'ADD_STRING_FILTER', 'ADD_STRING_FILTER_OUT'] + } + // Support filtering by field value in Explore modifyQuery(query: CHQuery, action: QueryFixAction): CHQuery { - if (query.editorType !== EditorType.Builder || !action.options || !action.options.key || !action.options.value) { + if (query.editorType !== EditorType.Builder || !action.options || !action.options.value) { return query; } - let columnName = action.options.key || ''; + + let columnName = (() => { + const isStringFilterAction = action.type === 'ADD_STRING_FILTER' || action.type === 'ADD_STRING_FILTER_OUT'; + + if (isStringFilterAction) { + // has no key — resolve the column name from the log message hint. + const logMessageColumn = getColumnByHint(query.builderOptions, ColumnHint.LogMessage); + return logMessageColumn?.alias || logMessageColumn?.name || action.options.key || '' + } + + return action.options.key || '' + })() + + if (!columnName) { + return query + } + const actionValue = action.options.value; let mapKey = ''; @@ -412,6 +436,24 @@ export class Datasource operator: FilterOperator.NotEquals, value: actionValue, }); + } else if (action.type === 'ADD_STRING_FILTER') { + nextFilters.push({ + condition: 'AND', + key: columnName, + filterType: 'custom', + type: 'string', + operator: FilterOperator.ILike, + value: actionValue, + }); + } else if (action.type === 'ADD_STRING_FILTER_OUT') { + nextFilters.push({ + condition: 'AND', + key: columnName, + filterType: 'custom', + type: 'string', + operator: FilterOperator.NotILike, + value: actionValue, + }); } // the query is updated to trigger the URL update and propagation to the panels diff --git a/src/data/sqlGenerator.ts b/src/data/sqlGenerator.ts index f35c7689..09d447ec 100644 --- a/src/data/sqlGenerator.ts +++ b/src/data/sqlGenerator.ts @@ -823,6 +823,9 @@ const getFilters = (options: QueryBuilderOptions): string => { } else if (filter.operator === FilterOperator.NotLike) { operator = 'LIKE'; negate = true; + } else if (filter.operator === FilterOperator.NotILike) { + operator = 'ILIKE'; + negate = true; } else if (filter.operator === FilterOperator.OutsideGrafanaTimeRange) { operator = ''; negate = true; @@ -866,7 +869,12 @@ const getFilters = (options: QueryBuilderOptions): string => { } } } else if (isStringFilter(type, filter.operator)) { - if (filter.operator === FilterOperator.Like || filter.operator === FilterOperator.NotLike) { + if ( + filter.operator === FilterOperator.Like || + filter.operator === FilterOperator.NotLike || + filter.operator === FilterOperator.ILike || + filter.operator === FilterOperator.NotILike + ) { filterParts.push(`'%${filter.value || ''}%'`); } else { filterParts.push(escapeValue((filter as StringFilter).value || '')); diff --git a/src/types/queryBuilder.ts b/src/types/queryBuilder.ts index 6f3e7b06..11dc19e7 100644 --- a/src/types/queryBuilder.ts +++ b/src/types/queryBuilder.ts @@ -236,6 +236,8 @@ export enum FilterOperator { GreaterThanOrEqual = '>=', Like = 'LIKE', NotLike = 'NOT LIKE', + ILike = 'ILIKE', + NotILike = 'NOT ILIKE', In = 'IN', NotIn = 'NOT IN', WithInGrafanaTimeRange = 'WITH IN DASHBOARD TIME RANGE', @@ -293,7 +295,9 @@ export interface StringFilter extends CommonFilterProps { | FilterOperator.Equals | FilterOperator.NotEquals | FilterOperator.Like - | FilterOperator.NotLike; + | FilterOperator.NotLike + | FilterOperator.ILike + | FilterOperator.NotILike; value: string; }