Compare commits

..

13 Commits

Author SHA1 Message Date
ahrefabhi
603d6d6fce chore: minor updates in tests 2025-08-18 12:07:07 +05:30
ahrefabhi
339d81a240 fix: added changes for handling non value operators 2025-08-18 12:04:17 +05:30
ahrefabhi
2acee366a0 test: added more cases in querycontextutils tests 2025-08-18 00:55:15 +05:30
ahrefabhi
76e8672ee5 test: fixed querybuilderv2 utils tests 2025-08-18 00:01:00 +05:30
ahrefabhi
64c0bc1b97 chore: minor fix 2025-08-17 23:40:48 +05:30
ahrefabhi
9d3733ed72 Merge branch 'main' of https://github.com/SigNoz/signoz into fix/extract-query-params 2025-08-17 23:38:58 +05:30
ahrefabhi
72ec4a1b1f test: added test for querybuilderv2 utils 2025-08-17 23:38:14 +05:30
ahrefabhi
f5bf8f9f70 test: added tests for querycontextutils 2025-08-17 23:16:16 +05:30
ahrefabhi
365cbdd835 chore: logic refactor for filter to expression 2025-08-17 15:14:43 +05:30
Vibhu Pandey
d26efd2833 feat: address bitnami migration (#8808) 2025-08-14 20:54:28 +05:30
ahrefabhi
30bf3a53f5 Merge branch 'main' of https://github.com/SigNoz/signoz into fix/extract-query-params 2025-08-14 13:09:08 +05:30
Abhi Kumar
0dd085c48e feat: optimize query value comparison in QueryBuilderV2 2025-08-08 13:11:20 +05:30
Abhi Kumar
531a0a12dd fix: added fix for extractquerypararms when value is string in multivalue operator 2025-08-07 15:52:56 +05:30
12 changed files with 1085 additions and 287 deletions

View File

@@ -24,7 +24,7 @@ services:
depends_on:
- zookeeper
zookeeper:
image: bitnami/zookeeper:3.7.1
image: signoz/zookeeper:3.7.1
container_name: zookeeper
volumes:
- ${PWD}/fs/tmp/zookeeper:/bitnami/zookeeper

View File

@@ -39,7 +39,7 @@ x-clickhouse-defaults: &clickhouse-defaults
hard: 262144
x-zookeeper-defaults: &zookeeper-defaults
!!merge <<: *common
image: bitnami/zookeeper:3.7.1
image: signoz/zookeeper:3.7.1
user: root
deploy:
labels:

View File

@@ -38,7 +38,7 @@ x-clickhouse-defaults: &clickhouse-defaults
hard: 262144
x-zookeeper-defaults: &zookeeper-defaults
!!merge <<: *common
image: bitnami/zookeeper:3.7.1
image: signoz/zookeeper:3.7.1
user: root
deploy:
labels:

View File

@@ -42,7 +42,7 @@ x-clickhouse-defaults: &clickhouse-defaults
hard: 262144
x-zookeeper-defaults: &zookeeper-defaults
!!merge <<: *common
image: bitnami/zookeeper:3.7.1
image: signoz/zookeeper:3.7.1
user: root
labels:
signoz.io/scrape: "true"

View File

@@ -38,7 +38,7 @@ x-clickhouse-defaults: &clickhouse-defaults
hard: 262144
x-zookeeper-defaults: &zookeeper-defaults
!!merge <<: *common
image: bitnami/zookeeper:3.7.1
image: signoz/zookeeper:3.7.1
user: root
labels:
signoz.io/scrape: "true"

View File

@@ -0,0 +1,386 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { negateOperator, OPERATORS } from 'constants/antlrQueryConstants';
import { extractQueryPairs } from 'utils/queryContextUtils';
// Now import the function after all mocks are set up
import { convertFiltersToExpressionWithExistingQuery } from './utils';
jest.mock('utils/queryContextUtils', () => ({
extractQueryPairs: jest.fn(),
}));
// Type the mocked functions
const mockExtractQueryPairs = extractQueryPairs as jest.MockedFunction<
typeof extractQueryPairs
>;
describe('convertFiltersToExpressionWithExistingQuery', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('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'");
});
test('should handle empty filters', () => {
const filters = {
items: [],
op: 'AND',
};
const result = convertFiltersToExpressionWithExistingQuery(
filters,
undefined,
);
expect(result.filters).toEqual(filters);
expect(result.filter.expression).toBe('');
});
test('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'";
// Mock extractQueryPairs to return query pairs with position information
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'",
);
});
test('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();
// The function is currently returning the new value but with extra characters
expect(result.filter.expression).toBe(
"service.name IN ['service1', 'service2']",
);
});
test('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);
// The function is currently returning the new value but with extra characters
expect(result.filter.expression).toBe(
"service.name IN ['service1', 'service2'] ",
);
});
test('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);
// The function is currently returning the new value but with extra characters
expect(result.filter.expression).toBe(
"service.name NOT IN ['service1', 'service2'] ",
);
});
test('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'",
);
});
test('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);
// The function is currently returning the original expression (until we fix the replacement logic)
expect(result.filter.expression).toBe("status = 'success'");
});
test('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); // Original + new filter (even though it has no key)
expect(result.filter.expression).toBe("service.name = 'old-service'");
});
});

