Compare commits

..

7 Commits

Author SHA1 Message Date
Abhishek Kumar Singh
543fe613a5 chore: updated comment 2026-01-19 19:27:53 +05:30
Abhishek Kumar Singh
512e0519ee test: invalid target unit fix in test rule 2026-01-19 18:21:08 +05:30
Abhishek Kumar Singh
d56ab18691 refactor: removed tests for old code breaking CI 2026-01-19 18:14:15 +05:30
Abhishek Kumar Singh
9d95703539 refactor: updated validation for composite query changed from v3-4 to v5 format validation 2026-01-19 18:09:09 +05:30
Abhishek Kumar Singh
7dc54530ce feat: added validation for unit 2026-01-19 18:06:01 +05:30
Abhishek Kumar Singh
e712434e01 refactor: updated assign vars function to not import v3 package 2026-01-19 17:58:30 +05:30
Abhishek Kumar Singh
91ec60b923 chore: added Validate function for QueryBuilderFormula struct 2026-01-19 16:06:40 +05:30
24 changed files with 1030 additions and 1988 deletions

View File

@@ -211,10 +211,7 @@ describe('VariableItem Integration Tests', () => {
await user.clear(textInput);
await user.type(textInput, 'new-text-value');
// Blur the input to trigger the value update
await user.tab();
// Should call onValueUpdate after blur
// Should call onValueUpdate after debounce
await waitFor(
() => {
expect(mockOnValueUpdate).toHaveBeenCalledWith(

View File

@@ -1,12 +1,6 @@
/* eslint-disable sonarjs/no-identical-functions */
/* eslint-disable sonarjs/no-duplicate-string */
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import {
IDashboardVariable,
TSortVariableValuesType,
@@ -645,186 +639,4 @@ describe('VariableItem Component', () => {
await expectCircularDependencyError();
});
});
describe('Textbox Variable Default Value Handling', () => {
test('saves textbox variable with defaultValue and selectedValue set to textboxValue', async () => {
const user = userEvent.setup();
const textboxVariable: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Textbox Variable',
type: 'TEXTBOX',
textboxValue: 'my-default-value',
...VARIABLE_DEFAULTS,
order: 0,
};
renderVariableItem(textboxVariable);
// Click save button
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
await user.click(saveButton);
// Verify that onSave was called with defaultValue and selectedValue equal to textboxValue
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: 'TEXTBOX',
textboxValue: 'my-default-value',
defaultValue: 'my-default-value',
selectedValue: 'my-default-value',
}),
expect.anything(),
);
});
test('saves textbox variable with empty values when textboxValue is empty', async () => {
const user = userEvent.setup();
const textboxVariable: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Textbox Variable',
type: 'TEXTBOX',
textboxValue: '',
...VARIABLE_DEFAULTS,
order: 0,
};
renderVariableItem(textboxVariable);
// Click save button
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
await user.click(saveButton);
// Verify that onSave was called with empty defaultValue and selectedValue
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: 'TEXTBOX',
textboxValue: '',
defaultValue: '',
selectedValue: '',
}),
expect.anything(),
);
});
test('updates textbox defaultValue and selectedValue when user changes textboxValue input', async () => {
const user = userEvent.setup();
const textboxVariable: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Textbox Variable',
type: 'TEXTBOX',
textboxValue: 'initial-value',
...VARIABLE_DEFAULTS,
order: 0,
};
renderVariableItem(textboxVariable);
// Change the textbox value
const textboxInput = screen.getByPlaceholderText(
'Enter a default value (if any)...',
);
await user.clear(textboxInput);
await user.type(textboxInput, 'updated-value');
// Click save button
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
await user.click(saveButton);
// Verify that onSave was called with the updated defaultValue and selectedValue
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: 'TEXTBOX',
textboxValue: 'updated-value',
defaultValue: 'updated-value',
selectedValue: 'updated-value',
}),
expect.anything(),
);
});
test('non-textbox variables use variableDefaultValue instead of textboxValue', async () => {
const user = userEvent.setup();
const queryVariable: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Query Variable',
type: 'QUERY',
queryValue: 'SELECT * FROM test',
textboxValue: 'should-not-be-used',
defaultValue: 'query-default-value',
...VARIABLE_DEFAULTS,
order: 0,
};
renderVariableItem(queryVariable);
// Click save button
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
await user.click(saveButton);
// Verify that onSave was called with defaultValue not being textboxValue
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: 'QUERY',
defaultValue: 'query-default-value',
}),
expect.anything(),
);
// Verify that defaultValue is NOT the textboxValue
const savedVariable = onSave.mock.calls[0][1];
expect(savedVariable.defaultValue).not.toBe('should-not-be-used');
});
test('switching to textbox type sets defaultValue and selectedValue correctly on save', async () => {
const user = userEvent.setup();
// Start with a QUERY variable
const queryVariable: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Variable',
type: 'QUERY',
queryValue: 'SELECT * FROM test',
...VARIABLE_DEFAULTS,
order: 0,
};
renderVariableItem(queryVariable);
// Switch to TEXTBOX type
const textboxButton = findButtonByText(TEXT.TEXTBOX);
expect(textboxButton).toBeInTheDocument();
if (textboxButton) {
await user.click(textboxButton);
}
// Enter a default value in the textbox input
const textboxInput = screen.getByPlaceholderText(
'Enter a default value (if any)...',
);
await user.type(textboxInput, 'new-textbox-default');
// Click save button
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
await user.click(saveButton);
// Verify that onSave was called with type TEXTBOX and correct defaultValue and selectedValue
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: 'TEXTBOX',
textboxValue: 'new-textbox-default',
defaultValue: 'new-textbox-default',
selectedValue: 'new-textbox-default',
}),
expect.anything(),
);
});
});
});

View File

