From 4fb993bb6eb9662af2bb570ae31e3b81e6912ff7 Mon Sep 17 00:00:00 2001 From: ahrefabhi Date: Fri, 22 Aug 2025 12:08:26 +0530 Subject: [PATCH] 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]); + }); +});