View File

@@ -1,8 +1,8 @@
/* 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 { cloneDeep, isEqual, sortBy } from 'lodash-es';
import { IQueryPair } from 'types/antlrQueryTypes';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
@@ -87,10 +87,15 @@ export const convertFiltersToExpression = (
return '';
}
const sanitizedOperator = op.trim().toUpperCase();
if (isFunctionOperator(op)) {
return `${op}(${key.key}, ${value})`;
}
if (NON_VALUE_OPERATORS.includes(sanitizedOperator)) {
return `${key.key} ${op}`;
}
const formattedValue = formatValueForExpression(value, op);
return `${key.key} ${op} ${formattedValue}`;
})
@@ -201,6 +206,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 +

View File

@@ -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]);
});
});

View File

@@ -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;

View File

@@ -99,7 +99,7 @@ export function isQueryPairComplete(queryPair: Partial<IQueryPair>): boolean {
export function isFunctionOperator(operator: string): boolean {
const functionOperators = Object.values(QUERY_BUILDER_FUNCTIONS);
const sanitizedOperator = operator.trim();
const sanitizedOperator = operator.trim().toLowerCase();
// Check if it's a direct function operator
if (functionOperators.includes(sanitizedOperator)) {
return true;

View File

@@ -385,7 +385,7 @@ func (r *ClickHouseReader) buildResourceSubQuery(tags []model.TagQueryParam, svc
return resourceSubQuery, nil
}
func (r *ClickHouseReader) GetServicesOG(ctx context.Context, queryParams *model.GetServicesParams) (*[]model.ServiceItem, *model.ApiError) {
func (r *ClickHouseReader) GetServices(ctx context.Context, queryParams *model.GetServicesParams) (*[]model.ServiceItem, *model.ApiError) {
if r.indexTable == "" {
return nil, &model.ApiError{Typ: model.ErrorExec, Err: ErrNoIndexTable}
@@ -428,7 +428,7 @@ func (r *ClickHouseReader) GetServicesOG(ctx context.Context, queryParams *model
query := fmt.Sprintf(
`SELECT
toFloat64(quantileExact(0.99)(duration_nano)) as p99,
quantile(0.99)(duration_nano) as p99,
avg(duration_nano) as avgDuration,
count(*) as numCalls
FROM %s.%s
@@ -510,274 +510,6 @@ func (r *ClickHouseReader) GetServicesOG(ctx context.Context, queryParams *model
return &serviceItems, nil
}
func (r *ClickHouseReader) GetServices(ctx context.Context, queryParams *model.GetServicesParams) (*[]model.ServiceItem, *model.ApiError) {
if r.indexTable == "" {
return nil, &model.ApiError{Typ: model.ErrorExec, Err: ErrNoIndexTable}
}
topLevelOps, apiErr := r.GetTopLevelOperations(ctx, *queryParams.Start, *queryParams.End, nil)
if apiErr != nil {
return nil, apiErr
}
// Build parallel arrays for arrayZip approach
var ops []string
var svcs []string
serviceOperationsMap := make(map[string][]string)
for svc, opsList := range *topLevelOps {
// Cap operations to 1500 per service (same as original logic)
cappedOps := opsList[:int(math.Min(1500, float64(len(opsList))))]
serviceOperationsMap[svc] = cappedOps
// Add to parallel arrays
for _, op := range cappedOps {
ops = append(ops, op)
svcs = append(svcs, svc)
}
}
fmt.Printf("Operation pairs count: %d\n", len(ops))
// Build resource subquery for all services, but only include our target services
targetServices := make([]string, 0, len(*topLevelOps))
for svc := range *topLevelOps {
targetServices = append(targetServices, svc)
}
resourceSubQuery, err := r.buildResourceSubQueryForServices(queryParams.Tags, targetServices, *queryParams.Start, *queryParams.End)
if err != nil {
zap.L().Error("Error building resource subquery", zap.Error(err))
return nil, &model.ApiError{Typ: model.ErrorExec, Err: err}
}
// Build the optimized single query using arrayZip for tuple creation
query := fmt.Sprintf(`
SELECT
resource_string_service$$name AS serviceName,
toFloat64(quantileExact(0.99)(duration_nano)) AS p99,
avg(duration_nano) AS avgDuration,
count(*) AS numCalls,
countIf(statusCode = 2) AS numErrors
FROM %s.%s
WHERE (name, resource_string_service$$name) IN arrayZip(@ops, @svcs)
AND timestamp >= @start
AND timestamp <= @end
AND ts_bucket_start >= @start_bucket
AND ts_bucket_start <= @end_bucket
AND (resource_fingerprint GLOBAL IN %s)
GROUP BY serviceName
ORDER BY numCalls DESC`,
r.TraceDB, r.traceTableName, resourceSubQuery,
)
args := []interface{}{
clickhouse.Named("start", strconv.FormatInt(queryParams.Start.UnixNano(), 10)),
clickhouse.Named("end", strconv.FormatInt(queryParams.End.UnixNano(), 10)),
clickhouse.Named("start_bucket", strconv.FormatInt(queryParams.Start.Unix()-1800, 10)),
clickhouse.Named("end_bucket", strconv.FormatInt(queryParams.End.Unix(), 10)),
// Important: wrap slices with clickhouse.Array for IN/array params
clickhouse.Named("ops", ops),
clickhouse.Named("svcs", svcs),
}
fmt.Printf("Query: %s\n", query)
// Execute the single optimized query
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
zap.L().Error("Error executing optimized services query", zap.Error(err))
return nil, &model.ApiError{Typ: model.ErrorExec, Err: err}
}
defer rows.Close()
// Process results
serviceItems := []model.ServiceItem{}
for rows.Next() {
var serviceItem model.ServiceItem
err := rows.ScanStruct(&serviceItem)
if err != nil {
zap.L().Error("Error scanning service item", zap.Error(err))
continue
}
// Skip services with zero calls (match original behavior)
if serviceItem.NumCalls == 0 {
continue
}
// Add data warning for this service
if ops, exists := serviceOperationsMap[serviceItem.ServiceName]; exists {
serviceItem.DataWarning = model.DataWarning{
TopLevelOps: ops,
}
}
// Calculate derived fields
serviceItem.CallRate = float64(serviceItem.NumCalls) / float64(queryParams.Period)
if serviceItem.NumCalls > 0 {
serviceItem.ErrorRate = float64(serviceItem.NumErrors) * 100 / float64(serviceItem.NumCalls)
}
serviceItems = append(serviceItems, serviceItem)
}
if err = rows.Err(); err != nil {
zap.L().Error("Error iterating over service results", zap.Error(err))
return nil, &model.ApiError{Typ: model.ErrorExec, Err: err}
}
// Fetch results from the original GetServicesOG for comparison
ogResults, ogErr := r.GetServicesOG(ctx, queryParams)
if ogErr != nil {
zap.L().Error("Error fetching OG service results", zap.Error(ogErr))
} else {
// Compare the optimized results with OG results
ogMap := make(map[string]model.ServiceItem)
for _, ogItem := range *ogResults {
ogMap[ogItem.ServiceName] = ogItem
}
for _, optItem := range serviceItems {
if ogItem, exists := ogMap[optItem.ServiceName]; exists {
// Compare key fields (NumCalls, NumErrors, etc.)
if optItem.NumCalls != ogItem.NumCalls ||
optItem.NumErrors != ogItem.NumErrors ||
int64(optItem.Percentile99) != int64(ogItem.Percentile99) ||
int64(optItem.AvgDuration) != int64(ogItem.AvgDuration) {
fmt.Printf(
"[Discrepancy] Service: %s | optNumCalls: %d, ogNumCalls: %d | optNumErrors: %d, ogNumErrors: %d | optP99: %.2f, ogP99: %.2f | optAvgDuration: %.2f, ogAvgDuration: %.2f\n",
optItem.ServiceName,
optItem.NumCalls, ogItem.NumCalls,
optItem.NumErrors, ogItem.NumErrors,
optItem.Percentile99, ogItem.Percentile99,
optItem.AvgDuration, ogItem.AvgDuration,
)
}
} else {
zap.L().Warn("Service present in optimized results but missing in OG results",
zap.String("service", optItem.ServiceName))
}
}
// Check for services present in OG but missing in optimized
optMap := make(map[string]struct{})
for _, optItem := range serviceItems {
optMap[optItem.ServiceName] = struct{}{}
}
for _, ogItem := range *ogResults {
if _, exists := optMap[ogItem.ServiceName]; !exists {
fmt.Printf("Service present in OG results but missing in optimized results: %s\n", ogItem.ServiceName)
}
}
}
return &serviceItems, nil
}
// buildResourceSubQueryForServices builds a resource subquery that includes only specific services
// This maintains service context while optimizing for multiple services in a single query
func (r *ClickHouseReader) buildResourceSubQueryForServices(tags []model.TagQueryParam, targetServices []string, start, end time.Time) (string, error) {
if len(targetServices) == 0 {
return "", fmt.Errorf("no target services provided")
}
if len(tags) == 0 {
// For exact parity with per-service behavior, build via resource builder with only service filter
filterSet := v3.FilterSet{}
filterSet.Items = append(filterSet.Items, v3.FilterItem{
Key: v3.AttributeKey{
Key: "service.name",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeResource,
},
Operator: v3.FilterOperatorIn,
Value: targetServices,
})
resourceSubQuery, err := resource.BuildResourceSubQuery(
r.TraceDB,
r.traceResourceTableV3,
start.Unix()-1800,
end.Unix(),
&filterSet,
[]v3.AttributeKey{},
v3.AttributeKey{},
false)
if err != nil {
zap.L().Error("Error building resource subquery for services", zap.Error(err))
return "", err
}
return resourceSubQuery, nil
}
// Convert tags to filter set
filterSet := v3.FilterSet{}
for _, tag := range tags {
// Skip the collector id as we don't add it to traces
if tag.Key == "signoz.collector.id" {
continue
}
var it v3.FilterItem
it.Key = v3.AttributeKey{
Key: tag.Key,
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeResource,
}
switch tag.Operator {
case model.NotInOperator:
it.Operator = v3.FilterOperatorNotIn
it.Value = tag.StringValues
case model.InOperator:
it.Operator = v3.FilterOperatorIn
it.Value = tag.StringValues
default:
return "", fmt.Errorf("operator %s not supported", tag.Operator)
}
filterSet.Items = append(filterSet.Items, it)
}
// Add service filter to limit to our target services
filterSet.Items = append(filterSet.Items, v3.FilterItem{
Key: v3.AttributeKey{
Key: "service.name",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeResource,
},
Operator: v3.FilterOperatorIn,
Value: targetServices,
})
// Build resource subquery with service-specific filtering
resourceSubQuery, err := resource.BuildResourceSubQuery(
r.TraceDB,
r.traceResourceTableV3,
start.Unix()-1800,
end.Unix(),
&filterSet,
[]v3.AttributeKey{},
v3.AttributeKey{},
false)
if err != nil {
zap.L().Error("Error building resource subquery for services", zap.Error(err))
return "", err
}
return resourceSubQuery, nil
}
// buildServiceInClause creates a properly quoted IN clause for service names
func (r *ClickHouseReader) buildServiceInClause(services []string) string {
var quotedServices []string
for _, svc := range services {
// Escape single quotes and wrap in quotes
escapedSvc := strings.ReplaceAll(svc, "'", "\\'")
quotedServices = append(quotedServices, fmt.Sprintf("'%s'", escapedSvc))
}
return strings.Join(quotedServices, ", ")
}
func getStatusFilters(query string, statusParams []string, excludeMap map[string]struct{}) string {
// status can only be two and if both are selected than they are equivalent to none selected
if _, ok := excludeMap["status"]; ok {
@@ -797,6 +529,7 @@ func getStatusFilters(query string, statusParams []string, excludeMap map[string
}
return query
}
func createTagQueryFromTagQueryParams(queryParams []model.TagQueryParam) []model.TagQuery {
tags := []model.TagQuery{}
for _, tag := range queryParams {
@@ -953,6 +686,7 @@ func addExistsOperator(item model.TagQuery, tagMapType string, not bool) (string
}
return fmt.Sprintf(" AND %s (%s)", notStr, strings.Join(tagOperatorPair, " OR ")), args
}
func (r *ClickHouseReader) GetEntryPointOperations(ctx context.Context, queryParams *model.GetTopOperationsParams) (*[]model.TopOperationsItem, error) {
// Step 1: Get top operations for the given service
topOps, err := r.GetTopOperations(ctx, queryParams)
@@ -1021,9 +755,9 @@ func (r *ClickHouseReader) GetTopOperations(ctx context.Context, queryParams *mo
query := fmt.Sprintf(`
SELECT
toFloat64(quantileExact(0.5)(durationNano)) as p50,
toFloat64(quantileExact(0.95)(durationNano)) as p95,
toFloat64(quantileExact(0.99)(durationNano)) as p99,
quantile(0.5)(durationNano) as p50,
quantile(0.95)(durationNano) as p95,
quantile(0.99)(durationNano) as p99,
COUNT(*) as numCalls,
countIf(status_code=2) as errorCount,
name
@@ -1505,11 +1239,11 @@ func (r *ClickHouseReader) GetDependencyGraph(ctx context.Context, queryParams *
SELECT
src as parent,
dest as child,
toFloat64(result[1]) AS p50,
toFloat64(result[2]) AS p75,
toFloat64(result[3]) AS p90,
toFloat64(result[4]) AS p95,
toFloat64(result[5]) AS p99,
result[1] AS p50,
result[2] AS p75,
result[3] AS p90,
result[4] AS p95,
result[5] AS p99,
sum(total_count) as callCount,
sum(total_count)/ @duration AS callRate,
sum(error_count)/sum(total_count) * 100 as errorRate
@@ -1541,6 +1275,7 @@ func getLocalTableName(tableName string) string {
return tableNameSplit[0] + "." + strings.Split(tableNameSplit[1], "distributed_")[1]
}
func (r *ClickHouseReader) setTTLLogs(ctx context.Context, orgID string, params *model.TTLParams) (*model.SetTTLResponseItem, *model.ApiError) {
// uuid is used as transaction id
uuidWithHyphen := uuid.New()
@@ -1681,6 +1416,7 @@ func (r *ClickHouseReader) setTTLLogs(ctx context.Context, orgID string, params
}(ttlPayload)
return &model.SetTTLResponseItem{Message: "move ttl has been successfully set up"}, nil
}
func (r *ClickHouseReader) setTTLTraces(ctx context.Context, orgID string, params *model.TTLParams) (*model.SetTTLResponseItem, *model.ApiError) {
// uuid is used as transaction id
uuidWithHyphen := uuid.New()
@@ -2321,6 +2057,7 @@ func (r *ClickHouseReader) ListErrors(ctx context.Context, queryParams *model.Li
return &getErrorResponses, nil
}
func (r *ClickHouseReader) CountErrors(ctx context.Context, queryParams *model.CountErrorsParams) (uint64, *model.ApiError) {
var errorCount uint64
@@ -2432,6 +2169,7 @@ func (r *ClickHouseReader) GetNextPrevErrorIDs(ctx context.Context, queryParams
return &getNextPrevErrorIDsResponse, nil
}
func (r *ClickHouseReader) getNextErrorID(ctx context.Context, queryParams *model.GetErrorParams) (string, time.Time, *model.ApiError) {
var getNextErrorIDReponse []model.NextPrevErrorIDsDBResponse
@@ -3092,6 +2830,7 @@ func (r *ClickHouseReader) GetMetricAttributeKeys(ctx context.Context, req *v3.F
return &response, nil
}
func (r *ClickHouseReader) GetMeterAttributeKeys(ctx context.Context, req *v3.FilterAttributeKeyRequest) (*v3.FilterAttributeKeyResponse, error) {
var query string
var err error
@@ -3166,6 +2905,7 @@ func (r *ClickHouseReader) GetMetricAttributeValues(ctx context.Context, req *v3
return &attributeValues, nil
}
func (r *ClickHouseReader) GetMetricMetadata(ctx context.Context, orgID valuer.UUID, metricName, serviceName string) (*v3.MetricMetadataResponse, error) {
unixMilli := common.PastDayRoundOff()
@@ -3837,6 +3577,7 @@ func readRow(vars []interface{}, columnNames []string, countOfNumberCols int) ([
}
return groupBy, groupAttributes, groupAttributesArray, nil
}
func readRowsForTimeSeriesResult(rows driver.Rows, vars []interface{}, columnNames []string, countOfNumberCols int) ([]*v3.Series, error) {
// when groupBy is applied, each combination of cartesian product
// of attribute values is a separate series. Each item in seriesToPoints
@@ -4632,6 +4373,7 @@ func (r *ClickHouseReader) ReadRuleStateHistoryByRuleID(
return timeline, nil
}
func (r *ClickHouseReader) ReadRuleStateHistoryTopContributorsByRuleID(
ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) ([]model.RuleStateHistoryContributor, error) {
query := fmt.Sprintf(`SELECT
@@ -5220,6 +4962,7 @@ func (r *ClickHouseReader) GetActiveTimeSeriesForMetricName(ctx context.Context,
}
return timeSeries, nil
}
func (r *ClickHouseReader) ListSummaryMetrics(ctx context.Context, orgID valuer.UUID, req *metrics_explorer.SummaryListMetricsRequest) (*metrics_explorer.SummaryListMetricsResponse, *model.ApiError) {
var args []interface{}
@@ -5437,6 +5180,7 @@ func (r *ClickHouseReader) ListSummaryMetrics(ctx context.Context, orgID valuer.
return &response, nil
}
func (r *ClickHouseReader) GetMetricsTimeSeriesPercentage(ctx context.Context, req *metrics_explorer.TreeMapMetricsRequest) (*[]metrics_explorer.TreeMapResponseItem, *model.ApiError) {
var args []interface{}
@@ -6016,6 +5760,7 @@ func (r *ClickHouseReader) GetInspectMetrics(ctx context.Context, req *metrics_e
Series: &seriesList,
}, nil
}
func (r *ClickHouseReader) GetInspectMetricsFingerprints(ctx context.Context, attributes []string, req *metrics_explorer.InspectMetricsRequest) ([]string, *model.ApiError) {
// Build dynamic key selections and JSON extracts
var jsonExtracts []string
@@ -6188,6 +5933,7 @@ func (r *ClickHouseReader) CheckForLabelsInMetric(ctx context.Context, metricNam
}
return hasLE, nil
}
func (r *ClickHouseReader) GetUpdatedMetricsMetadata(ctx context.Context, orgID valuer.UUID, metricNames ...string) (map[string]*model.UpdateMetricsMetadata, *model.ApiError) {
cachedMetadata := make(map[string]*model.UpdateMetricsMetadata)
var missingMetrics []string

View File

@@ -20,7 +20,7 @@ def zookeeper(
def create() -> types.TestContainerDocker:
version = request.config.getoption("--zookeeper-version")
container = DockerContainer(image=f"bitnami/zookeeper:{version}")
container = DockerContainer(image=f"signoz/zookeeper:{version}")
container.with_env("ALLOW_ANONYMOUS_LOGIN", "yes")
container.with_exposed_ports(2181)
container.with_network(network=network)