@@ -320,10 +320,6 @@ function VariableItem({
]);
const variableValue = useMemo(() => {
if (queryType === 'TEXTBOX') {
return variableTextboxValue;
}
if (variableMultiSelect) {
let value = variableData.selectedValue;
if (isEmpty(value)) {
@@ -356,8 +352,6 @@ function VariableItem({
variableData.selectedValue,
variableData.showALLOption,
variableDefaultValue,
variableTextboxValue,
queryType,
previewValues,
]);
@@ -373,10 +367,13 @@ function VariableItem({
multiSelect: variableMultiSelect,
showALLOption: queryType === 'DYNAMIC' ? true : variableShowALLOption,
sort: variableSortType,
// the reason we need to do this is because defaultValues are treated differently in case of textbox type
// They are the exact same and not like the other types where defaultValue is a separate field
defaultValue:
queryType === 'TEXTBOX' ? variableTextboxValue : variableDefaultValue,
...(queryType === 'TEXTBOX' && {
selectedValue: (variableData.selectedValue ||
variableTextboxValue) as never,
}),
...(queryType !== 'TEXTBOX' && {
defaultValue: variableDefaultValue as never,
}),
modificationUUID: generateUUID(),
id: variableData.id || generateUUID(),
order: variableData.order,

View File

@@ -25,12 +25,6 @@
}
}
&.focused {
.variable-value {
outline: 1px solid var(--bg-robin-400);
}
}
.variable-value {
display: flex;
min-width: 120px;
@@ -99,12 +93,6 @@
.lightMode {
.variable-item {
&.focused {
.variable-value {
border: 1px solid var(--bg-robin-400);
}
}
.variable-name {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);

View File

@@ -80,12 +80,10 @@ describe('VariableItem', () => {
/>
</MockQueryClientProvider>,
);
expect(
screen.getByTestId('variable-textbox-test_variable'),
).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter value')).toBeInTheDocument();
});
test('calls onValueUpdate when Input value changes and blurs', async () => {
test('calls onChange event handler when Input value changes', async () => {
render(
<MockQueryClientProvider>
<VariableItem
@@ -104,19 +102,13 @@ describe('VariableItem', () => {
</MockQueryClientProvider>,
);
const inputElement = screen.getByTestId('variable-textbox-test_variable');
// Change the value
act(() => {
const inputElement = screen.getByPlaceholderText('Enter value');
fireEvent.change(inputElement, { target: { value: 'newValue' } });
});
// Blur the input to trigger the update
act(() => {
fireEvent.blur(inputElement);
});
await waitFor(() => {
// expect(mockOnValueUpdate).toHaveBeenCalledTimes(1);
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'testVariable',
'test_variable',

View File

@@ -8,14 +8,14 @@ import './DashboardVariableSelection.styles.scss';
import { orange } from '@ant-design/colors';
import { InfoCircleOutlined, WarningOutlined } from '@ant-design/icons';
import { Input, InputRef, Popover, Tooltip, Typography } from 'antd';
import { Input, Popover, Tooltip, Typography } from 'antd';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
import { debounce, isArray, isEmpty, isString } from 'lodash-es';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
@@ -71,15 +71,6 @@ function VariableItem({
string | string[] | undefined
>(undefined);
// Local state for textbox input to ensure smooth editing experience
const [textboxInputValue, setTextboxInputValue] = useState<string>(
(variableData.selectedValue?.toString() ||
variableData.defaultValue?.toString()) ??
'',
);
const [isTextboxFocused, setIsTextboxFocused] = useState<boolean>(false);
const textboxInputRef = useRef<InputRef>(null);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
@@ -380,7 +371,7 @@ function VariableItem({
}, [variableData.type, variableData.customValue]);
return (
<div className={`variable-item${isTextboxFocused ? ' focused' : ''}`}>
<div className="variable-item">
<Typography.Text className="variable-name" ellipsis>
${variableData.name}
{variableData.description && (
@@ -393,40 +384,16 @@ function VariableItem({
<div className="variable-value">
{variableData.type === 'TEXTBOX' ? (
<Input
ref={textboxInputRef}
placeholder="Enter value"
data-testid={`variable-textbox-${variableData.id}`}
bordered={false}
value={textboxInputValue}
title={textboxInputValue}
key={variableData.selectedValue?.toString()}
defaultValue={variableData.selectedValue?.toString()}
onChange={(e): void => {
setTextboxInputValue(e.target.value);
debouncedHandleChange(e.target.value || '');
}}
onFocus={(): void => {
setIsTextboxFocused(true);
}}
onBlur={(e): void => {
setIsTextboxFocused(false);
const value = e.target.value.trim();
// If empty, reset to default value
if (!value && variableData.defaultValue) {
setTextboxInputValue(variableData.defaultValue.toString());
debouncedHandleChange(variableData.defaultValue.toString());
} else {
debouncedHandleChange(value);
}
}}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
const value = textboxInputValue.trim();
if (!value && variableData.defaultValue) {
setTextboxInputValue(variableData.defaultValue.toString());
debouncedHandleChange(variableData.defaultValue.toString());
} else {
debouncedHandleChange(value);
}
textboxInputRef.current?.blur();
}
style={{
width:
50 + ((variableData.selectedValue?.toString()?.length || 0) * 7 || 50),
}}
/>
) : (

View File

@@ -291,10 +291,6 @@ export function DashboardProvider({
variable.order = order;
existingOrders.add(order);
// ! BWC - Specific case for backward compatibility where textboxValue was used instead of defaultValue
if (variable.type === 'TEXTBOX' && !variable.defaultValue) {
variable.defaultValue = variable.textboxValue || '';
}
}
if (variable.id === undefined) {

View File

@@ -1,5 +1,5 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { render, screen, waitFor } from '@testing-library/react';
import { render, waitFor } from '@testing-library/react';
import getDashboard from 'api/v1/dashboards/id/get';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
@@ -379,9 +379,12 @@ describe('Dashboard Provider - URL Variables Integration', () => {
// Empty URL variables - tests initialization flow
mockGetUrlVariables.mockReturnValue({});
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
dashboardId: DASHBOARD_ID,
});
const { getByTestId } = renderWithDashboardProvider(
`/dashboard/${DASHBOARD_ID}`,
{
dashboardId: DASHBOARD_ID,
},
);
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
@@ -412,14 +415,16 @@ describe('Dashboard Provider - URL Variables Integration', () => {
});
// Verify dashboard state contains the variables with default values
const dashboardVariables = await screen.findByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
await waitFor(() => {
const dashboardVariables = getByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
expect(parsedVariables).toHaveProperty('environment');
expect(parsedVariables).toHaveProperty('services');
// Default allSelected values should be preserved
expect(parsedVariables.environment.allSelected).toBe(false);
expect(parsedVariables.services.allSelected).toBe(false);
expect(parsedVariables).toHaveProperty('environment');
expect(parsedVariables).toHaveProperty('services');
// Default allSelected values should be preserved
expect(parsedVariables.environment.allSelected).toBe(false);
expect(parsedVariables.services.allSelected).toBe(false);
});
});
it('should merge URL variables with dashboard data and normalize values correctly', async () => {
@@ -433,9 +438,12 @@ describe('Dashboard Provider - URL Variables Integration', () => {
.mockReturnValueOnce('development')
.mockReturnValueOnce(['db', 'cache']);
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
dashboardId: DASHBOARD_ID,
});
const { getByTestId } = renderWithDashboardProvider(
`/dashboard/${DASHBOARD_ID}`,
{
dashboardId: DASHBOARD_ID,
},
);
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
@@ -466,16 +474,18 @@ describe('Dashboard Provider - URL Variables Integration', () => {
});
// Verify the dashboard state reflects the normalized URL values
const dashboardVariables = await screen.findByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
await waitFor(() => {
const dashboardVariables = getByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
// The selectedValue should be updated with normalized URL values
expect(parsedVariables.environment.selectedValue).toBe('development');
expect(parsedVariables.services.selectedValue).toEqual(['db', 'cache']);
// The selectedValue should be updated with normalized URL values
expect(parsedVariables.environment.selectedValue).toBe('development');
expect(parsedVariables.services.selectedValue).toEqual(['db', 'cache']);
// allSelected should be set to false when URL values override
expect(parsedVariables.environment.allSelected).toBe(false);
expect(parsedVariables.services.allSelected).toBe(false);
// allSelected should be set to false when URL values override
expect(parsedVariables.environment.allSelected).toBe(false);
expect(parsedVariables.services.allSelected).toBe(false);
});
});
it('should handle ALL_SELECTED_VALUE from URL and set allSelected correctly', async () => {
@@ -485,9 +495,12 @@ describe('Dashboard Provider - URL Variables Integration', () => {
mockGetUrlVariables.mockReturnValue(urlVariables);
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
dashboardId: DASHBOARD_ID,
});
const { getByTestId } = renderWithDashboardProvider(
`/dashboard/${DASHBOARD_ID}`,
{
dashboardId: DASHBOARD_ID,
},
);
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
@@ -500,8 +513,8 @@ describe('Dashboard Provider - URL Variables Integration', () => {
);
// Verify that allSelected is set to true for the services variable
await waitFor(async () => {
const dashboardVariables = await screen.findByTestId('dashboard-variables');
await waitFor(() => {
const dashboardVariables = getByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
expect(parsedVariables.services.allSelected).toBe(true);
@@ -550,203 +563,3 @@ describe('Dashboard Provider - URL Variables Integration', () => {
});
});
});
describe('Dashboard Provider - Textbox Variable Backward Compatibility', () => {
const DASHBOARD_ID = 'test-dashboard-id';
beforeEach(() => {
jest.clearAllMocks();
mockGetUrlVariables.mockReturnValue({});
// eslint-disable-next-line sonarjs/no-identical-functions
mockNormalizeUrlValueForVariable.mockImplementation((urlValue) => {
if (urlValue === undefined || urlValue === null) {
return urlValue;
}
return urlValue as IDashboardVariable['selectedValue'];
});
});
describe('Textbox Variable defaultValue Migration', () => {
it('should set defaultValue from textboxValue for TEXTBOX variables without defaultValue (BWC)', async () => {
// Mock dashboard with TEXTBOX variable that has textboxValue but no defaultValue
// This simulates old data format before the migration
/* eslint-disable @typescript-eslint/no-explicit-any */
mockGetDashboard.mockResolvedValue({
httpStatusCode: 200,
data: {
id: DASHBOARD_ID,
title: 'Test Dashboard',
data: {
variables: {
myTextbox: {
id: 'textbox-id',
name: 'myTextbox',
type: 'TEXTBOX',
textboxValue: 'legacy-default-value',
// defaultValue is intentionally missing to test BWC
multiSelect: false,
showALLOption: false,
sort: 'DISABLED',
} as any,
},
},
},
} as any);
/* eslint-enable @typescript-eslint/no-explicit-any */
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
dashboardId: DASHBOARD_ID,
});
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
});
// Verify that defaultValue is set from textboxValue
await waitFor(async () => {
const dashboardVariables = await screen.findByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
expect(parsedVariables.myTextbox.type).toBe('TEXTBOX');
expect(parsedVariables.myTextbox.textboxValue).toBe('legacy-default-value');
expect(parsedVariables.myTextbox.defaultValue).toBe('legacy-default-value');
});
});
it('should not override existing defaultValue for TEXTBOX variables', async () => {
// Mock dashboard with TEXTBOX variable that already has defaultValue
/* eslint-disable @typescript-eslint/no-explicit-any */
mockGetDashboard.mockResolvedValue({
httpStatusCode: 200,
data: {
id: DASHBOARD_ID,
title: 'Test Dashboard',
data: {
variables: {
myTextbox: {
id: 'textbox-id',
name: 'myTextbox',
type: 'TEXTBOX',
textboxValue: 'old-textbox-value',
defaultValue: 'existing-default-value',
multiSelect: false,
showALLOption: false,
sort: 'DISABLED',
} as any,
},
},
},
} as any);
/* eslint-enable @typescript-eslint/no-explicit-any */
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
dashboardId: DASHBOARD_ID,
});
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
});
// Verify that existing defaultValue is preserved
await waitFor(async () => {
const dashboardVariables = await screen.findByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
expect(parsedVariables.myTextbox.type).toBe('TEXTBOX');
expect(parsedVariables.myTextbox.defaultValue).toBe(
'existing-default-value',
);
});
});
it('should set empty defaultValue when textboxValue is also empty for TEXTBOX variables', async () => {
// Mock dashboard with TEXTBOX variable with empty textboxValue and no defaultValue
/* eslint-disable @typescript-eslint/no-explicit-any */
mockGetDashboard.mockResolvedValue({
httpStatusCode: 200,
data: {
id: DASHBOARD_ID,
title: 'Test Dashboard',
data: {
variables: {
myTextbox: {
id: 'textbox-id',
name: 'myTextbox',
type: 'TEXTBOX',
textboxValue: '',
// defaultValue is intentionally missing
multiSelect: false,
showALLOption: false,
sort: 'DISABLED',
} as any,
},
},
},
} as any);
/* eslint-enable @typescript-eslint/no-explicit-any */
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
dashboardId: DASHBOARD_ID,
});
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
});
// Verify that defaultValue is set to empty string
await waitFor(async () => {
const dashboardVariables = await screen.findByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
expect(parsedVariables.myTextbox.type).toBe('TEXTBOX');
expect(parsedVariables.myTextbox.defaultValue).toBe('');
});
});
it('should not apply BWC logic to non-TEXTBOX variables', async () => {
// Mock dashboard with QUERY variable that has no defaultValue
/* eslint-disable @typescript-eslint/no-explicit-any */
mockGetDashboard.mockResolvedValue({
httpStatusCode: 200,
data: {
id: DASHBOARD_ID,
title: 'Test Dashboard',
data: {
variables: {
myQuery: {
id: 'query-id',
name: 'myQuery',
type: 'QUERY',
queryValue: 'SELECT * FROM test',
textboxValue: 'should-not-be-used',
// defaultValue is intentionally missing
multiSelect: false,
showALLOption: false,
sort: 'DISABLED',
} as any,
},
},
},
} as any);
/* eslint-enable @typescript-eslint/no-explicit-any */
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
dashboardId: DASHBOARD_ID,
});
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
});
// Verify that defaultValue is NOT set from textboxValue for QUERY type
await waitFor(async () => {
const dashboardVariables = await screen.findByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
expect(parsedVariables.myQuery.type).toBe('QUERY');
// defaultValue should not be set to textboxValue for non-TEXTBOX variables
expect(parsedVariables.myQuery.defaultValue).not.toBe('should-not-be-used');
});
});
});
});

View File

@@ -37,7 +37,6 @@ export interface IDashboardVariable {
// Custom
customValue?: string;
// Textbox
// special case of variable where defaultValue is same as this. Otherwise, defaultValue is a single field
textboxValue?: string;
sort: TSortVariableValuesType;

View File

@@ -887,7 +887,7 @@ func ParseQueryRangeParams(r *http.Request) (*v3.QueryRangeParamsV3, *model.ApiE
keys := make([]string, 0, len(queryRangeParams.Variables))
querytemplate.AssignReservedVarsV3(queryRangeParams)
querytemplate.AssignReservedVars(queryRangeParams.Variables, queryRangeParams.Start, queryRangeParams.End)
for k := range queryRangeParams.Variables {
keys = append(keys, k)
@@ -927,7 +927,7 @@ func ParseQueryRangeParams(r *http.Request) (*v3.QueryRangeParamsV3, *model.ApiE
continue
}
querytemplate.AssignReservedVarsV3(queryRangeParams)
querytemplate.AssignReservedVars(queryRangeParams.Variables, queryRangeParams.Start, queryRangeParams.End)
keys := make([]string, 0, len(queryRangeParams.Variables))

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,17 @@
package converter
import "github.com/SigNoz/signoz/pkg/errors"
// Unit represents a unit of measurement
type Unit string
func (u Unit) Validate() error {
if !IsValidUnit(u) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid unit: %s", u)
}
return nil
}
// Value represents a value with a unit of measurement
type Value struct {
F float64
@@ -60,6 +69,27 @@ func FromUnit(u Unit) Converter {
}
}
// IsValidUnit returns true if the given unit is valid
func IsValidUnit(u Unit) bool {
switch u {
// Duration unit
case "ns", "us", "µs", "ms", "s", "m", "h", "d", "min",
// Data unit
"bytes", "decbytes", "bits", "decbits", "kbytes", "decKbytes", "deckbytes", "mbytes", "decMbytes", "decmbytes", "gbytes", "decGbytes", "decgbytes", "tbytes", "decTbytes", "dectbytes", "pbytes", "decPbytes", "decpbytes", "By", "kBy", "MBy", "GBy", "TBy", "PBy",
// Data rate unit
"binBps", "Bps", "binbps", "bps", "KiBs", "Kibits", "KBs", "Kbits", "MiBs", "Mibits", "MBs", "Mbits", "GiBs", "Gibits", "GBs", "Gbits", "TiBs", "Tibits", "TBs", "Tbits", "PiBs", "Pibits", "PBs", "Pbits", "By/s", "kBy/s", "MBy/s", "GBy/s", "TBy/s", "PBy/s", "bit/s", "kbit/s", "Mbit/s", "Gbit/s", "Tbit/s", "Pbit/s",
// Percent unit
"percent", "percentunit", "%",
// Bool unit
"bool", "bool_yes_no", "bool_true_false", "bool_1_0",
// Throughput unit
"cps", "ops", "reqps", "rps", "wps", "iops", "cpm", "opm", "rpm", "wpm", "{count}/s", "{ops}/s", "{req}/s", "{read}/s", "{write}/s", "{iops}/s", "{count}/min", "{ops}/min", "{read}/min", "{write}/min":
return true
default:
return false
}
}
func UnitToName(u string) string {
switch u {
case "ns":

View File

@@ -9,8 +9,9 @@ import (
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/query-service/converter"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/pkg/errors"
"go.uber.org/zap"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
@@ -601,43 +602,170 @@ func (c *CompositeQuery) Sanitize() {
func (c *CompositeQuery) Validate() error {
if c == nil {
return fmt.Errorf("composite query is required")
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"composite query is required",
)
}
if c.BuilderQueries == nil && c.ClickHouseQueries == nil && c.PromQueries == nil && len(c.Queries) == 0 {
return fmt.Errorf("composite query must contain at least one query type")
}
if c.QueryType == QueryTypeBuilder {
for name, query := range c.BuilderQueries {
if err := query.Validate(c.PanelType); err != nil {
return fmt.Errorf("builder query %s is invalid: %w", name, err)
}
}
}
if c.QueryType == QueryTypeClickHouseSQL {
for name, query := range c.ClickHouseQueries {
if err := query.Validate(); err != nil {
return fmt.Errorf("clickhouse query %s is invalid: %w", name, err)
}
}
}
if c.QueryType == QueryTypePromQL {
for name, query := range c.PromQueries {
if err := query.Validate(); err != nil {
return fmt.Errorf("prom query %s is invalid: %w", name, err)
}
// Validate unit if supplied
if c.Unit != "" {
unit := converter.Unit(c.Unit)
err := unit.Validate()
if err != nil {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid unit: %s",
err.Error(),
)
}
}
if err := c.PanelType.Validate(); err != nil {
return fmt.Errorf("panel type is invalid: %w", err)
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"panel type is invalid: %s",
err.Error(),
)
}
if err := c.QueryType.Validate(); err != nil {
return fmt.Errorf("query type is invalid: %w", err)
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"query type is invalid: %s",
err.Error(),
)
}
if len(c.Queries) == 0 {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"at least one query is required",
)
}
// Validate each query
for i, envelope := range c.Queries {
queryId := qbtypes.GetQueryIdentifier(envelope, i)
switch envelope.Type {
case qbtypes.QueryTypeBuilder, qbtypes.QueryTypeSubQuery:
switch spec := envelope.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
if err := spec.Validate(qbtypes.RequestTypeTimeSeries); err != nil {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid %s: %s",
queryId,
err.Error(),
)
}
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
if err := spec.Validate(qbtypes.RequestTypeTimeSeries); err != nil {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid %s: %s",
queryId,
err.Error(),
)
}
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
if err := spec.Validate(qbtypes.RequestTypeTimeSeries); err != nil {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid %s: %s",
queryId,
err.Error(),
)
}
default:
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"unknown query spec type for %s",
queryId,
)
}
case qbtypes.QueryTypePromQL:
spec, ok := envelope.Spec.(qbtypes.PromQuery)
if !ok {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid spec for %s",
queryId,
)
}
if spec.Query == "" {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"query expression is required for %s",
queryId,
)
}
if err := validatePromQLQuery(spec.Query); err != nil {
return err
}
case qbtypes.QueryTypeClickHouseSQL:
spec, ok := envelope.Spec.(qbtypes.ClickHouseQuery)
if !ok {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid spec for %s",
queryId,
)
}
if spec.Query == "" {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"query expression is required for %s",
queryId,
)
}
if err := validateClickHouseQuery(spec.Query); err != nil {
return err
}
case qbtypes.QueryTypeFormula:
spec, ok := envelope.Spec.(qbtypes.QueryBuilderFormula)
if !ok {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid spec for %s",
queryId,
)
}
if err := spec.Validate(); err != nil {
return err
}
case qbtypes.QueryTypeTraceOperator:
spec, ok := envelope.Spec.(qbtypes.QueryBuilderTraceOperator)
if !ok {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid spec for %s",
queryId,
)
}
err := spec.ValidateTraceOperator(c.Queries)
if err != nil {
return err
}
default:
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"unknown query type '%s' for %s",
envelope.Type,
queryId,
).WithAdditional(
"Valid query types are: builder_query, builder_sub_query, builder_formula, builder_join, promql, clickhouse_sql, trace_operator",
)
}
}
// Check if all queries are disabled
if allDisabled := checkQueriesDisabled(c); allDisabled {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"all queries are disabled - at least one query must be enabled",
)
}
return nil
@@ -1203,7 +1331,7 @@ func (f *FilterSet) Scan(src interface{}) error {
func (f *FilterSet) Value() (driver.Value, error) {
filterSetJson, err := json.Marshal(f)
if err != nil {
return nil, errors.Wrap(err, "could not serialize FilterSet to JSON")
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "could not serialize FilterSet to JSON")
}
return filterSetJson, nil
}

