diff --git a/frontend/src/components/QueryBuilderV2/__tests__/utils.test.ts b/frontend/src/components/QueryBuilderV2/__tests__/utils.test.ts index 6e30c69ab338..4e752d3bb91b 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 = 'updated-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: 7, + operatorEnd: 7, + valueStart: 9, + valueEnd: 19, + }, + }, + ]); + + const result = convertFiltersToExpressionWithExistingQuery( + filters, + existingQuery, + ); + + expect(result.filters.items).toHaveLength(1); + expect(result.filter.expression).toBe("status = 'error'"); + }); + + 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/components/QueryBuilderV2/utils.ts b/frontend/src/components/QueryBuilderV2/utils.ts index a9556aaf31b9..2abca6d239b6 100644 --- a/frontend/src/components/QueryBuilderV2/utils.ts +++ b/frontend/src/components/QueryBuilderV2/utils.ts @@ -163,6 +163,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, @@ -195,24 +208,12 @@ export const convertFiltersToExpressionWithExistingQuery = ( }; } - // Extract query pairs from the existing query - const queryPairs = extractQueryPairs(existingQuery.trim()); - let queryPairsMap: Map = new Map(); 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; @@ -242,21 +243,22 @@ export const convertFiltersToExpressionWithExistingQuery = ( ) { 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)) { - // Remove quotes from values before comparison - const cleanExistingValues = existingPair.valueList.map((val) => - typeof val === 'string' ? val.replace(/^['"]|['"]$/g, '') : val, - ); - const cleanFilterValues = filter.value.map((val) => - typeof val === 'string' ? val.replace(/^['"]|['"]$/g, '') : val, - ); + // Clean quotes from string values for comparison + const cleanValues = (values: any[]): any[] => + values.map((val) => (typeof val === 'string' ? unquote(val) : val)); - // Check if the value arrays are the same (order-independent) - if ( + 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)) - ) { - // Use existingPair.value instead of formattedValue + isEqual(sortBy(cleanExistingValues), sortBy(cleanFilterValues)); + + if (isSameValues) { + // Values are identical, preserve existing formatting modifiedQuery = modifiedQuery.slice(0, existingPair.position.valueStart) + existingPair.value + @@ -269,6 +271,8 @@ export const convertFiltersToExpressionWithExistingQuery = ( modifiedQuery.slice(0, existingPair.position.valueStart) + formattedValue + modifiedQuery.slice(existingPair.position.valueEnd + 1); + + queryPairsMap = getQueryPairsMap(modifiedQuery); return; } @@ -294,6 +298,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 ( @@ -310,6 +315,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 ( @@ -326,6 +332,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 } @@ -347,6 +354,7 @@ export const convertFiltersToExpressionWithExistingQuery = ( } ${formattedValue} ${modifiedQuery.slice( notEqualsPair.position.valueEnd + 1, )}`; + queryPairsMap = getQueryPairsMap(modifiedQuery); } shouldAddToNonExisting = false; // Don't add this to non-existing filters } @@ -359,6 +367,23 @@ export const convertFiltersToExpressionWithExistingQuery = ( if ( queryPairsMap.has(`${filter.key?.key}-${filter.op}`.trim().toLowerCase()) ) { + const existingPair = queryPairsMap.get( + `${filter.key?.key}-${filter.op}`.trim().toLowerCase(), + ); + 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/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/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); 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 */} {/* ({ + 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]); + }); +}); diff --git a/pkg/transition/migrate_common.go b/pkg/transition/migrate_common.go index 4e00ebf563a0..1572f71a8012 100644 --- a/pkg/transition/migrate_common.go +++ b/pkg/transition/migrate_common.go @@ -366,7 +366,7 @@ func (mc *migrateCommon) createAggregations(ctx context.Context, queryData map[s aggregation = map[string]any{ "metricName": aggregateAttr["key"], "temporality": queryData["temporality"], - "timeAggregation": aggregateOp, + "timeAggregation": queryData["timeAggregation"], "spaceAggregation": queryData["spaceAggregation"], } if reduceTo, ok := queryData["reduceTo"].(string); ok {