Compare commits

...

8 Commits

Author SHA1 Message Date
Srikanth Chekuri
38f4b85a78 Merge branch 'main' into fix/query-range-validation-gaps 2026-11-05 21:04:47 +05:30
Srikanth Chekuri
add47f6516 fix(telemetrystore): return 400 for five more ClickHouse SQL mistakes (#13444)
- Five ClickHouse errors that user SQL causes returned 500. They now
return 400 with a suggestion, as #13328 does for 170, 288, and 407: 386
(no common type), 215 (column neither in GROUP BY nor in an aggregate),
184 (aggregate where none is allowed, mostly an alias that shadows a
column in WHERE), 190 (arrays of different sizes), 427 (a regular
expression RE2 cannot compile).
- Code 48 stays a 500. A correlated subquery over a distributed table
raises it, but the code covers every unimplemented feature, so a mapping
could hide a fault in generated SQL.
- 215 and 386 can also come from a builder bug; that is the trade-off
#13328 made.
- `test_clickhouse_user_mistake_is_bad_request` raises each code with a
real query, plus a builder `REGEXP` filter.
2026-11-05 15:17:03 +00:00
Gaurav Tewari
2d3c4fd7d2 fix(ai-assistant): disable open actions for the page already open (#13370)
#### Description
- Clicking a Noz "Open …" action (e.g. "Open alert") while already on
that resource re-navigated to the same page; the page re-initialised and
the Noz chat jumped up.
- Open-resource chips are now disabled, with a "You're already viewing
this page" tooltip, when their target is the current page. Elsewhere
they navigate as before.
- The alert Overview tab counts as the alert's page, since it renders
the same editor as `/alerts/edit`; the History tab keeps the button
enabled.
- Saved-view chips are intentionally never disabled: reopening one
reapplies the saved query, resetting unsaved explorer edits.

#### Issues closed by this PR


https://github.com/orgs/SigNoz/projects/59/views/18?pane=issue&itemId=201428604&issue=SigNoz%7Cnerve-pod%7C69

#### Screenshots / Screen Recordings

Before - 


https://github.com/user-attachments/assets/56983c3a-cc75-4290-88b6-cdfc893f5984


Now - 


https://github.com/user-attachments/assets/63507ea7-f2e5-44ae-b722-771188cb17e3



#### Additional Information
<img width="648" height="329" alt="image"
src="https://github.com/user-attachments/assets/656855d9-64c5-47ea-ab49-2ea917c247d4"
/>

in Main 3 tests fails , i have added 2 extra test cases for the base
folow. lmk if we should remove them.

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-05 13:17:39 +00:00
srikanthccv
7268d5ebf5 test(querier): name the new validation cases in the PascalCase form
Assisted-by: Claude Opus 5.5
2026-11-05 15:47:33 +05:30
srikanthccv
9fc012d819 chore: generate openapi spec
Assisted-by: Claude Opus 5.5
2026-11-05 02:40:45 +05:30
srikanthccv
e17252420c fix(querier): keep distribution in the request type description
Assisted-by: Claude Opus 5.5
2026-11-05 02:40:44 +05:30
srikanthccv
ac153c34f3 chore: generate openapi spec
Assisted-by: Claude Opus 5.5
2026-11-05 02:29:38 +05:30
srikanthccv
f234da5983 fix(querier): reject negative formula limits and empty raw order keys, and name the valid request types
distribution still decodes, because saved views and dashboards can store it.
Validate rejects it and lists the request types a query can use. Rules and
saved views validate on write only, so stored ones still load.

Assisted-by: Claude Opus 5.5
2026-11-05 02:29:38 +05:30
11 changed files with 422 additions and 42 deletions

View File

@@ -48,18 +48,19 @@ import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import { getOpenResourceTarget } from './utils/getOpenResourceTarget';
import {
getPanelTypeForRequestType,
requestTypeFromActionQuery,
} from './utils/getPanelTypeForRequestType';
import { isOnResourcePage } from './utils/isOnResourcePage';
import { openSavedViewByKey } from './utils/openSavedView';
import {
isSavedViewOpenAction,
resolveOpenResourceType,
resolveResourceId,
resolveSavedViewSourceHint,
} from './utils/resolveOpenResource';
import { ResourceType, resourceRoute } from './utils/resourceRoute';
import { ResourceType } from './utils/resourceRoute';
import styles from './ActionsSection.module.scss';
@@ -397,7 +398,7 @@ export default function ActionsSection({
actions,
messageId,
}: ActionsSectionProps): JSX.Element | null {
const { pathname } = useAppLocation();
const { pathname, search } = useAppLocation();
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const { threadId, page, mode } = useAIAssistantAnalyticsContext();
const { redirectWithQueryBuilderData, handleSetQueryData } = useQueryBuilder();
@@ -493,23 +494,17 @@ export default function ActionsSection({
return;
}
const resourceType = resolveOpenResourceType(action);
const resourceId = resolveResourceId(action);
if (!resourceType || !resourceId) {
return;
}
const path = resourceRoute(resourceType, resourceId);
if (!path) {
const target = getOpenResourceTarget(action);
if (!target) {
return;
}
void logEvent(AIAssistantEvents.ResourceOpened, {
threadId,
messageId,
targetModule: targetModuleForResource(resourceType),
resourceId,
targetModule: targetModuleForResource(target.resourceType),
resourceId: target.resourceId,
});
navigate(path);
navigate(target.path);
};
const handleClick = (key: string, action: MessageActionDTO): void => {
@@ -607,6 +602,10 @@ export default function ActionsSection({
// is still surfaced visually via the suffix pill below.
const tooltip = isError ? result.error : (action.tooltip ?? undefined);
const openTarget = getOpenResourceTarget(action);
const isViewingResource =
openTarget !== null &&
isOnResourcePage(openTarget.path, { pathname, search });
let icon: JSX.Element;
if (isSuccess) {
@@ -619,12 +618,16 @@ export default function ActionsSection({
const chip = (
<Button
disabledTooltip="This action already ran"
disabledTooltip={
isViewingResource
? "You're already viewing this page"
: 'This action already ran'
}
variant="outlined"
color="secondary"
size="sm"
onClick={(): void => handleClick(key, action)}
disabled={isSuccess}
disabled={isSuccess || isViewingResource}
loading={isLoading}
aria-label={action.label}
testId={`ai-action-chip-${action.kind}`}

View File

@@ -0,0 +1,99 @@
import {
MessageActionDTO,
MessageActionKindDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import logEvent from 'api/common/logEvent';
import { getCurrentLocation, navigate } from 'lib/router/navigation';
import { act, render, screen, userEvent, waitFor } from 'tests/test-utils';
import { AIAssistantEvents } from '../../../events';
import ActionsSection from '../ActionsSection';
jest.mock('api/common/logEvent');
const mockLogEvent = jest.mocked(logEvent);
const OPEN_ALERT: MessageActionDTO = {
kind: MessageActionKindDTO.open_resource,
label: 'Open alert',
resourceType: 'alert',
resourceId: 'rule-1',
actionMetadataId: 'meta-1',
};
const OPEN_ALERT_CHIP = 'ai-action-chip-open_resource';
describe('ActionsSection open_resource', () => {
beforeEach(() => {
mockLogEvent.mockClear();
});
it.each([
'/alerts/edit?ruleId=rule-1&tab=1',
'/alerts/overview?ruleId=rule-1&relativeTime=6h',
])('disables Open alert while that alert is open at %s', async (route) => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ActionsSection actions={[OPEN_ALERT]} messageId="message-1" />,
undefined,
{ initialRoute: route },
);
const chip = screen.getByTestId(OPEN_ALERT_CHIP);
// With a disabled tooltip the button is aria-disabled so the tooltip stays reachable.
expect(chip).toHaveAttribute('aria-disabled', 'true');
await user.click(chip);
expect(`${getCurrentLocation().pathname}${getCurrentLocation().search}`).toBe(
route,
);
expect(mockLogEvent).not.toHaveBeenCalled();
});
it.each(['/alerts/edit?ruleId=rule-2', '/alerts/history?ruleId=rule-1'])(
'opens the alert from %s',
async (route) => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ActionsSection actions={[OPEN_ALERT]} messageId="message-2" />,
undefined,
{ initialRoute: route },
);
const chip = screen.getByTestId(OPEN_ALERT_CHIP);
expect(chip).not.toHaveAttribute('aria-disabled', 'true');
await user.click(chip);
await waitFor(() =>
expect(
`${getCurrentLocation().pathname}${getCurrentLocation().search}`,
).toBe('/alerts/edit?ruleId=rule-1'),
);
expect(mockLogEvent).toHaveBeenCalledWith(
AIAssistantEvents.ResourceOpened,
expect.objectContaining({ resourceId: 'rule-1' }),
);
},
);
it('enables Open alert again after navigating away from that alert', () => {
render(
<ActionsSection actions={[OPEN_ALERT]} messageId="message-3" />,
undefined,
{ initialRoute: '/alerts/edit?ruleId=rule-1' },
);
expect(screen.getByTestId(OPEN_ALERT_CHIP)).toHaveAttribute(
'aria-disabled',
'true',
);
act(() => navigate('/alerts/edit?ruleId=rule-2'));
expect(screen.getByTestId(OPEN_ALERT_CHIP)).not.toHaveAttribute(
'aria-disabled',
'true',
);
});
});

View File

@@ -0,0 +1,39 @@
import {
type MessageActionDTO,
MessageActionKindDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import {
isSavedViewOpenAction,
resolveOpenResourceType,
resolveResourceId,
} from './resolveOpenResource';
import { resourceRoute } from './resourceRoute';
export interface OpenResourceTarget {
path: string;
resourceType: string;
resourceId: string;
}
// Saved views return null so they're never disabled: reopening one resets the explorer to the saved query.
export function getOpenResourceTarget(
action: MessageActionDTO,
): OpenResourceTarget | null {
if (
action.kind !== MessageActionKindDTO.open_resource ||
isSavedViewOpenAction(action)
) {
return null;
}
const resourceType = resolveOpenResourceType(action);
const resourceId = resolveResourceId(action);
if (!resourceType || !resourceId) {
return null;
}
const path = resourceRoute(resourceType, resourceId);
if (!path) {
return null;
}
return { path, resourceType, resourceId };
}

View File

@@ -0,0 +1,23 @@
import ROUTES from 'constants/routes';
import type { AppLocation } from 'lib/router/types';
// The alert details Overview tab renders the same editor as the edit route.
const EQUIVALENT_PATHNAMES: Record<string, string[]> = {
[ROUTES.EDIT_ALERTS]: [ROUTES.ALERT_OVERVIEW],
};
// Extra params on the current page (time range, view options) don't make it a different resource.
export function isOnResourcePage(
path: string,
location: Pick<AppLocation, 'pathname' | 'search'>,
): boolean {
const [pathname, query = ''] = path.split('?');
const pathnames = [pathname, ...(EQUIVALENT_PATHNAMES[pathname] ?? [])];
if (!pathnames.includes(location.pathname)) {
return false;
}
const current = new URLSearchParams(location.search);
return [...new URLSearchParams(query)].every(
([key, value]) => current.get(key) === value,
);
}

View File

@@ -9,12 +9,13 @@ import (
// NewValidReferences / NewSuggestionsOnLevenshteinDistance to phrase the
// "valid <noun> are ..." list consistently across the codebase.
const (
NounFields = "fields"
NounKeys = "keys"
NounServices = "services"
NounQueryTypes = "query types"
NounSignals = "signals"
NounReferences = "references"
NounFields = "fields"
NounKeys = "keys"
NounServices = "services"
NounQueryTypes = "query types"
NounRequestTypes = "request types"
NounSignals = "signals"
NounReferences = "references"
)
const (

View File

@@ -22,6 +22,10 @@ var (
ErrCodeDistributedSubqueryDenied = errors.MustNewCode("distributed_subquery_denied")
ErrCodeDecimalOverflow = errors.MustNewCode("decimal_overflow")
ErrCodeNoCommonType = errors.MustNewCode("no_common_type")
ErrCodeNotAnAggregate = errors.MustNewCode("not_an_aggregate")
ErrCodeIllegalAggregation = errors.MustNewCode("illegal_aggregation")
ErrCodeInvalidRegexp = errors.MustNewCode("invalid_regexp")
ErrCodeQueryCanceled = errors.MustNewCode("query_canceled")
ErrCodeQueryTimeout = errors.MustNewCode("query_timeout")
@@ -87,6 +91,26 @@ var clickHouseExceptionWrappers = map[chproto.Error]func(cause error, ex *clickh
return errors.WrapInvalidInputf(cause, ErrCodeDecimalOverflow, "decimal overflow: %s", ex.Message).
WithSuggestions("cast the Decimal values to Float64, or to a wider Decimal such as Decimal128, before the arithmetic")
},
chproto.ErrNoCommonType: func(cause error, ex *clickhouse.Exception) error {
return errors.WrapInvalidInputf(cause, ErrCodeNoCommonType, "values have no common type: %s", ex.Message).
WithSuggestions("cast the values to one type, for example with toInt64 or toFloat64, so that the branches of if or multiIf, the elements of an array, or the columns of a UNION share it; count() returns UInt64, which has no common type with Int64")
},
chproto.ErrNotAnAggregate: func(cause error, ex *clickhouse.Exception) error {
return errors.WrapInvalidInputf(cause, ErrCodeNotAnAggregate, "column is neither in GROUP BY nor inside an aggregate function: %s", ex.Message).
WithSuggestions("add the column to GROUP BY, or wrap it in an aggregate function such as any()")
},
chproto.ErrIllegalAggregation: func(cause error, ex *clickhouse.Exception) error {
return errors.WrapInvalidInputf(cause, ErrCodeIllegalAggregation, "aggregate function in a place that does not allow one: %s", ex.Message).
WithSuggestions("give an aggregate an alias that is not a column name, because ClickHouse reads the alias in WHERE; move a condition on an aggregate from WHERE to HAVING; compute a nested aggregate in a subquery")
},
chproto.ErrSizesOfArraysDontMatch: func(cause error, ex *clickhouse.Exception) error {
return errors.WrapInvalidInputf(cause, ErrCodeIllegalArgument, "arrays of different sizes: %s", ex.Message).
WithSuggestions("pass arrays of the same length to a function that takes several arrays, such as arrayMap or arrayZip")
},
chproto.ErrCannotCompileRegexp: func(cause error, ex *clickhouse.Exception) error {
return errors.WrapInvalidInputf(cause, ErrCodeInvalidRegexp, "regular expression does not compile: %s", ex.Message).
WithSuggestions("write the pattern in RE2 syntax, which has no lookaround or backreferences, and escape a backslash that is meant literally")
},
}
type provider struct {

View File

@@ -104,6 +104,10 @@ func (f QueryBuilderFormula) Validate() error {
)
}
if f.Limit < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be non-negative, got %d", f.Limit)
}
// Validate functions if present
for i, fn := range f.Functions {
if err := fn.Validate(); err != nil {

View File

@@ -23,7 +23,8 @@ func (r *RequestType) UnmarshalJSON(data []byte) error {
*r = v
return nil
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown request type %q; allowed values: %s", s, "`scalar`, `time_series`, `raw`, `raw_stream`, `trace`, `distribution`, `heatmap`")
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown request type %q", s).
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
}
}

View File

@@ -495,6 +495,18 @@ func (q *QueryBuilderQuery[T]) validateOrderBy(cfg validationConfig) error {
return q.validateOrderByForAggregation()
}
// a raw order key resolves like a select field, so only an empty one is
// invalid before resolution
for i, order := range q.Order {
if order.Key.Name == "" {
orderID := fmt.Sprintf("order by clause #%d", i+1)
if q.Name != "" {
orderID = fmt.Sprintf("order by clause #%d in query '%s'", i+1, q.Name)
}
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid empty key name for %s", orderID)
}
}
return nil
}
@@ -582,19 +594,10 @@ func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
return err
}
// Validate request type
switch r.RequestType {
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap:
opts = append(opts, GetValidationOptions(r.RequestType)...)
default:
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid request type: %s",
r.RequestType,
).WithAdditional(
"Valid request types are: raw, timeseries, scalar, heatmap",
)
if err := r.validateRequestType(); err != nil {
return err
}
opts = append(opts, GetValidationOptions(r.RequestType)...)
if err := r.validateHeatmap(); err != nil {
return err
@@ -635,14 +638,10 @@ func (r *QueryRangeRequest) ValidateRequestScope() ([]ValidationOption, error) {
return nil, err
}
var opts []ValidationOption
switch r.RequestType {
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap:
opts = GetValidationOptions(r.RequestType)
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid request type: %s", r.RequestType).
WithAdditional("Valid request types are: raw, timeseries, scalar, heatmap")
if err := r.validateRequestType(); err != nil {
return nil, err
}
opts := GetValidationOptions(r.RequestType)
if err := r.validateHeatmap(); err != nil {
return nil, err
@@ -699,6 +698,20 @@ func (e QueryEnvelope) Validate(opts ...ValidationOption) error {
return validateQueryEnvelope(e, opts...)
}
// Enum is the one list of request types a query can use, so the spec and the
// suggestions agree with what is accepted.
func (r *QueryRangeRequest) validateRequestType() error {
if slices.Contains(RequestType{}.Enum(), any(r.RequestType)) {
return nil
}
if r.RequestType == RequestTypeUnknown {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "requestType is required").
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
}
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request type %q is not supported", r.RequestType.StringValue()).
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
}
func (r *QueryRangeRequest) validateTimeRange() error {
if r.RequestType == RequestTypeRawStream {
return nil

View File

@@ -1,12 +1,17 @@
package querybuildertypesv5
import (
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func contains(s, substr string) bool {
@@ -521,6 +526,28 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
wantErr: true,
errMsg: "expression cannot be blank",
},
{
name: "Formula_NegativeLimit_Rejected",
request: QueryRangeRequest{
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeTimeSeries,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeFormula,
Spec: QueryBuilderFormula{
Name: "F1",
Expression: "A * 2",
Limit: -5,
},
},
},
},
},
wantErr: true,
errMsg: "invalid formula 'F1': limit must be non-negative, got -5",
},
{
name: "promql with empty query should return error",
request: QueryRangeRequest{
@@ -696,6 +723,31 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
wantErr: true,
errMsg: "raw request type is not supported for metric queries",
},
{
name: "Raw_EmptyOrderKey_Rejected",
request: QueryRangeRequest{
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeRaw,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[LogAggregation]{
Name: "B",
Signal: telemetrytypes.SignalLogs,
Order: []OrderBy{
{Key: OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "timestamp"}}, Direction: OrderDirectionDesc},
{Key: OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: ""}}, Direction: OrderDirectionAsc},
},
},
},
},
},
},
wantErr: true,
errMsg: "invalid query 'B': invalid empty key name for order by clause #2 in query 'B'",
},
{
name: "timeseries request with group by timestamp should return error",
request: QueryRangeRequest{
@@ -1407,6 +1459,44 @@ func TestRequestType_IsAggregation(t *testing.T) {
}
}
func TestQueryRangeRequest_ValidateRequestType(t *testing.T) {
validRequestTypes := []string{"valid request types are `scalar`, `time_series`, `raw`, `raw_stream`, `trace`, `heatmap`"}
testCases := []struct {
name string
requestType string
message string
}{
{
name: "Missing_Required",
requestType: "",
message: "requestType is required",
},
{
name: "Timeseries_Unknown",
requestType: `"requestType": "timeseries",`,
message: `unknown request type "timeseries"`,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
body := fmt.Sprintf(`{"schemaVersion": "v1", "start": 1640995200000, "end": 1640998800000, %s "compositeQuery": {"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs"}}]}}`, testCase.requestType)
var request QueryRangeRequest
err := json.Unmarshal([]byte(body), &request)
if err == nil {
err = request.Validate()
}
require.Error(t, err)
got := errors.AsJSON(err)
assert.Equal(t, testCase.message, got.Message)
assert.Equal(t, validRequestTypes, got.Suggestions)
})
}
}
func TestNonAggregationFieldsSkipped(t *testing.T) {
// Fields that only apply to aggregation queries (groupBy, having, aggregations)
// should be silently skipped for non-aggregation request types.

View File

@@ -2,8 +2,11 @@ from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
from fixtures import querier, types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
def test_clickhouse_scalar_numeric_result_alias_classified_as_aggregation(
@@ -68,3 +71,83 @@ def test_clickhouse_scalar_numeric_result_alias_classified_as_aggregation(
assert columns[0]["name"] == "value"
assert columns[0]["columnType"] == "aggregation"
assert columns[0]["aggregationIndex"] == 0
NO_COMMON_TYPE = "cast the values to one type, for example with toInt64 or toFloat64, so that the branches of if or multiIf, the elements of an array, or the columns of a UNION share it; count() returns UInt64, which has no common type with Int64"
ILLEGAL_AGGREGATION = "give an aggregate an alias that is not a column name, because ClickHouse reads the alias in WHERE; move a condition on an aggregate from WHERE to HAVING; compute a nested aggregate in a subquery"
INVALID_REGEXP = "write the pattern in RE2 syntax, which has no lookaround or backreferences, and escape a backslash that is meant literally"
@pytest.mark.parametrize(
"query,code,suggestion",
[
pytest.param(
{"type": "clickhouse_sql", "spec": {"name": "A", "query": "SELECT if(number > 5, toInt64(-1), count()) AS value FROM numbers(10) GROUP BY number"}},
"no_common_type",
NO_COMMON_TYPE,
id="no_common_type",
),
pytest.param(
{"type": "clickhouse_sql", "spec": {"name": "A", "query": "SELECT number, count() AS value FROM numbers(10) GROUP BY number % 2"}},
"not_an_aggregate",
"add the column to GROUP BY, or wrap it in an aggregate function such as any()",
id="not_an_aggregate",
),
pytest.param(
{"type": "clickhouse_sql", "spec": {"name": "A", "query": "SELECT any(number) AS number FROM numbers(10) WHERE number > 5"}},
"illegal_aggregation",
ILLEGAL_AGGREGATION,
id="aggregate_alias_in_where",
),
pytest.param(
{"type": "clickhouse_sql", "spec": {"name": "A", "query": "SELECT sum(count()) AS value FROM numbers(10)"}},
"illegal_aggregation",
ILLEGAL_AGGREGATION,
id="nested_aggregate",
),
pytest.param(
{"type": "clickhouse_sql", "spec": {"name": "A", "query": "SELECT arrayMap((x, y) -> x + y, [1, 2], [1]) AS value"}},
"illegal_argument",
"pass arrays of the same length to a function that takes several arrays, such as arrayMap or arrayZip",
id="arrays_of_different_sizes",
),
pytest.param(
{"type": "clickhouse_sql", "spec": {"name": "A", "query": "SELECT match('abc', '(?=a)') AS value"}},
"invalid_regexp",
INVALID_REGEXP,
id="regexp_in_sql",
),
pytest.param(
querier.build_scalar_query("A", "logs", [querier.build_aggregation("count()")], filter_expression="body REGEXP '(?=a)'"),
"invalid_regexp",
INVALID_REGEXP,
id="regexp_in_filter",
),
],
)
def test_clickhouse_user_mistake_is_bad_request(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
query: dict,
code: str,
suggestion: str,
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
# ClickHouse compiles a filter's regular expression only when it reads a part.
insert_logs([Logs(timestamp=now - timedelta(minutes=1), body="abc")])
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = querier.make_query_request(
signoz,
token,
int((now - timedelta(hours=1)).timestamp() * 1000),
int(now.timestamp() * 1000),
[query],
request_type=querier.RequestType.SCALAR,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
error = response.json()["error"]
assert (error["code"], error["suggestions"]) == (code, [suggestion]), error