View File

@@ -0,0 +1,137 @@
package v3
import (
"bytes"
"fmt"
"text/template"
"time"
clickhouse "github.com/AfterShip/clickhouse-sql-parser/parser"
"github.com/SigNoz/signoz/pkg/errors"
querytemplate "github.com/SigNoz/signoz/pkg/query-service/utils/queryTemplate"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/prometheus/prometheus/promql/parser"
)
type QueryParseError struct {
StartPosition *int
EndPosition *int
ErrorMessage string
Query string
}
func (e *QueryParseError) Error() string {
if e.StartPosition != nil && e.EndPosition != nil {
return fmt.Sprintf("query parse error: %s at position %d:%d", e.ErrorMessage, *e.StartPosition, *e.EndPosition)
}
return fmt.Sprintf("query parse error: %s", e.ErrorMessage)
}
// validatePromQLQuery validates a PromQL query syntax using the Prometheus parser
func validatePromQLQuery(query string) error {
_, err := parser.ParseExpr(query)
if err != nil {
if syntaxErrs, ok := err.(parser.ParseErrors); ok {
syntaxErr := syntaxErrs[0]
startPosition := int(syntaxErr.PositionRange.Start)
endPosition := int(syntaxErr.PositionRange.End)
return &QueryParseError{
StartPosition: &startPosition,
EndPosition: &endPosition,
ErrorMessage: syntaxErr.Error(),
Query: query,
}
}
}
return err
}
// validateClickHouseQuery validates a ClickHouse SQL query syntax using the ClickHouse parser
func validateClickHouseQuery(query string) error {
// Assign the default template variables with dummy values
variables := make(map[string]interface{})
start := time.Now().UnixMilli()
end := start + 1000
querytemplate.AssignReservedVars(variables, start, end)
// Apply the values for default template variables before parsing the query
tmpl := template.New("clickhouse-query")
tmpl, err := tmpl.Parse(query)
if err != nil {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"failed to parse clickhouse query: %s",
err.Error(),
)
}
var queryBuffer bytes.Buffer
err = tmpl.Execute(&queryBuffer, variables)
if err != nil {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"failed to execute clickhouse query template: %s",
err.Error(),
)
}
// Parse the ClickHouse query with the default template variables applied
p := clickhouse.NewParser(queryBuffer.String())
_, err = p.ParseStmts()
if err != nil {
// TODO: errors returned here is errors.errorString
// we should think on using some other library that parses the CH query in more accurate manner,
// current CH parser does very minimal checks and on just the known keywords, without validating the syntax of given query.
// Sample Error: "line 0:36 expected table name or subquery, got ;\nSELECT department, avg(salary) FROM ;\n ^\n"
return &QueryParseError{
ErrorMessage: err.Error(),
Query: query,
}
}
return nil
}
// checkQueriesDisabled checks if all queries are disabled. Returns true if all queries are disabled, false otherwise.
func checkQueriesDisabled(compositeQuery *CompositeQuery) bool {
for _, envelope := range compositeQuery.Queries {
switch envelope.Type {
case qbtypes.QueryTypeBuilder, qbtypes.QueryTypeSubQuery:
switch spec := envelope.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
if !spec.Disabled {
return false
}
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
if !spec.Disabled {
return false
}
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
if !spec.Disabled {
return false
}
}
case qbtypes.QueryTypeFormula:
if spec, ok := envelope.Spec.(qbtypes.QueryBuilderFormula); ok && !spec.Disabled {
return false
}
case qbtypes.QueryTypeTraceOperator:
if spec, ok := envelope.Spec.(qbtypes.QueryBuilderTraceOperator); ok && !spec.Disabled {
return false
}
case qbtypes.QueryTypeJoin:
if spec, ok := envelope.Spec.(qbtypes.QueryBuilderJoin); ok && !spec.Disabled {
return false
}
case qbtypes.QueryTypePromQL:
if spec, ok := envelope.Spec.(qbtypes.PromQuery); ok && !spec.Disabled {
return false
}
case qbtypes.QueryTypeClickHouseSQL:
if spec, ok := envelope.Spec.(qbtypes.ClickHouseQuery); ok && !spec.Disabled {
return false
}
}
}
// If we reach here, all queries are disabled
return true
}

