From e3b0a2e33fba431f6cb4fcc80e364d6e6eb93929 Mon Sep 17 00:00:00 2001 From: ahrefabhi Date: Mon, 18 Aug 2025 21:02:52 +0530 Subject: [PATCH 1/7] fix: added fix for query builder filters --- .../src/components/QueryBuilderV2/utils.ts | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/QueryBuilderV2/utils.ts b/frontend/src/components/QueryBuilderV2/utils.ts index 3bed5857a06d..967f6a2d13fe 100644 --- a/frontend/src/components/QueryBuilderV2/utils.ts +++ b/frontend/src/components/QueryBuilderV2/utils.ts @@ -1,6 +1,6 @@ /* eslint-disable sonarjs/cognitive-complexity */ import { createAggregation } from 'api/v5/queryRange/prepareQueryRangePayloadV5'; -import { OPERATORS } from 'constants/antlrQueryConstants'; +import { NON_VALUE_OPERATORS, OPERATORS } from 'constants/antlrQueryConstants'; import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; import { cloneDeep } from 'lodash-es'; import { IQueryPair } from 'types/antlrQueryTypes'; @@ -87,6 +87,10 @@ export const convertFiltersToExpression = ( return ''; } + if (NON_VALUE_OPERATORS.includes(op.trim().toUpperCase())) { + return `${key.key} ${op}`; + } + if (isFunctionOperator(op)) { return `${op}(${key.key}, ${value})`; } @@ -140,6 +144,19 @@ export const convertExpressionToFilters = ( return filters; }; +const getQueryPairsMap = (query: string): Map => { + const queryPairs = extractQueryPairs(query); + const queryPairsMap: Map = new Map(); + + queryPairs.forEach((pair) => { + const key = pair.hasNegation + ? `${pair.key}-not ${pair.operator}`.trim().toLowerCase() + : `${pair.key}-${pair.operator}`.trim().toLowerCase(); + queryPairsMap.set(key, pair); + }); + + return queryPairsMap; +}; export const convertFiltersToExpressionWithExistingQuery = ( filters: TagFilter, @@ -153,26 +170,13 @@ export const convertFiltersToExpressionWithExistingQuery = ( }; } - // 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 // Map extracted query pairs to key-specific pair information for faster access - if (queryPairs.length > 0) { - queryPairsMap = new Map( - queryPairs.map((pair) => { - const key = pair.hasNegation - ? `${pair.key}-not ${pair.operator}`.trim().toLowerCase() - : `${pair.key}-${pair.operator}`.trim().toLowerCase(); - return [key, pair]; - }), - ); - } + let queryPairsMap = getQueryPairsMap(existingQuery.trim()); filters?.items?.forEach((filter) => { const { key, op, value } = filter; @@ -205,6 +209,8 @@ export const convertFiltersToExpressionWithExistingQuery = ( modifiedQuery.slice(0, existingPair.position.valueStart) + formattedValue + modifiedQuery.slice(existingPair.position.valueEnd + 1); + + queryPairsMap = getQueryPairsMap(modifiedQuery); return; } @@ -230,6 +236,7 @@ export const convertFiltersToExpressionWithExistingQuery = ( )}${OPERATORS.IN} ${formattedValue} ${modifiedQuery.slice( notInPair.position.valueEnd + 1, )}`; + queryPairsMap = getQueryPairsMap(modifiedQuery.trim()); } shouldAddToNonExisting = false; // Don't add this to non-existing filters } else if ( @@ -246,6 +253,7 @@ export const convertFiltersToExpressionWithExistingQuery = ( )}${OPERATORS.IN} ${formattedValue} ${modifiedQuery.slice( equalsPair.position.valueEnd + 1, )}`; + queryPairsMap = getQueryPairsMap(modifiedQuery); } shouldAddToNonExisting = false; // Don't add this to non-existing filters } else if ( @@ -262,6 +270,7 @@ export const convertFiltersToExpressionWithExistingQuery = ( )}${OPERATORS.IN} ${formattedValue} ${modifiedQuery.slice( notEqualsPair.position.valueEnd + 1, )}`; + queryPairsMap = getQueryPairsMap(modifiedQuery); } shouldAddToNonExisting = false; // Don't add this to non-existing filters } @@ -283,6 +292,7 @@ export const convertFiltersToExpressionWithExistingQuery = ( } ${formattedValue} ${modifiedQuery.slice( notEqualsPair.position.valueEnd + 1, )}`; + queryPairsMap = getQueryPairsMap(modifiedQuery); } shouldAddToNonExisting = false; // Don't add this to non-existing filters } From 0a3d40806abd1c4553e1f1f8518df5bfe6eb0869 Mon Sep 17 00:00:00 2001 From: ahrefabhi Date: Thu, 21 Aug 2025 15:15:29 +0530 Subject: [PATCH 2/7] fix: added fix for multivalue operator without brackets --- .../src/components/QueryBuilderV2/utils.ts | 27 ++++++++++++++++++- frontend/src/utils/queryContextUtils.ts | 9 +++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/QueryBuilderV2/utils.ts b/frontend/src/components/QueryBuilderV2/utils.ts index dad5d91cf87a..c9689a66c927 100644 --- a/frontend/src/components/QueryBuilderV2/utils.ts +++ b/frontend/src/components/QueryBuilderV2/utils.ts @@ -6,7 +6,7 @@ import { QUERY_BUILDER_FUNCTIONS, } from 'constants/antlrQueryConstants'; import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; -import { cloneDeep } from 'lodash-es'; +import { cloneDeep, isEqual, sortBy } from 'lodash-es'; import { IQueryPair } from 'types/antlrQueryTypes'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { @@ -242,6 +242,31 @@ export const convertFiltersToExpressionWithExistingQuery = ( existingPair.position?.valueEnd ) { visitedPairs.add(`${key.key}-${op}`.trim().toLowerCase()); + + // Check if existing values match current filter values (for array-based operators) + if (existingPair.valueList && filter.value && Array.isArray(filter.value)) { + // Clean quotes from string values for comparison + const cleanValues = (values: any[]): any[] => + values.map((val) => (typeof val === 'string' ? unquote(val) : val)); + + const cleanExistingValues = cleanValues(existingPair.valueList); + const cleanFilterValues = cleanValues(filter.value); + + // Compare arrays (order-independent) - if identical, keep existing value + const isSameValues = + cleanExistingValues.length === cleanFilterValues.length && + isEqual(sortBy(cleanExistingValues), sortBy(cleanFilterValues)); + + if (isSameValues) { + // Values are identical, preserve existing formatting + modifiedQuery = + modifiedQuery.slice(0, existingPair.position.valueStart) + + existingPair.value + + modifiedQuery.slice(existingPair.position.valueEnd + 1); + return; + } + } + modifiedQuery = modifiedQuery.slice(0, existingPair.position.valueStart) + formattedValue + diff --git a/frontend/src/utils/queryContextUtils.ts b/frontend/src/utils/queryContextUtils.ts index 330a083f2cc5..029266d23c31 100644 --- a/frontend/src/utils/queryContextUtils.ts +++ b/frontend/src/utils/queryContextUtils.ts @@ -1279,6 +1279,15 @@ export function extractQueryPairs(query: string): IQueryPair[] { if (allTokens[iterator].type === closingToken) { multiValueEnd = allTokens[iterator].stop; } + } else if (isValueToken(allTokens[iterator].type)) { + valueList.push(allTokens[iterator].text); + valuesPosition.push({ + start: allTokens[iterator].start, + end: allTokens[iterator].stop, + }); + multiValueStart = allTokens[iterator].start; + multiValueEnd = allTokens[iterator].stop; + iterator += 1; } currentPair.valuesPosition = valuesPosition; From e5ab664483778b4478cbfee892fe4005b6e8ed81 Mon Sep 17 00:00:00 2001 From: Amlan Kumar Nandy <45410599+amlannandy@users.noreply.github.com> Date: Thu, 21 Aug 2025 20:51:15 +0700 Subject: [PATCH 3/7] fix: resolve sentry issues in alert list (#8878) * fix: resolve sentry issues in alert list * chore: update the key --------- Co-authored-by: srikanthccv --- .../container/FormAlertRules/labels/utils.ts | 2 +- .../src/container/ListAlertRules/ListAlert.tsx | 9 ++++----- .../AlertDetails/AlertHeader/AlertHeader.tsx | 17 +++++++++-------- frontend/src/types/api/alerts/def.ts | 2 +- pkg/transition/migrate_common.go | 2 +- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/frontend/src/container/FormAlertRules/labels/utils.ts b/frontend/src/container/FormAlertRules/labels/utils.ts index 1a2943f3ee67..927dbfff3f6f 100644 --- a/frontend/src/container/FormAlertRules/labels/utils.ts +++ b/frontend/src/container/FormAlertRules/labels/utils.ts @@ -23,7 +23,7 @@ export const flattenLabels = (labels: Labels): ILabelRecord[] => { if (!hiddenLabels.includes(key)) { recs.push({ key, - value: labels[key], + value: labels[key] || '', }); } }); diff --git a/frontend/src/container/ListAlertRules/ListAlert.tsx b/frontend/src/container/ListAlertRules/ListAlert.tsx index 638c210abe93..0a4e61e52e40 100644 --- a/frontend/src/container/ListAlertRules/ListAlert.tsx +++ b/frontend/src/container/ListAlertRules/ListAlert.tsx @@ -272,12 +272,11 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element { width: 80, key: 'severity', sorter: (a, b): number => - (a.labels ? a.labels.severity.length : 0) - - (b.labels ? b.labels.severity.length : 0), + (a?.labels?.severity?.length || 0) - (b?.labels?.severity?.length || 0), render: (value): JSX.Element => { - const objectKeys = Object.keys(value); + const objectKeys = value ? Object.keys(value) : []; const withSeverityKey = objectKeys.find((e) => e === 'severity') || ''; - const severityValue = value[withSeverityKey]; + const severityValue = withSeverityKey ? value[withSeverityKey] : '-'; return {severityValue}; }, @@ -290,7 +289,7 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element { align: 'center', width: 100, render: (value): JSX.Element => { - const objectKeys = Object.keys(value); + const objectKeys = value ? Object.keys(value) : []; const withOutSeverityKeys = objectKeys.filter((e) => e !== 'severity'); if (withOutSeverityKeys.length === 0) { diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx index 5b831ba08971..c562e52053a4 100644 --- a/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx @@ -14,7 +14,7 @@ export type AlertHeaderProps = { state: string; alert: string; id: string; - labels: Record; + labels: Record | undefined; disabled: boolean; }; }; @@ -23,13 +23,14 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element { const { alertRuleState } = useAlertRule(); const [updatedName, setUpdatedName] = useState(alertName); - const labelsWithoutSeverity = useMemo( - () => - Object.fromEntries( + const labelsWithoutSeverity = useMemo(() => { + if (labels) { + return Object.fromEntries( Object.entries(labels).filter(([key]) => key !== 'severity'), - ), - [labels], - ); + ); + } + return {}; + }, [labels]); return (
@@ -43,7 +44,7 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
- {labels.severity && } + {labels?.severity && } {/* // TODO(shaheer): Get actual data when we are able to get alert firing from state from API */} {/* Date: Fri, 22 Aug 2025 12:08:26 +0530 Subject: [PATCH 4/7] test: added tests for querycontextUtils + querybuilderv2 utils --- .../QueryBuilderV2/__tests__/utils.test.ts | 380 ++++++++++- .../utils/__tests__/queryContextUtils.test.ts | 627 ++++++++++++++++++ 2 files changed, 1006 insertions(+), 1 deletion(-) create mode 100644 frontend/src/utils/__tests__/queryContextUtils.test.ts diff --git a/frontend/src/components/QueryBuilderV2/__tests__/utils.test.ts b/frontend/src/components/QueryBuilderV2/__tests__/utils.test.ts index 6e30c69ab338..a0a81d69fffa 100644 --- a/frontend/src/components/QueryBuilderV2/__tests__/utils.test.ts +++ b/frontend/src/components/QueryBuilderV2/__tests__/utils.test.ts @@ -1,9 +1,27 @@ /* eslint-disable sonarjs/no-duplicate-string */ +import { negateOperator, OPERATORS } from 'constants/antlrQueryConstants'; import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; +import { extractQueryPairs } from 'utils/queryContextUtils'; -import { convertFiltersToExpression } from '../utils'; +import { + convertFiltersToExpression, + convertFiltersToExpressionWithExistingQuery, +} from '../utils'; + +jest.mock('utils/queryContextUtils', () => ({ + extractQueryPairs: jest.fn(), +})); + +// Type the mocked functions +const mockExtractQueryPairs = extractQueryPairs as jest.MockedFunction< + typeof extractQueryPairs +>; describe('convertFiltersToExpression', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should handle empty, null, and undefined inputs', () => { // Test null and undefined expect(convertFiltersToExpression(null as any)).toEqual({ expression: '' }); @@ -533,4 +551,364 @@ describe('convertFiltersToExpression', () => { "user_id NOT EXISTS AND description NOT CONTAINS 'error' AND NOT has(tags, 'production') AND NOT hasAny(labels, ['env:prod', 'service:api'])", }); }); + + it('should return filters with new expression when no existing query', () => { + const filters = { + items: [ + { + id: '1', + key: { id: 'service.name', key: 'service.name', type: 'string' }, + op: OPERATORS['='], + value: 'test-service', + }, + ], + op: 'AND', + }; + + const result = convertFiltersToExpressionWithExistingQuery( + filters, + undefined, + ); + + expect(result.filters).toEqual(filters); + expect(result.filter.expression).toBe("service.name = 'test-service'"); + }); + + it('should handle empty filters', () => { + const filters = { + items: [], + op: 'AND', + }; + + const result = convertFiltersToExpressionWithExistingQuery( + filters, + undefined, + ); + + expect(result.filters).toEqual(filters); + expect(result.filter.expression).toBe(''); + }); + + it('should handle existing query with matching filters', () => { + const filters = { + items: [ + { + id: '1', + key: { id: 'service.name', key: 'service.name', type: 'string' }, + op: OPERATORS['='], + value: 'updated-service', + }, + ], + op: 'AND', + }; + + const existingQuery = "service.name = 'old-service'"; + + mockExtractQueryPairs.mockReturnValue([ + { + key: 'service.name', + operator: OPERATORS['='], + value: "'old-service'", + hasNegation: false, + isMultiValue: false, + isComplete: true, + position: { + keyStart: 0, + keyEnd: 11, + operatorStart: 13, + operatorEnd: 13, + valueStart: 15, + valueEnd: 28, + }, + }, + ]); + + const result = convertFiltersToExpressionWithExistingQuery( + filters, + existingQuery, + ); + + expect(result.filters).toBeDefined(); + expect(result.filter).toBeDefined(); + expect(result.filter.expression).toBe("service.name = 'old-service'"); + expect(mockExtractQueryPairs).toHaveBeenCalledWith( + "service.name = 'old-service'", + ); + }); + + it('should handle IN operator with existing query', () => { + const filters = { + items: [ + { + id: '1', + key: { id: 'service.name', key: 'service.name', type: 'string' }, + op: OPERATORS.IN, + value: ['service1', 'service2'], + }, + ], + op: 'AND', + }; + + const existingQuery = "service.name IN ['old-service']"; + + mockExtractQueryPairs.mockReturnValue([ + { + key: 'service.name', + operator: 'IN', + value: "['old-service']", + valueList: ["'old-service'"], + valuesPosition: [ + { + start: 17, + end: 29, + }, + ], + hasNegation: false, + isMultiValue: true, + position: { + keyStart: 0, + keyEnd: 11, + operatorStart: 13, + operatorEnd: 14, + valueStart: 16, + valueEnd: 30, + negationStart: 0, + negationEnd: 0, + }, + isComplete: true, + }, + ]); + + const result = convertFiltersToExpressionWithExistingQuery( + filters, + existingQuery, + ); + + expect(result.filters).toBeDefined(); + expect(result.filter).toBeDefined(); + expect(result.filter.expression).toBe( + "service.name IN ['service1', 'service2']", + ); + }); + + it('should handle IN operator conversion from equals', () => { + const filters = { + items: [ + { + id: '1', + key: { id: 'service.name', key: 'service.name', type: 'string' }, + op: OPERATORS.IN, + value: ['service1', 'service2'], + }, + ], + op: 'AND', + }; + + const existingQuery = "service.name = 'old-service'"; + + mockExtractQueryPairs.mockReturnValue([ + { + key: 'service.name', + operator: OPERATORS['='], + value: "'old-service'", + hasNegation: false, + isMultiValue: false, + isComplete: true, + position: { + keyStart: 0, + keyEnd: 11, + operatorStart: 13, + operatorEnd: 13, + valueStart: 15, + valueEnd: 28, + }, + }, + ]); + + const result = convertFiltersToExpressionWithExistingQuery( + filters, + existingQuery, + ); + + expect(result.filters.items).toHaveLength(1); + expect(result.filter.expression).toBe( + "service.name IN ['service1', 'service2'] ", + ); + }); + + it('should handle NOT IN operator conversion from not equals', () => { + const filters = { + items: [ + { + id: '1', + key: { id: 'service.name', key: 'service.name', type: 'string' }, + op: negateOperator(OPERATORS.IN), + value: ['service1', 'service2'], + }, + ], + op: 'AND', + }; + + const existingQuery = "service.name != 'old-service'"; + + mockExtractQueryPairs.mockReturnValue([ + { + key: 'service.name', + operator: OPERATORS['!='], + value: "'old-service'", + hasNegation: false, + isMultiValue: false, + isComplete: true, + position: { + keyStart: 0, + keyEnd: 11, + operatorStart: 13, + operatorEnd: 14, + valueStart: 16, + valueEnd: 28, + }, + }, + ]); + + const result = convertFiltersToExpressionWithExistingQuery( + filters, + existingQuery, + ); + + expect(result.filters.items).toHaveLength(1); + expect(result.filter.expression).toBe( + "service.name NOT IN ['service1', 'service2'] ", + ); + }); + + it('should add new filters when they do not exist in existing query', () => { + const filters = { + items: [ + { + id: '1', + key: { id: 'new.key', key: 'new.key', type: 'string' }, + op: OPERATORS['='], + value: 'new-value', + }, + ], + op: 'AND', + }; + + const existingQuery = "service.name = 'old-service'"; + + mockExtractQueryPairs.mockReturnValue([ + { + key: 'service.name', + operator: OPERATORS['='], + value: "'old-service'", + hasNegation: false, + isMultiValue: false, + isComplete: true, + position: { + keyStart: 0, + keyEnd: 11, + operatorStart: 13, + operatorEnd: 13, + valueStart: 15, + valueEnd: 28, + }, + }, + ]); + + const result = convertFiltersToExpressionWithExistingQuery( + filters, + existingQuery, + ); + + expect(result.filters.items).toHaveLength(2); // Original + new filter + expect(result.filter.expression).toBe( + "service.name = 'old-service' new.key = 'new-value'", + ); + }); + + it('should handle simple value replacement', () => { + const filters = { + items: [ + { + id: '1', + key: { id: 'status', key: 'status', type: 'string' }, + op: OPERATORS['='], + value: 'error', + }, + ], + op: 'AND', + }; + + const existingQuery = "status = 'success'"; + + mockExtractQueryPairs.mockReturnValue([ + { + key: 'status', + operator: OPERATORS['='], + value: "'success'", + hasNegation: false, + isMultiValue: false, + isComplete: true, + position: { + keyStart: 0, + keyEnd: 6, + operatorStart: 8, + operatorEnd: 8, + valueStart: 10, + valueEnd: 19, + }, + }, + ]); + + const result = convertFiltersToExpressionWithExistingQuery( + filters, + existingQuery, + ); + + expect(result.filters.items).toHaveLength(1); + expect(result.filter.expression).toBe("status = 'success'"); + }); + + it('should handle filters with no key gracefully', () => { + const filters = { + items: [ + { + id: '1', + key: undefined, + op: OPERATORS['='], + value: 'test-value', + }, + ], + op: 'AND', + }; + + const existingQuery = "service.name = 'old-service'"; + + mockExtractQueryPairs.mockReturnValue([ + { + key: 'service.name', + operator: OPERATORS['='], + value: "'old-service'", + hasNegation: false, + isMultiValue: false, + isComplete: true, + position: { + keyStart: 0, + keyEnd: 11, + operatorStart: 13, + operatorEnd: 13, + valueStart: 15, + valueEnd: 28, + }, + }, + ]); + + const result = convertFiltersToExpressionWithExistingQuery( + filters, + existingQuery, + ); + + expect(result.filters.items).toHaveLength(2); + expect(result.filter.expression).toBe("service.name = 'old-service'"); + }); }); diff --git a/frontend/src/utils/__tests__/queryContextUtils.test.ts b/frontend/src/utils/__tests__/queryContextUtils.test.ts new file mode 100644 index 000000000000..e3060506a812 --- /dev/null +++ b/frontend/src/utils/__tests__/queryContextUtils.test.ts @@ -0,0 +1,627 @@ +/* eslint-disable */ + +// Mock all dependencies before importing the function +// Global variable to store the current test input +let currentTestInput = ''; + +// Now import the function after all mocks are set up +// Import the mocked antlr4 to access CharStreams +import * as antlr4 from 'antlr4'; + +import { + createContext, + extractQueryPairs, + getCurrentQueryPair, + getCurrentValueIndexAtCursor, +} from '../queryContextUtils'; + +jest.mock('antlr4', () => ({ + CharStreams: { + fromString: jest.fn().mockImplementation((input: string) => { + currentTestInput = input; + return { + inputSource: { strdata: input }, + }; + }), + }, + CommonTokenStream: jest.fn().mockImplementation(() => { + // Use the dynamically captured input string from the current test + const input = currentTestInput; + + // Generate tokens dynamically based on the input + const tokens = []; + let currentPos = 0; + let i = 0; + + while (i < input.length) { + // Skip whitespace + while (i < input.length && /\s/.test(input[i])) { + i++; + currentPos++; + } + if (i >= input.length) break; + + // Handle array brackets + if (input[i] === '[') { + tokens.push({ + type: 3, // LBRACK + text: '[', + start: currentPos, + stop: currentPos, + channel: 0, + }); + i++; + currentPos++; + continue; + } + + if (input[i] === ']') { + tokens.push({ + type: 4, // RBRACK + text: ']', + start: currentPos, + stop: currentPos, + channel: 0, + }); + i++; + currentPos++; + continue; + } + + if (input[i] === ',') { + tokens.push({ + type: 5, // COMMA + text: ',', + start: currentPos, + stop: currentPos, + channel: 0, + }); + i++; + currentPos++; + continue; + } + + // Find the end of the current token + let tokenEnd = i; + let inQuotes = false; + let quoteChar = ''; + + while (tokenEnd < input.length) { + const char = input[tokenEnd]; + + if ( + !inQuotes && + (char === ' ' || char === '[' || char === ']' || char === ',') + ) { + break; + } + + if ((char === '"' || char === "'") && !inQuotes) { + inQuotes = true; + quoteChar = char; + } else if (char === quoteChar && inQuotes) { + inQuotes = false; + quoteChar = ''; + } + + tokenEnd++; + } + + const tokenText = input.substring(i, tokenEnd); + + // Determine token type + let tokenType = 28; // Default to QUOTED_TEXT + + if (tokenText === 'IN') { + tokenType = 19; + } else if (tokenText === 'AND') { + tokenType = 21; + } else if (tokenText === '=') { + tokenType = 6; + } else if (tokenText === '<') { + tokenType = 9; + } else if (tokenText === '>') { + tokenType = 10; + } else if (tokenText === '!=') { + tokenType = 7; + } else if (tokenText.includes('.')) { + tokenType = 29; // KEY + } else if (/^\d+$/.test(tokenText)) { + tokenType = 27; // NUMBER + } else if ( + (tokenText.startsWith("'") && tokenText.endsWith("'")) || + (tokenText.startsWith('"') && tokenText.endsWith('"')) + ) { + tokenType = 28; // QUOTED_TEXT + } + + tokens.push({ + type: tokenType, + text: tokenText, + start: currentPos, + stop: currentPos + tokenText.length - 1, + channel: 0, + }); + + currentPos += tokenText.length; + i = tokenEnd; + } + + return { + fill: jest.fn(), + tokens: [ + ...tokens, + // EOF + { type: -1, text: '', start: 0, stop: 0, channel: 0 }, + ], + }; + }), + Token: { + EOF: -1, + }, +})); + +jest.mock('parser/FilterQueryLexer', () => ({ + __esModule: true, + default: class MockFilterQueryLexer { + static readonly KEY = 29; + + static readonly IN = 19; + + static readonly EQUALS = 6; + + static readonly LT = 9; + + static readonly AND = 21; + + static readonly LPAREN = 1; + + static readonly RPAREN = 2; + + static readonly LBRACK = 3; + + static readonly RBRACK = 4; + + static readonly COMMA = 5; + + static readonly NOT = 20; + + static readonly OR = 22; + + static readonly EOF = -1; + + static readonly QUOTED_TEXT = 28; + + static readonly NUMBER = 27; + + static readonly WS = 30; + + static readonly FREETEXT = 31; + }, +})); + +jest.mock('parser/analyzeQuery', () => ({})); + +jest.mock('../tokenUtils', () => ({ + isOperatorToken: jest.fn((tokenType: number) => + [6, 9, 19, 20].includes(tokenType), + ), + isMultiValueOperator: jest.fn((operator: string) => operator === 'IN'), + isValueToken: jest.fn((tokenType: number) => [27, 28, 29].includes(tokenType)), + isConjunctionToken: jest.fn((tokenType: number) => + [21, 22].includes(tokenType), + ), + isQueryPairComplete: jest.fn((pair: any) => { + if (!pair) return false; + if (pair.operator === 'EXISTS') { + return !!pair.key && !!pair.operator; + } + return Boolean(pair.key && pair.operator && pair.value); + }), +})); + +describe('extractQueryPairs', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should extract query pairs from complex query with IN operator and multiple conditions', () => { + const input = + "service.name IN ['adservice', 'consumer-svc-1'] AND cloud.account.id = 'signoz-staging' code.lineno < 172"; + + const result = extractQueryPairs(input); + + expect(result).toEqual([ + { + key: 'service.name', + operator: 'IN', + value: "['adservice', 'consumer-svc-1']", + valueList: ["'adservice'", "'consumer-svc-1'"], + valuesPosition: [ + { + start: 17, + end: 27, + }, + { + start: 30, + end: 45, + }, + ], + hasNegation: false, + isMultiValue: true, + position: { + keyStart: 0, + keyEnd: 11, + operatorStart: 13, + operatorEnd: 14, + valueStart: 16, + valueEnd: 46, + negationStart: 0, + negationEnd: 0, + }, + isComplete: true, + }, + { + key: 'cloud.account.id', + operator: '=', + value: "'signoz-staging'", + valueList: [], + valuesPosition: [], + hasNegation: false, + isMultiValue: false, + position: { + keyStart: 52, + keyEnd: 67, + operatorStart: 69, + operatorEnd: 69, + valueStart: 71, + valueEnd: 86, + negationStart: 0, + negationEnd: 0, + }, + isComplete: true, + }, + { + key: 'code.lineno', + operator: '<', + value: '172', + valueList: [], + valuesPosition: [], + hasNegation: false, + isMultiValue: false, + position: { + keyStart: 88, + keyEnd: 98, + operatorStart: 100, + operatorEnd: 100, + valueStart: 102, + valueEnd: 104, + negationStart: 0, + negationEnd: 0, + }, + isComplete: true, + }, + ]); + }); + + test('should extract query pairs from complex query with IN operator without brackets', () => { + const input = + "service.name IN 'adservice' AND cloud.account.id = 'signoz-staging' code.lineno < 172"; + + const result = extractQueryPairs(input); + expect(result).toEqual([ + { + key: 'service.name', + operator: 'IN', + value: "'adservice'", + valueList: ["'adservice'"], + valuesPosition: [ + { + start: 16, + end: 26, + }, + ], + hasNegation: false, + isMultiValue: true, + position: { + keyStart: 0, + keyEnd: 11, + operatorStart: 13, + operatorEnd: 14, + valueStart: 16, + valueEnd: 26, + negationStart: 0, + negationEnd: 0, + }, + isComplete: true, + }, + { + key: 'cloud.account.id', + operator: '=', + value: "'signoz-staging'", + valueList: [], + valuesPosition: [], + hasNegation: false, + isMultiValue: false, + position: { + keyStart: 32, + keyEnd: 47, + operatorStart: 49, + operatorEnd: 49, + valueStart: 51, + valueEnd: 66, + negationStart: 0, + negationEnd: 0, + }, + isComplete: true, + }, + { + key: 'code.lineno', + operator: '<', + value: '172', + valueList: [], + valuesPosition: [], + hasNegation: false, + isMultiValue: false, + position: { + keyStart: 68, + keyEnd: 78, + operatorStart: 80, + operatorEnd: 80, + valueStart: 82, + valueEnd: 84, + negationStart: 0, + negationEnd: 0, + }, + isComplete: true, + }, + ]); + }); + + test('should handle error gracefully and return empty array', () => { + // Mock console.error to suppress output during test + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + // Mock CharStreams to throw an error + jest.mocked(antlr4.CharStreams.fromString).mockImplementation(() => { + throw new Error('Mock error'); + }); + + const input = 'some query'; + const result = extractQueryPairs(input); + + expect(result).toEqual([]); + + // Restore console.error + consoleSpy.mockRestore(); + }); + + test('should handle recursion guard', () => { + // This test verifies the recursion protection in the function + // We'll mock the function to simulate recursion + + // Mock console.warn to capture the warning + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + // Call the function multiple times to trigger recursion guard + // Note: This is a simplified test since we can't easily trigger the actual recursion + const result = extractQueryPairs('test'); + + // The function should still work normally + expect(Array.isArray(result)).toBe(true); + + consoleSpy.mockRestore(); + }); +}); + +describe('createContext', () => { + test('should create a context object with all parameters', () => { + const mockToken = { + type: 29, + text: 'test', + start: 0, + stop: 3, + }; + + const result = createContext( + mockToken as any, + true, // isInKey + false, // isInNegation + false, // isInOperator + false, // isInValue + 'testKey', // keyToken + '=', // operatorToken + 'testValue', // valueToken + [], // queryPairs + null, // currentPair + ); + + expect(result).toEqual({ + tokenType: 29, + text: 'test', + start: 0, + stop: 3, + currentToken: 'test', + isInKey: true, + isInNegation: false, + isInOperator: false, + isInValue: false, + isInFunction: false, + isInConjunction: false, + isInParenthesis: false, + keyToken: 'testKey', + operatorToken: '=', + valueToken: 'testValue', + queryPairs: [], + currentPair: null, + }); + }); + + test('should create a context object with minimal parameters', () => { + const mockToken = { + type: 29, + text: 'test', + start: 0, + stop: 3, + }; + + const result = createContext(mockToken as any, false, false, false, false); + + expect(result).toEqual({ + tokenType: 29, + text: 'test', + start: 0, + stop: 3, + currentToken: 'test', + isInKey: false, + isInNegation: false, + isInOperator: false, + isInValue: false, + isInFunction: false, + isInConjunction: false, + isInParenthesis: false, + keyToken: undefined, + operatorToken: undefined, + valueToken: undefined, + queryPairs: [], + currentPair: undefined, + }); + }); +}); + +describe('getCurrentValueIndexAtCursor', () => { + test('should return correct value index when cursor is within a value range', () => { + const valuesPosition = [ + { start: 0, end: 10 }, + { start: 15, end: 25 }, + { start: 30, end: 40 }, + ]; + + const result = getCurrentValueIndexAtCursor(valuesPosition, 20); + + expect(result).toBe(1); + }); + + test('should return null when cursor is not within any value range', () => { + const valuesPosition = [ + { start: 0, end: 10 }, + { start: 15, end: 25 }, + ]; + + const result = getCurrentValueIndexAtCursor(valuesPosition, 12); + + expect(result).toBeNull(); + }); + + test('should return correct index when cursor is at the boundary', () => { + const valuesPosition = [ + { start: 0, end: 10 }, + { start: 15, end: 25 }, + ]; + + const result = getCurrentValueIndexAtCursor(valuesPosition, 10); + + expect(result).toBe(0); + }); + + test('should return null for empty valuesPosition array', () => { + const result = getCurrentValueIndexAtCursor([], 5); + + expect(result).toBeNull(); + }); +}); + +describe('getCurrentQueryPair', () => { + test('should return the correct query pair at cursor position', () => { + const queryPairs = [ + { + key: 'a', + operator: '=', + value: '1', + position: { + keyStart: 0, + keyEnd: 0, + operatorStart: 2, + operatorEnd: 2, + valueStart: 4, + valueEnd: 4, + }, + isComplete: true, + } as any, + { + key: 'b', + operator: '=', + value: '2', + position: { + keyStart: 10, + keyEnd: 10, + operatorStart: 12, + operatorEnd: 12, + valueStart: 14, + valueEnd: 14, + }, + isComplete: true, + } as any, + ]; + + const query = 'a = 1 AND b = 2'; + const result = getCurrentQueryPair(queryPairs, query, 15); + + expect(result).toEqual(queryPairs[1]); + }); + + test('should return null when no pairs match cursor position', () => { + const queryPairs = [ + { + key: 'a', + operator: '=', + value: '1', + position: { + keyStart: 0, + keyEnd: 0, + operatorStart: 2, + operatorEnd: 2, + valueStart: 4, + valueEnd: 4, + }, + isComplete: true, + } as any, + ]; + + const query = 'a = 1'; + // Test with cursor position that's before any pair starts + const result = getCurrentQueryPair(queryPairs, query, -1); + + expect(result).toBeNull(); + }); + + test('should return null for empty queryPairs array', () => { + const result = getCurrentQueryPair([], 'test query', 5); + + expect(result).toBeNull(); + }); + + test('should return last pair when cursor is at the end', () => { + const queryPairs = [ + { + key: 'a', + operator: '=', + value: '1', + position: { + keyStart: 0, + keyEnd: 0, + operatorStart: 2, + operatorEnd: 2, + valueStart: 4, + valueEnd: 4, + }, + isComplete: true, + } as any, + ]; + + const query = 'a = 1'; + const result = getCurrentQueryPair(queryPairs, query, 5); + + expect(result).toEqual(queryPairs[0]); + }); +}); From 049f1f396d1ec9a057bcf8b3ff4fd3907c93dbd1 Mon Sep 17 00:00:00 2001 From: ahrefabhi Date: Fri, 22 Aug 2025 12:20:03 +0530 Subject: [PATCH 5/7] fix: added fix for replacing filter with the new value --- frontend/src/components/QueryBuilderV2/utils.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/src/components/QueryBuilderV2/utils.ts b/frontend/src/components/QueryBuilderV2/utils.ts index c9689a66c927..97a10dbead0f 100644 --- a/frontend/src/components/QueryBuilderV2/utils.ts +++ b/frontend/src/components/QueryBuilderV2/utils.ts @@ -367,6 +367,13 @@ export const convertFiltersToExpressionWithExistingQuery = ( if ( queryPairsMap.has(`${filter.key?.key}-${filter.op}`.trim().toLowerCase()) ) { + const formattedValue = formatValueForExpression(value, op); + // replace the value with the new value + modifiedQuery = modifiedQuery.replace( + `${filter.key?.key}-${filter.op}`, + `${filter.key?.key}-${filter.op} ${formattedValue}`, + ); + queryPairsMap = getQueryPairsMap(modifiedQuery); visitedPairs.add(`${filter.key?.key}-${filter.op}`.trim().toLowerCase()); } From 796497adfc3ecba68229a090ef6cf81064edeefd Mon Sep 17 00:00:00 2001 From: ahrefabhi Date: Fri, 22 Aug 2025 13:12:49 +0530 Subject: [PATCH 6/7] fix: added fix for replacing filters + datetimepicker composite query --- .../src/components/QueryBuilderV2/utils.ts | 22 ++++++++++++++----- .../TopNav/DateTimeSelectionV2/index.tsx | 17 +++++++++----- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/QueryBuilderV2/utils.ts b/frontend/src/components/QueryBuilderV2/utils.ts index 97a10dbead0f..2abca6d239b6 100644 --- a/frontend/src/components/QueryBuilderV2/utils.ts +++ b/frontend/src/components/QueryBuilderV2/utils.ts @@ -367,13 +367,23 @@ export const convertFiltersToExpressionWithExistingQuery = ( if ( queryPairsMap.has(`${filter.key?.key}-${filter.op}`.trim().toLowerCase()) ) { - const formattedValue = formatValueForExpression(value, op); - // replace the value with the new value - modifiedQuery = modifiedQuery.replace( - `${filter.key?.key}-${filter.op}`, - `${filter.key?.key}-${filter.op} ${formattedValue}`, + const existingPair = queryPairsMap.get( + `${filter.key?.key}-${filter.op}`.trim().toLowerCase(), ); - queryPairsMap = getQueryPairsMap(modifiedQuery); + if ( + existingPair && + existingPair.position?.valueStart && + existingPair.position?.valueEnd + ) { + const formattedValue = formatValueForExpression(value, op); + // replace the value with the new value + modifiedQuery = + modifiedQuery.slice(0, existingPair.position.valueStart) + + formattedValue + + modifiedQuery.slice(existingPair.position.valueEnd + 1); + queryPairsMap = getQueryPairsMap(modifiedQuery); + } + visitedPairs.add(`${filter.key?.key}-${filter.op}`.trim().toLowerCase()); } diff --git a/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx b/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx index c5a48b14022a..ef1ff875f87b 100644 --- a/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx +++ b/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx @@ -34,7 +34,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useQueryClient } from 'react-query'; import { connect, useDispatch, useSelector } from 'react-redux'; import { RouteComponentProps, withRouter } from 'react-router-dom'; -import { useNavigationType } from 'react-router-dom-v5-compat'; +import { useNavigationType, useSearchParams } from 'react-router-dom-v5-compat'; import { useCopyToClipboard } from 'react-use'; import { bindActionCreators, Dispatch } from 'redux'; import { ThunkDispatch } from 'redux-thunk'; @@ -117,6 +117,8 @@ function DateTimeSelection({ ); const [modalEndTime, setModalEndTime] = useState(initialModalEndTime); + const [searchParams] = useSearchParams(); + // Effect to update modal time state when props change useEffect(() => { if (modalInitialStartTime !== undefined) { @@ -410,8 +412,10 @@ function DateTimeSelection({ // Remove Hidden Filters from URL query parameters on time change urlQuery.delete(QueryParams.activeLogId); - const updatedCompositeQuery = getUpdatedCompositeQuery(); - urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery); + if (searchParams.has(QueryParams.compositeQuery)) { + const updatedCompositeQuery = getUpdatedCompositeQuery(); + urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery); + } const generatedUrl = `${location.pathname}?${urlQuery.toString()}`; safeNavigate(generatedUrl); @@ -428,6 +432,7 @@ function DateTimeSelection({ updateLocalStorageForRoutes, updateTimeInterval, urlQuery, + searchParams, ], ); @@ -488,8 +493,10 @@ function DateTimeSelection({ urlQuery.set(QueryParams.endTime, endTime?.toDate().getTime().toString()); urlQuery.delete(QueryParams.relativeTime); - const updatedCompositeQuery = getUpdatedCompositeQuery(); - urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery); + if (searchParams.has(QueryParams.compositeQuery)) { + const updatedCompositeQuery = getUpdatedCompositeQuery(); + urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery); + } const generatedUrl = `${location.pathname}?${urlQuery.toString()}`; safeNavigate(generatedUrl); From 83df91bba5a26ae5e2ec6233c1c0fcde130895da Mon Sep 17 00:00:00 2001 From: ahrefabhi Date: Fri, 22 Aug 2025 13:22:34 +0530 Subject: [PATCH 7/7] test: fixed querybuilderv2 utils test --- .../components/QueryBuilderV2/__tests__/utils.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/QueryBuilderV2/__tests__/utils.test.ts b/frontend/src/components/QueryBuilderV2/__tests__/utils.test.ts index a0a81d69fffa..4e752d3bb91b 100644 --- a/frontend/src/components/QueryBuilderV2/__tests__/utils.test.ts +++ b/frontend/src/components/QueryBuilderV2/__tests__/utils.test.ts @@ -630,7 +630,7 @@ describe('convertFiltersToExpression', () => { expect(result.filters).toBeDefined(); expect(result.filter).toBeDefined(); - expect(result.filter.expression).toBe("service.name = 'old-service'"); + expect(result.filter.expression).toBe("service.name = 'updated-service'"); expect(mockExtractQueryPairs).toHaveBeenCalledWith( "service.name = 'old-service'", ); @@ -852,9 +852,9 @@ describe('convertFiltersToExpression', () => { position: { keyStart: 0, keyEnd: 6, - operatorStart: 8, - operatorEnd: 8, - valueStart: 10, + operatorStart: 7, + operatorEnd: 7, + valueStart: 9, valueEnd: 19, }, }, @@ -866,7 +866,7 @@ describe('convertFiltersToExpression', () => { ); expect(result.filters.items).toHaveLength(1); - expect(result.filter.expression).toBe("status = 'success'"); + expect(result.filter.expression).toBe("status = 'error'"); }); it('should handle filters with no key gracefully', () => {