diff --git a/frontend/src/components/QueryBuilderV2/__tests__/utils.test.ts b/frontend/src/components/QueryBuilderV2/__tests__/utils.test.ts new file mode 100644 index 000000000000..6e30c69ab338 --- /dev/null +++ b/frontend/src/components/QueryBuilderV2/__tests__/utils.test.ts @@ -0,0 +1,536 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; + +import { convertFiltersToExpression } from '../utils'; + +describe('convertFiltersToExpression', () => { + it('should handle empty, null, and undefined inputs', () => { + // Test null and undefined + expect(convertFiltersToExpression(null as any)).toEqual({ expression: '' }); + expect(convertFiltersToExpression(undefined as any)).toEqual({ + expression: '', + }); + + // Test empty filters + expect(convertFiltersToExpression({ items: [], op: 'AND' })).toEqual({ + expression: '', + }); + expect( + convertFiltersToExpression({ items: undefined, op: 'AND' } as any), + ).toEqual({ expression: '' }); + }); + + it('should convert basic comparison operators with proper value formatting', () => { + const filters: TagFilter = { + items: [ + { + id: '1', + key: { key: 'service', type: 'string' }, + op: '=', + value: 'api-gateway', + }, + { + id: '2', + key: { key: 'status', type: 'string' }, + op: '!=', + value: 'error', + }, + { + id: '3', + key: { key: 'duration', type: 'number' }, + op: '>', + value: 100, + }, + { + id: '4', + key: { key: 'count', type: 'number' }, + op: '<=', + value: 50, + }, + { + id: '5', + key: { key: 'is_active', type: 'boolean' }, + op: '=', + value: true, + }, + { + id: '6', + key: { key: 'enabled', type: 'boolean' }, + op: '=', + value: false, + }, + { + id: '7', + key: { key: 'count', type: 'number' }, + op: '=', + value: 0, + }, + { + id: '7', + key: { key: 'regex', type: 'string' }, + op: 'regex', + value: '.*', + }, + ], + op: 'AND', + }; + + const result = convertFiltersToExpression(filters); + expect(result).toEqual({ + expression: + "service = 'api-gateway' AND status != 'error' AND duration > 100 AND count <= 50 AND is_active = true AND enabled = false AND count = 0 AND regex REGEXP '.*'", + }); + }); + + it('should handle string value formatting and escaping', () => { + const filters: TagFilter = { + items: [ + { + id: '1', + key: { key: 'message', type: 'string' }, + op: '=', + value: "user's data", + }, + { + id: '2', + key: { key: 'description', type: 'string' }, + op: '=', + value: '', + }, + { + id: '3', + key: { key: 'path', type: 'string' }, + op: '=', + value: '/api/v1/users', + }, + ], + op: 'AND', + }; + + const result = convertFiltersToExpression(filters); + expect(result).toEqual({ + expression: + "message = 'user\\'s data' AND description = '' AND path = '/api/v1/users'", + }); + }); + + it('should handle IN operator with various value types and array formatting', () => { + const filters: TagFilter = { + items: [ + { + id: '1', + key: { key: 'service', type: 'string' }, + op: 'IN', + value: ['api-gateway', 'user-service', 'auth-service'], + }, + { + id: '2', + key: { key: 'status', type: 'string' }, + op: 'IN', + value: 'success', // Single value should be converted to array + }, + { + id: '3', + key: { key: 'tags', type: 'string' }, + op: 'IN', + value: [], // Empty array + }, + { + id: '4', + key: { key: 'name', type: 'string' }, + op: 'IN', + value: ["John's", "Mary's", 'Bob'], // Values with quotes + }, + ], + op: 'AND', + }; + + const result = convertFiltersToExpression(filters); + expect(result).toEqual({ + expression: + "service in ['api-gateway', 'user-service', 'auth-service'] AND status in ['success'] AND tags in [] AND name in ['John\\'s', 'Mary\\'s', 'Bob']", + }); + }); + + it('should convert deprecated operators to their modern equivalents', () => { + const filters: TagFilter = { + items: [ + { + id: '1', + key: { key: 'service', type: 'string' }, + op: 'nin', + value: ['api-gateway', 'user-service'], + }, + { + id: '2', + key: { key: 'message', type: 'string' }, + op: 'nlike', + value: 'error', + }, + { + id: '3', + key: { key: 'path', type: 'string' }, + op: 'nregex', + value: '/api/.*', + }, + { + id: '4', + key: { key: 'service', type: 'string' }, + op: 'NIN', // Test case insensitivity + value: ['api-gateway'], + }, + { + id: '5', + key: { key: 'user_id', type: 'string' }, + op: 'nexists', + value: '', + }, + { + id: '6', + key: { key: 'description', type: 'string' }, + op: 'ncontains', + value: 'error', + }, + { + id: '7', + key: { key: 'tags', type: 'string' }, + op: 'nhas', + value: 'production', + }, + { + id: '8', + key: { key: 'labels', type: 'string' }, + op: 'nhasany', + value: ['env:prod', 'service:api'], + }, + ], + op: 'AND', + }; + + const result = convertFiltersToExpression(filters); + expect(result).toEqual({ + expression: + "service NOT IN ['api-gateway', 'user-service'] AND message NOT LIKE 'error' AND path NOT REGEXP '/api/.*' AND service NOT IN ['api-gateway'] AND user_id NOT EXISTS AND description NOT CONTAINS 'error' AND NOT has(tags, 'production') AND NOT hasAny(labels, ['env:prod', 'service:api'])", + }); + }); + + it('should handle non-value operators and function operators', () => { + const filters: TagFilter = { + items: [ + { + id: '1', + key: { key: 'user_id', type: 'string' }, + op: 'EXISTS', + value: '', // Value should be ignored for EXISTS + }, + { + id: '2', + key: { key: 'user_id', type: 'string' }, + op: 'EXISTS', + value: 'some-value', // Value should be ignored for EXISTS + }, + { + id: '3', + key: { key: 'tags', type: 'string' }, + op: 'has', + value: 'production', + }, + { + id: '4', + key: { key: 'tags', type: 'string' }, + op: 'hasAny', + value: ['production', 'staging'], + }, + { + id: '5', + key: { key: 'tags', type: 'string' }, + op: 'hasAll', + value: ['production', 'monitoring'], + }, + ], + op: 'AND', + }; + + const result = convertFiltersToExpression(filters); + expect(result).toEqual({ + expression: + "user_id exists AND user_id exists AND has(tags, 'production') AND hasAny(tags, ['production', 'staging']) AND hasAll(tags, ['production', 'monitoring'])", + }); + }); + + it('should filter out invalid filters and handle edge cases', () => { + const filters: TagFilter = { + items: [ + { + id: '1', + key: { key: 'service', type: 'string' }, + op: '=', + value: 'api-gateway', + }, + { + id: '2', + key: undefined, // Invalid filter - should be skipped + op: '=', + value: 'error', + }, + { + id: '3', + key: { key: '', type: 'string' }, // Invalid filter with empty key - should be skipped + op: '=', + value: 'test', + }, + { + id: '4', + key: { key: 'status', type: 'string' }, + op: ' = ', // Test whitespace handling + value: 'success', + }, + { + id: '5', + key: { key: 'service', type: 'string' }, + op: 'In', // Test mixed case handling + value: ['api-gateway'], + }, + ], + op: 'AND', + }; + + const result = convertFiltersToExpression(filters); + expect(result).toEqual({ + expression: + "service = 'api-gateway' AND status = 'success' AND service in ['api-gateway']", + }); + }); + + it('should handle complex mixed operator scenarios with proper joining', () => { + const filters: TagFilter = { + items: [ + { + id: '1', + key: { key: 'service', type: 'string' }, + op: 'IN', + value: ['api-gateway', 'user-service'], + }, + { + id: '2', + key: { key: 'user_id', type: 'string' }, + op: 'EXISTS', + value: '', + }, + { + id: '3', + key: { key: 'tags', type: 'string' }, + op: 'has', + value: 'production', + }, + { + id: '4', + key: { key: 'duration', type: 'number' }, + op: '>', + value: 100, + }, + { + id: '5', + key: { key: 'status', type: 'string' }, + op: 'nin', + value: ['error', 'timeout'], + }, + { + id: '6', + key: { key: 'method', type: 'string' }, + op: '=', + value: 'POST', + }, + ], + op: 'AND', + }; + + const result = convertFiltersToExpression(filters); + expect(result).toEqual({ + expression: + "service in ['api-gateway', 'user-service'] AND user_id exists AND has(tags, 'production') AND duration > 100 AND status NOT IN ['error', 'timeout'] AND method = 'POST'", + }); + }); + + it('should handle all numeric comparison operators and edge cases', () => { + const filters: TagFilter = { + items: [ + { + id: '1', + key: { key: 'count', type: 'number' }, + op: '=', + value: 0, + }, + { + id: '2', + key: { key: 'score', type: 'number' }, + op: '>', + value: 100, + }, + { + id: '3', + key: { key: 'limit', type: 'number' }, + op: '>=', + value: 50, + }, + { + id: '4', + key: { key: 'threshold', type: 'number' }, + op: '<', + value: 1000, + }, + { + id: '5', + key: { key: 'max_value', type: 'number' }, + op: '<=', + value: 999, + }, + { + id: '6', + key: { key: 'values', type: 'string' }, + op: 'IN', + value: ['1', '2', '3', '4', '5'], + }, + ], + op: 'AND', + }; + + const result = convertFiltersToExpression(filters); + expect(result).toEqual({ + expression: + "count = 0 AND score > 100 AND limit >= 50 AND threshold < 1000 AND max_value <= 999 AND values in ['1', '2', '3', '4', '5']", + }); + }); + + it('should handle boolean values and string comparisons with special characters', () => { + const filters: TagFilter = { + items: [ + { + id: '1', + key: { key: 'is_active', type: 'boolean' }, + op: '=', + value: true, + }, + { + id: '2', + key: { key: 'is_deleted', type: 'boolean' }, + op: '=', + value: false, + }, + { + id: '3', + key: { key: 'email', type: 'string' }, + op: '=', + value: 'user@example.com', + }, + { + id: '4', + key: { key: 'description', type: 'string' }, + op: '=', + value: 'Contains "quotes" and \'apostrophes\'', + }, + { + id: '5', + key: { key: 'path', type: 'string' }, + op: '=', + value: '/api/v1/users/123?filter=true', + }, + ], + op: 'AND', + }; + + const result = convertFiltersToExpression(filters); + expect(result).toEqual({ + expression: + "is_active = true AND is_deleted = false AND email = 'user@example.com' AND description = 'Contains \"quotes\" and \\'apostrophes\\'' AND path = '/api/v1/users/123?filter=true'", + }); + }); + + it('should handle all function operators and complex array scenarios', () => { + const filters: TagFilter = { + items: [ + { + id: '1', + key: { key: 'tags', type: 'string' }, + op: 'has', + value: 'production', + }, + { + id: '2', + key: { key: 'labels', type: 'string' }, + op: 'hasAny', + value: ['env:prod', 'service:api'], + }, + { + id: '3', + key: { key: 'metadata', type: 'string' }, + op: 'hasAll', + value: ['version:1.0', 'team:backend'], + }, + { + id: '4', + key: { key: 'services', type: 'string' }, + op: 'IN', + value: ['api-gateway', 'user-service', 'auth-service', 'payment-service'], + }, + { + id: '5', + key: { key: 'excluded_services', type: 'string' }, + op: 'nin', + value: ['legacy-service', 'deprecated-service'], + }, + { + id: '6', + key: { key: 'status_codes', type: 'string' }, + op: 'IN', + value: ['200', '201', '400', '500'], + }, + ], + op: 'AND', + }; + + const result = convertFiltersToExpression(filters); + expect(result).toEqual({ + expression: + "has(tags, 'production') AND hasAny(labels, ['env:prod', 'service:api']) AND hasAll(metadata, ['version:1.0', 'team:backend']) AND services in ['api-gateway', 'user-service', 'auth-service', 'payment-service'] AND excluded_services NOT IN ['legacy-service', 'deprecated-service'] AND status_codes in ['200', '201', '400', '500']", + }); + }); + + it('should handle specific deprecated operators: nhas, ncontains, nexists', () => { + const filters: TagFilter = { + items: [ + { + id: '1', + key: { key: 'user_id', type: 'string' }, + op: 'nexists', + value: '', + }, + { + id: '2', + key: { key: 'description', type: 'string' }, + op: 'ncontains', + value: 'error', + }, + { + id: '3', + key: { key: 'tags', type: 'string' }, + op: 'nhas', + value: 'production', + }, + { + id: '4', + key: { key: 'labels', type: 'string' }, + op: 'nhasany', + value: ['env:prod', 'service:api'], + }, + ], + op: 'AND', + }; + + const result = convertFiltersToExpression(filters); + expect(result).toEqual({ + expression: + "user_id NOT EXISTS AND description NOT CONTAINS 'error' AND NOT has(tags, 'production') AND NOT hasAny(labels, ['env:prod', 'service:api'])", + }); + }); +}); diff --git a/frontend/src/components/QueryBuilderV2/utils.ts b/frontend/src/components/QueryBuilderV2/utils.ts index 3bed5857a06d..49f6b8988de1 100644 --- a/frontend/src/components/QueryBuilderV2/utils.ts +++ b/frontend/src/components/QueryBuilderV2/utils.ts @@ -1,6 +1,10 @@ /* eslint-disable sonarjs/cognitive-complexity */ import { createAggregation } from 'api/v5/queryRange/prepareQueryRangePayloadV5'; -import { OPERATORS } from 'constants/antlrQueryConstants'; +import { + DEPRECATED_OPERATORS_MAP, + OPERATORS, + QUERY_BUILDER_FUNCTIONS, +} from 'constants/antlrQueryConstants'; import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; import { cloneDeep } from 'lodash-es'; import { IQueryPair } from 'types/antlrQueryTypes'; @@ -21,7 +25,7 @@ import { EQueryType } from 'types/common/dashboard'; import { DataSource } from 'types/common/queryBuilder'; import { extractQueryPairs } from 'utils/queryContextUtils'; import { unquote } from 'utils/stringUtils'; -import { isFunctionOperator } from 'utils/tokenUtils'; +import { isFunctionOperator, isNonValueOperator } from 'utils/tokenUtils'; import { v4 as uuid } from 'uuid'; /** @@ -87,12 +91,32 @@ export const convertFiltersToExpression = ( return ''; } - if (isFunctionOperator(op)) { - return `${op}(${key.key}, ${value})`; + let operator = op.trim().toLowerCase(); + if (Object.keys(DEPRECATED_OPERATORS_MAP).includes(operator)) { + operator = + DEPRECATED_OPERATORS_MAP[ + operator as keyof typeof DEPRECATED_OPERATORS_MAP + ]; } - const formattedValue = formatValueForExpression(value, op); - return `${key.key} ${op} ${formattedValue}`; + if (isNonValueOperator(operator)) { + return `${key.key} ${operator}`; + } + + if (isFunctionOperator(operator)) { + // Get the proper function name from QUERY_BUILDER_FUNCTIONS + const functionOperators = Object.values(QUERY_BUILDER_FUNCTIONS); + const properFunctionName = + functionOperators.find( + (func: string) => func.toLowerCase() === operator.toLowerCase(), + ) || operator; + + const formattedValue = formatValueForExpression(value, operator); + return `${properFunctionName}(${key.key}, ${formattedValue})`; + } + + const formattedValue = formatValueForExpression(value, operator); + return `${key.key} ${operator} ${formattedValue}`; }) .filter((expression) => expression !== ''); // Remove empty expressions @@ -117,7 +141,6 @@ export const convertExpressionToFilters = ( if (!expression) return []; const queryPairs = extractQueryPairs(expression); - const filters: TagFilterItem[] = []; queryPairs.forEach((pair) => { @@ -145,19 +168,36 @@ export const convertFiltersToExpressionWithExistingQuery = ( filters: TagFilter, existingQuery: string | undefined, ): { filters: TagFilter; filter: { expression: string } } => { + // Check for deprecated operators and replace them with new operators + const updatedFilters = cloneDeep(filters); + + // Replace deprecated operators in filter items + if (updatedFilters?.items) { + updatedFilters.items = updatedFilters.items.map((item) => { + const opLower = item.op?.toLowerCase(); + if (Object.keys(DEPRECATED_OPERATORS_MAP).includes(opLower)) { + return { + ...item, + op: DEPRECATED_OPERATORS_MAP[ + opLower as keyof typeof DEPRECATED_OPERATORS_MAP + ].toLowerCase(), + }; + } + return item; + }); + } + if (!existingQuery) { // If no existing query, return filters with a newly generated expression return { - filters, - filter: convertFiltersToExpression(filters), + filters: updatedFilters, + filter: convertFiltersToExpression(updatedFilters), }; } // Extract query pairs from the existing query const queryPairs = extractQueryPairs(existingQuery.trim()); let queryPairsMap: Map = new Map(); - - const updatedFilters = cloneDeep(filters); // Clone filters to avoid direct mutation const nonExistingFilters: TagFilterItem[] = []; let modifiedQuery = existingQuery; // We'll modify this query as we proceed const visitedPairs: Set = new Set(); // Set to track visited query pairs diff --git a/frontend/src/constants/antlrQueryConstants.ts b/frontend/src/constants/antlrQueryConstants.ts index 5f783c770a54..49c7c06c71ab 100644 --- a/frontend/src/constants/antlrQueryConstants.ts +++ b/frontend/src/constants/antlrQueryConstants.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + export const OPERATORS = { IN: 'IN', LIKE: 'LIKE', @@ -21,6 +23,44 @@ export const QUERY_BUILDER_FUNCTIONS = { HASALL: 'hasAll', }; +export function negateOperator(operatorOrFunction: string): string { + // Special cases for equals/not equals + if (operatorOrFunction === OPERATORS['=']) { + return OPERATORS['!=']; + } + if (operatorOrFunction === OPERATORS['!=']) { + return OPERATORS['=']; + } + // For all other operators and functions, add NOT in front + return `${OPERATORS.NOT} ${operatorOrFunction}`; +} + +export enum DEPRECATED_OPERATORS { + REGEX = 'regex', + NIN = 'nin', + NREGEX = 'nregex', + NLIKE = 'nlike', + NILIKE = 'nilike', + NEXTISTS = 'nexists', + NCONTAINS = 'ncontains', + NHAS = 'nhas', + NHASANY = 'nhasany', + NHASALL = 'nhasall', +} + +export const DEPRECATED_OPERATORS_MAP = { + [DEPRECATED_OPERATORS.REGEX]: OPERATORS.REGEXP, + [DEPRECATED_OPERATORS.NIN]: negateOperator(OPERATORS.IN), + [DEPRECATED_OPERATORS.NREGEX]: negateOperator(OPERATORS.REGEXP), + [DEPRECATED_OPERATORS.NLIKE]: negateOperator(OPERATORS.LIKE), + [DEPRECATED_OPERATORS.NILIKE]: negateOperator(OPERATORS.ILIKE), + [DEPRECATED_OPERATORS.NEXTISTS]: negateOperator(OPERATORS.EXISTS), + [DEPRECATED_OPERATORS.NCONTAINS]: negateOperator(OPERATORS.CONTAINS), + [DEPRECATED_OPERATORS.NHAS]: negateOperator(QUERY_BUILDER_FUNCTIONS.HAS), + [DEPRECATED_OPERATORS.NHASANY]: negateOperator(QUERY_BUILDER_FUNCTIONS.HASANY), + [DEPRECATED_OPERATORS.NHASALL]: negateOperator(QUERY_BUILDER_FUNCTIONS.HASALL), +}; + export const NON_VALUE_OPERATORS = [OPERATORS.EXISTS]; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -82,15 +122,3 @@ export const queryOperatorSuggestions = [ { label: OPERATORS.NOT, type: 'operator', info: 'Not' }, ...negationQueryOperatorSuggestions, ]; - -export function negateOperator(operatorOrFunction: string): string { - // Special cases for equals/not equals - if (operatorOrFunction === OPERATORS['=']) { - return OPERATORS['!=']; - } - if (operatorOrFunction === OPERATORS['!=']) { - return OPERATORS['=']; - } - // For all other operators and functions, add NOT in front - return `${OPERATORS.NOT} ${operatorOrFunction}`; -} diff --git a/frontend/src/utils/tokenUtils.ts b/frontend/src/utils/tokenUtils.ts index 3f1eea4c7eea..96fc830c13d0 100644 --- a/frontend/src/utils/tokenUtils.ts +++ b/frontend/src/utils/tokenUtils.ts @@ -100,16 +100,36 @@ export function isFunctionOperator(operator: string): boolean { const functionOperators = Object.values(QUERY_BUILDER_FUNCTIONS); const sanitizedOperator = operator.trim(); - // Check if it's a direct function operator - if (functionOperators.includes(sanitizedOperator)) { + // Check if it's a direct function operator (case-insensitive) + if ( + functionOperators.some( + (func) => func.toLowerCase() === sanitizedOperator.toLowerCase(), + ) + ) { return true; } // Check if it's a NOT function operator (e.g., "NOT has") if (sanitizedOperator.toUpperCase().startsWith(OPERATORS.NOT)) { const operatorWithoutNot = sanitizedOperator.substring(4).toLowerCase(); - return functionOperators.includes(operatorWithoutNot); + return functionOperators.some( + (func) => func.toLowerCase() === operatorWithoutNot, + ); } return false; } + +export function isNonValueOperator(operator: string): boolean { + const upperOperator = operator.toUpperCase(); + // Check if it's a direct non-value operator + if (NON_VALUE_OPERATORS.includes(upperOperator)) { + return true; + } + // Check if it's a NOT non-value operator (e.g., "NOT EXISTS") + if (upperOperator.startsWith(OPERATORS.NOT)) { + const operatorWithoutNot = upperOperator.substring(4).trim(); // Remove "NOT " prefix + return NON_VALUE_OPERATORS.includes(operatorWithoutNot); + } + return false; +}