View File

@@ -0,0 +1,528 @@
package v3
import (
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/require"
)
func TestValidateCompositeQuery(t *testing.T) {
tests := []struct {
name string
compositeQuery *CompositeQuery
wantErr bool
errContains string
}{
{
name: "nil composite query should return error",
compositeQuery: nil,
wantErr: true,
errContains: "composite query is required",
},
{
name: "empty queries array should return error",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{},
},
wantErr: true,
errContains: "at least one query is required",
},
{
name: "invalid input error",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Unit: "some_invalid_unit",
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{
Name: "prom_query",
Query: "rate(http_requests_total[5m])",
},
},
},
},
wantErr: true,
errContains: "invalid unit",
},
{
name: "valid metric builder query should pass",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Unit: "bytes", // valid unit
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "metric_query",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "cpu_usage",
},
},
},
},
},
},
wantErr: false,
},
{
name: "valid log builder query should pass",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Unit: "µs", // valid unit
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Name: "log_query",
Signal: telemetrytypes.SignalLogs,
Aggregations: []qbtypes.LogAggregation{
{
Expression: "count()",
},
},
},
},
},
},
wantErr: false,
},
{
name: "valid trace builder query should pass",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Unit: "MBs", // valid unit
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "trace_query",
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{
{
Expression: "count()",
},
},
},
},
},
},
wantErr: false,
},
{
name: "valid PromQL query should pass",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Unit: "{req}/s", // valid unit
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{
Name: "prom_query",
Query: "rate(http_requests_total[5m])",
},
},
},
},
wantErr: false,
},
{
name: "valid ClickHouse query should pass",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeClickHouseSQL,
Spec: qbtypes.ClickHouseQuery{
Name: "ch_query",
Query: "SELECT count(*) FROM metrics WHERE metric_name = 'cpu_usage'",
},
},
},
},
wantErr: false,
},
{
name: "valid formula query should pass",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeFormula,
Spec: qbtypes.QueryBuilderFormula{
Name: "formula_query",
Expression: "A + B",
},
},
},
},
wantErr: false,
},
// We've not added support for join query yet
{
name: "valid join query should pass",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeJoin,
Spec: qbtypes.QueryBuilderJoin{
Name: "join_query",
Left: qbtypes.QueryRef{Name: "A"},
Right: qbtypes.QueryRef{Name: "B"},
Type: qbtypes.JoinTypeInner,
On: "service_name",
},
},
},
},
wantErr: true,
errContains: "unknown query type",
},
{
name: "valid trace operator query should pass",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{
{
Expression: "count()",
},
},
},
},
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "B",
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{
{
Expression: "count()",
},
},
},
},
{
Type: qbtypes.QueryTypeTraceOperator,
Spec: qbtypes.QueryBuilderTraceOperator{
Name: "trace_operator",
Expression: "A && B",
},
},
},
},
wantErr: false,
},
{
name: "invalid metric builder query - missing aggregation should return error",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "metric_query",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{},
},
},
},
},
wantErr: true,
errContains: "invalid",
},
{
name: "invalid PromQL query - empty query should return error",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{
Name: "prom_query",
Query: "",
},
},
},
},
wantErr: true,
errContains: "query expression is required",
},
{
name: "invalid PromQL query - syntax error should return error",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{
Name: "prom_query",
Query: "rate(http_requests_total[5m",
},
},
},
},
wantErr: true,
errContains: "unclosed left parenthesis",
},
{
name: "invalid ClickHouse query - empty query should return error",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeClickHouseSQL,
Spec: qbtypes.ClickHouseQuery{
Name: "ch_query",
Query: "",
},
},
},
},
wantErr: true,
errContains: "query expression is required",
},
{
name: "invalid ClickHouse query - syntax error should return error",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeClickHouseSQL,
Spec: qbtypes.ClickHouseQuery{
Name: "ch_query",
Query: "SELECT * FROM metrics WHERE",
},
},
},
},
wantErr: true,
errContains: "query parse error",
},
{
name: "invalid formula query - empty expression should return error",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeFormula,
Spec: qbtypes.QueryBuilderFormula{
Name: "formula_query",
Expression: "",
},
},
},
},
wantErr: true,
errContains: "formula expression cannot be blank",
},
{
name: "invalid trace operator query - empty expression should return error",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeTraceOperator,
Spec: qbtypes.QueryBuilderTraceOperator{
Name: "trace_operator",
Expression: "",
},
},
},
},
wantErr: true,
errContains: "expression cannot be empty",
},
{
name: "all queries disabled should return error",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "metric_query",
Disabled: true,
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "cpu_usage",
},
},
},
},
{
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{
Name: "prom_query",
Query: "rate(http_requests_total[5m])",
Disabled: true,
},
},
},
},
wantErr: true,
errContains: "all queries are disabled",
},
{
name: "mixed disabled and enabled queries should pass",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "metric_query",
Disabled: true,
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "cpu_usage",
},
},
},
},
{
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{
Name: "prom_query",
Query: "rate(http_requests_total[5m])",
Disabled: false,
},
},
},
},
wantErr: false,
},
{
name: "multiple valid queries should pass",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "metric_query",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "cpu_usage",
},
},
},
},
{
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{
Name: "prom_query",
Query: "rate(http_requests_total[5m])",
},
},
{
Type: qbtypes.QueryTypeClickHouseSQL,
Spec: qbtypes.ClickHouseQuery{
Name: "ch_query",
Query: "SELECT count(*) FROM metrics WHERE metric_name = 'cpu_usage'",
},
},
},
},
wantErr: false,
},
{
name: "invalid query in multiple queries should return error",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "metric_query",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "cpu_usage",
},
},
},
},
{
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{
Name: "prom_query",
Query: "invalid promql syntax [",
},
},
},
},
wantErr: true,
errContains: "query parse error",
},
{
name: "unknown query type should return error",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryType{String: valuer.NewString("invalid_query_type")},
Spec: qbtypes.PromQuery{
Name: "prom_query",
Query: "rate(http_requests_total[5m])",
},
},
},
},
wantErr: true,
errContains: "unknown query type",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.compositeQuery.Validate()
if tt.wantErr {
require.Error(t, err)
if tt.errContains != "" {
require.Contains(t, err.Error(), tt.errContains)
}
} else {
require.NoError(t, err)
}
})
}
}

View File

@@ -133,7 +133,7 @@ func (r *ThresholdRule) prepareQueryRange(ctx context.Context, ts time.Time) (*v
Variables: make(map[string]interface{}, 0),
NoCache: true,
}
querytemplate.AssignReservedVarsV3(params)
querytemplate.AssignReservedVars(params.Variables, start, end)
for name, chQuery := range r.ruleCondition.CompositeQuery.ClickHouseQueries {
if chQuery.Disabled {
continue

View File

@@ -2,26 +2,21 @@ package querytemplate
import (
"fmt"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
)
// AssignReservedVars assigns values for go template vars. assumes that
// model.QueryRangeParamsV3.Start and End are Unix Nano timestamps
func AssignReservedVarsV3(queryRangeParams *v3.QueryRangeParamsV3) {
queryRangeParams.Variables["start_timestamp"] = queryRangeParams.Start / 1000
queryRangeParams.Variables["end_timestamp"] = queryRangeParams.End / 1000
func AssignReservedVars(variables map[string]interface{}, start int64, end int64) {
variables["start_timestamp"] = start / 1000
variables["end_timestamp"] = end / 1000
queryRangeParams.Variables["start_timestamp_ms"] = queryRangeParams.Start
queryRangeParams.Variables["end_timestamp_ms"] = queryRangeParams.End
variables["start_timestamp_ms"] = start
variables["end_timestamp_ms"] = end
queryRangeParams.Variables["SIGNOZ_START_TIME"] = queryRangeParams.Start
queryRangeParams.Variables["SIGNOZ_END_TIME"] = queryRangeParams.End
variables["SIGNOZ_START_TIME"] = start
variables["SIGNOZ_END_TIME"] = end
queryRangeParams.Variables["start_timestamp_nano"] = queryRangeParams.Start * 1e6
queryRangeParams.Variables["end_timestamp_nano"] = queryRangeParams.End * 1e6
queryRangeParams.Variables["start_datetime"] = fmt.Sprintf("toDateTime(%d)", queryRangeParams.Start/1000)
queryRangeParams.Variables["end_datetime"] = fmt.Sprintf("toDateTime(%d)", queryRangeParams.End/1000)
variables["start_timestamp_nano"] = start * 1e6
variables["end_timestamp_nano"] = end * 1e6
variables["start_datetime"] = fmt.Sprintf("toDateTime(%d)", start/1000)
variables["end_datetime"] = fmt.Sprintf("toDateTime(%d)", end/1000)
}

View File

@@ -553,6 +553,11 @@ func (f Function) Copy() Function {
return c
}
// Validate validates the Function by calling Validate on its Name
func (f Function) Validate() error {
return f.Name.Validate()
}
type LimitBy struct {
// keys to limit by
Keys []string `json:"keys"`

View File

@@ -73,6 +73,43 @@ func (f *QueryBuilderFormula) UnmarshalJSON(data []byte) error {
return nil
}
// Validate checks if the QueryBuilderFormula fields are valid
func (f QueryBuilderFormula) Validate() error {
// Validate name is not blank
if strings.TrimSpace(f.Name) == "" {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"formula name cannot be blank",
)
}
// Validate expression is not blank
if strings.TrimSpace(f.Expression) == "" {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"formula expression cannot be blank",
)
}
// Validate functions if present
for i, fn := range f.Functions {
if err := fn.Validate(); err != nil {
fnId := fmt.Sprintf("function #%d", i+1)
if f.Name != "" {
fnId = fmt.Sprintf("function #%d in formula '%s'", i+1, f.Name)
}
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid %s: %s",
fnId,
err.Error(),
)
}
}
return nil
}
// small container to store the query name and index or alias reference
// for a variable in the formula expression
// read below for more details on aggregation references

View File

@@ -5,6 +5,7 @@ import (
"slices"
"strconv"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -33,6 +34,37 @@ var (
FunctionNameFillZero = FunctionName{valuer.NewString("fillZero")}
)
// Validate checks if the FunctionName is valid and one of the known types
func (fn FunctionName) Validate() error {
switch fn {
case FunctionNameCutOffMin,
FunctionNameCutOffMax,
FunctionNameClampMin,
FunctionNameClampMax,
FunctionNameAbsolute,
FunctionNameRunningDiff,
FunctionNameLog2,
FunctionNameLog10,
FunctionNameCumulativeSum,
FunctionNameEWMA3,
FunctionNameEWMA5,
FunctionNameEWMA7,
FunctionNameMedian3,
FunctionNameMedian5,
FunctionNameMedian7,
FunctionNameTimeShift,
FunctionNameAnomaly,
FunctionNameFillZero:
return nil
default:
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid function name: %s",
fn.StringValue(),
)
}
}
// ApplyFunction applies the given function to the result data
func ApplyFunction(fn Function, result *TimeSeries) *TimeSeries {
// Extract the function name and arguments

View File

@@ -10,8 +10,8 @@ import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// getQueryIdentifier returns a friendly identifier for a query based on its type and name/content
func getQueryIdentifier(envelope QueryEnvelope, index int) string {
// GetQueryIdentifier returns a friendly identifier for a query based on its type and name/content
func GetQueryIdentifier(envelope QueryEnvelope, index int) string {
switch envelope.Type {
case QueryTypeBuilder, QueryTypeSubQuery:
switch spec := envelope.Spec.(type) {
@@ -567,7 +567,7 @@ func (r *QueryRangeRequest) validateCompositeQuery() error {
switch spec := envelope.Spec.(type) {
case QueryBuilderQuery[TraceAggregation]:
if err := spec.Validate(r.RequestType); err != nil {
queryId := getQueryIdentifier(envelope, i)
queryId := GetQueryIdentifier(envelope, i)
return wrapValidationError(err, queryId, "invalid %s: %s")
}
// Check name uniqueness for non-formula context
@@ -583,7 +583,7 @@ func (r *QueryRangeRequest) validateCompositeQuery() error {
}
case QueryBuilderQuery[LogAggregation]:
if err := spec.Validate(r.RequestType); err != nil {
queryId := getQueryIdentifier(envelope, i)
queryId := GetQueryIdentifier(envelope, i)
return wrapValidationError(err, queryId, "invalid %s: %s")
}
// Check name uniqueness for non-formula context
@@ -599,7 +599,7 @@ func (r *QueryRangeRequest) validateCompositeQuery() error {
}
case QueryBuilderQuery[MetricAggregation]:
if err := spec.Validate(r.RequestType); err != nil {
queryId := getQueryIdentifier(envelope, i)
queryId := GetQueryIdentifier(envelope, i)
return wrapValidationError(err, queryId, "invalid %s: %s")
}
// Check name uniqueness for non-formula context
@@ -614,7 +614,7 @@ func (r *QueryRangeRequest) validateCompositeQuery() error {
queryNames[spec.Name] = true
}
default:
queryId := getQueryIdentifier(envelope, i)
queryId := GetQueryIdentifier(envelope, i)
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"unknown spec type for %s",
@@ -625,7 +625,7 @@ func (r *QueryRangeRequest) validateCompositeQuery() error {
// Formula validation is handled separately
spec, ok := envelope.Spec.(QueryBuilderFormula)
if !ok {
queryId := getQueryIdentifier(envelope, i)
queryId := GetQueryIdentifier(envelope, i)
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid spec for %s",
@@ -633,7 +633,7 @@ func (r *QueryRangeRequest) validateCompositeQuery() error {
)
}
if spec.Expression == "" {
queryId := getQueryIdentifier(envelope, i)
queryId := GetQueryIdentifier(envelope, i)
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"expression is required for %s",
@@ -644,7 +644,7 @@ func (r *QueryRangeRequest) validateCompositeQuery() error {
// Join validation is handled separately
_, ok := envelope.Spec.(QueryBuilderJoin)
if !ok {
queryId := getQueryIdentifier(envelope, i)
queryId := GetQueryIdentifier(envelope, i)
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid spec for %s",
@@ -654,7 +654,7 @@ func (r *QueryRangeRequest) validateCompositeQuery() error {
case QueryTypeTraceOperator:
spec, ok := envelope.Spec.(QueryBuilderTraceOperator)
if !ok {
queryId := getQueryIdentifier(envelope, i)
queryId := GetQueryIdentifier(envelope, i)
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid spec for %s",
@@ -662,7 +662,7 @@ func (r *QueryRangeRequest) validateCompositeQuery() error {
)
}
if spec.Expression == "" {
queryId := getQueryIdentifier(envelope, i)
queryId := GetQueryIdentifier(envelope, i)
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"expression is required for %s",
@@ -673,7 +673,7 @@ func (r *QueryRangeRequest) validateCompositeQuery() error {
// PromQL validation is handled separately
spec, ok := envelope.Spec.(PromQuery)
if !ok {
queryId := getQueryIdentifier(envelope, i)
queryId := GetQueryIdentifier(envelope, i)
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid spec for %s",
@@ -681,7 +681,7 @@ func (r *QueryRangeRequest) validateCompositeQuery() error {
)
}
if spec.Query == "" {
queryId := getQueryIdentifier(envelope, i)
queryId := GetQueryIdentifier(envelope, i)
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"query expression is required for %s",
@@ -692,7 +692,7 @@ func (r *QueryRangeRequest) validateCompositeQuery() error {
// ClickHouse SQL validation is handled separately
spec, ok := envelope.Spec.(ClickHouseQuery)
if !ok {
queryId := getQueryIdentifier(envelope, i)
queryId := GetQueryIdentifier(envelope, i)
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid spec for %s",
@@ -700,7 +700,7 @@ func (r *QueryRangeRequest) validateCompositeQuery() error {
)
}
if spec.Query == "" {
queryId := getQueryIdentifier(envelope, i)
queryId := GetQueryIdentifier(envelope, i)
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"query expression is required for %s",
@@ -708,7 +708,7 @@ func (r *QueryRangeRequest) validateCompositeQuery() error {
)
}
default:
queryId := getQueryIdentifier(envelope, i)
queryId := GetQueryIdentifier(envelope, i)
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"unknown query type '%s' for %s",
@@ -735,7 +735,7 @@ func (c *CompositeQuery) Validate(requestType RequestType) error {
// Validate each query
for i, envelope := range c.Queries {
if err := validateQueryEnvelope(envelope, requestType); err != nil {
queryId := getQueryIdentifier(envelope, i)
queryId := GetQueryIdentifier(envelope, i)
return wrapValidationError(err, queryId, "invalid %s: %s")
}
}

View File

@@ -4,17 +4,14 @@ import (
"encoding/json"
"fmt"
"net/url"
"slices"
"sort"
"strings"
"time"
signozError "github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"go.uber.org/zap"
)
// this file contains common structs and methods used by
@@ -97,30 +94,6 @@ const (
ValueOutsideBounds CompareOp = "7"
)
func (co CompareOp) Validate() error {
validCompareOps := []CompareOp{
CompareOpNone,
ValueIsAbove,
ValueIsBelow,
ValueIsEq,
ValueIsNotEq,
ValueAboveOrEq,
ValueBelowOrEq,
ValueOutsideBounds,
}
if slices.Contains(validCompareOps, co) {
return nil
}
validCompareOpValues := []string{}
for _, co := range validCompareOps {
validCompareOpValues = append(validCompareOpValues, string(co))
}
return signozError.NewInvalidInputf(signozError.CodeInvalidInput, "invalid compare operator: %s", string(co)).WithAdditional(fmt.Sprintf("valid compare operations are: %s", strings.Join(validCompareOpValues, ", ")))
}
type MatchType string
const (
@@ -132,26 +105,6 @@ const (
Last MatchType = "5"
)
func (mt MatchType) Validate() error {
validMatchTypes := []MatchType{
MatchTypeNone,
AtleastOnce,
AllTheTimes,
OnAverage,
InTotal,
Last,
}
if slices.Contains(validMatchTypes, mt) {
return nil
}
validMatchTypeValues := []string{}
for _, mt := range validMatchTypes {
validMatchTypeValues = append(validMatchTypeValues, string(mt))
}
return signozError.NewInvalidInputf(signozError.CodeInvalidInput, "invalid match type: %s", string(mt)).WithAdditional(fmt.Sprintf("valid match types are: %s", strings.Join(validMatchTypeValues, ", ")))
}
type RuleCondition struct {
CompositeQuery *v3.CompositeQuery `json:"compositeQuery,omitempty"`
CompareOp CompareOp `json:"op,omitempty"`
@@ -168,122 +121,6 @@ type RuleCondition struct {
Thresholds *RuleThresholdData `json:"thresholds,omitempty"`
}
func (rc *RuleCondition) UnmarshalJSON(data []byte) error {
type Alias RuleCondition
aux := (*Alias)(rc)
if err := json.Unmarshal(data, aux); err != nil {
return signozError.NewInvalidInputf(signozError.CodeInvalidInput, "failed to parse rule condition json: %v", err)
}
var errs []error
// Validate CompositeQuery - must be non-nil and pass validation
if rc.CompositeQuery == nil {
errs = append(errs, signozError.NewInvalidInputf(signozError.CodeInvalidInput, "composite query is required"))
}
// Validate AlertOnAbsent + AbsentFor - if AlertOnAbsent is true, AbsentFor must be > 0
if rc.AlertOnAbsent && rc.AbsentFor == 0 {
errs = append(errs, signozError.NewInvalidInputf(signozError.CodeInvalidInput, "absentFor must be greater than 0 when alertOnAbsent is true"))
}
// Validate Seasonality - must be one of the allowed values when provided
if !isValidSeasonality(rc.Seasonality) {
errs = append(errs, signozError.NewInvalidInputf(signozError.CodeInvalidInput, "invalid seasonality: %s, supported values: hourly, daily, weekly", rc.Seasonality))
}
// Only validate CompareOp and MatchType if they are provided
// as in new rule condition, these are not present in rule condition
if rc.CompareOp != "" {
if err := rc.CompareOp.Validate(); err != nil {
errs = append(errs, err)
}
}
if rc.MatchType != "" {
if err := rc.MatchType.Validate(); err != nil {
errs = append(errs, err)
}
}
// Validate SelectedQueryName - must match one of the query names from CompositeQuery
if rc.SelectedQuery != "" && rc.CompositeQuery != nil {
queryNames := getAllQueryNames(rc.CompositeQuery)
if _, exists := queryNames[rc.SelectedQuery]; !exists {
errs = append(errs, signozError.NewInvalidInputf(signozError.CodeInvalidInput, "selected query name '%s' does not match any query in composite query", rc.SelectedQuery))
}
}
// Validate RequireMinPoints + RequiredNumPoints - if RequireMinPoints is true, RequiredNumPoints must be > 0
if rc.RequireMinPoints && rc.RequiredNumPoints <= 0 {
errs = append(errs, signozError.NewInvalidInputf(signozError.CodeInvalidInput, "requiredNumPoints must be greater than 0 when requireMinPoints is true"))
}
if len(errs) > 0 {
// return signozError.Join(errs...)
zap.L().Warn("expected validation errors in rule condition", zap.Errors("errors", errs))
return nil
}
return nil
}
// getAllQueryNames extracts all query names from CompositeQuery across all query types
// Returns a map of query names for quick lookup
func getAllQueryNames(compositeQuery *v3.CompositeQuery) map[string]struct{} {
queryNames := make(map[string]struct{})
// Extract names from Queries (v5 envelopes)
if compositeQuery != nil && compositeQuery.Queries != nil {
for _, query := range compositeQuery.Queries {
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
if spec.Name != "" {
queryNames[spec.Name] = struct{}{}
}
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
if spec.Name != "" {
queryNames[spec.Name] = struct{}{}
}
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
if spec.Name != "" {
queryNames[spec.Name] = struct{}{}
}
case qbtypes.QueryBuilderFormula:
if spec.Name != "" {
queryNames[spec.Name] = struct{}{}
}
case qbtypes.QueryBuilderTraceOperator:
if spec.Name != "" {
queryNames[spec.Name] = struct{}{}
}
case qbtypes.PromQuery:
if spec.Name != "" {
queryNames[spec.Name] = struct{}{}
}
case qbtypes.ClickHouseQuery:
if spec.Name != "" {
queryNames[spec.Name] = struct{}{}
}
}
}
}
return queryNames
}
// isValidSeasonality validates that Seasonality is one of the allowed values
func isValidSeasonality(seasonality string) bool {
if seasonality == "" {
return true // empty seasonality is allowed (optional field)
}
switch seasonality {
case "hourly", "daily", "weekly":
return true
default:
return false
}
}
func (rc *RuleCondition) GetSelectedQueryName() string {
if rc != nil {
if rc.SelectedQuery != "" {

View File

@@ -405,7 +405,7 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
"spec": [{
"name": "existing_threshold",
"target": 50.0,
"targetUnit": "MB",
"targetUnit": "MBs",
"ruleUnit": "bytes",
"matchType": "1",
"op": "1"

View File

@@ -252,6 +252,15 @@ func (b BasicRuleThreshold) Validate() error {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid match type: %s", string(b.MatchType)))
}
// Only validate unit if specified
if b.TargetUnit != "" {
unit := converter.Unit(b.TargetUnit)
err := unit.Validate()
if err != nil {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid unit"))
}
}
return errors.Join(errs...)
}