Compare commits

..

5 Commits

Author SHA1 Message Date
SagarRajput-7
78ca0642b2 test(settings): formatted all the files under settings module 2026-06-16 12:31:41 +05:30
SagarRajput-7
57ef60f0e3 Merge branch 'main' into settings-e2e 2026-06-16 12:27:51 +05:30
SagarRajput-7
b96b6918e9 test(settings): updated test cases 2026-06-16 12:24:25 +05:30
SagarRajput-7
9b774bb8d0 test(settings): add persona-aware Settings module e2e suite 2026-06-16 12:14:21 +05:30
Pandey
58b55c922d fix(openapi): omit content type for responses without a body (#11720)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
ServeOpenAPI's nil-response branch still passed WithContentType, so any route
with Response == nil but a ResponseContentType set (notably 204 No Content)
emitted a content block in the generated spec. Clients then try to decode an
empty body and fail — e.g. "unexpected end of JSON input" on
DELETE /api/v1/service_accounts/{id}.

Omit the content type when Response is nil. Regenerate docs/api/openapi.yml (18
bodyless responses drop their content block) and the frontend orval client.

Signed-off-by: grandwizard28 <vibhupandey28@gmail.com>
2026-06-15 13:07:16 +00:00
76 changed files with 2995 additions and 2453 deletions

View File

@@ -9004,10 +9004,6 @@ paths:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"401":
content:
@@ -9160,10 +9156,6 @@ paths:
$ref: '#/components/schemas/DashboardtypesUpdatablePublicDashboard'
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"401":
content:
@@ -9758,10 +9750,6 @@ paths:
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeRequest'
responses:
"200":
content:
application/json:
schema:
type: string
description: OK
"400":
content:
@@ -10946,10 +10934,6 @@ paths:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"401":
content:
@@ -11063,10 +11047,6 @@ paths:
$ref: '#/components/schemas/AuthtypesPatchableRole'
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"401":
content:
@@ -11213,10 +11193,6 @@ paths:
$ref: '#/components/schemas/CoretypesPatchableObjects'
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
@@ -11666,10 +11642,6 @@ paths:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"401":
content:
@@ -11777,10 +11749,6 @@ paths:
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccount'
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
@@ -11962,10 +11930,6 @@ paths:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"401":
content:
@@ -12023,10 +11987,6 @@ paths:
$ref: '#/components/schemas/ServiceaccounttypesUpdatableFactorAPIKey'
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
@@ -12209,10 +12169,6 @@ paths:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"401":
content:
@@ -12288,10 +12244,6 @@ paths:
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccount'
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"404":
content:
@@ -13516,10 +13468,6 @@ paths:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
@@ -13779,10 +13727,6 @@ paths:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
@@ -13835,10 +13779,6 @@ paths:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
@@ -15569,10 +15509,6 @@ paths:
$ref: '#/components/schemas/MetricsexplorertypesUpdateMetricMetadataRequest'
responses:
"200":
content:
application/json:
schema:
type: string
description: OK
"400":
content:
@@ -20871,10 +20807,6 @@ paths:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
@@ -20922,10 +20854,6 @@ paths:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:

View File

@@ -63,7 +63,7 @@ export const deletePublicDashboard = (
{ id }: DeletePublicDashboardPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v1/dashboards/${id}/public`,
method: 'DELETE',
signal,
@@ -346,7 +346,7 @@ export const updatePublicDashboard = (
dashboardtypesUpdatablePublicDashboardDTO?: BodyType<DashboardtypesUpdatablePublicDashboardDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v1/dashboards/${id}/public`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -836,7 +836,7 @@ export const deleteDashboardV2 = (
{ id }: DeleteDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v2/dashboards/${id}`,
method: 'DELETE',
signal,
@@ -1214,7 +1214,7 @@ export const unlockDashboardV2 = (
{ id }: UnlockDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v2/dashboards/${id}/lock`,
method: 'DELETE',
signal,
@@ -1293,7 +1293,7 @@ export const lockDashboardV2 = (
{ id }: LockDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v2/dashboards/${id}/lock`,
method: 'PUT',
signal,
@@ -1471,7 +1471,7 @@ export const unpinDashboardV2 = (
{ id }: UnpinDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v2/users/me/dashboards/${id}/pins`,
method: 'DELETE',
signal,
@@ -1550,7 +1550,7 @@ export const pinDashboardV2 = (
{ id }: PinDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v2/users/me/dashboards/${id}/pins`,
method: 'PUT',
signal,

View File

@@ -37,7 +37,7 @@ export const handleExportRawDataPOST = (
params?: HandleExportRawDataPOSTParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v1/export_raw_data`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },

View File

@@ -680,7 +680,7 @@ export const updateMetricMetadata = (
metricsexplorertypesUpdateMetricMetadataRequestDTO?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v2/metrics/${metricName}/metadata`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },

View File

@@ -203,7 +203,7 @@ export const deleteRole = (
{ id }: DeleteRolePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v1/roles/${id}`,
method: 'DELETE',
signal,
@@ -372,7 +372,7 @@ export const patchRole = (
authtypesPatchableRoleDTO?: BodyType<AuthtypesPatchableRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v1/roles/${id}`,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -572,7 +572,7 @@ export const patchObjects = (
coretypesPatchableObjectsDTO?: BodyType<CoretypesPatchableObjectsDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v1/roles/${id}/relations/${relation}/objects`,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },

View File

@@ -222,7 +222,7 @@ export const deleteServiceAccount = (
{ id }: DeleteServiceAccountPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v1/service_accounts/${id}`,
method: 'DELETE',
signal,
@@ -405,7 +405,7 @@ export const updateServiceAccount = (
serviceaccounttypesPostableServiceAccountDTO?: BodyType<ServiceaccounttypesPostableServiceAccountDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v1/service_accounts/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -707,7 +707,7 @@ export const revokeServiceAccountKey = (
{ id, fid }: RevokeServiceAccountKeyPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v1/service_accounts/${id}/keys/${fid}`,
method: 'DELETE',
signal,
@@ -788,7 +788,7 @@ export const updateServiceAccountKey = (
serviceaccounttypesUpdatableFactorAPIKeyDTO?: BodyType<ServiceaccounttypesUpdatableFactorAPIKeyDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v1/service_accounts/${id}/keys/${fid}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -1090,7 +1090,7 @@ export const deleteServiceAccountRole = (
{ id, rid }: DeleteServiceAccountRolePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v1/service_accounts/${id}/roles/${rid}`,
method: 'DELETE',
signal,
@@ -1254,7 +1254,7 @@ export const updateMyServiceAccount = (
serviceaccounttypesPostableServiceAccountDTO?: BodyType<ServiceaccounttypesPostableServiceAccountDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
return GeneratedAPIInstance<void>({
url: `/api/v1/service_accounts/me`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },

File diff suppressed because one or more lines are too long

View File

@@ -24,13 +24,12 @@ HASTOKEN=23
HAS=24
HASANY=25
HASALL=26
SEARCH=27
BOOL=28
NUMBER=29
QUOTED_TEXT=30
KEY=31
WS=32
FREETEXT=33
BOOL=27
NUMBER=28
QUOTED_TEXT=29
KEY=30
WS=31
FREETEXT=32
'('=1
')'=2
'['=3

File diff suppressed because one or more lines are too long

View File

@@ -24,13 +24,12 @@ HASTOKEN=23
HAS=24
HASANY=25
HASALL=26
SEARCH=27
BOOL=28
NUMBER=29
QUOTED_TEXT=30
KEY=31
WS=32
FREETEXT=33
BOOL=27
NUMBER=28
QUOTED_TEXT=29
KEY=30
WS=31
FREETEXT=32
'('=1
')'=2
'['=3

View File

@@ -1,4 +1,4 @@
// Generated from grammar/FilterQuery.g4 by ANTLR 4.13.2
// Generated from FilterQuery.g4 by ANTLR 4.13.1
// noinspection ES6UnusedImports,JSUnusedGlobalSymbols,JSUnusedLocalSymbols
import {
ATN,
@@ -38,13 +38,12 @@ export default class FilterQueryLexer extends Lexer {
public static readonly HAS = 24;
public static readonly HASANY = 25;
public static readonly HASALL = 26;
public static readonly SEARCH = 27;
public static readonly BOOL = 28;
public static readonly NUMBER = 29;
public static readonly QUOTED_TEXT = 30;
public static readonly KEY = 31;
public static readonly WS = 32;
public static readonly FREETEXT = 33;
public static readonly BOOL = 27;
public static readonly NUMBER = 28;
public static readonly QUOTED_TEXT = 29;
public static readonly KEY = 30;
public static readonly WS = 31;
public static readonly FREETEXT = 32;
public static readonly EOF = Token.EOF;
public static readonly channelNames: string[] = [ "DEFAULT_TOKEN_CHANNEL", "HIDDEN" ];
@@ -69,9 +68,8 @@ export default class FilterQueryLexer extends Lexer {
"AND", "OR",
"HASTOKEN",
"HAS", "HASANY",
"HASALL", "SEARCH",
"BOOL", "NUMBER",
"QUOTED_TEXT",
"HASALL", "BOOL",
"NUMBER", "QUOTED_TEXT",
"KEY", "WS",
"FREETEXT" ];
public static readonly modeNames: string[] = [ "DEFAULT_MODE", ];
@@ -80,8 +78,8 @@ export default class FilterQueryLexer extends Lexer {
"LPAREN", "RPAREN", "LBRACK", "RBRACK", "COMMA", "EQUALS", "NOT_EQUALS",
"NEQ", "LT", "LE", "GT", "GE", "LIKE", "ILIKE", "BETWEEN", "EXISTS", "REGEXP",
"CONTAINS", "IN", "NOT", "AND", "OR", "HASTOKEN", "HAS", "HASANY", "HASALL",
"SEARCH", "BOOL", "SIGN", "NUMBER", "QUOTED_TEXT", "SEGMENT", "EMPTY_BRACKS",
"OLD_JSON_BRACKS", "KEY", "WS", "DIGIT", "FREETEXT",
"BOOL", "SIGN", "NUMBER", "QUOTED_TEXT", "SEGMENT", "EMPTY_BRACKS", "OLD_JSON_BRACKS",
"KEY", "WS", "DIGIT", "FREETEXT",
];
@@ -102,122 +100,119 @@ export default class FilterQueryLexer extends Lexer {
public get modeNames(): string[] { return FilterQueryLexer.modeNames; }
public static readonly _serializedATN: number[] = [4,0,33,329,6,-1,2,0,
public static readonly _serializedATN: number[] = [4,0,32,320,6,-1,2,0,
7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7,6,2,7,7,7,2,8,7,8,2,9,
7,9,2,10,7,10,2,11,7,11,2,12,7,12,2,13,7,13,2,14,7,14,2,15,7,15,2,16,7,
16,2,17,7,17,2,18,7,18,2,19,7,19,2,20,7,20,2,21,7,21,2,22,7,22,2,23,7,23,
2,24,7,24,2,25,7,25,2,26,7,26,2,27,7,27,2,28,7,28,2,29,7,29,2,30,7,30,2,
31,7,31,2,32,7,32,2,33,7,33,2,34,7,34,2,35,7,35,2,36,7,36,2,37,7,37,1,0,
1,0,1,1,1,1,1,2,1,2,1,3,1,3,1,4,1,4,1,5,1,5,1,5,3,5,91,8,5,1,6,1,6,1,6,
1,7,1,7,1,7,1,8,1,8,1,9,1,9,1,9,1,10,1,10,1,11,1,11,1,11,1,12,1,12,1,12,
1,12,1,12,1,13,1,13,1,13,1,13,1,13,1,13,1,14,1,14,1,14,1,14,1,14,1,14,1,
14,1,14,1,15,1,15,1,15,1,15,1,15,1,15,3,15,134,8,15,1,16,1,16,1,16,1,16,
1,16,1,16,1,16,1,17,1,17,1,17,1,17,1,17,1,17,1,17,1,17,3,17,151,8,17,1,
18,1,18,1,18,1,19,1,19,1,19,1,19,1,20,1,20,1,20,1,20,1,21,1,21,1,21,1,22,
1,22,1,22,1,22,1,22,1,22,1,22,1,22,1,22,1,23,1,23,1,23,1,23,1,24,1,24,1,
24,1,24,1,24,1,24,1,24,1,25,1,25,1,25,1,25,1,25,1,25,1,25,1,26,1,26,1,26,
1,26,1,26,1,26,1,26,1,27,1,27,1,27,1,27,1,27,1,27,1,27,1,27,1,27,3,27,210,
8,27,1,28,1,28,1,29,3,29,215,8,29,1,29,4,29,218,8,29,11,29,12,29,219,1,
29,1,29,5,29,224,8,29,10,29,12,29,227,9,29,3,29,229,8,29,1,29,1,29,3,29,
233,8,29,1,29,4,29,236,8,29,11,29,12,29,237,3,29,240,8,29,1,29,3,29,243,
8,29,1,29,1,29,4,29,247,8,29,11,29,12,29,248,1,29,1,29,3,29,253,8,29,1,
29,4,29,256,8,29,11,29,12,29,257,3,29,260,8,29,3,29,262,8,29,1,30,1,30,
1,30,1,30,5,30,268,8,30,10,30,12,30,271,9,30,1,30,1,30,1,30,1,30,1,30,5,
30,278,8,30,10,30,12,30,281,9,30,1,30,3,30,284,8,30,1,31,1,31,5,31,288,
8,31,10,31,12,31,291,9,31,1,32,1,32,1,32,1,33,1,33,1,33,1,33,1,34,1,34,
1,34,1,34,1,34,1,34,1,34,4,34,307,8,34,11,34,12,34,308,5,34,311,8,34,10,
34,12,34,314,9,34,1,35,4,35,317,8,35,11,35,12,35,318,1,35,1,35,1,36,1,36,
1,37,4,37,326,8,37,11,37,12,37,327,0,0,38,1,1,3,2,5,3,7,4,9,5,11,6,13,7,
15,8,17,9,19,10,21,11,23,12,25,13,27,14,29,15,31,16,33,17,35,18,37,19,39,
20,41,21,43,22,45,23,47,24,49,25,51,26,53,27,55,28,57,0,59,29,61,30,63,
0,65,0,67,0,69,31,71,32,73,0,75,33,1,0,29,2,0,76,76,108,108,2,0,73,73,105,
105,2,0,75,75,107,107,2,0,69,69,101,101,2,0,66,66,98,98,2,0,84,84,116,116,
2,0,87,87,119,119,2,0,78,78,110,110,2,0,88,88,120,120,2,0,83,83,115,115,
2,0,82,82,114,114,2,0,71,71,103,103,2,0,80,80,112,112,2,0,67,67,99,99,2,
0,79,79,111,111,2,0,65,65,97,97,2,0,68,68,100,100,2,0,72,72,104,104,2,0,
89,89,121,121,2,0,85,85,117,117,2,0,70,70,102,102,2,0,43,43,45,45,2,0,34,
34,92,92,2,0,39,39,92,92,4,0,35,36,64,90,95,95,97,123,7,0,35,36,45,45,47,
58,64,90,95,95,97,123,125,125,3,0,9,10,13,13,32,32,1,0,48,57,8,0,9,10,13,
13,32,34,39,41,44,44,60,62,91,91,93,93,353,0,1,1,0,0,0,0,3,1,0,0,0,0,5,
1,0,0,0,0,7,1,0,0,0,0,9,1,0,0,0,0,11,1,0,0,0,0,13,1,0,0,0,0,15,1,0,0,0,
0,17,1,0,0,0,0,19,1,0,0,0,0,21,1,0,0,0,0,23,1,0,0,0,0,25,1,0,0,0,0,27,1,
0,0,0,0,29,1,0,0,0,0,31,1,0,0,0,0,33,1,0,0,0,0,35,1,0,0,0,0,37,1,0,0,0,
0,39,1,0,0,0,0,41,1,0,0,0,0,43,1,0,0,0,0,45,1,0,0,0,0,47,1,0,0,0,0,49,1,
0,0,0,0,51,1,0,0,0,0,53,1,0,0,0,0,55,1,0,0,0,0,59,1,0,0,0,0,61,1,0,0,0,
0,69,1,0,0,0,0,71,1,0,0,0,0,75,1,0,0,0,1,77,1,0,0,0,3,79,1,0,0,0,5,81,1,
0,0,0,7,83,1,0,0,0,9,85,1,0,0,0,11,90,1,0,0,0,13,92,1,0,0,0,15,95,1,0,0,
0,17,98,1,0,0,0,19,100,1,0,0,0,21,103,1,0,0,0,23,105,1,0,0,0,25,108,1,0,
0,0,27,113,1,0,0,0,29,119,1,0,0,0,31,127,1,0,0,0,33,135,1,0,0,0,35,142,
1,0,0,0,37,152,1,0,0,0,39,155,1,0,0,0,41,159,1,0,0,0,43,163,1,0,0,0,45,
166,1,0,0,0,47,175,1,0,0,0,49,179,1,0,0,0,51,186,1,0,0,0,53,193,1,0,0,0,
55,209,1,0,0,0,57,211,1,0,0,0,59,261,1,0,0,0,61,283,1,0,0,0,63,285,1,0,
0,0,65,292,1,0,0,0,67,295,1,0,0,0,69,299,1,0,0,0,71,316,1,0,0,0,73,322,
1,0,0,0,75,325,1,0,0,0,77,78,5,40,0,0,78,2,1,0,0,0,79,80,5,41,0,0,80,4,
1,0,0,0,81,82,5,91,0,0,82,6,1,0,0,0,83,84,5,93,0,0,84,8,1,0,0,0,85,86,5,
44,0,0,86,10,1,0,0,0,87,91,5,61,0,0,88,89,5,61,0,0,89,91,5,61,0,0,90,87,
1,0,0,0,90,88,1,0,0,0,91,12,1,0,0,0,92,93,5,33,0,0,93,94,5,61,0,0,94,14,
1,0,0,0,95,96,5,60,0,0,96,97,5,62,0,0,97,16,1,0,0,0,98,99,5,60,0,0,99,18,
1,0,0,0,100,101,5,60,0,0,101,102,5,61,0,0,102,20,1,0,0,0,103,104,5,62,0,
0,104,22,1,0,0,0,105,106,5,62,0,0,106,107,5,61,0,0,107,24,1,0,0,0,108,109,
7,0,0,0,109,110,7,1,0,0,110,111,7,2,0,0,111,112,7,3,0,0,112,26,1,0,0,0,
113,114,7,1,0,0,114,115,7,0,0,0,115,116,7,1,0,0,116,117,7,2,0,0,117,118,
7,3,0,0,118,28,1,0,0,0,119,120,7,4,0,0,120,121,7,3,0,0,121,122,7,5,0,0,
122,123,7,6,0,0,123,124,7,3,0,0,124,125,7,3,0,0,125,126,7,7,0,0,126,30,
1,0,0,0,127,128,7,3,0,0,128,129,7,8,0,0,129,130,7,1,0,0,130,131,7,9,0,0,
131,133,7,5,0,0,132,134,7,9,0,0,133,132,1,0,0,0,133,134,1,0,0,0,134,32,
1,0,0,0,135,136,7,10,0,0,136,137,7,3,0,0,137,138,7,11,0,0,138,139,7,3,0,
0,139,140,7,8,0,0,140,141,7,12,0,0,141,34,1,0,0,0,142,143,7,13,0,0,143,
144,7,14,0,0,144,145,7,7,0,0,145,146,7,5,0,0,146,147,7,15,0,0,147,148,7,
1,0,0,148,150,7,7,0,0,149,151,7,9,0,0,150,149,1,0,0,0,150,151,1,0,0,0,151,
36,1,0,0,0,152,153,7,1,0,0,153,154,7,7,0,0,154,38,1,0,0,0,155,156,7,7,0,
0,156,157,7,14,0,0,157,158,7,5,0,0,158,40,1,0,0,0,159,160,7,15,0,0,160,
161,7,7,0,0,161,162,7,16,0,0,162,42,1,0,0,0,163,164,7,14,0,0,164,165,7,
10,0,0,165,44,1,0,0,0,166,167,7,17,0,0,167,168,7,15,0,0,168,169,7,9,0,0,
169,170,7,5,0,0,170,171,7,14,0,0,171,172,7,2,0,0,172,173,7,3,0,0,173,174,
7,7,0,0,174,46,1,0,0,0,175,176,7,17,0,0,176,177,7,15,0,0,177,178,7,9,0,
0,178,48,1,0,0,0,179,180,7,17,0,0,180,181,7,15,0,0,181,182,7,9,0,0,182,
183,7,15,0,0,183,184,7,7,0,0,184,185,7,18,0,0,185,50,1,0,0,0,186,187,7,
17,0,0,187,188,7,15,0,0,188,189,7,9,0,0,189,190,7,15,0,0,190,191,7,0,0,
0,191,192,7,0,0,0,192,52,1,0,0,0,193,194,7,9,0,0,194,195,7,3,0,0,195,196,
7,15,0,0,196,197,7,10,0,0,197,198,7,13,0,0,198,199,7,17,0,0,199,54,1,0,
0,0,200,201,7,5,0,0,201,202,7,10,0,0,202,203,7,19,0,0,203,210,7,3,0,0,204,
205,7,20,0,0,205,206,7,15,0,0,206,207,7,0,0,0,207,208,7,9,0,0,208,210,7,
3,0,0,209,200,1,0,0,0,209,204,1,0,0,0,210,56,1,0,0,0,211,212,7,21,0,0,212,
58,1,0,0,0,213,215,3,57,28,0,214,213,1,0,0,0,214,215,1,0,0,0,215,217,1,
0,0,0,216,218,3,73,36,0,217,216,1,0,0,0,218,219,1,0,0,0,219,217,1,0,0,0,
219,220,1,0,0,0,220,228,1,0,0,0,221,225,5,46,0,0,222,224,3,73,36,0,223,
222,1,0,0,0,224,227,1,0,0,0,225,223,1,0,0,0,225,226,1,0,0,0,226,229,1,0,
0,0,227,225,1,0,0,0,228,221,1,0,0,0,228,229,1,0,0,0,229,239,1,0,0,0,230,
232,7,3,0,0,231,233,3,57,28,0,232,231,1,0,0,0,232,233,1,0,0,0,233,235,1,
0,0,0,234,236,3,73,36,0,235,234,1,0,0,0,236,237,1,0,0,0,237,235,1,0,0,0,
237,238,1,0,0,0,238,240,1,0,0,0,239,230,1,0,0,0,239,240,1,0,0,0,240,262,
1,0,0,0,241,243,3,57,28,0,242,241,1,0,0,0,242,243,1,0,0,0,243,244,1,0,0,
0,244,246,5,46,0,0,245,247,3,73,36,0,246,245,1,0,0,0,247,248,1,0,0,0,248,
246,1,0,0,0,248,249,1,0,0,0,249,259,1,0,0,0,250,252,7,3,0,0,251,253,3,57,
28,0,252,251,1,0,0,0,252,253,1,0,0,0,253,255,1,0,0,0,254,256,3,73,36,0,
255,254,1,0,0,0,256,257,1,0,0,0,257,255,1,0,0,0,257,258,1,0,0,0,258,260,
1,0,0,0,259,250,1,0,0,0,259,260,1,0,0,0,260,262,1,0,0,0,261,214,1,0,0,0,
261,242,1,0,0,0,262,60,1,0,0,0,263,269,5,34,0,0,264,268,8,22,0,0,265,266,
5,92,0,0,266,268,9,0,0,0,267,264,1,0,0,0,267,265,1,0,0,0,268,271,1,0,0,
0,269,267,1,0,0,0,269,270,1,0,0,0,270,272,1,0,0,0,271,269,1,0,0,0,272,284,
5,34,0,0,273,279,5,39,0,0,274,278,8,23,0,0,275,276,5,92,0,0,276,278,9,0,
0,0,277,274,1,0,0,0,277,275,1,0,0,0,278,281,1,0,0,0,279,277,1,0,0,0,279,
280,1,0,0,0,280,282,1,0,0,0,281,279,1,0,0,0,282,284,5,39,0,0,283,263,1,
0,0,0,283,273,1,0,0,0,284,62,1,0,0,0,285,289,7,24,0,0,286,288,7,25,0,0,
287,286,1,0,0,0,288,291,1,0,0,0,289,287,1,0,0,0,289,290,1,0,0,0,290,64,
1,0,0,0,291,289,1,0,0,0,292,293,5,91,0,0,293,294,5,93,0,0,294,66,1,0,0,
0,295,296,5,91,0,0,296,297,5,42,0,0,297,298,5,93,0,0,298,68,1,0,0,0,299,
312,3,63,31,0,300,301,5,46,0,0,301,311,3,63,31,0,302,311,3,65,32,0,303,
311,3,67,33,0,304,306,5,46,0,0,305,307,3,73,36,0,306,305,1,0,0,0,307,308,
1,0,0,0,308,306,1,0,0,0,308,309,1,0,0,0,309,311,1,0,0,0,310,300,1,0,0,0,
310,302,1,0,0,0,310,303,1,0,0,0,310,304,1,0,0,0,311,314,1,0,0,0,312,310,
1,0,0,0,312,313,1,0,0,0,313,70,1,0,0,0,314,312,1,0,0,0,315,317,7,26,0,0,
316,315,1,0,0,0,317,318,1,0,0,0,318,316,1,0,0,0,318,319,1,0,0,0,319,320,
1,0,0,0,320,321,6,35,0,0,321,72,1,0,0,0,322,323,7,27,0,0,323,74,1,0,0,0,
324,326,8,28,0,0,325,324,1,0,0,0,326,327,1,0,0,0,327,325,1,0,0,0,327,328,
1,0,0,0,328,76,1,0,0,0,29,0,90,133,150,209,214,219,225,228,232,237,239,
242,248,252,257,259,261,267,269,277,279,283,289,308,310,312,318,327,1,6,
0,0];
31,7,31,2,32,7,32,2,33,7,33,2,34,7,34,2,35,7,35,2,36,7,36,1,0,1,0,1,1,1,
1,1,2,1,2,1,3,1,3,1,4,1,4,1,5,1,5,1,5,3,5,89,8,5,1,6,1,6,1,6,1,7,1,7,1,
7,1,8,1,8,1,9,1,9,1,9,1,10,1,10,1,11,1,11,1,11,1,12,1,12,1,12,1,12,1,12,
1,13,1,13,1,13,1,13,1,13,1,13,1,14,1,14,1,14,1,14,1,14,1,14,1,14,1,14,1,
15,1,15,1,15,1,15,1,15,1,15,3,15,132,8,15,1,16,1,16,1,16,1,16,1,16,1,16,
1,16,1,17,1,17,1,17,1,17,1,17,1,17,1,17,1,17,3,17,149,8,17,1,18,1,18,1,
18,1,19,1,19,1,19,1,19,1,20,1,20,1,20,1,20,1,21,1,21,1,21,1,22,1,22,1,22,
1,22,1,22,1,22,1,22,1,22,1,22,1,23,1,23,1,23,1,23,1,24,1,24,1,24,1,24,1,
24,1,24,1,24,1,25,1,25,1,25,1,25,1,25,1,25,1,25,1,26,1,26,1,26,1,26,1,26,
1,26,1,26,1,26,1,26,3,26,201,8,26,1,27,1,27,1,28,3,28,206,8,28,1,28,4,28,
209,8,28,11,28,12,28,210,1,28,1,28,5,28,215,8,28,10,28,12,28,218,9,28,3,
28,220,8,28,1,28,1,28,3,28,224,8,28,1,28,4,28,227,8,28,11,28,12,28,228,
3,28,231,8,28,1,28,3,28,234,8,28,1,28,1,28,4,28,238,8,28,11,28,12,28,239,
1,28,1,28,3,28,244,8,28,1,28,4,28,247,8,28,11,28,12,28,248,3,28,251,8,28,
3,28,253,8,28,1,29,1,29,1,29,1,29,5,29,259,8,29,10,29,12,29,262,9,29,1,
29,1,29,1,29,1,29,1,29,5,29,269,8,29,10,29,12,29,272,9,29,1,29,3,29,275,
8,29,1,30,1,30,5,30,279,8,30,10,30,12,30,282,9,30,1,31,1,31,1,31,1,32,1,
32,1,32,1,32,1,33,1,33,1,33,1,33,1,33,1,33,1,33,4,33,298,8,33,11,33,12,
33,299,5,33,302,8,33,10,33,12,33,305,9,33,1,34,4,34,308,8,34,11,34,12,34,
309,1,34,1,34,1,35,1,35,1,36,4,36,317,8,36,11,36,12,36,318,0,0,37,1,1,3,
2,5,3,7,4,9,5,11,6,13,7,15,8,17,9,19,10,21,11,23,12,25,13,27,14,29,15,31,
16,33,17,35,18,37,19,39,20,41,21,43,22,45,23,47,24,49,25,51,26,53,27,55,
0,57,28,59,29,61,0,63,0,65,0,67,30,69,31,71,0,73,32,1,0,29,2,0,76,76,108,
108,2,0,73,73,105,105,2,0,75,75,107,107,2,0,69,69,101,101,2,0,66,66,98,
98,2,0,84,84,116,116,2,0,87,87,119,119,2,0,78,78,110,110,2,0,88,88,120,
120,2,0,83,83,115,115,2,0,82,82,114,114,2,0,71,71,103,103,2,0,80,80,112,
112,2,0,67,67,99,99,2,0,79,79,111,111,2,0,65,65,97,97,2,0,68,68,100,100,
2,0,72,72,104,104,2,0,89,89,121,121,2,0,85,85,117,117,2,0,70,70,102,102,
2,0,43,43,45,45,2,0,34,34,92,92,2,0,39,39,92,92,4,0,35,36,64,90,95,95,97,
123,7,0,35,36,45,45,47,58,64,90,95,95,97,123,125,125,3,0,9,10,13,13,32,
32,1,0,48,57,8,0,9,10,13,13,32,34,39,41,44,44,60,62,91,91,93,93,344,0,1,
1,0,0,0,0,3,1,0,0,0,0,5,1,0,0,0,0,7,1,0,0,0,0,9,1,0,0,0,0,11,1,0,0,0,0,
13,1,0,0,0,0,15,1,0,0,0,0,17,1,0,0,0,0,19,1,0,0,0,0,21,1,0,0,0,0,23,1,0,
0,0,0,25,1,0,0,0,0,27,1,0,0,0,0,29,1,0,0,0,0,31,1,0,0,0,0,33,1,0,0,0,0,
35,1,0,0,0,0,37,1,0,0,0,0,39,1,0,0,0,0,41,1,0,0,0,0,43,1,0,0,0,0,45,1,0,
0,0,0,47,1,0,0,0,0,49,1,0,0,0,0,51,1,0,0,0,0,53,1,0,0,0,0,57,1,0,0,0,0,
59,1,0,0,0,0,67,1,0,0,0,0,69,1,0,0,0,0,73,1,0,0,0,1,75,1,0,0,0,3,77,1,0,
0,0,5,79,1,0,0,0,7,81,1,0,0,0,9,83,1,0,0,0,11,88,1,0,0,0,13,90,1,0,0,0,
15,93,1,0,0,0,17,96,1,0,0,0,19,98,1,0,0,0,21,101,1,0,0,0,23,103,1,0,0,0,
25,106,1,0,0,0,27,111,1,0,0,0,29,117,1,0,0,0,31,125,1,0,0,0,33,133,1,0,
0,0,35,140,1,0,0,0,37,150,1,0,0,0,39,153,1,0,0,0,41,157,1,0,0,0,43,161,
1,0,0,0,45,164,1,0,0,0,47,173,1,0,0,0,49,177,1,0,0,0,51,184,1,0,0,0,53,
200,1,0,0,0,55,202,1,0,0,0,57,252,1,0,0,0,59,274,1,0,0,0,61,276,1,0,0,0,
63,283,1,0,0,0,65,286,1,0,0,0,67,290,1,0,0,0,69,307,1,0,0,0,71,313,1,0,
0,0,73,316,1,0,0,0,75,76,5,40,0,0,76,2,1,0,0,0,77,78,5,41,0,0,78,4,1,0,
0,0,79,80,5,91,0,0,80,6,1,0,0,0,81,82,5,93,0,0,82,8,1,0,0,0,83,84,5,44,
0,0,84,10,1,0,0,0,85,89,5,61,0,0,86,87,5,61,0,0,87,89,5,61,0,0,88,85,1,
0,0,0,88,86,1,0,0,0,89,12,1,0,0,0,90,91,5,33,0,0,91,92,5,61,0,0,92,14,1,
0,0,0,93,94,5,60,0,0,94,95,5,62,0,0,95,16,1,0,0,0,96,97,5,60,0,0,97,18,
1,0,0,0,98,99,5,60,0,0,99,100,5,61,0,0,100,20,1,0,0,0,101,102,5,62,0,0,
102,22,1,0,0,0,103,104,5,62,0,0,104,105,5,61,0,0,105,24,1,0,0,0,106,107,
7,0,0,0,107,108,7,1,0,0,108,109,7,2,0,0,109,110,7,3,0,0,110,26,1,0,0,0,
111,112,7,1,0,0,112,113,7,0,0,0,113,114,7,1,0,0,114,115,7,2,0,0,115,116,
7,3,0,0,116,28,1,0,0,0,117,118,7,4,0,0,118,119,7,3,0,0,119,120,7,5,0,0,
120,121,7,6,0,0,121,122,7,3,0,0,122,123,7,3,0,0,123,124,7,7,0,0,124,30,
1,0,0,0,125,126,7,3,0,0,126,127,7,8,0,0,127,128,7,1,0,0,128,129,7,9,0,0,
129,131,7,5,0,0,130,132,7,9,0,0,131,130,1,0,0,0,131,132,1,0,0,0,132,32,
1,0,0,0,133,134,7,10,0,0,134,135,7,3,0,0,135,136,7,11,0,0,136,137,7,3,0,
0,137,138,7,8,0,0,138,139,7,12,0,0,139,34,1,0,0,0,140,141,7,13,0,0,141,
142,7,14,0,0,142,143,7,7,0,0,143,144,7,5,0,0,144,145,7,15,0,0,145,146,7,
1,0,0,146,148,7,7,0,0,147,149,7,9,0,0,148,147,1,0,0,0,148,149,1,0,0,0,149,
36,1,0,0,0,150,151,7,1,0,0,151,152,7,7,0,0,152,38,1,0,0,0,153,154,7,7,0,
0,154,155,7,14,0,0,155,156,7,5,0,0,156,40,1,0,0,0,157,158,7,15,0,0,158,
159,7,7,0,0,159,160,7,16,0,0,160,42,1,0,0,0,161,162,7,14,0,0,162,163,7,
10,0,0,163,44,1,0,0,0,164,165,7,17,0,0,165,166,7,15,0,0,166,167,7,9,0,0,
167,168,7,5,0,0,168,169,7,14,0,0,169,170,7,2,0,0,170,171,7,3,0,0,171,172,
7,7,0,0,172,46,1,0,0,0,173,174,7,17,0,0,174,175,7,15,0,0,175,176,7,9,0,
0,176,48,1,0,0,0,177,178,7,17,0,0,178,179,7,15,0,0,179,180,7,9,0,0,180,
181,7,15,0,0,181,182,7,7,0,0,182,183,7,18,0,0,183,50,1,0,0,0,184,185,7,
17,0,0,185,186,7,15,0,0,186,187,7,9,0,0,187,188,7,15,0,0,188,189,7,0,0,
0,189,190,7,0,0,0,190,52,1,0,0,0,191,192,7,5,0,0,192,193,7,10,0,0,193,194,
7,19,0,0,194,201,7,3,0,0,195,196,7,20,0,0,196,197,7,15,0,0,197,198,7,0,
0,0,198,199,7,9,0,0,199,201,7,3,0,0,200,191,1,0,0,0,200,195,1,0,0,0,201,
54,1,0,0,0,202,203,7,21,0,0,203,56,1,0,0,0,204,206,3,55,27,0,205,204,1,
0,0,0,205,206,1,0,0,0,206,208,1,0,0,0,207,209,3,71,35,0,208,207,1,0,0,0,
209,210,1,0,0,0,210,208,1,0,0,0,210,211,1,0,0,0,211,219,1,0,0,0,212,216,
5,46,0,0,213,215,3,71,35,0,214,213,1,0,0,0,215,218,1,0,0,0,216,214,1,0,
0,0,216,217,1,0,0,0,217,220,1,0,0,0,218,216,1,0,0,0,219,212,1,0,0,0,219,
220,1,0,0,0,220,230,1,0,0,0,221,223,7,3,0,0,222,224,3,55,27,0,223,222,1,
0,0,0,223,224,1,0,0,0,224,226,1,0,0,0,225,227,3,71,35,0,226,225,1,0,0,0,
227,228,1,0,0,0,228,226,1,0,0,0,228,229,1,0,0,0,229,231,1,0,0,0,230,221,
1,0,0,0,230,231,1,0,0,0,231,253,1,0,0,0,232,234,3,55,27,0,233,232,1,0,0,
0,233,234,1,0,0,0,234,235,1,0,0,0,235,237,5,46,0,0,236,238,3,71,35,0,237,
236,1,0,0,0,238,239,1,0,0,0,239,237,1,0,0,0,239,240,1,0,0,0,240,250,1,0,
0,0,241,243,7,3,0,0,242,244,3,55,27,0,243,242,1,0,0,0,243,244,1,0,0,0,244,
246,1,0,0,0,245,247,3,71,35,0,246,245,1,0,0,0,247,248,1,0,0,0,248,246,1,
0,0,0,248,249,1,0,0,0,249,251,1,0,0,0,250,241,1,0,0,0,250,251,1,0,0,0,251,
253,1,0,0,0,252,205,1,0,0,0,252,233,1,0,0,0,253,58,1,0,0,0,254,260,5,34,
0,0,255,259,8,22,0,0,256,257,5,92,0,0,257,259,9,0,0,0,258,255,1,0,0,0,258,
256,1,0,0,0,259,262,1,0,0,0,260,258,1,0,0,0,260,261,1,0,0,0,261,263,1,0,
0,0,262,260,1,0,0,0,263,275,5,34,0,0,264,270,5,39,0,0,265,269,8,23,0,0,
266,267,5,92,0,0,267,269,9,0,0,0,268,265,1,0,0,0,268,266,1,0,0,0,269,272,
1,0,0,0,270,268,1,0,0,0,270,271,1,0,0,0,271,273,1,0,0,0,272,270,1,0,0,0,
273,275,5,39,0,0,274,254,1,0,0,0,274,264,1,0,0,0,275,60,1,0,0,0,276,280,
7,24,0,0,277,279,7,25,0,0,278,277,1,0,0,0,279,282,1,0,0,0,280,278,1,0,0,
0,280,281,1,0,0,0,281,62,1,0,0,0,282,280,1,0,0,0,283,284,5,91,0,0,284,285,
5,93,0,0,285,64,1,0,0,0,286,287,5,91,0,0,287,288,5,42,0,0,288,289,5,93,
0,0,289,66,1,0,0,0,290,303,3,61,30,0,291,292,5,46,0,0,292,302,3,61,30,0,
293,302,3,63,31,0,294,302,3,65,32,0,295,297,5,46,0,0,296,298,3,71,35,0,
297,296,1,0,0,0,298,299,1,0,0,0,299,297,1,0,0,0,299,300,1,0,0,0,300,302,
1,0,0,0,301,291,1,0,0,0,301,293,1,0,0,0,301,294,1,0,0,0,301,295,1,0,0,0,
302,305,1,0,0,0,303,301,1,0,0,0,303,304,1,0,0,0,304,68,1,0,0,0,305,303,
1,0,0,0,306,308,7,26,0,0,307,306,1,0,0,0,308,309,1,0,0,0,309,307,1,0,0,
0,309,310,1,0,0,0,310,311,1,0,0,0,311,312,6,34,0,0,312,70,1,0,0,0,313,314,
7,27,0,0,314,72,1,0,0,0,315,317,8,28,0,0,316,315,1,0,0,0,317,318,1,0,0,
0,318,316,1,0,0,0,318,319,1,0,0,0,319,74,1,0,0,0,29,0,88,131,148,200,205,
210,216,219,223,228,230,233,239,243,248,250,252,258,260,268,270,274,280,
299,301,303,309,318,1,6,0,0];
private static __ATN: ATN;
public static get _ATN(): ATN {

View File

@@ -1,26 +1,25 @@
// Generated from grammar/FilterQuery.g4 by ANTLR 4.13.2
// Generated from FilterQuery.g4 by ANTLR 4.13.1
import {ParseTreeListener} from "antlr4";
import { QueryContext } from "./FilterQueryParser.js";
import { ExpressionContext } from "./FilterQueryParser.js";
import { OrExpressionContext } from "./FilterQueryParser.js";
import { AndExpressionContext } from "./FilterQueryParser.js";
import { UnaryExpressionContext } from "./FilterQueryParser.js";
import { PrimaryContext } from "./FilterQueryParser.js";
import { ComparisonContext } from "./FilterQueryParser.js";
import { InClauseContext } from "./FilterQueryParser.js";
import { NotInClauseContext } from "./FilterQueryParser.js";
import { ValueListContext } from "./FilterQueryParser.js";
import { FreeTextContext } from "./FilterQueryParser.js";
import { FunctionCallContext } from "./FilterQueryParser.js";
import { FullTextContext } from "./FilterQueryParser.js";
import { FunctionParamListContext } from "./FilterQueryParser.js";
import { FunctionParamContext } from "./FilterQueryParser.js";
import { ArrayContext } from "./FilterQueryParser.js";
import { ValueContext } from "./FilterQueryParser.js";
import { KeyContext } from "./FilterQueryParser.js";
import { QueryContext } from "./FilterQueryParser";
import { ExpressionContext } from "./FilterQueryParser";
import { OrExpressionContext } from "./FilterQueryParser";
import { AndExpressionContext } from "./FilterQueryParser";
import { UnaryExpressionContext } from "./FilterQueryParser";
import { PrimaryContext } from "./FilterQueryParser";
import { ComparisonContext } from "./FilterQueryParser";
import { InClauseContext } from "./FilterQueryParser";
import { NotInClauseContext } from "./FilterQueryParser";
import { ValueListContext } from "./FilterQueryParser";
import { FullTextContext } from "./FilterQueryParser";
import { FunctionCallContext } from "./FilterQueryParser";
import { FunctionParamListContext } from "./FilterQueryParser";
import { FunctionParamContext } from "./FilterQueryParser";
import { ArrayContext } from "./FilterQueryParser";
import { ValueContext } from "./FilterQueryParser";
import { KeyContext } from "./FilterQueryParser";
/**
@@ -129,15 +128,15 @@ export default class FilterQueryListener extends ParseTreeListener {
*/
exitValueList?: (ctx: ValueListContext) => void;
/**
* Enter a parse tree produced by `FilterQueryParser.freeText`.
* Enter a parse tree produced by `FilterQueryParser.fullText`.
* @param ctx the parse tree
*/
enterFreeText?: (ctx: FreeTextContext) => void;
enterFullText?: (ctx: FullTextContext) => void;
/**
* Exit a parse tree produced by `FilterQueryParser.freeText`.
* Exit a parse tree produced by `FilterQueryParser.fullText`.
* @param ctx the parse tree
*/
exitFreeText?: (ctx: FreeTextContext) => void;
exitFullText?: (ctx: FullTextContext) => void;
/**
* Enter a parse tree produced by `FilterQueryParser.functionCall`.
* @param ctx the parse tree
@@ -148,16 +147,6 @@ export default class FilterQueryListener extends ParseTreeListener {
* @param ctx the parse tree
*/
exitFunctionCall?: (ctx: FunctionCallContext) => void;
/**
* Enter a parse tree produced by `FilterQueryParser.fullText`.
* @param ctx the parse tree
*/
enterFullText?: (ctx: FullTextContext) => void;
/**
* Exit a parse tree produced by `FilterQueryParser.fullText`.
* @param ctx the parse tree
*/
exitFullText?: (ctx: FullTextContext) => void;
/**
* Enter a parse tree produced by `FilterQueryParser.functionParamList`.
* @param ctx the parse tree

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,25 @@
// Generated from grammar/FilterQuery.g4 by ANTLR 4.13.2
// Generated from FilterQuery.g4 by ANTLR 4.13.1
import {ParseTreeVisitor} from 'antlr4';
import { QueryContext } from "./FilterQueryParser.js";
import { ExpressionContext } from "./FilterQueryParser.js";
import { OrExpressionContext } from "./FilterQueryParser.js";
import { AndExpressionContext } from "./FilterQueryParser.js";
import { UnaryExpressionContext } from "./FilterQueryParser.js";
import { PrimaryContext } from "./FilterQueryParser.js";
import { ComparisonContext } from "./FilterQueryParser.js";
import { InClauseContext } from "./FilterQueryParser.js";
import { NotInClauseContext } from "./FilterQueryParser.js";
import { ValueListContext } from "./FilterQueryParser.js";
import { FreeTextContext } from "./FilterQueryParser.js";
import { FunctionCallContext } from "./FilterQueryParser.js";
import { FullTextContext } from "./FilterQueryParser.js";
import { FunctionParamListContext } from "./FilterQueryParser.js";
import { FunctionParamContext } from "./FilterQueryParser.js";
import { ArrayContext } from "./FilterQueryParser.js";
import { ValueContext } from "./FilterQueryParser.js";
import { KeyContext } from "./FilterQueryParser.js";
import { QueryContext } from "./FilterQueryParser";
import { ExpressionContext } from "./FilterQueryParser";
import { OrExpressionContext } from "./FilterQueryParser";
import { AndExpressionContext } from "./FilterQueryParser";
import { UnaryExpressionContext } from "./FilterQueryParser";
import { PrimaryContext } from "./FilterQueryParser";
import { ComparisonContext } from "./FilterQueryParser";
import { InClauseContext } from "./FilterQueryParser";
import { NotInClauseContext } from "./FilterQueryParser";
import { ValueListContext } from "./FilterQueryParser";
import { FullTextContext } from "./FilterQueryParser";
import { FunctionCallContext } from "./FilterQueryParser";
import { FunctionParamListContext } from "./FilterQueryParser";
import { FunctionParamContext } from "./FilterQueryParser";
import { ArrayContext } from "./FilterQueryParser";
import { ValueContext } from "./FilterQueryParser";
import { KeyContext } from "./FilterQueryParser";
/**
@@ -92,23 +91,17 @@ export default class FilterQueryVisitor<Result> extends ParseTreeVisitor<Result>
*/
visitValueList?: (ctx: ValueListContext) => Result;
/**
* Visit a parse tree produced by `FilterQueryParser.freeText`.
* Visit a parse tree produced by `FilterQueryParser.fullText`.
* @param ctx the parse tree
* @return the visitor result
*/
visitFreeText?: (ctx: FreeTextContext) => Result;
visitFullText?: (ctx: FullTextContext) => Result;
/**
* Visit a parse tree produced by `FilterQueryParser.functionCall`.
* @param ctx the parse tree
* @return the visitor result
*/
visitFunctionCall?: (ctx: FunctionCallContext) => Result;
/**
* Visit a parse tree produced by `FilterQueryParser.fullText`.
* @param ctx the parse tree
* @return the visitor result
*/
visitFullText?: (ctx: FullTextContext) => Result;
/**
* Visit a parse tree produced by `FilterQueryParser.functionParamList`.
* @param ctx the parse tree

View File

@@ -32,13 +32,12 @@ unaryExpression
;
// Primary constructs: grouped expressions, a comparison (key op value),
// a function call, a fullText (search() call), or a free-text search string
// a function call, or a full-text string
primary
: LPAREN orExpression RPAREN
| comparison
| functionCall
| fullText
| freeText
| key
| value
;
@@ -94,9 +93,9 @@ valueList
: value ( COMMA value )*
;
// Free-text search: a standalone quoted string or bare word is allowed as a "primary"
// Full-text search: a standalone quoted string is allowed as a "primary"
// e.g. `"Waiting for response" http.status_code=200`
freeText
fullText
: QUOTED_TEXT
| FREETEXT
;
@@ -111,13 +110,6 @@ functionCall
: (HASTOKEN | HAS | HASANY | HASALL) LPAREN functionParamList RPAREN
;
/*
* Full-text search call: search() function
*/
fullText
: SEARCH LPAREN functionParamList RPAREN
;
// Function parameters can be keys, single scalar values, or arrays
functionParamList
: functionParam ( COMMA functionParam )*
@@ -192,7 +184,6 @@ HASTOKEN : [Hh][Aa][Ss][Tt][Oo][Kk][Ee][Nn];
HAS : [Hh][Aa][Ss] ;
HASANY : [Hh][Aa][Ss][Aa][Nn][Yy] ;
HASALL : [Hh][Aa][Ss][Aa][Ll][Ll] ;
SEARCH : [Ss][Ee][Aa][Rr][Cc][Hh] ;
// Potential boolean constants
BOOL

View File

@@ -175,8 +175,6 @@ func (r *WhereClauseRewriter) VisitPrimary(ctx *parser.PrimaryContext) interface
ctx.FunctionCall().Accept(r)
} else if ctx.FullText() != nil {
ctx.FullText().Accept(r)
} else if ctx.FreeText() != nil {
ctx.FreeText().Accept(r)
} else if ctx.Key() != nil {
ctx.Key().Accept(r)
} else if ctx.Value() != nil {
@@ -360,19 +358,9 @@ func (r *WhereClauseRewriter) VisitValueList(ctx *parser.ValueListContext) inter
return nil
}
// VisitFreeText visits free text expressions.
func (r *WhereClauseRewriter) VisitFreeText(ctx *parser.FreeTextContext) interface{} {
r.rewritten.WriteString(ctx.GetText())
return nil
}
// VisitFullText visits search() calls.
// VisitFullText visits full text expressions.
func (r *WhereClauseRewriter) VisitFullText(ctx *parser.FullTextContext) interface{} {
r.rewritten.WriteString("search(")
if ctx.FunctionParamList() != nil {
ctx.FunctionParamList().Accept(r)
}
r.rewritten.WriteString(")")
r.rewritten.WriteString(ctx.GetText())
return nil
}

View File

@@ -113,9 +113,11 @@ func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
openapi.WithHTTPStatus(handler.openAPIDef.SuccessStatusCode),
)
} else {
// No response body (e.g. 204 No Content): omit the content type so the
// spec doesn't declare a body for a bodyless response, which would make
// clients try to decode an empty payload.
opCtx.AddRespStructure(
nil,
openapi.WithContentType(handler.openAPIDef.ResponseContentType),
openapi.WithHTTPStatus(handler.openAPIDef.SuccessStatusCode),
)
}

View File

@@ -392,7 +392,7 @@ func (m *module) buildFilterClause(ctx context.Context, filter *qbtypes.Filter,
Logger: m.logger,
FieldMapper: m.fieldMapper,
ConditionBuilder: m.condBuilder,
FreeTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "metric_name", FieldContext: telemetrytypes.FieldContextMetric},
FullTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "metric_name", FieldContext: telemetrytypes.FieldContextMetric},
FieldKeys: keys,
StartNs: querybuilder.ToNanoSecs(uint64(startMillis)),
EndNs: querybuilder.ToNanoSecs(uint64(endMillis)),

View File

@@ -936,7 +936,7 @@ func (m *module) buildFilterClause(ctx context.Context, filter *qbtypes.Filter,
Logger: m.logger,
FieldMapper: m.fieldMapper,
ConditionBuilder: m.condBuilder,
FreeTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "metric_name", FieldContext: telemetrytypes.FieldContextMetric},
FullTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "metric_name", FieldContext: telemetrytypes.FieldContextMetric},
FieldKeys: keys,
StartNs: querybuilder.ToNanoSecs(uint64(startMillis)),
EndNs: querybuilder.ToNanoSecs(uint64(endMillis)),

View File

@@ -104,4 +104,3 @@ func (c *conditionBuilder) ConditionFor(
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported operator: %v", operator)
}

View File

@@ -501,7 +501,7 @@ func (s *store) buildFilterClause(ctx context.Context, filter qbtypes.Filter, st
FieldMapper: s.fieldMapper,
ConditionBuilder: s.conditionBuilder,
FieldKeys: fieldKeys,
FreeTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "labels", FieldContext: telemetrytypes.FieldContextAttribute},
FullTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "labels", FieldContext: telemetrytypes.FieldContextAttribute},
}
opts.StartNs = querybuilder.ToNanoSecs(uint64(startMillis))

File diff suppressed because one or more lines are too long

View File

@@ -24,13 +24,12 @@ HASTOKEN=23
HAS=24
HASANY=25
HASALL=26
SEARCH=27
BOOL=28
NUMBER=29
QUOTED_TEXT=30
KEY=31
WS=32
FREETEXT=33
BOOL=27
NUMBER=28
QUOTED_TEXT=29
KEY=30
WS=31
FREETEXT=32
'('=1
')'=2
'['=3

File diff suppressed because one or more lines are too long

View File

@@ -24,13 +24,12 @@ HASTOKEN=23
HAS=24
HASANY=25
HASALL=26
SEARCH=27
BOOL=28
NUMBER=29
QUOTED_TEXT=30
KEY=31
WS=32
FREETEXT=33
BOOL=27
NUMBER=28
QUOTED_TEXT=29
KEY=30
WS=31
FREETEXT=32
'('=1
')'=2
'['=3

View File

@@ -81,11 +81,11 @@ func (s *BaseFilterQueryListener) EnterValueList(ctx *ValueListContext) {}
// ExitValueList is called when production valueList is exited.
func (s *BaseFilterQueryListener) ExitValueList(ctx *ValueListContext) {}
// EnterFreeText is called when production freeText is entered.
func (s *BaseFilterQueryListener) EnterFreeText(ctx *FreeTextContext) {}
// EnterFullText is called when production fullText is entered.
func (s *BaseFilterQueryListener) EnterFullText(ctx *FullTextContext) {}
// ExitFreeText is called when production freeText is exited.
func (s *BaseFilterQueryListener) ExitFreeText(ctx *FreeTextContext) {}
// ExitFullText is called when production fullText is exited.
func (s *BaseFilterQueryListener) ExitFullText(ctx *FullTextContext) {}
// EnterFunctionCall is called when production functionCall is entered.
func (s *BaseFilterQueryListener) EnterFunctionCall(ctx *FunctionCallContext) {}
@@ -93,12 +93,6 @@ func (s *BaseFilterQueryListener) EnterFunctionCall(ctx *FunctionCallContext) {}
// ExitFunctionCall is called when production functionCall is exited.
func (s *BaseFilterQueryListener) ExitFunctionCall(ctx *FunctionCallContext) {}
// EnterFullText is called when production fullText is entered.
func (s *BaseFilterQueryListener) EnterFullText(ctx *FullTextContext) {}
// ExitFullText is called when production fullText is exited.
func (s *BaseFilterQueryListener) ExitFullText(ctx *FullTextContext) {}
// EnterFunctionParamList is called when production functionParamList is entered.
func (s *BaseFilterQueryListener) EnterFunctionParamList(ctx *FunctionParamListContext) {}

View File

@@ -48,7 +48,7 @@ func (v *BaseFilterQueryVisitor) VisitValueList(ctx *ValueListContext) interface
return v.VisitChildren(ctx)
}
func (v *BaseFilterQueryVisitor) VisitFreeText(ctx *FreeTextContext) interface{} {
func (v *BaseFilterQueryVisitor) VisitFullText(ctx *FullTextContext) interface{} {
return v.VisitChildren(ctx)
}
@@ -56,10 +56,6 @@ func (v *BaseFilterQueryVisitor) VisitFunctionCall(ctx *FunctionCallContext) int
return v.VisitChildren(ctx)
}
func (v *BaseFilterQueryVisitor) VisitFullText(ctx *FullTextContext) interface{} {
return v.VisitChildren(ctx)
}
func (v *BaseFilterQueryVisitor) VisitFunctionParamList(ctx *FunctionParamListContext) interface{} {
return v.VisitChildren(ctx)
}

View File

@@ -4,9 +4,10 @@ package parser
import (
"fmt"
"github.com/antlr4-go/antlr/v4"
"sync"
"unicode"
"github.com/antlr4-go/antlr/v4"
)
// Suppress unused import error
@@ -50,174 +51,170 @@ func filterquerylexerLexerInit() {
"", "LPAREN", "RPAREN", "LBRACK", "RBRACK", "COMMA", "EQUALS", "NOT_EQUALS",
"NEQ", "LT", "LE", "GT", "GE", "LIKE", "ILIKE", "BETWEEN", "EXISTS",
"REGEXP", "CONTAINS", "IN", "NOT", "AND", "OR", "HASTOKEN", "HAS", "HASANY",
"HASALL", "SEARCH", "BOOL", "NUMBER", "QUOTED_TEXT", "KEY", "WS", "FREETEXT",
"HASALL", "BOOL", "NUMBER", "QUOTED_TEXT", "KEY", "WS", "FREETEXT",
}
staticData.RuleNames = []string{
"LPAREN", "RPAREN", "LBRACK", "RBRACK", "COMMA", "EQUALS", "NOT_EQUALS",
"NEQ", "LT", "LE", "GT", "GE", "LIKE", "ILIKE", "BETWEEN", "EXISTS",
"REGEXP", "CONTAINS", "IN", "NOT", "AND", "OR", "HASTOKEN", "HAS", "HASANY",
"HASALL", "SEARCH", "BOOL", "SIGN", "NUMBER", "QUOTED_TEXT", "SEGMENT",
"EMPTY_BRACKS", "OLD_JSON_BRACKS", "KEY", "WS", "DIGIT", "FREETEXT",
"HASALL", "BOOL", "SIGN", "NUMBER", "QUOTED_TEXT", "SEGMENT", "EMPTY_BRACKS",
"OLD_JSON_BRACKS", "KEY", "WS", "DIGIT", "FREETEXT",
}
staticData.PredictionContextCache = antlr.NewPredictionContextCache()
staticData.serializedATN = []int32{
4, 0, 33, 329, 6, -1, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2,
4, 0, 32, 320, 6, -1, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2,
4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 2,
10, 7, 10, 2, 11, 7, 11, 2, 12, 7, 12, 2, 13, 7, 13, 2, 14, 7, 14, 2, 15,
7, 15, 2, 16, 7, 16, 2, 17, 7, 17, 2, 18, 7, 18, 2, 19, 7, 19, 2, 20, 7,
20, 2, 21, 7, 21, 2, 22, 7, 22, 2, 23, 7, 23, 2, 24, 7, 24, 2, 25, 7, 25,
2, 26, 7, 26, 2, 27, 7, 27, 2, 28, 7, 28, 2, 29, 7, 29, 2, 30, 7, 30, 2,
31, 7, 31, 2, 32, 7, 32, 2, 33, 7, 33, 2, 34, 7, 34, 2, 35, 7, 35, 2, 36,
7, 36, 2, 37, 7, 37, 1, 0, 1, 0, 1, 1, 1, 1, 1, 2, 1, 2, 1, 3, 1, 3, 1,
4, 1, 4, 1, 5, 1, 5, 1, 5, 3, 5, 91, 8, 5, 1, 6, 1, 6, 1, 6, 1, 7, 1, 7,
1, 7, 1, 8, 1, 8, 1, 9, 1, 9, 1, 9, 1, 10, 1, 10, 1, 11, 1, 11, 1, 11,
1, 12, 1, 12, 1, 12, 1, 12, 1, 12, 1, 13, 1, 13, 1, 13, 1, 13, 1, 13, 1,
13, 1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 15, 1, 15,
1, 15, 1, 15, 1, 15, 1, 15, 3, 15, 134, 8, 15, 1, 16, 1, 16, 1, 16, 1,
16, 1, 16, 1, 16, 1, 16, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17,
1, 17, 3, 17, 151, 8, 17, 1, 18, 1, 18, 1, 18, 1, 19, 1, 19, 1, 19, 1,
19, 1, 20, 1, 20, 1, 20, 1, 20, 1, 21, 1, 21, 1, 21, 1, 22, 1, 22, 1, 22,
1, 22, 1, 22, 1, 22, 1, 22, 1, 22, 1, 22, 1, 23, 1, 23, 1, 23, 1, 23, 1,
24, 1, 24, 1, 24, 1, 24, 1, 24, 1, 24, 1, 24, 1, 25, 1, 25, 1, 25, 1, 25,
1, 25, 1, 25, 1, 25, 1, 26, 1, 26, 1, 26, 1, 26, 1, 26, 1, 26, 1, 26, 1,
27, 1, 27, 1, 27, 1, 27, 1, 27, 1, 27, 1, 27, 1, 27, 1, 27, 3, 27, 210,
8, 27, 1, 28, 1, 28, 1, 29, 3, 29, 215, 8, 29, 1, 29, 4, 29, 218, 8, 29,
11, 29, 12, 29, 219, 1, 29, 1, 29, 5, 29, 224, 8, 29, 10, 29, 12, 29, 227,
9, 29, 3, 29, 229, 8, 29, 1, 29, 1, 29, 3, 29, 233, 8, 29, 1, 29, 4, 29,
236, 8, 29, 11, 29, 12, 29, 237, 3, 29, 240, 8, 29, 1, 29, 3, 29, 243,
8, 29, 1, 29, 1, 29, 4, 29, 247, 8, 29, 11, 29, 12, 29, 248, 1, 29, 1,
29, 3, 29, 253, 8, 29, 1, 29, 4, 29, 256, 8, 29, 11, 29, 12, 29, 257, 3,
29, 260, 8, 29, 3, 29, 262, 8, 29, 1, 30, 1, 30, 1, 30, 1, 30, 5, 30, 268,
8, 30, 10, 30, 12, 30, 271, 9, 30, 1, 30, 1, 30, 1, 30, 1, 30, 1, 30, 5,
30, 278, 8, 30, 10, 30, 12, 30, 281, 9, 30, 1, 30, 3, 30, 284, 8, 30, 1,
31, 1, 31, 5, 31, 288, 8, 31, 10, 31, 12, 31, 291, 9, 31, 1, 32, 1, 32,
1, 32, 1, 33, 1, 33, 1, 33, 1, 33, 1, 34, 1, 34, 1, 34, 1, 34, 1, 34, 1,
34, 1, 34, 4, 34, 307, 8, 34, 11, 34, 12, 34, 308, 5, 34, 311, 8, 34, 10,
34, 12, 34, 314, 9, 34, 1, 35, 4, 35, 317, 8, 35, 11, 35, 12, 35, 318,
1, 35, 1, 35, 1, 36, 1, 36, 1, 37, 4, 37, 326, 8, 37, 11, 37, 12, 37, 327,
0, 0, 38, 1, 1, 3, 2, 5, 3, 7, 4, 9, 5, 11, 6, 13, 7, 15, 8, 17, 9, 19,
7, 36, 1, 0, 1, 0, 1, 1, 1, 1, 1, 2, 1, 2, 1, 3, 1, 3, 1, 4, 1, 4, 1, 5,
1, 5, 1, 5, 3, 5, 89, 8, 5, 1, 6, 1, 6, 1, 6, 1, 7, 1, 7, 1, 7, 1, 8, 1,
8, 1, 9, 1, 9, 1, 9, 1, 10, 1, 10, 1, 11, 1, 11, 1, 11, 1, 12, 1, 12, 1,
12, 1, 12, 1, 12, 1, 13, 1, 13, 1, 13, 1, 13, 1, 13, 1, 13, 1, 14, 1, 14,
1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 14, 1, 15, 1, 15, 1, 15, 1, 15, 1,
15, 1, 15, 3, 15, 132, 8, 15, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16, 1, 16,
1, 16, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 1, 17, 3, 17, 149,
8, 17, 1, 18, 1, 18, 1, 18, 1, 19, 1, 19, 1, 19, 1, 19, 1, 20, 1, 20, 1,
20, 1, 20, 1, 21, 1, 21, 1, 21, 1, 22, 1, 22, 1, 22, 1, 22, 1, 22, 1, 22,
1, 22, 1, 22, 1, 22, 1, 23, 1, 23, 1, 23, 1, 23, 1, 24, 1, 24, 1, 24, 1,
24, 1, 24, 1, 24, 1, 24, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25, 1, 25,
1, 26, 1, 26, 1, 26, 1, 26, 1, 26, 1, 26, 1, 26, 1, 26, 1, 26, 3, 26, 201,
8, 26, 1, 27, 1, 27, 1, 28, 3, 28, 206, 8, 28, 1, 28, 4, 28, 209, 8, 28,
11, 28, 12, 28, 210, 1, 28, 1, 28, 5, 28, 215, 8, 28, 10, 28, 12, 28, 218,
9, 28, 3, 28, 220, 8, 28, 1, 28, 1, 28, 3, 28, 224, 8, 28, 1, 28, 4, 28,
227, 8, 28, 11, 28, 12, 28, 228, 3, 28, 231, 8, 28, 1, 28, 3, 28, 234,
8, 28, 1, 28, 1, 28, 4, 28, 238, 8, 28, 11, 28, 12, 28, 239, 1, 28, 1,
28, 3, 28, 244, 8, 28, 1, 28, 4, 28, 247, 8, 28, 11, 28, 12, 28, 248, 3,
28, 251, 8, 28, 3, 28, 253, 8, 28, 1, 29, 1, 29, 1, 29, 1, 29, 5, 29, 259,
8, 29, 10, 29, 12, 29, 262, 9, 29, 1, 29, 1, 29, 1, 29, 1, 29, 1, 29, 5,
29, 269, 8, 29, 10, 29, 12, 29, 272, 9, 29, 1, 29, 3, 29, 275, 8, 29, 1,
30, 1, 30, 5, 30, 279, 8, 30, 10, 30, 12, 30, 282, 9, 30, 1, 31, 1, 31,
1, 31, 1, 32, 1, 32, 1, 32, 1, 32, 1, 33, 1, 33, 1, 33, 1, 33, 1, 33, 1,
33, 1, 33, 4, 33, 298, 8, 33, 11, 33, 12, 33, 299, 5, 33, 302, 8, 33, 10,
33, 12, 33, 305, 9, 33, 1, 34, 4, 34, 308, 8, 34, 11, 34, 12, 34, 309,
1, 34, 1, 34, 1, 35, 1, 35, 1, 36, 4, 36, 317, 8, 36, 11, 36, 12, 36, 318,
0, 0, 37, 1, 1, 3, 2, 5, 3, 7, 4, 9, 5, 11, 6, 13, 7, 15, 8, 17, 9, 19,
10, 21, 11, 23, 12, 25, 13, 27, 14, 29, 15, 31, 16, 33, 17, 35, 18, 37,
19, 39, 20, 41, 21, 43, 22, 45, 23, 47, 24, 49, 25, 51, 26, 53, 27, 55,
28, 57, 0, 59, 29, 61, 30, 63, 0, 65, 0, 67, 0, 69, 31, 71, 32, 73, 0,
75, 33, 1, 0, 29, 2, 0, 76, 76, 108, 108, 2, 0, 73, 73, 105, 105, 2, 0,
75, 75, 107, 107, 2, 0, 69, 69, 101, 101, 2, 0, 66, 66, 98, 98, 2, 0, 84,
84, 116, 116, 2, 0, 87, 87, 119, 119, 2, 0, 78, 78, 110, 110, 2, 0, 88,
88, 120, 120, 2, 0, 83, 83, 115, 115, 2, 0, 82, 82, 114, 114, 2, 0, 71,
71, 103, 103, 2, 0, 80, 80, 112, 112, 2, 0, 67, 67, 99, 99, 2, 0, 79, 79,
111, 111, 2, 0, 65, 65, 97, 97, 2, 0, 68, 68, 100, 100, 2, 0, 72, 72, 104,
104, 2, 0, 89, 89, 121, 121, 2, 0, 85, 85, 117, 117, 2, 0, 70, 70, 102,
102, 2, 0, 43, 43, 45, 45, 2, 0, 34, 34, 92, 92, 2, 0, 39, 39, 92, 92,
4, 0, 35, 36, 64, 90, 95, 95, 97, 123, 7, 0, 35, 36, 45, 45, 47, 58, 64,
90, 95, 95, 97, 123, 125, 125, 3, 0, 9, 10, 13, 13, 32, 32, 1, 0, 48, 57,
8, 0, 9, 10, 13, 13, 32, 34, 39, 41, 44, 44, 60, 62, 91, 91, 93, 93, 353,
0, 1, 1, 0, 0, 0, 0, 3, 1, 0, 0, 0, 0, 5, 1, 0, 0, 0, 0, 7, 1, 0, 0, 0,
0, 9, 1, 0, 0, 0, 0, 11, 1, 0, 0, 0, 0, 13, 1, 0, 0, 0, 0, 15, 1, 0, 0,
0, 0, 17, 1, 0, 0, 0, 0, 19, 1, 0, 0, 0, 0, 21, 1, 0, 0, 0, 0, 23, 1, 0,
0, 0, 0, 25, 1, 0, 0, 0, 0, 27, 1, 0, 0, 0, 0, 29, 1, 0, 0, 0, 0, 31, 1,
0, 0, 0, 0, 33, 1, 0, 0, 0, 0, 35, 1, 0, 0, 0, 0, 37, 1, 0, 0, 0, 0, 39,
1, 0, 0, 0, 0, 41, 1, 0, 0, 0, 0, 43, 1, 0, 0, 0, 0, 45, 1, 0, 0, 0, 0,
47, 1, 0, 0, 0, 0, 49, 1, 0, 0, 0, 0, 51, 1, 0, 0, 0, 0, 53, 1, 0, 0, 0,
0, 55, 1, 0, 0, 0, 0, 59, 1, 0, 0, 0, 0, 61, 1, 0, 0, 0, 0, 69, 1, 0, 0,
0, 0, 71, 1, 0, 0, 0, 0, 75, 1, 0, 0, 0, 1, 77, 1, 0, 0, 0, 3, 79, 1, 0,
0, 0, 5, 81, 1, 0, 0, 0, 7, 83, 1, 0, 0, 0, 9, 85, 1, 0, 0, 0, 11, 90,
1, 0, 0, 0, 13, 92, 1, 0, 0, 0, 15, 95, 1, 0, 0, 0, 17, 98, 1, 0, 0, 0,
19, 100, 1, 0, 0, 0, 21, 103, 1, 0, 0, 0, 23, 105, 1, 0, 0, 0, 25, 108,
1, 0, 0, 0, 27, 113, 1, 0, 0, 0, 29, 119, 1, 0, 0, 0, 31, 127, 1, 0, 0,
0, 33, 135, 1, 0, 0, 0, 35, 142, 1, 0, 0, 0, 37, 152, 1, 0, 0, 0, 39, 155,
1, 0, 0, 0, 41, 159, 1, 0, 0, 0, 43, 163, 1, 0, 0, 0, 45, 166, 1, 0, 0,
0, 47, 175, 1, 0, 0, 0, 49, 179, 1, 0, 0, 0, 51, 186, 1, 0, 0, 0, 53, 193,
1, 0, 0, 0, 55, 209, 1, 0, 0, 0, 57, 211, 1, 0, 0, 0, 59, 261, 1, 0, 0,
0, 61, 283, 1, 0, 0, 0, 63, 285, 1, 0, 0, 0, 65, 292, 1, 0, 0, 0, 67, 295,
1, 0, 0, 0, 69, 299, 1, 0, 0, 0, 71, 316, 1, 0, 0, 0, 73, 322, 1, 0, 0,
0, 75, 325, 1, 0, 0, 0, 77, 78, 5, 40, 0, 0, 78, 2, 1, 0, 0, 0, 79, 80,
5, 41, 0, 0, 80, 4, 1, 0, 0, 0, 81, 82, 5, 91, 0, 0, 82, 6, 1, 0, 0, 0,
83, 84, 5, 93, 0, 0, 84, 8, 1, 0, 0, 0, 85, 86, 5, 44, 0, 0, 86, 10, 1,
0, 0, 0, 87, 91, 5, 61, 0, 0, 88, 89, 5, 61, 0, 0, 89, 91, 5, 61, 0, 0,
90, 87, 1, 0, 0, 0, 90, 88, 1, 0, 0, 0, 91, 12, 1, 0, 0, 0, 92, 93, 5,
33, 0, 0, 93, 94, 5, 61, 0, 0, 94, 14, 1, 0, 0, 0, 95, 96, 5, 60, 0, 0,
96, 97, 5, 62, 0, 0, 97, 16, 1, 0, 0, 0, 98, 99, 5, 60, 0, 0, 99, 18, 1,
0, 0, 0, 100, 101, 5, 60, 0, 0, 101, 102, 5, 61, 0, 0, 102, 20, 1, 0, 0,
0, 103, 104, 5, 62, 0, 0, 104, 22, 1, 0, 0, 0, 105, 106, 5, 62, 0, 0, 106,
107, 5, 61, 0, 0, 107, 24, 1, 0, 0, 0, 108, 109, 7, 0, 0, 0, 109, 110,
7, 1, 0, 0, 110, 111, 7, 2, 0, 0, 111, 112, 7, 3, 0, 0, 112, 26, 1, 0,
0, 0, 113, 114, 7, 1, 0, 0, 114, 115, 7, 0, 0, 0, 115, 116, 7, 1, 0, 0,
116, 117, 7, 2, 0, 0, 117, 118, 7, 3, 0, 0, 118, 28, 1, 0, 0, 0, 119, 120,
7, 4, 0, 0, 120, 121, 7, 3, 0, 0, 121, 122, 7, 5, 0, 0, 122, 123, 7, 6,
0, 0, 123, 124, 7, 3, 0, 0, 124, 125, 7, 3, 0, 0, 125, 126, 7, 7, 0, 0,
126, 30, 1, 0, 0, 0, 127, 128, 7, 3, 0, 0, 128, 129, 7, 8, 0, 0, 129, 130,
7, 1, 0, 0, 130, 131, 7, 9, 0, 0, 131, 133, 7, 5, 0, 0, 132, 134, 7, 9,
0, 0, 133, 132, 1, 0, 0, 0, 133, 134, 1, 0, 0, 0, 134, 32, 1, 0, 0, 0,
135, 136, 7, 10, 0, 0, 136, 137, 7, 3, 0, 0, 137, 138, 7, 11, 0, 0, 138,
139, 7, 3, 0, 0, 139, 140, 7, 8, 0, 0, 140, 141, 7, 12, 0, 0, 141, 34,
1, 0, 0, 0, 142, 143, 7, 13, 0, 0, 143, 144, 7, 14, 0, 0, 144, 145, 7,
7, 0, 0, 145, 146, 7, 5, 0, 0, 146, 147, 7, 15, 0, 0, 147, 148, 7, 1, 0,
0, 148, 150, 7, 7, 0, 0, 149, 151, 7, 9, 0, 0, 150, 149, 1, 0, 0, 0, 150,
151, 1, 0, 0, 0, 151, 36, 1, 0, 0, 0, 152, 153, 7, 1, 0, 0, 153, 154, 7,
7, 0, 0, 154, 38, 1, 0, 0, 0, 155, 156, 7, 7, 0, 0, 156, 157, 7, 14, 0,
0, 157, 158, 7, 5, 0, 0, 158, 40, 1, 0, 0, 0, 159, 160, 7, 15, 0, 0, 160,
161, 7, 7, 0, 0, 161, 162, 7, 16, 0, 0, 162, 42, 1, 0, 0, 0, 163, 164,
7, 14, 0, 0, 164, 165, 7, 10, 0, 0, 165, 44, 1, 0, 0, 0, 166, 167, 7, 17,
0, 0, 167, 168, 7, 15, 0, 0, 168, 169, 7, 9, 0, 0, 169, 170, 7, 5, 0, 0,
170, 171, 7, 14, 0, 0, 171, 172, 7, 2, 0, 0, 172, 173, 7, 3, 0, 0, 173,
174, 7, 7, 0, 0, 174, 46, 1, 0, 0, 0, 175, 176, 7, 17, 0, 0, 176, 177,
7, 15, 0, 0, 177, 178, 7, 9, 0, 0, 178, 48, 1, 0, 0, 0, 179, 180, 7, 17,
0, 0, 180, 181, 7, 15, 0, 0, 181, 182, 7, 9, 0, 0, 182, 183, 7, 15, 0,
0, 183, 184, 7, 7, 0, 0, 184, 185, 7, 18, 0, 0, 185, 50, 1, 0, 0, 0, 186,
187, 7, 17, 0, 0, 187, 188, 7, 15, 0, 0, 188, 189, 7, 9, 0, 0, 189, 190,
7, 15, 0, 0, 190, 191, 7, 0, 0, 0, 191, 192, 7, 0, 0, 0, 192, 52, 1, 0,
0, 0, 193, 194, 7, 9, 0, 0, 194, 195, 7, 3, 0, 0, 195, 196, 7, 15, 0, 0,
196, 197, 7, 10, 0, 0, 197, 198, 7, 13, 0, 0, 198, 199, 7, 17, 0, 0, 199,
54, 1, 0, 0, 0, 200, 201, 7, 5, 0, 0, 201, 202, 7, 10, 0, 0, 202, 203,
7, 19, 0, 0, 203, 210, 7, 3, 0, 0, 204, 205, 7, 20, 0, 0, 205, 206, 7,
15, 0, 0, 206, 207, 7, 0, 0, 0, 207, 208, 7, 9, 0, 0, 208, 210, 7, 3, 0,
0, 209, 200, 1, 0, 0, 0, 209, 204, 1, 0, 0, 0, 210, 56, 1, 0, 0, 0, 211,
212, 7, 21, 0, 0, 212, 58, 1, 0, 0, 0, 213, 215, 3, 57, 28, 0, 214, 213,
1, 0, 0, 0, 214, 215, 1, 0, 0, 0, 215, 217, 1, 0, 0, 0, 216, 218, 3, 73,
36, 0, 217, 216, 1, 0, 0, 0, 218, 219, 1, 0, 0, 0, 219, 217, 1, 0, 0, 0,
219, 220, 1, 0, 0, 0, 220, 228, 1, 0, 0, 0, 221, 225, 5, 46, 0, 0, 222,
224, 3, 73, 36, 0, 223, 222, 1, 0, 0, 0, 224, 227, 1, 0, 0, 0, 225, 223,
1, 0, 0, 0, 225, 226, 1, 0, 0, 0, 226, 229, 1, 0, 0, 0, 227, 225, 1, 0,
0, 0, 228, 221, 1, 0, 0, 0, 228, 229, 1, 0, 0, 0, 229, 239, 1, 0, 0, 0,
230, 232, 7, 3, 0, 0, 231, 233, 3, 57, 28, 0, 232, 231, 1, 0, 0, 0, 232,
233, 1, 0, 0, 0, 233, 235, 1, 0, 0, 0, 234, 236, 3, 73, 36, 0, 235, 234,
1, 0, 0, 0, 236, 237, 1, 0, 0, 0, 237, 235, 1, 0, 0, 0, 237, 238, 1, 0,
0, 0, 238, 240, 1, 0, 0, 0, 239, 230, 1, 0, 0, 0, 239, 240, 1, 0, 0, 0,
240, 262, 1, 0, 0, 0, 241, 243, 3, 57, 28, 0, 242, 241, 1, 0, 0, 0, 242,
243, 1, 0, 0, 0, 243, 244, 1, 0, 0, 0, 244, 246, 5, 46, 0, 0, 245, 247,
3, 73, 36, 0, 246, 245, 1, 0, 0, 0, 247, 248, 1, 0, 0, 0, 248, 246, 1,
0, 0, 0, 248, 249, 1, 0, 0, 0, 249, 259, 1, 0, 0, 0, 250, 252, 7, 3, 0,
0, 251, 253, 3, 57, 28, 0, 252, 251, 1, 0, 0, 0, 252, 253, 1, 0, 0, 0,
253, 255, 1, 0, 0, 0, 254, 256, 3, 73, 36, 0, 255, 254, 1, 0, 0, 0, 256,
257, 1, 0, 0, 0, 257, 255, 1, 0, 0, 0, 257, 258, 1, 0, 0, 0, 258, 260,
1, 0, 0, 0, 259, 250, 1, 0, 0, 0, 259, 260, 1, 0, 0, 0, 260, 262, 1, 0,
0, 0, 261, 214, 1, 0, 0, 0, 261, 242, 1, 0, 0, 0, 262, 60, 1, 0, 0, 0,
263, 269, 5, 34, 0, 0, 264, 268, 8, 22, 0, 0, 265, 266, 5, 92, 0, 0, 266,
268, 9, 0, 0, 0, 267, 264, 1, 0, 0, 0, 267, 265, 1, 0, 0, 0, 268, 271,
1, 0, 0, 0, 269, 267, 1, 0, 0, 0, 269, 270, 1, 0, 0, 0, 270, 272, 1, 0,
0, 0, 271, 269, 1, 0, 0, 0, 272, 284, 5, 34, 0, 0, 273, 279, 5, 39, 0,
0, 274, 278, 8, 23, 0, 0, 275, 276, 5, 92, 0, 0, 276, 278, 9, 0, 0, 0,
277, 274, 1, 0, 0, 0, 277, 275, 1, 0, 0, 0, 278, 281, 1, 0, 0, 0, 279,
277, 1, 0, 0, 0, 279, 280, 1, 0, 0, 0, 280, 282, 1, 0, 0, 0, 281, 279,
1, 0, 0, 0, 282, 284, 5, 39, 0, 0, 283, 263, 1, 0, 0, 0, 283, 273, 1, 0,
0, 0, 284, 62, 1, 0, 0, 0, 285, 289, 7, 24, 0, 0, 286, 288, 7, 25, 0, 0,
287, 286, 1, 0, 0, 0, 288, 291, 1, 0, 0, 0, 289, 287, 1, 0, 0, 0, 289,
290, 1, 0, 0, 0, 290, 64, 1, 0, 0, 0, 291, 289, 1, 0, 0, 0, 292, 293, 5,
91, 0, 0, 293, 294, 5, 93, 0, 0, 294, 66, 1, 0, 0, 0, 295, 296, 5, 91,
0, 0, 296, 297, 5, 42, 0, 0, 297, 298, 5, 93, 0, 0, 298, 68, 1, 0, 0, 0,
299, 312, 3, 63, 31, 0, 300, 301, 5, 46, 0, 0, 301, 311, 3, 63, 31, 0,
302, 311, 3, 65, 32, 0, 303, 311, 3, 67, 33, 0, 304, 306, 5, 46, 0, 0,
305, 307, 3, 73, 36, 0, 306, 305, 1, 0, 0, 0, 307, 308, 1, 0, 0, 0, 308,
306, 1, 0, 0, 0, 308, 309, 1, 0, 0, 0, 309, 311, 1, 0, 0, 0, 310, 300,
1, 0, 0, 0, 310, 302, 1, 0, 0, 0, 310, 303, 1, 0, 0, 0, 310, 304, 1, 0,
0, 0, 311, 314, 1, 0, 0, 0, 312, 310, 1, 0, 0, 0, 312, 313, 1, 0, 0, 0,
313, 70, 1, 0, 0, 0, 314, 312, 1, 0, 0, 0, 315, 317, 7, 26, 0, 0, 316,
315, 1, 0, 0, 0, 317, 318, 1, 0, 0, 0, 318, 316, 1, 0, 0, 0, 318, 319,
1, 0, 0, 0, 319, 320, 1, 0, 0, 0, 320, 321, 6, 35, 0, 0, 321, 72, 1, 0,
0, 0, 322, 323, 7, 27, 0, 0, 323, 74, 1, 0, 0, 0, 324, 326, 8, 28, 0, 0,
325, 324, 1, 0, 0, 0, 326, 327, 1, 0, 0, 0, 327, 325, 1, 0, 0, 0, 327,
328, 1, 0, 0, 0, 328, 76, 1, 0, 0, 0, 29, 0, 90, 133, 150, 209, 214, 219,
225, 228, 232, 237, 239, 242, 248, 252, 257, 259, 261, 267, 269, 277, 279,
283, 289, 308, 310, 312, 318, 327, 1, 6, 0, 0,
0, 57, 28, 59, 29, 61, 0, 63, 0, 65, 0, 67, 30, 69, 31, 71, 0, 73, 32,
1, 0, 29, 2, 0, 76, 76, 108, 108, 2, 0, 73, 73, 105, 105, 2, 0, 75, 75,
107, 107, 2, 0, 69, 69, 101, 101, 2, 0, 66, 66, 98, 98, 2, 0, 84, 84, 116,
116, 2, 0, 87, 87, 119, 119, 2, 0, 78, 78, 110, 110, 2, 0, 88, 88, 120,
120, 2, 0, 83, 83, 115, 115, 2, 0, 82, 82, 114, 114, 2, 0, 71, 71, 103,
103, 2, 0, 80, 80, 112, 112, 2, 0, 67, 67, 99, 99, 2, 0, 79, 79, 111, 111,
2, 0, 65, 65, 97, 97, 2, 0, 68, 68, 100, 100, 2, 0, 72, 72, 104, 104, 2,
0, 89, 89, 121, 121, 2, 0, 85, 85, 117, 117, 2, 0, 70, 70, 102, 102, 2,
0, 43, 43, 45, 45, 2, 0, 34, 34, 92, 92, 2, 0, 39, 39, 92, 92, 4, 0, 35,
36, 64, 90, 95, 95, 97, 123, 7, 0, 35, 36, 45, 45, 47, 58, 64, 90, 95,
95, 97, 123, 125, 125, 3, 0, 9, 10, 13, 13, 32, 32, 1, 0, 48, 57, 8, 0,
9, 10, 13, 13, 32, 34, 39, 41, 44, 44, 60, 62, 91, 91, 93, 93, 344, 0,
1, 1, 0, 0, 0, 0, 3, 1, 0, 0, 0, 0, 5, 1, 0, 0, 0, 0, 7, 1, 0, 0, 0, 0,
9, 1, 0, 0, 0, 0, 11, 1, 0, 0, 0, 0, 13, 1, 0, 0, 0, 0, 15, 1, 0, 0, 0,
0, 17, 1, 0, 0, 0, 0, 19, 1, 0, 0, 0, 0, 21, 1, 0, 0, 0, 0, 23, 1, 0, 0,
0, 0, 25, 1, 0, 0, 0, 0, 27, 1, 0, 0, 0, 0, 29, 1, 0, 0, 0, 0, 31, 1, 0,
0, 0, 0, 33, 1, 0, 0, 0, 0, 35, 1, 0, 0, 0, 0, 37, 1, 0, 0, 0, 0, 39, 1,
0, 0, 0, 0, 41, 1, 0, 0, 0, 0, 43, 1, 0, 0, 0, 0, 45, 1, 0, 0, 0, 0, 47,
1, 0, 0, 0, 0, 49, 1, 0, 0, 0, 0, 51, 1, 0, 0, 0, 0, 53, 1, 0, 0, 0, 0,
57, 1, 0, 0, 0, 0, 59, 1, 0, 0, 0, 0, 67, 1, 0, 0, 0, 0, 69, 1, 0, 0, 0,
0, 73, 1, 0, 0, 0, 1, 75, 1, 0, 0, 0, 3, 77, 1, 0, 0, 0, 5, 79, 1, 0, 0,
0, 7, 81, 1, 0, 0, 0, 9, 83, 1, 0, 0, 0, 11, 88, 1, 0, 0, 0, 13, 90, 1,
0, 0, 0, 15, 93, 1, 0, 0, 0, 17, 96, 1, 0, 0, 0, 19, 98, 1, 0, 0, 0, 21,
101, 1, 0, 0, 0, 23, 103, 1, 0, 0, 0, 25, 106, 1, 0, 0, 0, 27, 111, 1,
0, 0, 0, 29, 117, 1, 0, 0, 0, 31, 125, 1, 0, 0, 0, 33, 133, 1, 0, 0, 0,
35, 140, 1, 0, 0, 0, 37, 150, 1, 0, 0, 0, 39, 153, 1, 0, 0, 0, 41, 157,
1, 0, 0, 0, 43, 161, 1, 0, 0, 0, 45, 164, 1, 0, 0, 0, 47, 173, 1, 0, 0,
0, 49, 177, 1, 0, 0, 0, 51, 184, 1, 0, 0, 0, 53, 200, 1, 0, 0, 0, 55, 202,
1, 0, 0, 0, 57, 252, 1, 0, 0, 0, 59, 274, 1, 0, 0, 0, 61, 276, 1, 0, 0,
0, 63, 283, 1, 0, 0, 0, 65, 286, 1, 0, 0, 0, 67, 290, 1, 0, 0, 0, 69, 307,
1, 0, 0, 0, 71, 313, 1, 0, 0, 0, 73, 316, 1, 0, 0, 0, 75, 76, 5, 40, 0,
0, 76, 2, 1, 0, 0, 0, 77, 78, 5, 41, 0, 0, 78, 4, 1, 0, 0, 0, 79, 80, 5,
91, 0, 0, 80, 6, 1, 0, 0, 0, 81, 82, 5, 93, 0, 0, 82, 8, 1, 0, 0, 0, 83,
84, 5, 44, 0, 0, 84, 10, 1, 0, 0, 0, 85, 89, 5, 61, 0, 0, 86, 87, 5, 61,
0, 0, 87, 89, 5, 61, 0, 0, 88, 85, 1, 0, 0, 0, 88, 86, 1, 0, 0, 0, 89,
12, 1, 0, 0, 0, 90, 91, 5, 33, 0, 0, 91, 92, 5, 61, 0, 0, 92, 14, 1, 0,
0, 0, 93, 94, 5, 60, 0, 0, 94, 95, 5, 62, 0, 0, 95, 16, 1, 0, 0, 0, 96,
97, 5, 60, 0, 0, 97, 18, 1, 0, 0, 0, 98, 99, 5, 60, 0, 0, 99, 100, 5, 61,
0, 0, 100, 20, 1, 0, 0, 0, 101, 102, 5, 62, 0, 0, 102, 22, 1, 0, 0, 0,
103, 104, 5, 62, 0, 0, 104, 105, 5, 61, 0, 0, 105, 24, 1, 0, 0, 0, 106,
107, 7, 0, 0, 0, 107, 108, 7, 1, 0, 0, 108, 109, 7, 2, 0, 0, 109, 110,
7, 3, 0, 0, 110, 26, 1, 0, 0, 0, 111, 112, 7, 1, 0, 0, 112, 113, 7, 0,
0, 0, 113, 114, 7, 1, 0, 0, 114, 115, 7, 2, 0, 0, 115, 116, 7, 3, 0, 0,
116, 28, 1, 0, 0, 0, 117, 118, 7, 4, 0, 0, 118, 119, 7, 3, 0, 0, 119, 120,
7, 5, 0, 0, 120, 121, 7, 6, 0, 0, 121, 122, 7, 3, 0, 0, 122, 123, 7, 3,
0, 0, 123, 124, 7, 7, 0, 0, 124, 30, 1, 0, 0, 0, 125, 126, 7, 3, 0, 0,
126, 127, 7, 8, 0, 0, 127, 128, 7, 1, 0, 0, 128, 129, 7, 9, 0, 0, 129,
131, 7, 5, 0, 0, 130, 132, 7, 9, 0, 0, 131, 130, 1, 0, 0, 0, 131, 132,
1, 0, 0, 0, 132, 32, 1, 0, 0, 0, 133, 134, 7, 10, 0, 0, 134, 135, 7, 3,
0, 0, 135, 136, 7, 11, 0, 0, 136, 137, 7, 3, 0, 0, 137, 138, 7, 8, 0, 0,
138, 139, 7, 12, 0, 0, 139, 34, 1, 0, 0, 0, 140, 141, 7, 13, 0, 0, 141,
142, 7, 14, 0, 0, 142, 143, 7, 7, 0, 0, 143, 144, 7, 5, 0, 0, 144, 145,
7, 15, 0, 0, 145, 146, 7, 1, 0, 0, 146, 148, 7, 7, 0, 0, 147, 149, 7, 9,
0, 0, 148, 147, 1, 0, 0, 0, 148, 149, 1, 0, 0, 0, 149, 36, 1, 0, 0, 0,
150, 151, 7, 1, 0, 0, 151, 152, 7, 7, 0, 0, 152, 38, 1, 0, 0, 0, 153, 154,
7, 7, 0, 0, 154, 155, 7, 14, 0, 0, 155, 156, 7, 5, 0, 0, 156, 40, 1, 0,
0, 0, 157, 158, 7, 15, 0, 0, 158, 159, 7, 7, 0, 0, 159, 160, 7, 16, 0,
0, 160, 42, 1, 0, 0, 0, 161, 162, 7, 14, 0, 0, 162, 163, 7, 10, 0, 0, 163,
44, 1, 0, 0, 0, 164, 165, 7, 17, 0, 0, 165, 166, 7, 15, 0, 0, 166, 167,
7, 9, 0, 0, 167, 168, 7, 5, 0, 0, 168, 169, 7, 14, 0, 0, 169, 170, 7, 2,
0, 0, 170, 171, 7, 3, 0, 0, 171, 172, 7, 7, 0, 0, 172, 46, 1, 0, 0, 0,
173, 174, 7, 17, 0, 0, 174, 175, 7, 15, 0, 0, 175, 176, 7, 9, 0, 0, 176,
48, 1, 0, 0, 0, 177, 178, 7, 17, 0, 0, 178, 179, 7, 15, 0, 0, 179, 180,
7, 9, 0, 0, 180, 181, 7, 15, 0, 0, 181, 182, 7, 7, 0, 0, 182, 183, 7, 18,
0, 0, 183, 50, 1, 0, 0, 0, 184, 185, 7, 17, 0, 0, 185, 186, 7, 15, 0, 0,
186, 187, 7, 9, 0, 0, 187, 188, 7, 15, 0, 0, 188, 189, 7, 0, 0, 0, 189,
190, 7, 0, 0, 0, 190, 52, 1, 0, 0, 0, 191, 192, 7, 5, 0, 0, 192, 193, 7,
10, 0, 0, 193, 194, 7, 19, 0, 0, 194, 201, 7, 3, 0, 0, 195, 196, 7, 20,
0, 0, 196, 197, 7, 15, 0, 0, 197, 198, 7, 0, 0, 0, 198, 199, 7, 9, 0, 0,
199, 201, 7, 3, 0, 0, 200, 191, 1, 0, 0, 0, 200, 195, 1, 0, 0, 0, 201,
54, 1, 0, 0, 0, 202, 203, 7, 21, 0, 0, 203, 56, 1, 0, 0, 0, 204, 206, 3,
55, 27, 0, 205, 204, 1, 0, 0, 0, 205, 206, 1, 0, 0, 0, 206, 208, 1, 0,
0, 0, 207, 209, 3, 71, 35, 0, 208, 207, 1, 0, 0, 0, 209, 210, 1, 0, 0,
0, 210, 208, 1, 0, 0, 0, 210, 211, 1, 0, 0, 0, 211, 219, 1, 0, 0, 0, 212,
216, 5, 46, 0, 0, 213, 215, 3, 71, 35, 0, 214, 213, 1, 0, 0, 0, 215, 218,
1, 0, 0, 0, 216, 214, 1, 0, 0, 0, 216, 217, 1, 0, 0, 0, 217, 220, 1, 0,
0, 0, 218, 216, 1, 0, 0, 0, 219, 212, 1, 0, 0, 0, 219, 220, 1, 0, 0, 0,
220, 230, 1, 0, 0, 0, 221, 223, 7, 3, 0, 0, 222, 224, 3, 55, 27, 0, 223,
222, 1, 0, 0, 0, 223, 224, 1, 0, 0, 0, 224, 226, 1, 0, 0, 0, 225, 227,
3, 71, 35, 0, 226, 225, 1, 0, 0, 0, 227, 228, 1, 0, 0, 0, 228, 226, 1,
0, 0, 0, 228, 229, 1, 0, 0, 0, 229, 231, 1, 0, 0, 0, 230, 221, 1, 0, 0,
0, 230, 231, 1, 0, 0, 0, 231, 253, 1, 0, 0, 0, 232, 234, 3, 55, 27, 0,
233, 232, 1, 0, 0, 0, 233, 234, 1, 0, 0, 0, 234, 235, 1, 0, 0, 0, 235,
237, 5, 46, 0, 0, 236, 238, 3, 71, 35, 0, 237, 236, 1, 0, 0, 0, 238, 239,
1, 0, 0, 0, 239, 237, 1, 0, 0, 0, 239, 240, 1, 0, 0, 0, 240, 250, 1, 0,
0, 0, 241, 243, 7, 3, 0, 0, 242, 244, 3, 55, 27, 0, 243, 242, 1, 0, 0,
0, 243, 244, 1, 0, 0, 0, 244, 246, 1, 0, 0, 0, 245, 247, 3, 71, 35, 0,
246, 245, 1, 0, 0, 0, 247, 248, 1, 0, 0, 0, 248, 246, 1, 0, 0, 0, 248,
249, 1, 0, 0, 0, 249, 251, 1, 0, 0, 0, 250, 241, 1, 0, 0, 0, 250, 251,
1, 0, 0, 0, 251, 253, 1, 0, 0, 0, 252, 205, 1, 0, 0, 0, 252, 233, 1, 0,
0, 0, 253, 58, 1, 0, 0, 0, 254, 260, 5, 34, 0, 0, 255, 259, 8, 22, 0, 0,
256, 257, 5, 92, 0, 0, 257, 259, 9, 0, 0, 0, 258, 255, 1, 0, 0, 0, 258,
256, 1, 0, 0, 0, 259, 262, 1, 0, 0, 0, 260, 258, 1, 0, 0, 0, 260, 261,
1, 0, 0, 0, 261, 263, 1, 0, 0, 0, 262, 260, 1, 0, 0, 0, 263, 275, 5, 34,
0, 0, 264, 270, 5, 39, 0, 0, 265, 269, 8, 23, 0, 0, 266, 267, 5, 92, 0,
0, 267, 269, 9, 0, 0, 0, 268, 265, 1, 0, 0, 0, 268, 266, 1, 0, 0, 0, 269,
272, 1, 0, 0, 0, 270, 268, 1, 0, 0, 0, 270, 271, 1, 0, 0, 0, 271, 273,
1, 0, 0, 0, 272, 270, 1, 0, 0, 0, 273, 275, 5, 39, 0, 0, 274, 254, 1, 0,
0, 0, 274, 264, 1, 0, 0, 0, 275, 60, 1, 0, 0, 0, 276, 280, 7, 24, 0, 0,
277, 279, 7, 25, 0, 0, 278, 277, 1, 0, 0, 0, 279, 282, 1, 0, 0, 0, 280,
278, 1, 0, 0, 0, 280, 281, 1, 0, 0, 0, 281, 62, 1, 0, 0, 0, 282, 280, 1,
0, 0, 0, 283, 284, 5, 91, 0, 0, 284, 285, 5, 93, 0, 0, 285, 64, 1, 0, 0,
0, 286, 287, 5, 91, 0, 0, 287, 288, 5, 42, 0, 0, 288, 289, 5, 93, 0, 0,
289, 66, 1, 0, 0, 0, 290, 303, 3, 61, 30, 0, 291, 292, 5, 46, 0, 0, 292,
302, 3, 61, 30, 0, 293, 302, 3, 63, 31, 0, 294, 302, 3, 65, 32, 0, 295,
297, 5, 46, 0, 0, 296, 298, 3, 71, 35, 0, 297, 296, 1, 0, 0, 0, 298, 299,
1, 0, 0, 0, 299, 297, 1, 0, 0, 0, 299, 300, 1, 0, 0, 0, 300, 302, 1, 0,
0, 0, 301, 291, 1, 0, 0, 0, 301, 293, 1, 0, 0, 0, 301, 294, 1, 0, 0, 0,
301, 295, 1, 0, 0, 0, 302, 305, 1, 0, 0, 0, 303, 301, 1, 0, 0, 0, 303,
304, 1, 0, 0, 0, 304, 68, 1, 0, 0, 0, 305, 303, 1, 0, 0, 0, 306, 308, 7,
26, 0, 0, 307, 306, 1, 0, 0, 0, 308, 309, 1, 0, 0, 0, 309, 307, 1, 0, 0,
0, 309, 310, 1, 0, 0, 0, 310, 311, 1, 0, 0, 0, 311, 312, 6, 34, 0, 0, 312,
70, 1, 0, 0, 0, 313, 314, 7, 27, 0, 0, 314, 72, 1, 0, 0, 0, 315, 317, 8,
28, 0, 0, 316, 315, 1, 0, 0, 0, 317, 318, 1, 0, 0, 0, 318, 316, 1, 0, 0,
0, 318, 319, 1, 0, 0, 0, 319, 74, 1, 0, 0, 0, 29, 0, 88, 131, 148, 200,
205, 210, 216, 219, 223, 228, 230, 233, 239, 243, 248, 250, 252, 258, 260,
268, 270, 274, 280, 299, 301, 303, 309, 318, 1, 6, 0, 0,
}
deserializer := antlr.NewATNDeserializer(nil)
staticData.atn = deserializer.Deserialize(staticData.serializedATN)
@@ -284,11 +281,10 @@ const (
FilterQueryLexerHAS = 24
FilterQueryLexerHASANY = 25
FilterQueryLexerHASALL = 26
FilterQueryLexerSEARCH = 27
FilterQueryLexerBOOL = 28
FilterQueryLexerNUMBER = 29
FilterQueryLexerQUOTED_TEXT = 30
FilterQueryLexerKEY = 31
FilterQueryLexerWS = 32
FilterQueryLexerFREETEXT = 33
FilterQueryLexerBOOL = 27
FilterQueryLexerNUMBER = 28
FilterQueryLexerQUOTED_TEXT = 29
FilterQueryLexerKEY = 30
FilterQueryLexerWS = 31
FilterQueryLexerFREETEXT = 32
)

View File

@@ -38,15 +38,12 @@ type FilterQueryListener interface {
// EnterValueList is called when entering the valueList production.
EnterValueList(c *ValueListContext)
// EnterFreeText is called when entering the freeText production.
EnterFreeText(c *FreeTextContext)
// EnterFullText is called when entering the fullText production.
EnterFullText(c *FullTextContext)
// EnterFunctionCall is called when entering the functionCall production.
EnterFunctionCall(c *FunctionCallContext)
// EnterFullText is called when entering the fullText production.
EnterFullText(c *FullTextContext)
// EnterFunctionParamList is called when entering the functionParamList production.
EnterFunctionParamList(c *FunctionParamListContext)
@@ -92,15 +89,12 @@ type FilterQueryListener interface {
// ExitValueList is called when exiting the valueList production.
ExitValueList(c *ValueListContext)
// ExitFreeText is called when exiting the freeText production.
ExitFreeText(c *FreeTextContext)
// ExitFullText is called when exiting the fullText production.
ExitFullText(c *FullTextContext)
// ExitFunctionCall is called when exiting the functionCall production.
ExitFunctionCall(c *FunctionCallContext)
// ExitFullText is called when exiting the fullText production.
ExitFullText(c *FullTextContext)
// ExitFunctionParamList is called when exiting the functionParamList production.
ExitFunctionParamList(c *FunctionParamListContext)

File diff suppressed because it is too large Load Diff

View File

@@ -38,15 +38,12 @@ type FilterQueryVisitor interface {
// Visit a parse tree produced by FilterQueryParser#valueList.
VisitValueList(ctx *ValueListContext) interface{}
// Visit a parse tree produced by FilterQueryParser#freeText.
VisitFreeText(ctx *FreeTextContext) interface{}
// Visit a parse tree produced by FilterQueryParser#fullText.
VisitFullText(ctx *FullTextContext) interface{}
// Visit a parse tree produced by FilterQueryParser#functionCall.
VisitFunctionCall(ctx *FunctionCallContext) interface{}
// Visit a parse tree produced by FilterQueryParser#fullText.
VisitFullText(ctx *FullTextContext) interface{}
// Visit a parse tree produced by FilterQueryParser#functionParamList.
VisitFunctionParamList(ctx *FunctionParamListContext) interface{}

View File

@@ -122,7 +122,6 @@ func newProvider(
logAggExprRewriter,
telemetrylogs.DefaultFullTextColumn,
telemetrylogs.GetBodyJSONKey,
logConditionBuilder.ConditionForSearch,
flagger,
telemetryStore,
cfg.SkipResourceFingerprint.Enabled,

View File

@@ -88,7 +88,6 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
logAggExprRewriter,
telemetrylogs.DefaultFullTextColumn,
telemetrylogs.GetBodyJSONKey,
logConditionBuilder.ConditionForSearch,
fl,
nil,
false,

View File

@@ -221,7 +221,7 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
FieldMapper: v.fieldMapper,
ConditionBuilder: v.conditionBuilder,
BodyJSONEnabled: bodyJSONEnabled,
FreeTextColumn: v.fullTextColumn,
FullTextColumn: v.fullTextColumn,
JsonKeyToKey: v.jsonKeyToKey,
StartNs: v.startNs,
EndNs: v.endNs,

View File

@@ -5,15 +5,9 @@ const (
SkipConditionLiteral = "__skip__"
ErrorConditionLiteral = "__skip_because_of_error__"
// BodyFreeTextSearchWarning is emitted when a full-text search or "body" searches are hit
// BodyFullTextSearchDefaultWarning is emitted when a full-text search or "body" searches are hit
// with New JSON Body enhancements.
BodyFreeTextSearchWarning = "Free text searches default to `body.message:string`. Use `body.<key>` to search a different field inside body"
// FullTextSearchDefaultWarning is emitted when a search() function call is used.
FullTextSearchDefaultWarning = "Full text searches across all fields and will be slow and expensive. Consider using specific field to search e.g. <context>.<field_key>:<type>"
// FTSMaxWindowNs is the maximum allowed time range for a search() query (6 hours).
FTSMaxWindowNs = uint64(6 * 60 * 60 * 1_000_000_000)
BodyFullTextSearchDefaultWarning = "Full text searches default to `body.message:string`. Use `body.<key>` to search a different field inside body"
)
var (

View File

@@ -32,7 +32,7 @@ var friendly = map[string]string{
"BETWEEN": "BETWEEN", "IN": "IN", "EXISTS": "EXISTS",
"REGEXP": "REGEXP", "CONTAINS": "CONTAINS",
"HAS": "has()", "HASANY": "hasAny()", "HASALL": "hasAll()",
"HASTOKEN": "hasToken()", "SEARCH": "search()",
"HASTOKEN": "hasToken()",
// literals / identifiers
"NUMBER": "number",

View File

@@ -192,10 +192,7 @@ func (d *LogicalContradictionDetector) VisitPrimary(ctx *grammar.PrimaryContext)
// Handle function calls if needed
return nil
} else if ctx.FullText() != nil {
// Handle search calls if needed
return nil
} else if ctx.FreeText() != nil {
// Handle free text search if needed
// Handle full text search if needed
return nil
}

View File

@@ -34,12 +34,11 @@ type filterExpressionVisitor struct {
errors []string
mainErrorURL string
builder *sqlbuilder.SelectBuilder
freeTextColumn *telemetrytypes.TelemetryFieldKey
fullTextColumn *telemetrytypes.TelemetryFieldKey
jsonKeyToKey qbtypes.JsonKeyToFieldFunc
bodyJSONEnabled bool
skipResourceFilter bool
skipFreeTextFilter bool
skipFullTextSearch bool
skipFullTextFilter bool
skipFunctionCalls bool
ignoreNotFoundKeys bool
variables map[string]qbtypes.VariableItem
@@ -47,7 +46,6 @@ type filterExpressionVisitor struct {
keysWithWarnings map[string]bool
startNs uint64
endNs uint64
ftsCondition qbtypes.FTSConditionFunc
}
type FilterExprVisitorOpts struct {
@@ -57,13 +55,11 @@ type FilterExprVisitorOpts struct {
ConditionBuilder qbtypes.ConditionBuilder
FieldKeys map[string][]*telemetrytypes.TelemetryFieldKey
Builder *sqlbuilder.SelectBuilder
FreeTextColumn *telemetrytypes.TelemetryFieldKey
FullTextColumn *telemetrytypes.TelemetryFieldKey
JsonKeyToKey qbtypes.JsonKeyToFieldFunc
FTSCondition qbtypes.FTSConditionFunc
BodyJSONEnabled bool
SkipResourceFilter bool
SkipFreeTextFilter bool
SkipFullTextSearch bool
SkipFullTextFilter bool
SkipFunctionCalls bool
IgnoreNotFoundKeys bool
Variables map[string]qbtypes.VariableItem
@@ -80,13 +76,11 @@ func newFilterExpressionVisitor(opts FilterExprVisitorOpts) *filterExpressionVis
conditionBuilder: opts.ConditionBuilder,
fieldKeys: opts.FieldKeys,
builder: opts.Builder,
freeTextColumn: opts.FreeTextColumn,
fullTextColumn: opts.FullTextColumn,
jsonKeyToKey: opts.JsonKeyToKey,
ftsCondition: opts.FTSCondition,
bodyJSONEnabled: opts.BodyJSONEnabled,
skipResourceFilter: opts.SkipResourceFilter,
skipFreeTextFilter: opts.SkipFreeTextFilter,
skipFullTextSearch: opts.SkipFullTextSearch,
skipFullTextFilter: opts.SkipFullTextFilter,
skipFunctionCalls: opts.SkipFunctionCalls,
ignoreNotFoundKeys: opts.IgnoreNotFoundKeys,
variables: opts.Variables,
@@ -217,12 +211,10 @@ func (v *filterExpressionVisitor) Visit(tree antlr.ParseTree) any {
return v.VisitNotInClause(t)
case *grammar.ValueListContext:
return v.VisitValueList(t)
case *grammar.FreeTextContext:
return v.VisitFreeText(t)
case *grammar.FunctionCallContext:
return v.VisitFunctionCall(t)
case *grammar.FullTextContext:
return v.VisitFullText(t)
case *grammar.FunctionCallContext:
return v.VisitFunctionCall(t)
case *grammar.FunctionParamListContext:
return v.VisitFunctionParamList(t)
case *grammar.FunctionParamContext:
@@ -337,21 +329,18 @@ func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any
return v.Visit(ctx.FunctionCall())
} else if ctx.FullText() != nil {
return v.Visit(ctx.FullText())
} else if ctx.FreeText() != nil {
return v.Visit(ctx.FreeText())
}
// Handle standalone key/value as a full text search term
if ctx.GetChildCount() == 1 {
if v.skipFreeTextFilter {
if v.skipFullTextFilter {
return SkipConditionLiteral
}
if v.freeTextColumn == nil {
v.errors = append(v.errors, "free text search is not supported")
if v.fullTextColumn == nil {
v.errors = append(v.errors, "full text search is not supported")
return ErrorConditionLiteral
}
child := ctx.GetChild(0)
var searchText string
if keyCtx, ok := child.(*grammar.KeyContext); ok {
@@ -371,14 +360,13 @@ func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any
return ErrorConditionLiteral
}
}
cond, err := v.conditionBuilder.ConditionFor(context.Background(), v.startNs, v.endNs, v.freeTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(searchText), v.builder)
cond, err := v.conditionBuilder.ConditionFor(context.Background(), v.startNs, v.endNs, v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(searchText), v.builder)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to build full text search condition: %s", err.Error()))
return ErrorConditionLiteral
}
if v.bodyJSONEnabled && v.freeTextColumn.Name == "body" {
v.warnings = append(v.warnings, BodyFreeTextSearchWarning)
if v.bodyJSONEnabled && v.fullTextColumn.Name == "body" {
v.warnings = append(v.warnings, BodyFullTextSearchDefaultWarning)
}
return cond
@@ -709,9 +697,9 @@ func (v *filterExpressionVisitor) VisitValueList(ctx *grammar.ValueListContext)
return parts
}
// VisitFreeText handles standalone quoted strings for full-text search.
func (v *filterExpressionVisitor) VisitFreeText(ctx *grammar.FreeTextContext) any {
if v.skipFreeTextFilter {
// VisitFullText handles standalone quoted strings for full-text search.
func (v *filterExpressionVisitor) VisitFullText(ctx *grammar.FullTextContext) any {
if v.skipFullTextFilter {
// A skipped FT term must be treated as TrueConditionLiteral, not "".
// Returning "" would silently drop this branch from an OR, incorrectly
// excluding rows that could match the FT condition on the real table.
@@ -726,71 +714,24 @@ func (v *filterExpressionVisitor) VisitFreeText(ctx *grammar.FreeTextContext) an
text = ctx.FREETEXT().GetText()
}
if v.freeTextColumn == nil {
v.errors = append(v.errors, "free text search is not supported")
if v.fullTextColumn == nil {
v.errors = append(v.errors, "full text search is not supported")
return ErrorConditionLiteral
}
cond, err := v.conditionBuilder.ConditionFor(v.context, v.startNs, v.endNs, v.freeTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(text), v.builder)
cond, err := v.conditionBuilder.ConditionFor(v.context, v.startNs, v.endNs, v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(text), v.builder)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to build full text search condition: %s", err.Error()))
return ErrorConditionLiteral
}
if v.bodyJSONEnabled && v.freeTextColumn.Name == "body" {
v.warnings = append(v.warnings, BodyFreeTextSearchWarning)
if v.bodyJSONEnabled && v.fullTextColumn.Name == "body" {
v.warnings = append(v.warnings, BodyFullTextSearchDefaultWarning)
}
return cond
}
// VisitFullText handles the search() function call.
func (v *filterExpressionVisitor) VisitFullText(ctx *grammar.FullTextContext) any {
if v.skipFunctionCalls || v.skipFullTextSearch {
return SkipConditionLiteral
}
// ftsCondition nil means search() is not enabled for this signal.
// Only log statement builders set these; traces/metrics leave them nil.
if v.ftsCondition == nil {
v.errors = append(v.errors, "search() is only supported for log queries")
return ErrorConditionLiteral
}
paramCtxs := ctx.FunctionParamList().AllFunctionParam()
if len(paramCtxs) < 1 {
v.errors = append(v.errors, "search() requires exactly one quoted string parameter, e.g. search('error')")
return ErrorConditionLiteral
}
if len(paramCtxs) > 1 {
v.errors = append(v.errors, fmt.Sprintf("search() accepts exactly one parameter but got %d", len(paramCtxs)))
return ErrorConditionLiteral
}
paramCtx := paramCtxs[0]
var searchText string
if paramCtx.Value() != nil {
raw := v.Visit(paramCtx.Value())
searchText = fmt.Sprintf("%v", raw)
} else {
v.errors = append(v.errors, "search() parameter must be a quoted string, e.g. search('error')")
return ErrorConditionLiteral
}
if v.endNs > 0 && v.startNs > 0 && (v.endNs-v.startNs) > FTSMaxWindowNs {
v.errors = append(v.errors, "search() is restricted to a maximum of 6-hour time window")
return ErrorConditionLiteral
}
formattedText := FormatFullTextSearch(searchText)
cond, err := v.ftsCondition(v.context, formattedText, v.builder)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("search() could not build condition: %s", err.Error()))
return ErrorConditionLiteral
}
v.warnings = append(v.warnings, FullTextSearchDefaultWarning)
return cond
}
// VisitFunctionCall handles function calls like has(), hasAny(), hasAll(), hasToken().
// VisitFunctionCall handles function calls like has(), hasAny(), etc.
func (v *filterExpressionVisitor) VisitFunctionCall(ctx *grammar.FunctionCallContext) any {
if v.skipFunctionCalls {
return SkipConditionLiteral

View File

@@ -755,6 +755,7 @@ func (b *conditionBuilder) ConditionFor(
_ any,
_ *sqlbuilder.SelectBuilder,
) (string, error) {
return fmt.Sprintf("%s_cond", key.Name), nil
}
@@ -797,7 +798,7 @@ func visitComparisonOpts(t *testing.T) (rsbOpts, sbOpts FilterExprVisitorOpts) {
ConditionBuilder: &resourceConditionBuilder{},
Variables: allVariable,
SkipResourceFilter: false,
SkipFreeTextFilter: true,
SkipFullTextFilter: true,
SkipFunctionCalls: true,
IgnoreNotFoundKeys: true,
}
@@ -807,10 +808,10 @@ func visitComparisonOpts(t *testing.T) (rsbOpts, sbOpts FilterExprVisitorOpts) {
ConditionBuilder: &conditionBuilder{},
Variables: allVariable,
SkipResourceFilter: true,
SkipFreeTextFilter: false,
SkipFullTextFilter: false,
SkipFunctionCalls: false,
IgnoreNotFoundKeys: false,
FreeTextColumn: bodyCol,
FullTextColumn: bodyCol,
}
return
}
@@ -1271,111 +1272,110 @@ func TestVisitComparison_Parens(t *testing.T) {
}
}
// TestVisitComparison_FreeTextSearch covers Free Text Search — bare/quoted string literals
// that route through freeTextColumn (body only). No search() involved.
// TestVisitComparison_FullText covers full-text (bare string literal) expressions.
// rsbOpts has SkipFullTextFilter=true → TrueConditionLiteral.
// sbOpts has SkipFullTextFilter=false, FreeTextColumn=bodyCol → "body_cond".
func TestVisitComparison_FreeTextSearch(t *testing.T) {
// sbOpts has SkipFullTextFilter=false, FullTextColumn=bodyCol → "body_cond".
func TestVisitComparison_FullText(t *testing.T) {
rsbOpts, sbOpts := visitComparisonOpts(t)
tests := []visitComparisonCase{
{
name: "standalone free-text term",
name: "standalone full-text term",
expr: "'hello'",
wantRSB: "",
wantSB: "WHERE body_cond",
},
{
// RSB: FT→true, a→true; AND propagates true.
name: "free-text AND attribute",
name: "full-text AND attribute",
expr: "'hello' AND a = 'a'",
wantRSB: "",
wantSB: "WHERE (body_cond AND a_cond)",
},
{
// RSB: FT→true stripped; x_cond survives.
name: "free-text AND resource",
name: "full-text AND resource",
expr: "'hello' AND x = 'x'",
wantRSB: "WHERE x_cond",
wantSB: "WHERE body_cond",
},
{
// RSB: NOT(FT→SkipConditionLiteral)→SkipConditionLiteral. SB: structural NOT applied.
name: "NOT free-text term",
name: "NOT full-text term",
expr: "NOT 'hello'",
wantRSB: "",
wantSB: "WHERE NOT (body_cond)",
},
{
// RSB: FT→true short-circuits OR.
name: "free-text OR resource",
name: "full-text OR resource",
expr: "'hello' OR x = 'x'",
wantRSB: "",
wantSB: "WHERE (body_cond OR x_cond)",
},
{
name: "free-text OR attribute",
name: "full-text OR attribute",
expr: "'hello' OR a = 'a'",
wantRSB: "",
wantSB: "WHERE (body_cond OR a_cond)",
},
{
name: "two free-text terms ANDed",
name: "two full-text terms ANDed",
expr: "'hello' AND 'world'",
wantRSB: "",
wantSB: "WHERE (body_cond AND body_cond)",
},
{
name: "two free-text terms ORed",
name: "two full-text terms ORed",
expr: "'hello' OR 'world'",
wantRSB: "",
wantSB: "WHERE (body_cond OR body_cond)",
},
{
name: "free-text in parentheses",
name: "full-text in parentheses",
expr: "('hello')",
wantRSB: "",
wantSB: "WHERE (body_cond)",
},
{
name: "two free-text AND attribute",
name: "two full-text AND attribute",
expr: "'hello' AND 'world' AND a = 'a'",
wantRSB: "",
wantSB: "WHERE (body_cond AND body_cond AND a_cond)",
},
{
name: "free-text OR attr OR resource all types",
name: "full-text OR attr OR resource all types",
expr: "'hello' OR a = 'a' OR x = 'x'",
wantRSB: "",
wantSB: "WHERE (body_cond OR a_cond OR x_cond)",
},
{
name: "NOT of paren free-text AND attr",
name: "NOT of paren full-text AND attr",
expr: "NOT ('hello' AND a = 'a')",
wantRSB: "",
wantSB: "WHERE NOT (((body_cond AND a_cond)))",
},
{
// RSB: NOT(FT→SkipConditionLiteral)→SkipConditionLiteral stripped from AND; x_cond survives.
name: "NOT free-text AND resource",
name: "NOT full-text AND resource",
expr: "NOT 'hello' AND x = 'x'",
wantRSB: "WHERE x_cond",
wantSB: "WHERE NOT (body_cond)",
},
{
name: "NOT free-text OR resource",
name: "NOT full-text OR resource",
expr: "NOT 'hello' OR x = 'x'",
wantRSB: "",
wantSB: "WHERE (NOT (body_cond) OR x_cond)",
},
{
// RSB: FT→true stripped; x_cond survives.
name: "free-text AND BETWEEN",
name: "full-text AND BETWEEN",
expr: "'hello' AND x BETWEEN 1 AND 3",
wantRSB: "WHERE x_cond",
wantSB: "WHERE body_cond",
},
{
name: "free-text AND EXISTS",
name: "full-text AND EXISTS",
expr: "'hello' AND x EXISTS",
wantRSB: "WHERE x_cond",
wantSB: "WHERE body_cond",
@@ -1383,21 +1383,21 @@ func TestVisitComparison_FreeTextSearch(t *testing.T) {
{
// RSB: FT→true and allVariable→true; AND propagates true.
// SB: allVariable→TrueConditionLiteral stripped; body_cond survives.
name: "free-text AND allVariable",
name: "full-text AND allVariable",
expr: "'hello' AND x IN $service",
wantRSB: "",
wantSB: "WHERE body_cond",
},
{
// SB: body_cond added first; then allVariable→TrueConditionLiteral short-circuits OR.
name: "free-text OR allVariable",
name: "full-text OR allVariable",
expr: "'hello' OR x IN $service",
wantRSB: "",
wantSB: "",
},
{
// SB: body_cond
name: "free-text with sentinel value",
name: "full-text with sentinel value",
expr: SkipConditionLiteral,
wantRSB: "",
wantSB: "WHERE body_cond",
@@ -1684,68 +1684,6 @@ func TestVisitComparison_UnknownKeys(t *testing.T) {
}
}
// TestVisitComparison_FullTextSearch covers Full Text Search — the explicit search()
// function that fans out across all FTSSet columns. FTSSet must be set for
// search() to be enabled; invalid param counts must error.
func TestVisitComparison_FullTextSearch(t *testing.T) {
ftsOpts := FilterExprVisitorOpts{
Context: t.Context(),
FieldKeys: visitTestKeys,
ConditionBuilder: &conditionBuilder{},
SkipResourceFilter: false,
SkipFreeTextFilter: false,
SkipFunctionCalls: false,
IgnoreNotFoundKeys: false,
FTSCondition: func(_ context.Context, _ any, _ *sqlbuilder.SelectBuilder) (string, error) {
return "fts_cond", nil
},
}
tests := []struct {
name string
expr string
opts FilterExprVisitorOpts
wantErr bool
}{
{
name: "search with quoted string - valid",
expr: "search('error')",
opts: ftsOpts,
wantErr: false,
},
{
name: "search with unquoted word - invalid, quotes required",
expr: "search(error)",
opts: ftsOpts,
wantErr: true,
},
{
name: "search(\"err1\", \"err2\") - too many params",
expr: `search("err1", "err2")`,
opts: ftsOpts,
wantErr: true,
},
{
name: "search(attributes, \"err\") - too many params",
expr: `search(attributes, "err")`,
opts: ftsOpts,
wantErr: true,
},
{
name: "search() without params - error",
expr: "search()",
opts: ftsOpts,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := PrepareWhereClause(tt.expr, tt.opts)
assert.Equal(t, tt.wantErr, err != nil, "error expectation mismatch: err=%v", err)
})
}
}
// TestVisitComparison_SkippableLiteralValues guards against two distinct collision risks
// involving SkippableConditionLiterals ("true", "__skip__", "__skip_because_of_error__"):.
func TestVisitComparison_SkippableLiteralValues(t *testing.T) {

View File

@@ -198,4 +198,3 @@ func (c *conditionBuilder) ConditionFor(
return condition, nil
}

View File

@@ -550,7 +550,7 @@ func (b *auditQueryStatementBuilder) addFilterCondition(
ConditionBuilder: b.cb,
FieldKeys: keys,
SkipResourceFilter: true,
FreeTextColumn: b.fullTextColumn,
FullTextColumn: b.fullTextColumn,
JsonKeyToKey: b.jsonKeyToKey,
Variables: variables,
StartNs: start,

View File

@@ -25,50 +25,6 @@ func NewConditionBuilder(fm qbtypes.FieldMapper, fl flagger.Flagger) *conditionB
return &conditionBuilder{fm: fm, fl: fl}
}
// ConditionForSearch builds the search condition for all FTS columns belonging
// to fieldContext. Body JSON column is skipped when useJSONBody is disabled.
// Returns ("", nil) when no searchable columns exist for the context.
func (c *conditionBuilder) ConditionForSearch(
ctx context.Context,
value any,
sb *sqlbuilder.SelectBuilder,
) (string, error) {
contexts := []telemetrytypes.FieldContext{
telemetrytypes.FieldContextLog,
telemetrytypes.FieldContextBody,
telemetrytypes.FieldContextAttribute,
telemetrytypes.FieldContextResource,
}
useJSONBody := c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{}))
var conditions []string
for _, fieldContext := range contexts {
for _, col := range ftsColumns(fieldContext, useJSONBody) {
switch col.Type.GetType() {
case schema.ColumnTypeEnumMap:
keysExpr := fmt.Sprintf("mapKeys(%s)", col.Name)
valsExpr := fmt.Sprintf("mapValues(%s)", col.Name)
if mc, ok := col.Type.(schema.MapColumnType); ok && mc.ValueType.GetType() != schema.ColumnTypeEnumString {
valsExpr = fmt.Sprintf("arrayMap(x -> toString(x), mapValues(%s))", col.Name)
}
conditions = append(conditions, sb.Or(
fmt.Sprintf(`arrayExists(x -> match(x, %s), %s)`, sb.Var(value), keysExpr),
fmt.Sprintf(`arrayExists(x -> match(x, %s), %s)`, sb.Var(value), valsExpr),
))
case schema.ColumnTypeEnumJSON:
conditions = append(conditions, fmt.Sprintf(`match(LOWER(toString(%s)), LOWER(%s))`, col.Name, sb.Var(value)))
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumLowCardinality:
conditions = append(conditions, fmt.Sprintf(`match(LOWER(%s), LOWER(%s))`, col.Name, sb.Var(value)))
default:
return "", errors.Newf(errors.TypeInternal, errors.CodeInternal, "FTS unsupported column type %s", col.Type)
}
}
}
return sb.Or(conditions...), nil
}
func (c *conditionBuilder) conditionFor(
ctx context.Context,
startNs, endNs uint64,
@@ -106,8 +62,7 @@ func (c *conditionBuilder) conditionFor(
// Check if this is a body JSON search - either by FieldContext
// TODO(Tushar): thread orgID here to evaluate correctly
if key.FieldContext == telemetrytypes.FieldContextBody &&
!c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{})) {
if key.FieldContext == telemetrytypes.FieldContextBody && !c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{})) {
fieldExpression, value = GetBodyJSONKey(ctx, key, operator, value)
}
@@ -161,6 +116,7 @@ func (c *conditionBuilder) conditionFor(
return sb.ILike(fieldExpression, fmt.Sprintf("%%%s%%", value)), nil
case qbtypes.FilterOperatorNotContains:
return sb.NotILike(fieldExpression, fmt.Sprintf("%%%s%%", value)), nil
case qbtypes.FilterOperatorRegexp:
// Note: Escape $$ to $$$$ to avoid sqlbuilder interpreting materialized $ signs
// Only needed because we are using sprintf instead of sb.Match (not implemented in sqlbuilder)

View File

@@ -34,7 +34,6 @@ const (
LogsV2AttributesNumberColumn = "attributes_number"
LogsV2AttributesBoolColumn = "attributes_bool"
LogsV2ResourcesStringColumn = "resources_string"
LogsV2ResourceJSONColumn = "resource"
LogsV2ScopeStringColumn = "scope_string"
BodyV2ColumnPrefix = constants.BodyV2ColumnPrefix

View File

@@ -426,34 +426,3 @@ func (m *fieldMapper) buildArrayMap(currentNode *telemetrytypes.JSONAccessNode,
return fmt.Sprintf("arrayMap(%s->%s, %s)", currentNode.Alias(), nestedExpr, arrayExpr), nil
}
// ftsColumns returns the physical columns for the given field context that
// search() fans out across.
func ftsColumns(fieldContext telemetrytypes.FieldContext, useJSONBody bool) []*schema.Column {
switch fieldContext {
case telemetrytypes.FieldContextLog:
return []*schema.Column{
logsV2Columns[LogsV2SeverityTextColumn],
logsV2Columns[LogsV2TraceIDColumn],
logsV2Columns[LogsV2SpanIDColumn],
}
case telemetrytypes.FieldContextBody:
if useJSONBody {
return []*schema.Column{logsV2Columns[LogsV2BodyV2Column]}
}
return []*schema.Column{logsV2Columns[LogsV2BodyColumn]}
case telemetrytypes.FieldContextAttribute:
return []*schema.Column{
logsV2Columns[LogsV2AttributesStringColumn],
logsV2Columns[LogsV2AttributesNumberColumn],
logsV2Columns[LogsV2AttributesBoolColumn],
}
case telemetrytypes.FieldContextResource:
return []*schema.Column{
logsV2Columns[LogsV2ResourcesStringColumn],
logsV2Columns[LogsV2ResourceJSONColumn],
}
default:
return nil
}
}

View File

@@ -27,7 +27,7 @@ func TestLikeAndILikeWithoutWildcards_Warns(t *testing.T) {
FieldMapper: fm,
ConditionBuilder: cb,
FieldKeys: keys,
FreeTextColumn: DefaultFullTextColumn,
FullTextColumn: DefaultFullTextColumn,
JsonKeyToKey: GetBodyJSONKey,
}
@@ -66,7 +66,7 @@ func TestLikeAndILikeWithWildcards_NoWarn(t *testing.T) {
FieldMapper: fm,
ConditionBuilder: cb,
FieldKeys: keys,
FreeTextColumn: DefaultFullTextColumn,
FullTextColumn: DefaultFullTextColumn,
JsonKeyToKey: GetBodyJSONKey,
}

View File

@@ -29,7 +29,7 @@ func TestFilterExprLogsBodyJSON(t *testing.T) {
FieldMapper: fm,
ConditionBuilder: cb,
FieldKeys: keys,
FreeTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "body"},
FullTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "body"},
JsonKeyToKey: GetBodyJSONKey,
}

View File

@@ -49,7 +49,7 @@ func TestFilterExprLogs(t *testing.T) {
FieldMapper: fm,
ConditionBuilder: cb,
FieldKeys: keys,
FreeTextColumn: DefaultFullTextColumn,
FullTextColumn: DefaultFullTextColumn,
JsonKeyToKey: GetBodyJSONKey,
StartNs: uint64(releaseTime.Add(-5 * time.Minute).UnixNano()),
EndNs: uint64(releaseTime.Add(5 * time.Minute).UnixNano()),
@@ -116,7 +116,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Single word",
query: "<script>alert('xss')</script>",
shouldPass: false,
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got '<'",
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got '<'",
},
// Single word searches with spaces
@@ -182,7 +182,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Special characters",
query: "[tracing]",
shouldPass: false,
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got '['",
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got '['",
},
{
category: "Special characters",
@@ -212,7 +212,7 @@ func TestFilterExprLogs(t *testing.T) {
category: "Special characters",
query: "ERROR: cannot execute update() in a read-only context",
shouldPass: false,
expectedErrorContains: "expecting one of {(, AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got ')'",
expectedErrorContains: "expecting one of {(, AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got ')'",
},
{
category: "Special characters",
@@ -634,7 +634,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: []any{},
expectedErrorContains: "expecting one of {(, ), FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'and'",
expectedErrorContains: "expecting one of {(, ), FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'and'",
},
{
category: "Keyword conflict",
@@ -642,7 +642,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: []any{},
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'or'",
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'or'",
},
{
category: "Keyword conflict",
@@ -650,7 +650,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: []any{},
expectedErrorContains: "expecting one of {(, ), FREETEXT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got EOF",
expectedErrorContains: "expecting one of {(, ), FREETEXT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got EOF",
},
{
category: "Keyword conflict",
@@ -658,7 +658,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: []any{},
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'like'",
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'like'",
},
{
category: "Keyword conflict",
@@ -666,7 +666,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: []any{},
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'between'",
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'between'",
},
{
category: "Keyword conflict",
@@ -674,7 +674,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: []any{},
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'in'",
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'in'",
},
{
category: "Keyword conflict",
@@ -682,7 +682,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: []any{},
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'exists'",
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'exists'",
},
{
category: "Keyword conflict",
@@ -690,7 +690,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: []any{},
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'regexp'",
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'regexp'",
},
{
category: "Keyword conflict",
@@ -698,7 +698,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: []any{},
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'contains'",
expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'contains'",
},
{
category: "Keyword conflict",
@@ -2034,9 +2034,9 @@ func TestFilterExprLogs(t *testing.T) {
expectedErrorContains: "",
},
{category: "Only keywords", query: "AND", shouldPass: false, expectedErrorContains: "expecting one of {(, ), FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'AND'"},
{category: "Only keywords", query: "OR", shouldPass: false, expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'OR'"},
{category: "Only keywords", query: "NOT", shouldPass: false, expectedErrorContains: "expecting one of {(, ), FREETEXT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got EOF"},
{category: "Only keywords", query: "AND", shouldPass: false, expectedErrorContains: "expecting one of {(, ), FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'AND'"},
{category: "Only keywords", query: "OR", shouldPass: false, expectedErrorContains: "expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'OR'"},
{category: "Only keywords", query: "NOT", shouldPass: false, expectedErrorContains: "expecting one of {(, ), FREETEXT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got EOF"},
{category: "Only functions", query: "has", shouldPass: false, expectedErrorContains: "expecting one of {(, )} but got EOF"},
{category: "Only functions", query: "hasAny", shouldPass: false, expectedErrorContains: "expecting one of {(, )} but got EOF"},
@@ -2178,7 +2178,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: nil,
expectedErrorContains: "line 1:0 expecting one of {(, ), FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'and'",
expectedErrorContains: "line 1:0 expecting one of {(, ), FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'and'",
},
{
category: "Operator keywords as keys",
@@ -2186,7 +2186,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: nil,
expectedErrorContains: "line 1:0 expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'or'",
expectedErrorContains: "line 1:0 expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'or'",
},
{
category: "Operator keywords as keys",
@@ -2194,7 +2194,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: nil,
expectedErrorContains: "line 1:3 expecting one of {(, ), FREETEXT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got '='",
expectedErrorContains: "line 1:3 expecting one of {(, ), FREETEXT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got '='",
},
{
category: "Operator keywords as keys",
@@ -2202,7 +2202,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: nil,
expectedErrorContains: "line 1:0 expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'between'",
expectedErrorContains: "line 1:0 expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'between'",
},
{
category: "Operator keywords as keys",
@@ -2210,7 +2210,7 @@ func TestFilterExprLogs(t *testing.T) {
shouldPass: false,
expectedQuery: "",
expectedArgs: nil,
expectedErrorContains: "line 1:0 expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text, search()} but got 'in'",
expectedErrorContains: "line 1:0 expecting one of {(, ), AND, FREETEXT, NOT, boolean, has(), hasAll(), hasAny(), hasToken(), number, quoted text} but got 'in'",
},
// Using function keywords as keys
@@ -2466,7 +2466,7 @@ func TestFilterExprLogsConflictNegation(t *testing.T) {
FieldMapper: fm,
ConditionBuilder: cb,
FieldKeys: keys,
FreeTextColumn: DefaultFullTextColumn,
FullTextColumn: DefaultFullTextColumn,
JsonKeyToKey: GetBodyJSONKey,
}

View File

@@ -1204,7 +1204,6 @@ func buildJSONTestStatementBuilder(t *testing.T, addIndexes bool) (*logQueryStat
aggExprRewriter,
DefaultFullTextColumn,
GetBodyJSONKey,
cb.ConditionForSearch,
fl,
nil,
false,

View File

@@ -29,9 +29,8 @@ type logQueryStatementBuilder struct {
fl flagger.Flagger
skipResourceFingerprintEnabled bool
freeTextColumn *telemetrytypes.TelemetryFieldKey
jsonKeyToKey qbtypes.JsonKeyToFieldFunc
ftsConditionFunc qbtypes.FTSConditionFunc
fullTextColumn *telemetrytypes.TelemetryFieldKey
jsonKeyToKey qbtypes.JsonKeyToFieldFunc
}
var _ qbtypes.StatementBuilder[qbtypes.LogAggregation] = (*logQueryStatementBuilder)(nil)
@@ -42,9 +41,8 @@ func NewLogQueryStatementBuilder(
fieldMapper qbtypes.FieldMapper,
conditionBuilder qbtypes.ConditionBuilder,
aggExprRewriter qbtypes.AggExprRewriter,
freeTextColumn *telemetrytypes.TelemetryFieldKey,
fullTextColumn *telemetrytypes.TelemetryFieldKey,
jsonKeyToKey qbtypes.JsonKeyToFieldFunc,
ftsConditionFunc qbtypes.FTSConditionFunc,
fl flagger.Flagger,
telemetryStore telemetrystore.TelemetryStore,
skipResourceFingerprintEnable bool,
@@ -59,7 +57,7 @@ func NewLogQueryStatementBuilder(
telemetrytypes.SignalLogs,
telemetrytypes.SourceUnspecified,
metadataStore,
freeTextColumn,
fullTextColumn,
jsonKeyToKey,
fl,
telemetryStore,
@@ -75,9 +73,8 @@ func NewLogQueryStatementBuilder(
aggExprRewriter: aggExprRewriter,
fl: fl,
skipResourceFingerprintEnabled: skipResourceFingerprintEnable,
freeTextColumn: freeTextColumn,
fullTextColumn: fullTextColumn,
jsonKeyToKey: jsonKeyToKey,
ftsConditionFunc: ftsConditionFunc,
}
}
@@ -90,6 +87,7 @@ func (b *logQueryStatementBuilder) Build(
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
start = querybuilder.ToNanoSecs(start)
end = querybuilder.ToNanoSecs(end)
// TODO(Tushar): thread orgID here to evaluate correctly
@@ -327,7 +325,7 @@ func (b *logQueryStatementBuilder) buildListQuery(
sb.From(fmt.Sprintf("%s.%s", DBName, LogsV2TableName))
// Add filter conditions
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter, true)
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter)
if err != nil {
return nil, err
@@ -433,7 +431,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
// Add FROM clause
sb.From(fmt.Sprintf("%s.%s", DBName, LogsV2TableName))
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter, true)
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter)
if err != nil {
return nil, err
@@ -592,7 +590,7 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
sb.From(fmt.Sprintf("%s.%s", DBName, LogsV2TableName))
// Add filter conditions
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter, false)
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter)
if err != nil {
return nil, err
@@ -657,7 +655,6 @@ func (b *logQueryStatementBuilder) addFilterCondition(
keys map[string][]*telemetrytypes.TelemetryFieldKey,
variables map[string]qbtypes.VariableItem,
skipResourceFilter bool,
enableFTS bool,
) (querybuilder.PreparedWhereClause, error) {
var preparedWhereClause querybuilder.PreparedWhereClause
@@ -666,26 +663,22 @@ func (b *logQueryStatementBuilder) addFilterCondition(
bodyJSONEnabled := b.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{}))
if query.Filter != nil && query.Filter.Expression != "" {
opts := querybuilder.FilterExprVisitorOpts{
// add filter expression
preparedWhereClause, err = querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{
Context: ctx,
Logger: b.logger,
FieldMapper: b.fm,
ConditionBuilder: b.cb,
FieldKeys: keys,
BodyJSONEnabled: bodyJSONEnabled,
FreeTextColumn: b.freeTextColumn,
SkipResourceFilter: skipResourceFilter,
FullTextColumn: b.fullTextColumn,
JsonKeyToKey: b.jsonKeyToKey,
Variables: variables,
StartNs: start,
EndNs: end,
}
if enableFTS {
opts.FTSCondition = b.ftsConditionFunc
}
})
// add filter expression
preparedWhereClause, err = querybuilder.PrepareWhereClause(query.Filter.Expression, opts)
if err != nil {
return preparedWhereClause, err
}

View File

@@ -3,7 +3,6 @@ package telemetrylogs
import (
"context"
"regexp"
"strings"
"testing"
"time"
@@ -229,7 +228,6 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
aggExprRewriter,
DefaultFullTextColumn,
GetBodyJSONKey,
cb.ConditionForSearch,
fl,
nil,
false,
@@ -374,7 +372,6 @@ func TestStatementBuilderListQuery(t *testing.T) {
aggExprRewriter,
DefaultFullTextColumn,
GetBodyJSONKey,
cb.ConditionForSearch,
fl,
nil,
false,
@@ -411,13 +408,29 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
expected qbtypes.Statement
expectedErr error
}{
{
name: "List with full text search",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{
Expression: "hello",
},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE match(LOWER(body), LOWER(?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"hello", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
},
{
name: "list query with mat col order by",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{
Expression: "service.name = 'cartservice'",
Expression: "service.name = 'cartservice' hello",
},
Limit: 10,
Order: []qbtypes.OrderBy{
@@ -434,8 +447,8 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY `attribute_string_materialized$$key$$name` AS `materialized.key.name` desc LIMIT ?",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND match(LOWER(body), LOWER(?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY `attribute_string_materialized$$key$$name` AS `materialized.key.name` desc LIMIT ?",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), "hello", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
},
@@ -508,7 +521,6 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
aggExprRewriter,
DefaultFullTextColumn,
GetBodyJSONKey,
cb.ConditionForSearch,
fl,
nil,
false,
@@ -588,7 +600,6 @@ func TestStatementBuilderTimeSeriesBodyGroupBy(t *testing.T) {
aggExprRewriter,
DefaultFullTextColumn,
GetBodyJSONKey,
cb.ConditionForSearch,
fl,
nil,
false,
@@ -687,7 +698,6 @@ func TestStatementBuilderListQueryServiceCollision(t *testing.T) {
aggExprRewriter,
DefaultFullTextColumn,
GetBodyJSONKey,
cb.ConditionForSearch,
fl,
nil,
false,
@@ -915,7 +925,6 @@ func TestAdjustKey(t *testing.T) {
aggExprRewriter,
DefaultFullTextColumn,
GetBodyJSONKey,
cb.ConditionForSearch,
fl,
nil,
false,
@@ -1055,14 +1064,6 @@ func TestStmtBuilderBodyField(t *testing.T) {
f := field
mockMetadataStore.KeysMap[field.Name] = append(mockMetadataStore.KeysMap[field.Name], &f)
}
mockMetadataStore.KeysMap["service.name"] = []*telemetrytypes.TelemetryFieldKey{
{
Name: "service.name",
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
}
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
statementBuilder := NewLogQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
@@ -1072,7 +1073,6 @@ func TestStmtBuilderBodyField(t *testing.T) {
aggExprRewriter,
DefaultFullTextColumn,
GetBodyJSONKey,
cb.ConditionForSearch,
fl,
nil,
false,
@@ -1097,23 +1097,17 @@ func TestStmtBuilderBodyField(t *testing.T) {
}
}
func TestStmtBuilderTextSearch(t *testing.T) {
func TestStmtBuilderBodyFullTextSearch(t *testing.T) {
cases := []struct {
name string
requestType qbtypes.RequestType
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
enableUseJSONBody bool
expected qbtypes.Statement
expectedErr string
// optional per-case time window (ms); zero → use default 1747947419000/1747983448000
startMs uint64
endMs uint64
expectedErr error
}{
// ── Free Text Search ──────────────────────────────────────────────────────────
// Bare/quoted tokens route through fullTextColumn (body / body_v2.message only).
// SQL: match(LOWER(<body_col>), LOWER(?))
{
name: "free_text_search",
name: "fts",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
@@ -1121,16 +1115,15 @@ func TestStmtBuilderTextSearch(t *testing.T) {
Limit: 10,
},
enableUseJSONBody: true,
startMs: 1705309200000,
endMs: 1705316400000,
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body_v2 as body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE match(LOWER(body_v2.message), LOWER(?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"error", "1705309200000000000", uint64(1705307400), "1705316400000000000", uint64(1705316400), 10},
Warnings: []string{querybuilder.BodyFreeTextSearchWarning},
Args: []any{"error", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{querybuilder.BodyFullTextSearchDefaultWarning},
},
expectedErr: nil,
},
{
name: "free_text_search_2",
name: "fts_2",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
@@ -1138,16 +1131,15 @@ func TestStmtBuilderTextSearch(t *testing.T) {
Limit: 10,
},
enableUseJSONBody: true,
startMs: 1705309200000,
endMs: 1705316400000,
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body_v2 as body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE match(LOWER(body_v2.message), LOWER(?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"error", "1705309200000000000", uint64(1705307400), "1705316400000000000", uint64(1705316400), 10},
Warnings: []string{querybuilder.BodyFreeTextSearchWarning},
Args: []any{"error", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{querybuilder.BodyFullTextSearchDefaultWarning},
},
expectedErr: nil,
},
{
name: "free_text_search_json_disabled",
name: "fts_disabled",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
@@ -1155,80 +1147,11 @@ func TestStmtBuilderTextSearch(t *testing.T) {
Limit: 10,
},
enableUseJSONBody: false,
startMs: 1705309200000,
endMs: 1705316400000,
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE match(LOWER(body), LOWER(?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"error", "1705309200000000000", uint64(1705307400), "1705316400000000000", uint64(1705316400), 10},
Warnings: nil,
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE match(LOWER(body), LOWER(?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"error", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
},
// ── Full Text Search ──────────────────────────────────────────────────────────
// search() fans out via ftsSupportedContexts (log, body, attribute, resource).
// Each context returns one OR-combined condition from ConditionForContext.
// Uses a 2-hour window to stay under the 6-hour limit.
{
name: "search_fans_out_to_all_columns",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{Expression: "search('error')"},
Limit: 10,
},
enableUseJSONBody: true,
startMs: 1705309200000,
endMs: 1705316400000,
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body_v2 as body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE (match(LOWER(severity_text), LOWER(?)) OR match(LOWER(trace_id), LOWER(?)) OR match(LOWER(span_id), LOWER(?)) OR match(LOWER(toString(body_v2)), LOWER(?)) OR (arrayExists(x -> match(x, ?), mapKeys(attributes_string)) OR arrayExists(x -> match(x, ?), mapValues(attributes_string))) OR (arrayExists(x -> match(x, ?), mapKeys(attributes_number)) OR arrayExists(x -> match(x, ?), arrayMap(x -> toString(x), mapValues(attributes_number)))) OR (arrayExists(x -> match(x, ?), mapKeys(attributes_bool)) OR arrayExists(x -> match(x, ?), arrayMap(x -> toString(x), mapValues(attributes_bool)))) OR (arrayExists(x -> match(x, ?), mapKeys(resources_string)) OR arrayExists(x -> match(x, ?), mapValues(resources_string))) OR match(LOWER(toString(resource)), LOWER(?))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"error", "error", "error", "error", "error", "error", "error", "error", "error", "error", "error", "error", "error", "1705309200000000000", uint64(1705307400), "1705316400000000000", uint64(1705316400), 10},
Warnings: []string{querybuilder.FullTextSearchDefaultWarning},
},
},
{
name: "search_not_wraps_condition",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{Expression: "NOT search('healthcheck')"},
Limit: 10,
},
enableUseJSONBody: false,
startMs: 1705309200000,
endMs: 1705316400000,
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE NOT ((match(LOWER(severity_text), LOWER(?)) OR match(LOWER(trace_id), LOWER(?)) OR match(LOWER(span_id), LOWER(?)) OR match(LOWER(body), LOWER(?)) OR (arrayExists(x -> match(x, ?), mapKeys(attributes_string)) OR arrayExists(x -> match(x, ?), mapValues(attributes_string))) OR (arrayExists(x -> match(x, ?), mapKeys(attributes_number)) OR arrayExists(x -> match(x, ?), arrayMap(x -> toString(x), mapValues(attributes_number)))) OR (arrayExists(x -> match(x, ?), mapKeys(attributes_bool)) OR arrayExists(x -> match(x, ?), arrayMap(x -> toString(x), mapValues(attributes_bool)))) OR (arrayExists(x -> match(x, ?), mapKeys(resources_string)) OR arrayExists(x -> match(x, ?), mapValues(resources_string))) OR match(LOWER(toString(resource)), LOWER(?)))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"healthcheck", "healthcheck", "healthcheck", "healthcheck", "healthcheck", "healthcheck", "healthcheck", "healthcheck", "healthcheck", "healthcheck", "healthcheck", "healthcheck", "healthcheck", "1705309200000000000", uint64(1705307400), "1705316400000000000", uint64(1705316400), 10},
Warnings: []string{querybuilder.FullTextSearchDefaultWarning},
},
},
{
name: "search_combined_with_filter",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{Expression: "search('error') AND severity_text = 'ERROR' AND service.name = 'cartservice'"},
Limit: 10,
},
enableUseJSONBody: false,
startMs: 1705309200000,
endMs: 1705316400000,
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((match(LOWER(severity_text), LOWER(?)) OR match(LOWER(trace_id), LOWER(?)) OR match(LOWER(span_id), LOWER(?)) OR match(LOWER(body), LOWER(?)) OR (arrayExists(x -> match(x, ?), mapKeys(attributes_string)) OR arrayExists(x -> match(x, ?), mapValues(attributes_string))) OR (arrayExists(x -> match(x, ?), mapKeys(attributes_number)) OR arrayExists(x -> match(x, ?), arrayMap(x -> toString(x), mapValues(attributes_number)))) OR (arrayExists(x -> match(x, ?), mapKeys(attributes_bool)) OR arrayExists(x -> match(x, ?), arrayMap(x -> toString(x), mapValues(attributes_bool)))) OR (arrayExists(x -> match(x, ?), mapKeys(resources_string)) OR arrayExists(x -> match(x, ?), mapValues(resources_string))) OR match(LOWER(toString(resource)), LOWER(?))) AND severity_text = ?) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1705307400), uint64(1705316400), "error", "error", "error", "error", "error", "error", "error", "error", "error", "error", "error", "error", "error", "ERROR", "1705309200000000000", uint64(1705307400), "1705316400000000000", uint64(1705316400), 10},
Warnings: []string{querybuilder.FullTextSearchDefaultWarning},
},
},
{
// default window is ~10h which exceeds the 6-hour search() limit
name: "search_window_exceeds_6h",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{Expression: "search('error')"},
Limit: 10,
},
enableUseJSONBody: false,
expectedErr: "maximum of 6-hour time",
expectedErr: nil,
},
}
@@ -1239,7 +1162,6 @@ func TestStmtBuilderTextSearch(t *testing.T) {
cb := NewConditionBuilder(fm, fl)
// build the key map
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap(time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC))
for _, field := range IntrinsicFields {
f := field
mockMetadataStore.KeysMap[field.Name] = append(mockMetadataStore.KeysMap[field.Name], &f)
@@ -1253,39 +1175,25 @@ func TestStmtBuilderTextSearch(t *testing.T) {
aggExprRewriter,
DefaultFullTextColumn,
GetBodyJSONKey,
cb.ConditionForSearch,
fl,
nil,
false,
100000,
)
startMs := uint64(1747947419000)
if c.startMs != 0 {
startMs = c.startMs
}
endMs := uint64(1747983448000)
if c.endMs != 0 {
endMs = c.endMs
}
q, err := statementBuilder.Build(context.Background(), startMs, endMs, c.requestType, c.query, nil)
if c.expectedErr != "" {
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
if c.expectedErr != nil {
require.Error(t, err)
errAsJSON := errors.AsJSON(err)
found := false
for _, e := range errAsJSON.Errors {
if strings.Contains(e.Message, c.expectedErr) {
found = true
break
}
}
require.True(t, found, "expected additionals to contain %q, got %v", c.expectedErr, errAsJSON.Errors)
require.Contains(t, err.Error(), c.expectedErr.Error())
} else {
require.NoError(t, err)
if c.expected.Query != "" {
require.Equal(t, c.expected.Query, q.Query)
require.Equal(t, c.expected.Args, q.Args)
require.Equal(t, c.expected.Warnings, q.Warnings)
if err != nil {
_, _, _, _, _, add := errors.Unwrapb(err)
t.Logf("error additionals: %v", add)
}
require.NoError(t, err)
require.Equal(t, c.expected.Query, q.Query)
require.Equal(t, c.expected.Args, q.Args)
require.Equal(t, c.expected.Warnings, q.Warnings)
}
})
}
@@ -1391,7 +1299,6 @@ func newSkipResourceFingerprintLogsBuilder(
aggExprRewriter,
DefaultFullTextColumn,
GetBodyJSONKey,
cb.ConditionForSearch,
fl,
telemetryStore,
skipEnable,

View File

@@ -135,4 +135,3 @@ func (c *conditionBuilder) ConditionFor(
return fmt.Sprintf(expr, columns[0].Name, sb.Var(key.Name), cond), nil
}

View File

@@ -152,7 +152,7 @@ func (b *meterQueryStatementBuilder) buildTemporalAggDeltaFastPath(
FieldMapper: b.fm,
ConditionBuilder: b.cb,
FieldKeys: keys,
FreeTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "labels"},
FullTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "labels"},
Variables: variables,
StartNs: start,
EndNs: end,
@@ -241,7 +241,7 @@ func (b *meterQueryStatementBuilder) buildTemporalAggDelta(
FieldMapper: b.fm,
ConditionBuilder: b.cb,
FieldKeys: keys,
FreeTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "labels"},
FullTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "labels"},
Variables: variables,
StartNs: start,
EndNs: end,
@@ -311,7 +311,7 @@ func (b *meterQueryStatementBuilder) buildTemporalAggCumulativeOrUnspecified(
FieldMapper: b.fm,
ConditionBuilder: b.cb,
FieldKeys: keys,
FreeTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "labels"},
FullTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "labels"},
Variables: variables,
StartNs: start,
EndNs: end,

View File

@@ -157,4 +157,3 @@ func (c *conditionBuilder) ConditionFor(
return condition, nil
}

View File

@@ -274,7 +274,7 @@ func (b *MetricQueryStatementBuilder) buildTimeSeriesCTE(
FieldMapper: b.fm,
ConditionBuilder: b.cb,
FieldKeys: keys,
FreeTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "labels"},
FullTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "labels"},
Variables: variables,
StartNs: start,
EndNs: end,

View File

@@ -206,4 +206,3 @@ func (b *defaultConditionBuilder) ConditionFor(
}
return "", qbtypes.ErrUnsupportedOperator
}

View File

@@ -172,10 +172,9 @@ func (b *resourceFilterStatementBuilder[T]) addConditions(
ConditionBuilder: b.conditionBuilder,
FieldKeys: keys,
BodyJSONEnabled: bodyJSONEnabled,
FreeTextColumn: b.fullTextColumn,
FullTextColumn: b.fullTextColumn,
JsonKeyToKey: b.jsonKeyToKey,
SkipFreeTextFilter: true,
SkipFullTextSearch: true,
SkipFullTextFilter: true,
SkipFunctionCalls: true,
// there is no need for "key" not found error for resource filtering
IgnoreNotFoundKeys: true,

View File

@@ -291,7 +291,6 @@ func (c *conditionBuilder) ConditionFor(
return condition, nil
}
func (c *conditionBuilder) isSpanScopeField(name string) bool {
keyName := strings.ToLower(name)
return keyName == SpanSearchScopeRoot || keyName == SpanSearchScopeEntryPoint

View File

@@ -19,11 +19,6 @@ var (
type JsonKeyToFieldFunc func(context.Context, *telemetrytypes.TelemetryFieldKey, FilterOperator, any) (string, any)
// FTSConditionFunc builds the search() SQL condition for all FTS columns.
// Returns ("", nil) when no columns are searchable for the
// context (e.g. body JSON when useJSONBody is off). Pass nil to disable search().
type FTSConditionFunc func(ctx context.Context, value any, sb *sqlbuilder.SelectBuilder) (string, error)
// FieldMapper maps the telemetry field key to the table field name.
type FieldMapper interface {
// FieldFor returns the field name for the given key.

View File

@@ -107,12 +107,10 @@ func (v *variableReplacementVisitor) Visit(tree antlr.ParseTree) any {
return v.VisitNotInClause(t)
case *grammar.ValueListContext:
return v.VisitValueList(t)
case *grammar.FreeTextContext:
return v.VisitFreeText(t)
case *grammar.FunctionCallContext:
return v.VisitFunctionCall(t)
case *grammar.FullTextContext:
return v.VisitFullText(t)
case *grammar.FunctionCallContext:
return v.VisitFunctionCall(t)
case *grammar.FunctionParamListContext:
return v.VisitFunctionParamList(t)
case *grammar.FunctionParamContext:
@@ -207,8 +205,6 @@ func (v *variableReplacementVisitor) VisitPrimary(ctx *grammar.PrimaryContext) a
return v.Visit(ctx.FunctionCall())
} else if ctx.FullText() != nil {
return v.Visit(ctx.FullText())
} else if ctx.FreeText() != nil {
return v.Visit(ctx.FreeText())
}
// Handle standalone key/value
@@ -386,7 +382,7 @@ func (v *variableReplacementVisitor) VisitValueList(ctx *grammar.ValueListContex
return "(" + strings.Join(parts, "") + ")"
}
func (v *variableReplacementVisitor) VisitFreeText(ctx *grammar.FreeTextContext) any {
func (v *variableReplacementVisitor) VisitFullText(ctx *grammar.FullTextContext) any {
if ctx.QUOTED_TEXT() != nil {
return ctx.QUOTED_TEXT().GetText()
} else if ctx.FREETEXT() != nil {
@@ -395,11 +391,6 @@ func (v *variableReplacementVisitor) VisitFreeText(ctx *grammar.FreeTextContext)
return ""
}
func (v *variableReplacementVisitor) VisitFullText(ctx *grammar.FullTextContext) any {
params := v.Visit(ctx.FunctionParamList()).(string)
return "search(" + params + ")"
}
func (v *variableReplacementVisitor) VisitFunctionCall(ctx *grammar.FunctionCallContext) any {
var functionName string
if ctx.HAS() != nil {

View File

@@ -6,6 +6,13 @@ import {
type Page,
} from '@playwright/test';
import {
detectPersona,
detectSettingsEnv,
type Persona,
type SettingsEnv,
} from '../helpers/persona';
export type User = { email: string; password: string };
// Default user — admin from the pytest bootstrap (.env.local) or staging .env.
@@ -20,6 +27,11 @@ export const ADMIN: User = {
type StorageState = Awaited<ReturnType<BrowserContext['storageState']>>;
const storageByUser = new Map<string, Promise<StorageState>>();
// Per-worker persona/env caches by user email. Detection is constant for a
// given backend + user, so it runs once per worker.
const personaByUser = new Map<string, Promise<Persona>>();
const envByUser = new Map<string, Promise<SettingsEnv>>();
async function storageFor(browser: Browser, user: User): Promise<StorageState> {
const cached = storageByUser.get(user.email);
if (cached) {
@@ -72,6 +84,10 @@ export const test = base.extend<{
* storageState is held in memory and reused for all later requests.
*/
authedPage: Page;
persona: Persona;
env: SettingsEnv;
}>({
user: [ADMIN, { option: true }],
@@ -93,6 +109,24 @@ export const test = base.extend<{
await use(page);
await ctx.close();
},
persona: async ({ authedPage, user }, use) => {
let task = personaByUser.get(user.email);
if (!task) {
task = detectPersona(authedPage);
personaByUser.set(user.email, task);
}
await use(await task);
},
env: async ({ authedPage, user }, use) => {
let task = envByUser.get(user.email);
if (!task) {
task = detectSettingsEnv(authedPage);
envByUser.set(user.email, task);
}
await use(await task);
},
});
export { expect };

View File

@@ -0,0 +1,124 @@
import type { Page } from '@playwright/test';
import { authToken } from './dashboards';
export type Tier =
| 'cloud'
| 'enterprise'
| 'community'
| 'community-enterprise';
export type Role = 'ADMIN' | 'EDITOR' | 'VIEWER' | 'ANONYMOUS';
export interface Persona {
tier: Tier;
role: Role;
}
export interface SettingsEnv {
isGatewayEnabled: boolean;
}
interface AuthzCheckItem {
authorized?: boolean;
object?: { selector?: string };
}
interface FeatureFlag {
name?: string;
active?: boolean;
}
const LICENSE_URL = '/api/v3/licenses/active';
const AUTHZ_CHECK_URL = '/api/v1/authz/check';
const FEATURES_URL = '/api/v1/features';
// Mirrors IsAdmin/Editor/Viewer in frontend/src/hooks/useAuthZ/legacy.ts:
// relation 'assignee' on resource kind/type 'role', selector = preset role id.
const ROLE_PROBES: { role: Exclude<Role, 'ANONYMOUS'>; selector: string }[] = [
{ role: 'ADMIN', selector: 'signoz-admin' },
{ role: 'EDITOR', selector: 'signoz-editor' },
{ role: 'VIEWER', selector: 'signoz-viewer' },
];
function authHeaders(token: string): Record<string, string> {
return { Authorization: `Bearer ${token}` };
}
function parseOverride(): Persona | null {
const raw = process.env.SIGNOZ_E2E_PERSONA;
if (!raw) {
return null;
}
const parts = raw.toLowerCase().split('-');
const roleRaw = parts.pop();
const tier = parts.join('-') as Tier;
const role = roleRaw?.toUpperCase() as Role;
return { tier, role };
}
async function detectTier(page: Page, token: string): Promise<Tier> {
const res = await page.request.get(LICENSE_URL, {
headers: authHeaders(token),
});
if (res.status() === 404) {
return 'community-enterprise';
}
if (res.status() === 501) {
return 'community';
}
const body = await res.json();
const platform = body?.data?.platform;
if (platform === 'CLOUD') {
return 'cloud';
}
return 'enterprise';
}
async function detectRole(page: Page, token: string): Promise<Role> {
const payload = ROLE_PROBES.map((p) => ({
relation: 'assignee',
object: {
resource: { kind: 'role', type: 'role' },
selector: p.selector,
},
}));
const res = await page.request.post(AUTHZ_CHECK_URL, {
headers: authHeaders(token),
data: payload,
});
const body = await res.json();
const items: AuthzCheckItem[] = body?.data ?? [];
const granted = new Set(
items.filter((i) => i?.authorized).map((i) => i?.object?.selector),
);
for (const p of ROLE_PROBES) {
if (granted.has(p.selector)) {
return p.role;
}
}
return 'ANONYMOUS';
}
export async function detectPersona(page: Page): Promise<Persona> {
const override = parseOverride();
if (override) {
return override;
}
const token = await authToken(page);
const [tier, role] = await Promise.all([
detectTier(page, token),
detectRole(page, token),
]);
return { tier, role };
}
export async function detectSettingsEnv(page: Page): Promise<SettingsEnv> {
const token = await authToken(page);
const res = await page.request.get(FEATURES_URL, {
headers: authHeaders(token),
});
const body = await res.json();
const flags: FeatureFlag[] = body?.data ?? [];
const gateway = flags.find((f) => f?.name === 'gateway');
return { isGatewayEnabled: !!gateway?.active };
}

View File

@@ -0,0 +1,52 @@
import type { Page } from '@playwright/test';
import { expect } from '../fixtures/auth';
// Verbatim from frontend/src/constants/routes.ts
export const SETTINGS_ROUTES = {
WORKSPACE: '/settings',
MY_SETTINGS: '/settings/my-settings',
ORG_SETTINGS: '/settings/org-settings',
ALL_CHANNELS: '/settings/channels',
INGESTION: '/settings/ingestion-settings',
BILLING: '/settings/billing',
ROLES: '/settings/roles',
MEMBERS: '/settings/members',
SERVICE_ACCOUNTS: '/settings/service-accounts',
SHORTCUTS: '/settings/shortcuts',
MCP_SERVER: '/settings/mcp-server',
INTEGRATIONS: '/integrations',
} as const;
export type SettingsRoute =
(typeof SETTINGS_ROUTES)[keyof typeof SETTINGS_ROUTES];
// Sidenav item data-testid == itemKey in menuItems.tsx settingsNavSections.
export const NAV_TESTID: Record<string, string> = {
[SETTINGS_ROUTES.WORKSPACE]: 'workspace',
[SETTINGS_ROUTES.MY_SETTINGS]: 'account',
[SETTINGS_ROUTES.ALL_CHANNELS]: 'notification-channels',
[SETTINGS_ROUTES.BILLING]: 'billing',
[SETTINGS_ROUTES.INTEGRATIONS]: 'integrations',
[SETTINGS_ROUTES.MCP_SERVER]: 'mcp-server',
[SETTINGS_ROUTES.ROLES]: 'roles',
[SETTINGS_ROUTES.MEMBERS]: 'members',
[SETTINGS_ROUTES.SERVICE_ACCOUNTS]: 'service-accounts',
[SETTINGS_ROUTES.INGESTION]: 'ingestion',
[SETTINGS_ROUTES.ORG_SETTINGS]: 'sso',
[SETTINGS_ROUTES.SHORTCUTS]: 'keyboard-shortcuts',
};
export async function gotoSettings(page: Page): Promise<void> {
await page.goto(SETTINGS_ROUTES.WORKSPACE);
await expect(page.getByTestId('settings-page-title')).toBeVisible();
}
export async function openSettingsTab(
page: Page,
route: SettingsRoute,
): Promise<void> {
const testid = NAV_TESTID[route];
await page.getByTestId('settings-page-sidenav').getByTestId(testid).click();
await expect(page).toHaveURL(new RegExp(route.replace(/\//g, '\\/')));
}

View File

@@ -0,0 +1,156 @@
import type { Persona, SettingsEnv, Tier } from './persona';
import { SETTINGS_ROUTES, NAV_TESTID } from './settings';
// Mirrors the isEnabled effect in frontend/src/pages/Settings/Settings.tsx.
// Returns the set of sidenav item testids (itemKeys) that should be visible.
export function visibleNavItems(
persona: Persona,
_env: SettingsEnv,
): Set<string> {
const { tier, role } = persona;
const isAdmin = role === 'ADMIN';
const isEditor = role === 'EDITOR';
const isViewer = role === 'VIEWER';
// Defaults that start enabled in menuItems.tsx settingsNavSections.
const s = new Set<string>([
'workspace',
'account',
'notification-channels',
'keyboard-shortcuts',
]);
const enableForAllUsers = (): void => {
s.add('roles');
s.add('service-accounts');
};
if (tier === 'cloud') {
enableForAllUsers();
if (isAdmin) {
[
'billing',
'integrations',
'ingestion',
'sso',
'members',
'mcp-server',
].forEach((k) => s.add(k));
}
if (isEditor) {
['ingestion', 'integrations', 'mcp-server'].forEach((k) => s.add(k));
}
if (isViewer) {
s.add('mcp-server');
}
return s;
}
if (tier === 'enterprise') {
enableForAllUsers();
if (isAdmin) {
[
'billing',
'integrations',
'sso',
'members',
'ingestion',
'mcp-server',
].forEach((k) => s.add(k));
}
if (isEditor) {
['integrations', 'ingestion', 'mcp-server'].forEach((k) => s.add(k));
}
if (isViewer) {
s.add('mcp-server');
}
return s;
}
// community / community-enterprise (!cloud && !enterprise)
enableForAllUsers();
if (isAdmin) {
s.add('sso');
s.add('members');
}
// billing & integrations explicitly disabled for non-cloud users.
s.delete('billing');
s.delete('integrations');
return s;
}
// Mirrors getRoutes() in frontend/src/pages/Settings/utils.ts.
// Returns the set of /settings route paths that are mounted (navigable).
export function registeredRoutes(
persona: Persona,
env: SettingsEnv,
): Set<string> {
const { tier, role } = persona;
const isAdmin = role === 'ADMIN';
const isEditor = role === 'EDITOR';
const isCloud = tier === 'cloud';
const isEnterprise = tier === 'enterprise';
const r = new Set<string>([
SETTINGS_ROUTES.WORKSPACE, // generalSettings — always
SETTINGS_ROUTES.ALL_CHANNELS, // always
SETTINGS_ROUTES.SERVICE_ACCOUNTS, // always
SETTINGS_ROUTES.ROLES, // always
SETTINGS_ROUTES.MY_SETTINGS, // always
SETTINGS_ROUTES.SHORTCUTS, // always
SETTINGS_ROUTES.MCP_SERVER, // always
]);
// organizationSettings — gated by current_org_settings; mirrored as admin-only.
if (isAdmin) {
r.add(SETTINGS_ROUTES.ORG_SETTINGS);
}
// multiIngestionSettings if gateway && (admin||editor); cloud read-only if cloud && !gateway.
if (
(env.isGatewayEnabled && (isAdmin || isEditor)) ||
(isCloud && !env.isGatewayEnabled)
) {
r.add(SETTINGS_ROUTES.INGESTION);
}
// membersSettings if admin.
if (isAdmin) {
r.add(SETTINGS_ROUTES.MEMBERS);
}
// billing if (cloud||enterprise) && admin.
if ((isCloud || isEnterprise) && isAdmin) {
r.add(SETTINGS_ROUTES.BILLING);
}
return r;
}
// Skip reason when a route's nav item is hidden for the persona; null when
// visible. Centralised so every skip reads identically and is greppable.
export function personaSkipReason(
persona: Persona,
env: SettingsEnv,
route: string,
): string | null {
const visible = visibleNavItems(persona, env);
const testid = NAV_TESTID[route];
if (testid && visible.has(testid)) {
return null;
}
return `PERSONA_SKIP: ${route} hidden for ${persona.tier}×${persona.role}`;
}
// Second skip axis: a route is visible but renders tier-specific CONTENT (e.g.
// /settings shows a cloud support card vs self-hosted retention controls).
// Gates a test to the tiers whose content it asserts. Shares the PERSONA_SKIP:
// prefix.
export function tierSkipReason(
persona: Persona,
allowedTiers: Tier[],
label: string,
): string | null {
if (allowedTiers.includes(persona.tier)) {
return null;
}
return `PERSONA_SKIP: ${label} not applicable for tier ${persona.tier} (needs ${allowedTiers.join(
'|',
)})`;
}

View File

@@ -0,0 +1,151 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import {
personaSkipReason,
tierSkipReason,
} from '../../helpers/settingsAccess';
import { SETTINGS_ROUTES } from '../../helpers/settings';
// Workspace (/settings) has two views: cloud (retention inputs disabled, no Save,
// GeneralSettingsCloud support card) and self-hosted (interactive inputs, per-row Save).
// Retention inputs in compact mode have no data-testid — role/text/CSS fallback.
async function gotoWorkspace(page: Page): Promise<void> {
await page.goto(SETTINGS_ROUTES.WORKSPACE);
// Retention data is fetched server-side; allow margin for the API response.
await expect(page.locator('.retention-controls-container')).toBeVisible({
timeout: 15_000,
});
}
function retentionRow(page: Page, signal: string) {
return page.locator('.retention-row').filter({ hasText: signal });
}
function retentionInput(page: Page, signal: string) {
return retentionRow(page, signal).locator('input[type="number"]').first();
}
function saveButton(page: Page, signal: string) {
return retentionRow(page, signal).getByRole('button', { name: /^save$/i });
}
// Tier sets for the two Workspace content variants.
const CLOUD_TIERS = ['cloud'] as const;
const SELF_HOSTED_TIERS = [
'enterprise',
'community',
'community-enterprise',
] as const;
test.describe('Settings — Workspace / General page', () => {
test('TC-01 page renders retention controls and license-key row', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE),
personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE) ?? undefined,
);
await gotoWorkspace(page);
// Scoped to avoid strict-mode conflict with the sidenav item.
await expect(page.locator('.general-settings-title')).toContainText(
'Workspace',
);
await expect(page.locator('.general-settings-subtitle')).toContainText(
'Manage your workspace settings.',
);
await expect(page.getByText('Retention Controls')).toBeVisible();
await expect(retentionRow(page, 'Metrics')).toBeVisible();
await expect(retentionRow(page, 'Traces')).toBeVisible();
await expect(retentionRow(page, 'Logs')).toBeVisible();
await expect(retentionInput(page, 'Metrics')).toBeVisible();
await expect(retentionInput(page, 'Traces')).toBeVisible();
await expect(retentionInput(page, 'Logs')).toBeVisible();
await expect(page.getByTestId('license-key-row-copy-btn')).toBeVisible();
});
// RISK MODE: read-only — only asserts disabled state, nothing is mutated.
test('TC-02 cloud view — retention inputs are disabled and support card is visible', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE),
personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE) ?? undefined,
);
test.skip(
!!tierSkipReason(persona, [...CLOUD_TIERS], 'cloud retention view'),
tierSkipReason(persona, [...CLOUD_TIERS], 'cloud retention view') ??
undefined,
);
await gotoWorkspace(page);
await expect(retentionInput(page, 'Metrics')).toBeDisabled();
await expect(retentionInput(page, 'Traces')).toBeDisabled();
await expect(retentionInput(page, 'Logs')).toBeDisabled();
await expect(saveButton(page, 'Metrics')).toHaveCount(0);
await expect(saveButton(page, 'Traces')).toHaveCount(0);
await expect(saveButton(page, 'Logs')).toHaveCount(0);
await expect(
page.getByText(/please.*email us.*or connect.*via chat support/i),
).toBeVisible();
});
// RISK MODE: never clicks Save — only asserts enable-on-change / disable-on-clear; no PUT/POST.
test('TC-03 self-hosted view — retention input enables/disables Save — no save triggered', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE),
personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE) ?? undefined,
);
test.skip(
!!tierSkipReason(
persona,
[...SELF_HOSTED_TIERS],
'self-hosted retention controls',
),
tierSkipReason(
persona,
[...SELF_HOSTED_TIERS],
'self-hosted retention controls',
) ?? undefined,
);
await gotoWorkspace(page);
const metricsInput = retentionInput(page, 'Metrics');
const metricsSaveBtn = saveButton(page, 'Metrics');
const originalValue = await metricsInput.inputValue();
try {
await metricsInput.fill('9999');
await expect(metricsSaveBtn).toBeEnabled();
await metricsInput.fill('');
await expect(metricsSaveBtn).toBeDisabled();
await expect(
page.getByText(/retention period for .+ is not set yet/i),
).toBeVisible();
} finally {
// Restore so unsaved UI state does not leak to other workers sharing this stack.
await metricsInput.fill(originalValue);
}
});
});

View File

@@ -0,0 +1,117 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import {
personaSkipReason,
tierSkipReason,
} from '../../helpers/settingsAccess';
import { SETTINGS_ROUTES } from '../../helpers/settings';
// Ingestion page, two variants gated by env.isGatewayEnabled / tier:
// MultiIngestionSettings (gateway ON) vs read-only IngestionSettings (cloud, gateway OFF).
// RISK MODE — READ-ONLY: never create/edit/delete keys or rate limits; create
// button and copy affordances asserted for presence only, never clicked.
// Each TC guards its variant via test.skip so bodies stay branch-free
// (playwright/no-conditional-in-test).
test.describe.configure({ mode: 'serial' });
async function gotoIngestion(page: Page): Promise<void> {
await page.goto(SETTINGS_ROUTES.INGESTION);
// Ingestion keys/settings are fetched server-side; allow margin for the API response.
await expect(
page
.locator('.ingestion-key-container, .ingestion-settings-container')
.first(),
).toBeVisible({ timeout: 15_000 });
}
test.describe('Settings — Ingestion page', () => {
test('TC-01 MultiIngestionSettings — page chrome, search, table, and create affordance render', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.INGESTION),
personaSkipReason(persona, env, SETTINGS_ROUTES.INGESTION) ?? undefined,
);
test.skip(
!!tierSkipReason(
persona,
['cloud', 'enterprise'],
'MultiIngestionSettings (gateway)',
) || !env.isGatewayEnabled,
!env.isGatewayEnabled
? 'PERSONA_SKIP: gateway feature flag is OFF — MultiIngestionSettings does not render'
: (tierSkipReason(
persona,
['cloud', 'enterprise'],
'MultiIngestionSettings (gateway)',
) ?? undefined),
);
await gotoIngestion(page);
const container = page.locator('.ingestion-key-container');
await expect(container).toBeVisible();
// Exact name match avoids the subtitle partial match.
await expect(
container.getByRole('heading', { name: 'Ingestion Keys' }),
).toBeVisible();
await expect(
container.getByText(/Create and manage ingestion keys/i),
).toBeVisible();
await expect(
container.getByPlaceholder('Search for ingestion key...'),
).toBeVisible();
await expect(
container.getByRole('button', { name: /new ingestion key/i }),
).toBeVisible();
await expect(container.locator('.ingestion-keys-table')).toBeVisible();
await expect(
container.locator('.ingestion-key-url-label', { hasText: 'Ingestion URL' }),
).toBeVisible();
});
test('TC-02 IngestionSettings (read-only) — table rows for URL, key, and region render', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.INGESTION),
personaSkipReason(persona, env, SETTINGS_ROUTES.INGESTION) ?? undefined,
);
// This view only renders on cloud when gateway is disabled
test.skip(
env.isGatewayEnabled,
'PERSONA_SKIP: gateway is ON — MultiIngestionSettings renders instead of read-only table',
);
test.skip(
!!tierSkipReason(persona, ['cloud'], 'IngestionSettings read-only table'),
tierSkipReason(persona, ['cloud'], 'IngestionSettings read-only table') ??
undefined,
);
await gotoIngestion(page);
const container = page.locator('.ingestion-settings-container');
await expect(container).toBeVisible();
await expect(
container.getByText(/start sending your telemetry data/i),
).toBeVisible();
const table = container.locator('.ant-table');
await expect(table).toBeVisible();
await expect(table.getByText('Ingestion URL')).toBeVisible();
await expect(table.getByText('Ingestion Key')).toBeVisible();
await expect(table.getByText('Ingestion Region')).toBeVisible();
});
});

View File

@@ -0,0 +1,153 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import { newAdminContext } from '../../helpers/auth';
import { authToken } from '../../helpers/dashboards';
import { personaSkipReason } from '../../helpers/settingsAccess';
import { SETTINGS_ROUTES } from '../../helpers/settings';
// MCP Server settings, two variants gated by mcp_url in /api/v1/global/config:
// full page (mcp_url present, cloud) vs NotCloudFallback (absent, community/self-hosted).
// RISK MODE — READ-ONLY: never create a service account; copy/create/install
// buttons asserted for presence only, never clicked.
// mcpEndpointPresent is probed in beforeAll (real backend state) so TC-01/TC-02
// skip via test.skip rather than branching in bodies (playwright/no-conditional-in-test).
test.describe.configure({ mode: 'serial' });
let mcpEndpointPresent = false;
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
const res = await page.request.get('/api/v1/global/config', {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok()) {
const body = await res.json();
const mcpUrl: unknown = body?.data?.mcp_url;
mcpEndpointPresent = typeof mcpUrl === 'string' && mcpUrl.length > 0;
}
} finally {
await ctx.close();
}
});
async function gotoMcpServer(page: Page): Promise<void> {
await page.goto(SETTINGS_ROUTES.MCP_SERVER);
// Spinner gone => either full page or fallback has rendered.
await expect(page.locator('.ant-spin-spinning')).toHaveCount(0);
}
test.describe('Settings — MCP Server page', () => {
// Locators below use CSS classes / role-text; only mcp-settings has a data-testid.
test('TC-01 full page renders: header, client tabs, auth card, use-cases card', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MCP_SERVER),
personaSkipReason(persona, env, SETTINGS_ROUTES.MCP_SERVER) ?? undefined,
);
// Full-page content requires mcp_url to be configured. If not present the
// NotCloudFallback renders instead — TC-02 covers that path.
test.skip(
!mcpEndpointPresent,
'PERSONA_SKIP: mcp_url not configured on this stack — NotCloudFallback renders; see TC-02',
);
await gotoMcpServer(page);
await expect(page.getByTestId('mcp-settings')).toBeVisible();
await expect(page.locator('.mcp-settings__header-title')).toContainText(
'SigNoz MCP Server',
);
await expect(page.locator('.mcp-settings__header-subtitle')).toContainText(
'Model Context Protocol',
);
await expect(page.locator('.mcp-settings__card')).toBeVisible();
await expect(page.locator('.mcp-settings__card-title')).toContainText(
'Configure your client',
);
const tabsRoot = page.locator('.mcp-client-tabs-root');
await expect(tabsRoot).toBeVisible();
await expect(tabsRoot.getByRole('tab', { name: /cursor/i })).toBeVisible();
await expect(
tabsRoot.getByRole('tab', { name: /claude code/i }),
).toBeVisible();
await expect(tabsRoot.getByRole('tab', { name: /vs code/i })).toBeVisible();
await expect(
page.locator('.mcp-client-tabs__snippet-pre').first(),
).toBeVisible();
await expect(
page.getByRole('button', { name: /copy cursor config/i }),
).toBeVisible();
const authCard = page.locator('.mcp-auth-card');
await expect(authCard).toBeVisible();
await expect(authCard.locator('.mcp-auth-card__title')).toContainText(
'Authenticate from your client',
);
await expect(
authCard.locator('.mcp-auth-card__field-label').first(),
).toContainText('SigNoz Instance URL');
await expect(
authCard.getByRole('button', { name: /copy signoz instance url/i }),
).toBeVisible();
await expect(
authCard.locator('.mcp-auth-card__field-label').nth(1),
).toContainText('API Key');
await expect(
authCard.getByRole('button', { name: /create service account/i }),
).toBeVisible();
const useCasesCard = page.locator('.mcp-use-cases-card');
await expect(useCasesCard).toBeVisible();
await expect(
useCasesCard.locator('.mcp-use-cases-card__title'),
).toContainText('What you can do with it');
await expect(useCasesCard.locator('.mcp-use-cases-card__list')).toBeVisible();
await expect(
useCasesCard.getByRole('button', { name: /see more use cases/i }),
).toBeVisible();
});
// Skipped when the beforeAll probe found mcp_url — full page renders instead.
test('TC-02 NotCloudFallback renders when MCP endpoint is not configured', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MCP_SERVER),
personaSkipReason(persona, env, SETTINGS_ROUTES.MCP_SERVER) ?? undefined,
);
test.skip(
mcpEndpointPresent,
'PERSONA_SKIP: mcp_url is configured on this stack — NotCloudFallback does not render',
);
await gotoMcpServer(page);
await expect(page.locator('.not-cloud-fallback')).toBeVisible();
await expect(page.locator('.not-cloud-fallback__title')).toContainText(
'MCP Server is available on SigNoz',
);
await expect(
page.getByRole('button', { name: /view mcp server docs/i }),
).toBeVisible();
await expect(page.getByTestId('mcp-settings')).toHaveCount(0);
});
});

View File

@@ -0,0 +1,205 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import { personaSkipReason } from '../../helpers/settingsAccess';
import { SETTINGS_ROUTES } from '../../helpers/settings';
// RISK MODE: read-only plus one non-submitting invite-modal check — no member is
// created/edited/deleted/role-changed. The fresh bootstrap stack has exactly one
// member (seeded admin, active), so filter/search coverage is limited to that row.
// No data-testid exists in MembersSettings/Table/InviteModal — role/placeholder/text/CSS fallback.
test.describe.configure({ mode: 'serial' });
const ADMIN_EMAIL = process.env.SIGNOZ_E2E_USERNAME ?? 'admin@integration.test';
const SEARCH_PLACEHOLDER = 'Search by name or email...';
async function gotoMembers(page: Page): Promise<void> {
await page.goto(SETTINGS_ROUTES.MEMBERS);
// Members list is fetched server-side; allow margin for the API response.
await expect(page.locator('.members-table-wrapper')).toBeVisible({
timeout: 15_000,
});
}
test.describe('Settings — Members page', () => {
test('TC-01 list renders with columns and the bootstrap admin user row', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS),
personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS) ?? undefined,
);
await gotoMembers(page);
await expect(
page.getByRole('heading', { name: 'Members', level: 1 }),
).toBeVisible();
await expect(
page.getByText('Overview of people added to this workspace.'),
).toBeVisible();
await expect(page.locator('.members-filter-trigger')).toBeVisible();
await expect(page.getByPlaceholder(SEARCH_PLACEHOLDER)).toBeVisible();
await expect(
page.getByRole('button', { name: /invite member/i }),
).toBeVisible();
const table = page.locator('.members-table');
await expect(
table.getByRole('columnheader', { name: 'Name / Email' }),
).toBeVisible();
await expect(
table.getByRole('columnheader', { name: 'Status' }),
).toBeVisible();
await expect(
table.getByRole('columnheader', { name: 'Joined On' }),
).toBeVisible();
await expect(
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
).toBeVisible();
const adminRow = page
.locator('tr')
.filter({ has: page.locator('.member-email', { hasText: ADMIN_EMAIL }) });
await expect(adminRow.getByText('ACTIVE')).toBeVisible();
});
// On the single-member stack, Pending/Deleted both yield the empty state.
test('TC-02 filter dropdown — cycles All / Pending / Deleted and updates the list', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS),
personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS) ?? undefined,
);
await gotoMembers(page);
await expect(
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
).toBeVisible();
await page.locator('.members-filter-trigger').click();
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await menu.getByText(/pending invites/i).click();
await expect(page.locator('.members-empty-state')).toBeVisible();
await expect(
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
).toHaveCount(0);
await page.locator('.members-filter-trigger').click();
await expect(page.getByRole('menu')).toBeVisible();
await page
.getByRole('menu')
.getByText(/^deleted/i)
.click();
await expect(page.locator('.members-empty-state')).toBeVisible();
await expect(
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
).toHaveCount(0);
await page.locator('.members-filter-trigger').click();
await expect(page.getByRole('menu')).toBeVisible();
await page
.getByRole('menu')
.getByText(/all members/i)
.click();
await expect(
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
).toBeVisible();
await expect(page.locator('.members-empty-state')).toHaveCount(0);
});
test('TC-03 search filters by email match and shows empty state on no match', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS),
personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS) ?? undefined,
);
await gotoMembers(page);
const searchInput = page.getByPlaceholder(SEARCH_PLACEHOLDER);
await searchInput.fill(ADMIN_EMAIL);
await expect(
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
).toBeVisible();
await expect(page.locator('.members-empty-state')).toHaveCount(0);
await searchInput.fill('xyznonexistentuser999@nowhere.invalid');
await expect(page.locator('.members-empty-state')).toBeVisible();
await expect(
page
.locator('.members-empty-state__text')
.getByText('xyznonexistentuser999@nowhere.invalid'),
).toBeVisible();
await expect(
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
).toHaveCount(0);
await searchInput.fill('');
await expect(
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
).toBeVisible();
await expect(page.locator('.members-empty-state')).toHaveCount(0);
});
// RISK MODE: submit is never clicked; no invite is sent.
test('TC-04 invite modal — renders correctly, submit disabled on untouched rows, Cancel dismisses', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS),
personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS) ?? undefined,
);
await gotoMembers(page);
await page.getByRole('button', { name: /invite member/i }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible();
await expect(
modal.getByRole('heading', { name: 'Invite Team Members' }),
).toBeVisible();
// Header cells scoped to class selectors to avoid matching input placeholders.
await expect(modal.locator('.email-header')).toBeVisible();
await expect(modal.locator('.role-header')).toBeVisible();
// Modal starts with 3 empty rows.
const emailInputs = modal.locator('input[type="email"]');
await expect(emailInputs.first()).toBeVisible();
await expect(emailInputs).toHaveCount(3);
await expect(
modal.getByRole('button', { name: /add another/i }),
).toBeVisible();
// Submit is disabled while all rows are untouched.
const submitBtn = modal.getByRole('button', { name: 'Invite Team Members' });
await expect(submitBtn).toBeVisible();
await expect(submitBtn).toBeDisabled();
await modal.getByRole('button', { name: /cancel/i }).click();
await expect(modal).not.toBeVisible();
});
});

View File

@@ -0,0 +1,262 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import { authToken } from '../../helpers/dashboards';
import { personaSkipReason } from '../../helpers/settingsAccess';
import { SETTINGS_ROUTES } from '../../helpers/settings';
test.describe.configure({ mode: 'serial' });
// Runtime branching lives in these helpers, not test() bodies — playwright/no-conditional-in-test.
async function gotoMySettings(page: Page): Promise<void> {
await page.goto(SETTINGS_ROUTES.MY_SETTINGS);
await expect(page.getByTestId('theme-selector')).toBeVisible();
}
async function readThemeState(
page: Page,
): Promise<{ theme: string; autoSwitch: string }> {
// globalThis cast: the evaluate callback runs in the browser, but the e2e
// tsconfig uses the ES2020 lib (no DOM), so `localStorage` isn't typed here.
return page.evaluate(() => ({
theme: (globalThis as any).localStorage.getItem('THEME') ?? 'dark',
autoSwitch:
(globalThis as any).localStorage.getItem('THEME_AUTO_SWITCH') ?? 'false',
}));
}
async function restoreTheme(
page: Page,
theme: string,
autoSwitch: string,
): Promise<void> {
await page.evaluate(
([t, a]) => {
(globalThis as any).localStorage.setItem('THEME', t);
(globalThis as any).localStorage.setItem('THEME_AUTO_SWITCH', a);
},
[theme, autoSwitch],
);
}
async function restoreSideNavPinned(
page: Page,
originalChecked: string,
): Promise<void> {
const token = await authToken(page);
await page.request.put('/api/v1/user/preferences/sidenav_pinned', {
data: { value: originalChecked === 'true' },
headers: { Authorization: `Bearer ${token}` },
});
}
function flipAriaChecked(current: string): string {
if (current === 'true') {
return 'false';
}
return 'true';
}
test.describe('My Settings — Account page', () => {
test('TC-01 page renders with all expected controls', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
);
await gotoMySettings(page);
await expect(
page.getByRole('button', { name: /update name/i }),
).toBeVisible();
await expect(
page.getByRole('button', { name: /reset password/i }).first(),
).toBeVisible();
await expect(page.getByTestId('theme-selector')).toBeVisible();
await expect(page.getByTestId('timezone-adaptation-switch')).toBeVisible();
await expect(page.getByTestId('side-nav-pinned-switch')).toBeVisible();
// License copy button renders because bootstrap issues an enterprise license on cloud.
await expect(page.getByTestId('license-key-copy-btn')).toBeVisible();
});
test('TC-02 theme toggle cycles dark → light → auto and applies', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
);
await gotoMySettings(page);
const originalTheme = await readThemeState(page);
try {
// Radix ToggleGroup renders items as role="radio" within a radiogroup.
const selector = page.getByTestId('theme-selector');
const darkRadio = selector.getByRole('radio', { name: /dark/i });
const lightRadio = selector.getByRole('radio', { name: /light/i });
const systemRadio = selector.getByRole('radio', { name: /system/i });
await lightRadio.click();
await expect(lightRadio).toBeChecked();
await systemRadio.click();
await expect(systemRadio).toBeChecked();
await darkRadio.click();
await expect(darkRadio).toBeChecked();
} finally {
await restoreTheme(page, originalTheme.theme, originalTheme.autoSwitch);
}
});
test('TC-03 sidebar pin toggle flips checked state', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
);
await gotoMySettings(page);
const switchEl = page.getByTestId('side-nav-pinned-switch');
const originalChecked =
(await switchEl.getAttribute('aria-checked')) ?? 'false';
const expectedAfterToggle = flipAriaChecked(originalChecked);
try {
await switchEl.click();
// Pin state persists server-side; allow margin for the update under
// parallel-worker CPU contention (default 5s expect timeout flakes).
await expect(switchEl).toHaveAttribute('aria-checked', expectedAfterToggle, {
timeout: 15_000,
});
} finally {
await restoreSideNavPinned(page, originalChecked);
}
});
test('TC-04 timezone adaptation toggle flips checked state', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
);
await gotoMySettings(page);
const switchEl = page.getByTestId('timezone-adaptation-switch');
const originalChecked =
(await switchEl.getAttribute('aria-checked')) ?? 'true';
const expectedAfterToggle = flipAriaChecked(originalChecked);
try {
await switchEl.click();
await expect(switchEl).toHaveAttribute('aria-checked', expectedAfterToggle, {
timeout: 15_000,
});
} finally {
// isAdaptationEnabled is not persisted — toggle back to restore session state.
await switchEl.click();
}
});
// note: PUT /api/v2/users/me returns root_user_operation_unsupported for the
// bootstrap admin user. Only the modal open/input/submit-button UI is tested
// here; the "name reflects in card after save" assertion cannot be verified
// against this stack.
test('TC-05 update name modal — opens, pre-fills, submit button active', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
);
await gotoMySettings(page);
const currentName = await page.locator('.user-name').first().innerText();
await page.getByRole('button', { name: /update name/i }).click();
const nameInput = page.getByPlaceholder('e.g. John Doe');
await expect(nameInput).toBeVisible();
await expect(nameInput).toHaveValue(currentName);
const submitBtn = page.getByTestId('update-name-btn');
await expect(submitBtn).toBeVisible();
await expect(submitBtn).toBeEnabled();
// Close via × button — Ant Modal's Escape handler can race with input focus in headless mode.
await page
.locator('.update-name-modal')
.getByRole('button', { name: 'Close' })
.click();
await expect(nameInput).not.toBeVisible();
});
test('TC-06 reset-password modal — validation only, never submits', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
);
await gotoMySettings(page);
// The button that OPENS the modal has no testid; reset-password-btn is the SUBMIT button inside.
await page
.getByRole('button', { name: /reset password/i })
.first()
.click();
const currentPasswordInput = page.getByTestId('current-password-textbox');
const newPasswordInput = page.getByTestId('new-password-textbox');
const submitBtn = page.getByTestId('reset-password-btn');
await expect(currentPasswordInput).toBeVisible();
await expect(newPasswordInput).toBeVisible();
await expect(submitBtn).toBeDisabled();
await currentPasswordInput.fill('somepassword');
await expect(submitBtn).toBeDisabled();
// Same value → passwords match → validation error + disabled
await newPasswordInput.fill('somepassword');
await expect(page.getByText(/new password must be different/i)).toBeVisible();
await expect(submitBtn).toBeDisabled();
// Stop at enabled — clicking would rotate the admin password and break every other worker.
await newPasswordInput.fill('differentpassword!1');
await expect(submitBtn).toBeEnabled();
await page
.locator('.reset-password-modal')
.getByRole('button', { name: 'Close' })
.click();
await expect(currentPasswordInput).not.toBeVisible();
});
});

View File

@@ -0,0 +1,106 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import { personaSkipReason } from '../../helpers/settingsAccess';
import { SETTINGS_ROUTES } from '../../helpers/settings';
// OrganizationSettings (/settings/org-settings): DisplayName form + AuthDomain section.
// Invite coverage lives in members.spec.ts — the #invite-team-members hash is ignored here.
//
// note: PUT /api/v2/orgs returns root_user_operation_unsupported for the bootstrap
// admin user. TC-02 only asserts the field is editable and the Submit button enables;
// it does NOT submit the form. The original org name is never mutated.
test.describe.configure({ mode: 'serial' });
async function gotoOrgSettings(page: Page): Promise<void> {
await page.goto(SETTINGS_ROUTES.ORG_SETTINGS);
await expect(page.getByLabel('Display name')).toBeVisible();
}
test.describe('Organization Settings — SSO & Org page', () => {
test('TC-01 page renders display-name field and authenticated-domains section', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS),
personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS) ?? undefined,
);
await gotoOrgSettings(page);
await expect(page.getByLabel('Display name')).toBeVisible();
await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible();
await expect(
page.getByRole('heading', { name: 'Authenticated Domains' }),
).toBeVisible();
await expect(page.getByRole('button', { name: 'Add Domain' })).toBeVisible();
});
// note: root_user_operation_unsupported on save (see header) — never clicks Submit; value restored in finally.
test('TC-02 org display name — field is editable and Submit enables on change', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS),
personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS) ?? undefined,
);
await gotoOrgSettings(page);
const nameInput = page.getByLabel('Display name');
const submitBtn = page.getByRole('button', { name: 'Submit' });
const originalValue = await nameInput.inputValue();
try {
// Submit is disabled when the value equals the current saved name.
await expect(submitBtn).toBeDisabled();
await nameInput.fill('org-sso-spec-temp');
await expect(nameInput).toHaveValue('org-sso-spec-temp');
await expect(submitBtn).toBeEnabled();
await nameInput.fill('');
await expect(submitBtn).toBeDisabled();
} finally {
// Restored value equals the saved one, so Submit stays disabled — no API call.
await nameInput.fill(originalValue);
await expect(submitBtn).toBeDisabled();
}
});
// RISK MODE: never enable SSO/SAML or click Save — that changes auth for the whole stack.
test('TC-03 SSO config — Add Domain opens provider-selector modal, close dismisses it', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS),
personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS) ?? undefined,
);
await gotoOrgSettings(page);
await page.getByRole('button', { name: 'Add Domain' }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible();
await expect(
modal.getByText('Configure Authentication Method'),
).toBeVisible();
await expect(modal.getByText('Google Apps Authentication')).toBeVisible();
// SAML/OIDC visibility depends on the SSO flag — only assert Google Auth, always enabled.
await modal.getByRole('button', { name: /close/i }).click();
await expect(modal).not.toBeVisible();
});
});

View File

@@ -0,0 +1,172 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import { newAdminContext } from '../../helpers/auth';
import { authToken } from '../../helpers/dashboards';
import { personaSkipReason } from '../../helpers/settingsAccess';
import { SETTINGS_ROUTES } from '../../helpers/settings';
// Roles page. RISK MODE — READ-ONLY: never create/edit/delete a role; TC-03
// only views a managed role's detail page and navigates back.
// rolesEnabled probes /api/v1/features for USE_FINE_GRAINED_AUTHZ — real backend
// state, not a guess; row navigation is only wired up when it is on, so TC-03 skips otherwise.
test.describe.configure({ mode: 'serial' });
let rolesEnabled = false;
async function gotoRolesList(page: Page): Promise<void> {
await page.goto(SETTINGS_ROUTES.ROLES);
await expect(page.getByTestId('roles-settings')).toBeVisible();
}
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
const res = await page.request.get('/api/v1/features', {
headers: { Authorization: `Bearer ${token}` },
});
const body = await res.json();
const flags: { name?: string; active?: boolean }[] = body?.data ?? [];
const flag = flags.find((f) => f?.name === 'use_fine_grained_authz');
rolesEnabled = !!flag?.active;
} finally {
await ctx.close();
}
});
test.describe('Settings — Roles page', () => {
test('TC-01 list renders with container, header, search, and managed-role rows', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES),
personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES) ?? undefined,
);
await gotoRolesList(page);
await expect(page.locator('.roles-settings-header-title')).toContainText(
'Roles',
);
await expect(
page.locator('.roles-settings-header-description'),
).toContainText('Create and manage custom roles for your team.');
await expect(page.locator('input[type="search"]')).toBeVisible();
await expect(
page.locator('input[placeholder="Search for roles..."]'),
).toBeVisible();
const table = page.locator('.roles-listing-table');
await expect(table).toBeVisible();
await expect(table.locator('.roles-table-header-cell--name')).toContainText(
'Name',
);
await expect(
table.locator('.roles-table-header-cell--description'),
).toContainText('Description');
await expect(
table.locator('.roles-table-header-cell--updated-at'),
).toContainText('Updated At');
await expect(
table.locator('.roles-table-header-cell--created-at'),
).toContainText('Created At');
await expect(
table.locator('.roles-table-section-header', { hasText: 'Managed roles' }),
).toBeVisible();
await expect(table.locator('.roles-table-row').first()).toBeVisible();
});
test('TC-02 search filters roles by match and shows empty state on no match', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES),
personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES) ?? undefined,
);
await gotoRolesList(page);
const searchInput = page.locator('input[placeholder="Search for roles..."]');
const table = page.locator('.roles-listing-table');
await searchInput.fill('Admin');
await expect(
table.locator('.roles-table-cell--name', { hasText: /admin/i }).first(),
).toBeVisible();
await expect(table.locator('.roles-table-empty')).toHaveCount(0);
await searchInput.fill('xyznonexistentrole999');
await expect(table.locator('.roles-table-empty')).toBeVisible();
await expect(table.locator('.roles-table-empty')).toContainText(
'No roles match your search.',
);
await expect(table.locator('.roles-table-row')).toHaveCount(0);
await searchInput.fill('');
await expect(table.locator('.roles-table-row').first()).toBeVisible();
await expect(table.locator('.roles-table-empty')).toHaveCount(0);
});
// Read-only: views a managed role, asserts no edit/delete, navigates back.
// Skipped when USE_FINE_GRAINED_AUTHZ is off — rows have no click handler.
test('TC-03 role detail page — clicking a managed role navigates to its detail view', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES),
personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES) ?? undefined,
);
test.skip(
!rolesEnabled,
'PERSONA_SKIP: USE_FINE_GRAINED_AUTHZ feature flag is off — role rows are not clickable',
);
await gotoRolesList(page);
const table = page.locator('.roles-listing-table');
const firstRow = table.locator('.roles-table-row').first();
await firstRow.scrollIntoViewIfNeeded();
await firstRow.click();
await expect(page).toHaveURL(/\/settings\/roles\/[^/]+/);
const detailPage = page.locator('.role-details-page');
await expect(detailPage).toBeVisible();
await expect(detailPage.locator('.role-details-title')).toBeVisible();
await expect(detailPage.locator('.role-details-title')).toContainText(
'Role —',
);
await expect(
detailPage.getByText(
'This is a managed role. Permissions and settings are view-only and cannot be modified.',
),
).toBeVisible();
await expect(
detailPage.getByRole('button', { name: 'Edit Role Details' }),
).toHaveCount(0);
await expect(
detailPage.locator('.role-details-section-label', {
hasText: 'Permissions',
}),
).toBeVisible();
await page.goto(SETTINGS_ROUTES.ROLES);
await expect(page.getByTestId('roles-settings')).toBeVisible();
});
});

View File

@@ -0,0 +1,191 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import { newAdminContext } from '../../helpers/auth';
import { authToken } from '../../helpers/dashboards';
import { personaSkipReason } from '../../helpers/settingsAccess';
import { SETTINGS_ROUTES } from '../../helpers/settings';
// Service Accounts page. RISK MODE — READ-ONLY: never create/edit/delete an
// account or generate a token; the create modal is never opened.
// listAccessible probes the real authz/check backend state in beforeAll (when
// use_fine_grained_authz is on the admin may lack serviceaccount:list, rendering
// PermissionDeniedFullPage); the functional TCs skip when it is false.
test.describe.configure({ mode: 'serial' });
let listAccessible = false;
async function gotoServiceAccounts(page: Page): Promise<void> {
await page.goto(SETTINGS_ROUTES.SERVICE_ACCOUNTS);
await expect(page.locator('.sa-settings__title')).toBeVisible();
}
function buildSkipReason(
persona: Parameters<typeof personaSkipReason>[0],
env: Parameters<typeof personaSkipReason>[1],
): string | null {
return personaSkipReason(persona, env, SETTINGS_ROUTES.SERVICE_ACCOUNTS);
}
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
const res = await page.request.get('/api/v1/features', {
headers: { Authorization: `Bearer ${token}` },
});
const body = await res.json();
const flags: { name?: string; active?: boolean }[] = body?.data ?? [];
const fgAuthz = flags.find((f) => f?.name === 'use_fine_grained_authz');
if (!fgAuthz?.active) {
// Without fine-grained authz the SA list is always accessible.
listAccessible = true;
return;
}
// Probe the authz check endpoint for serviceaccount:list (wildcard).
const authzRes = await page.request.post('/api/v1/authz/check', {
headers: { Authorization: `Bearer ${token}` },
data: [
{
relation: 'list',
object: {
resource: { kind: 'serviceaccount', type: 'serviceaccount' },
selector: '*',
},
},
],
});
const authzBody = await authzRes.json();
const items: { authorized?: boolean }[] = authzBody?.data ?? [];
listAccessible = items.some((i) => i?.authorized);
} finally {
await ctx.close();
}
});
test.describe('Settings — Service Accounts page', () => {
test('TC-01 page chrome and empty-state render', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!buildSkipReason(persona, env),
buildSkipReason(persona, env) ?? undefined,
);
test.skip(
!listAccessible,
'PERSONA_SKIP: serviceaccount:list permission not granted for this persona — PermissionDeniedFullPage renders instead',
);
await gotoServiceAccounts(page);
await expect(page.locator('.sa-settings__title')).toContainText(
'Service Accounts',
);
await expect(page.locator('.sa-settings__subtitle')).toContainText(
'Overview of service accounts added to this workspace.',
);
await expect(
page.locator('.sa-settings__subtitle a[href*="signoz.io/docs"]'),
).toBeVisible();
const controls = page.locator('.sa-settings__controls');
await expect(controls).toBeVisible();
await expect(
controls.getByRole('button', { name: /All accounts/i }),
).toBeVisible();
await expect(
controls.locator('input[placeholder="Search by name or email..."]'),
).toBeVisible();
await expect(
controls.getByRole('button', { name: /New Service Account/i }),
).toBeVisible();
await expect(page.locator('.sa-table-wrapper')).toBeVisible();
await expect(page.locator('.sa-empty-state')).toBeVisible();
await expect(page.locator('.sa-empty-state__text')).toContainText(
'No service accounts.',
);
});
test('TC-02 filter dropdown writes URL param and shows empty-state per mode', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!buildSkipReason(persona, env),
buildSkipReason(persona, env) ?? undefined,
);
test.skip(
!listAccessible,
'PERSONA_SKIP: serviceaccount:list permission not granted for this persona — PermissionDeniedFullPage renders instead',
);
await gotoServiceAccounts(page);
const filterTrigger = page.getByRole('button', { name: /All accounts/i });
await filterTrigger.click();
await page.getByText(/^Active ⎯/).click();
await expect(page).toHaveURL(/[?&]filter=active/);
await expect(page.locator('.sa-empty-state')).toBeVisible();
await page.getByRole('button', { name: /Active ⎯/i }).click();
await page.getByText(/^Deleted ⎯/).click();
await expect(page).toHaveURL(/[?&]filter=deleted/);
await expect(page.locator('.sa-empty-state')).toBeVisible();
await page.getByRole('button', { name: /Deleted ⎯/i }).click();
await page.getByText(/^All accounts ⎯/).click();
await expect(page).not.toHaveURL(/[?&]filter=active/);
await expect(page).not.toHaveURL(/[?&]filter=deleted/);
await expect(page.locator('.sa-empty-state')).toBeVisible();
});
test('TC-03 search updates URL and empty-state; create button enabled', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!buildSkipReason(persona, env),
buildSkipReason(persona, env) ?? undefined,
);
test.skip(
!listAccessible,
'PERSONA_SKIP: serviceaccount:list permission not granted for this persona — PermissionDeniedFullPage renders instead',
);
await gotoServiceAccounts(page);
const searchInput = page.locator(
'input[placeholder="Search by name or email..."]',
);
await searchInput.fill('xyznonexistent999');
await expect(page).toHaveURL(/[?&]search=xyznonexistent999/);
await expect(page.locator('.sa-empty-state__text')).toContainText(
'No results for',
);
await expect(page.locator('.sa-empty-state__text strong')).toContainText(
'xyznonexistent999',
);
await searchInput.fill('');
await expect(page).not.toHaveURL(/[?&]search=xyznonexistent999/);
await expect(page.locator('.sa-empty-state__text')).toContainText(
'No service accounts.',
);
const createBtn = page.getByRole('button', { name: /New Service Account/i });
await expect(createBtn).toBeVisible();
await expect(createBtn).toBeEnabled();
});
});

View File

@@ -0,0 +1,125 @@
import type { Persona, SettingsEnv } from '../../helpers/persona';
import { expect, test } from '../../fixtures/auth';
import {
registeredRoutes,
visibleNavItems,
} from '../../helpers/settingsAccess';
import {
NAV_TESTID,
SETTINGS_ROUTES,
gotoSettings,
} from '../../helpers/settings';
// Branching lives in module-level helpers, not test bodies — the repo's
// playwright/no-conditional-in-test rule forbids `if` inside `test()`.
function partitionNavTestids(
persona: Persona,
env: SettingsEnv,
): { visible: string[]; hidden: string[] } {
const all = Object.values(NAV_TESTID);
const expected = visibleNavItems(persona, env);
return {
visible: all.filter((testid) => expected.has(testid)),
hidden: all.filter((testid) => !expected.has(testid)),
};
}
// Visible nav items whose /settings route is not registered (mounted).
// INTEGRATIONS is excluded — it is a top-level page, not a RouteTab route.
function navRouteMismatches(persona: Persona, env: SettingsEnv): string[] {
const visible = visibleNavItems(persona, env);
const registered = registeredRoutes(persona, env);
const routeByTestid = Object.fromEntries(
Object.entries(NAV_TESTID).map(([route, testid]) => [testid, route]),
);
return [...visible]
.map((testid) => routeByTestid[testid])
.filter((route) => !!route && route !== SETTINGS_ROUTES.INTEGRATIONS)
.filter((route) => !registered.has(route))
.map((route) => `${route} is nav-visible but route not registered`);
}
test.describe('Settings — shell, gating matrix & integrity', () => {
test('TC-01 settings shell chrome renders with no JS pageerror', async ({
authedPage: page,
}) => {
const errors: Error[] = [];
page.on('pageerror', (err) => errors.push(err));
await gotoSettings(page);
await expect(page.getByTestId('settings-page-title')).toBeVisible();
await expect(page.getByTestId('settings-page-sidenav')).toBeVisible();
expect(errors, errors.map((e) => e.message).join('\n')).toHaveLength(0);
});
test('TC-02 sidenav shows exactly the matrix-predicted items', async ({
authedPage: page,
persona,
env,
}) => {
await gotoSettings(page);
const sidenav = page.getByTestId('settings-page-sidenav');
const { visible, hidden } = partitionNavTestids(persona, env);
for (const testid of visible) {
await expect(
sidenav.getByTestId(testid),
`${testid} should be visible`,
).toBeVisible();
}
for (const testid of hidden) {
await expect(
sidenav.getByTestId(testid),
`${testid} should be hidden`,
).toHaveCount(0);
}
});
test('TC-03 every registered route deep-links with no JS pageerror', async ({
authedPage: page,
persona,
env,
}) => {
const routes = [...registeredRoutes(persona, env)];
for (const route of routes) {
const errors: Error[] = [];
const onError = (err: Error): void => {
errors.push(err);
};
page.on('pageerror', onError);
await page.goto(route);
await expect(page.getByTestId('settings-page-title')).toBeVisible();
page.off('pageerror', onError);
expect(
errors,
`pageerror on ${route}: ${errors.map((e) => e.message).join('\n')}`,
).toHaveLength(0);
}
});
test('TC-04 every visible nav item resolves to a registered route', async ({
persona,
env,
}) => {
const mismatches = navRouteMismatches(persona, env);
expect(mismatches, mismatches.join('\n')).toHaveLength(0);
});
test('TC-05 clicking a nav item navigates and marks active', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!visibleNavItems(persona, env).has('account'),
'PERSONA_SKIP: account nav hidden',
);
await gotoSettings(page);
const sidenav = page.getByTestId('settings-page-sidenav');
await sidenav.getByTestId('account').click();
await expect(page).toHaveURL(/\/settings\/my-settings/);
});
});

View File

@@ -0,0 +1,69 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import { personaSkipReason } from '../../helpers/settingsAccess';
import { SETTINGS_ROUTES } from '../../helpers/settings';
// Keyboard Shortcuts — static read-only page (RISK MODE: nothing mutated).
// No testids here, so locators are CSS classes (.keyboard-shortcuts,
// .shortcut-section-heading) and role/text.
const ROUTE = SETTINGS_ROUTES.SHORTCUTS;
async function gotoShortcuts(page: Page): Promise<void> {
await page.goto(ROUTE);
await expect(page.locator('.keyboard-shortcuts')).toBeVisible();
}
test.describe('Settings — Keyboard Shortcuts page', () => {
test('TC-01 shortcuts page renders all four grouped sections with entries', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, ROUTE),
personaSkipReason(persona, env, ROUTE) ?? undefined,
);
await gotoShortcuts(page);
await expect(page.getByTestId('settings-page-title')).toBeVisible();
await expect(page.getByTestId('settings-page-sidenav')).toBeVisible();
await expect(
page.getByTestId('settings-page-sidenav').getByTestId('keyboard-shortcuts'),
).toBeVisible();
const sections = page.locator('.shortcut-section-heading');
await expect(sections).toHaveCount(4);
await expect(sections.nth(0)).toHaveText('Global Shortcuts');
await expect(sections.nth(1)).toHaveText('Logs Explorer Shortcuts');
await expect(sections.nth(2)).toHaveText('Query Builder Shortcuts');
await expect(sections.nth(3)).toHaveText('Dashboard Shortcuts');
await expect(page.locator('.shortcut-section-table')).toHaveCount(4);
const firstTable = page.locator('.shortcut-section-table').first();
await expect(
firstTable.getByRole('columnheader', { name: 'Keyboard Shortcut' }),
).toBeVisible();
await expect(
firstTable.getByRole('columnheader', { name: 'Description' }),
).toBeVisible();
// "shift+d" chosen as it is stable across OS variants (no cmd/ctrl).
const globalTable = page.locator('.shortcut-section-table').nth(0);
await expect(
globalTable.getByRole('cell', { name: 'shift+d' }),
).toBeVisible();
await expect(
globalTable.getByRole('cell', { name: 'Navigate to Dashboards List' }),
).toBeVisible();
for (let i = 0; i < 4; i++) {
const table = page.locator('.shortcut-section-table').nth(i);
await expect(table.locator('tbody tr').first()).toBeVisible();
}
});
});

View File

@@ -1,282 +0,0 @@
"""
Full-text search integration tests — context isolation.
Validates that FTS (bare text, "quoted", search()) correctly finds logs
when the search term lives in any one of the intended field contexts:
severity_text — LowCardinality(String), LOWER/match
trace_id — String, LOWER/match
span_id — String, LOWER/match
body — String (stringified JSON), LOWER(body)/match
attributes_string — Map(String,String), arrayExists(match, mapKeys/mapValues)
attributes_number — Map(String,Float64), arrayExists(match, mapKeys)
resources_string — Map(String,String), arrayExists(match, mapKeys/mapValues)
Each test log carries a unique token in exactly one context; all other fields
are neutral. Per-context assertions verify:
1. Exactly 1 row is returned (isolation — no cross-context bleed).
2. The returned row belongs to the expected log (service.name check).
3. The FTS warning is always present in the response.
"""
import json
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import build_raw_query, get_rows, make_query_request
def test_fts_across_contexts(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""
10 logs, one unique token per log, each token in a different field context.
Every FTS form (bare / quoted / search('')) is exercised per context.
search() requires a quoted argument; unquoted args must return HTTP 400.
"""
now = datetime.now(tz=UTC)
# ── unique tokens ────────────────────────────────────────────────────────
# TOK_SEV: a severity level unused by every other fixture log (all others are INFO).
# TOK_TID / TOK_SID: valid hex-format IDs; unique so no other inserted log matches them.
# The remaining tokens are lowercase so they work for both case-insensitive
# (String/JSON) and case-sensitive (Map) match(). Each token appears in exactly one log.
TOK_SEV = "FATAL" # → severity_text (others use INFO)
TOK_TID = "0af7651916cd43dd8448eb211c80319c" # → trace_id (valid 32-char hex, unique)
TOK_SID = "6e0c63257de34c92" # → span_id (valid 16-char hex, unique)
TOK_BKEY = "xfts_bkey_004" # → body JSON — key name
TOK_BVAL = "xfts_bval_005" # → body JSON — value
TOK_ASKEY = "xfts_askey_006" # → attributes_string key
TOK_ASVAL = "xfts_asval_007" # → attributes_string value
TOK_ANKEY = "xfts_ankey_008" # → attributes_number key (string key of the map)
TOK_RKEY = "xfts_rkey_009" # → resources_string key
TOK_RVAL = "xfts_rval_010" # → resources_string value
# Neutral body — does not contain any xfts_ token.
_N = json.dumps({"message": "neutral log entry"})
# ── log fixtures ─────────────────────────────────────────────────────────
# Each log uses a distinct service.name so assertions can verify identity.
log_sev = Logs(
timestamp=now - timedelta(seconds=10),
resources={"service.name": "severity-text-svc"},
body=_N,
severity_text=TOK_SEV, # ← token lives here
)
log_tid = Logs(
timestamp=now - timedelta(seconds=9),
resources={"service.name": "trace-id-svc"},
body=_N,
severity_text="INFO",
trace_id=TOK_TID, # ← token lives here
)
log_sid = Logs(
timestamp=now - timedelta(seconds=8),
resources={"service.name": "span-id-svc"},
body=_N,
severity_text="INFO",
span_id=TOK_SID, # ← token lives here
)
log_bkey = Logs(
timestamp=now - timedelta(seconds=7),
resources={"service.name": "body-key-svc"},
body=json.dumps({TOK_BKEY: "irrelevant_val"}), # ← token is a key in the stringified body
severity_text="INFO",
)
log_bval = Logs(
timestamp=now - timedelta(seconds=6),
resources={"service.name": "body-val-svc"},
body=json.dumps({"some_key": TOK_BVAL}), # ← token is a value in the stringified body
severity_text="INFO",
)
log_askey = Logs(
timestamp=now - timedelta(seconds=5),
resources={"service.name": "attr-str-key-svc"},
attributes={TOK_ASKEY: "other_val"}, # ← token is an attr string key
body=_N,
severity_text="INFO",
)
log_asval = Logs(
timestamp=now - timedelta(seconds=4),
resources={"service.name": "attr-str-val-svc"},
attributes={"some_attr": TOK_ASVAL}, # ← token is an attr string value
body=_N,
severity_text="INFO",
)
log_ankey = Logs(
timestamp=now - timedelta(seconds=3),
resources={"service.name": "attr-num-key-svc"},
attributes={TOK_ANKEY: 42}, # ← token is an attr_number key
body=_N,
severity_text="INFO",
)
log_rkey = Logs(
timestamp=now - timedelta(seconds=2),
resources={"service.name": "resource-key-svc", TOK_RKEY: "irrelevant_val"}, # ← token is a resource key
body=_N,
severity_text="INFO",
)
log_rval = Logs(
timestamp=now - timedelta(seconds=1),
resources={"service.name": "resource-val-svc", "some_res_key": TOK_RVAL}, # ← token is a resource value
body=_N,
severity_text="INFO",
)
logs_list = [
log_sev,
log_tid,
log_sid,
log_bkey,
log_bval,
log_askey,
log_asval,
log_ankey,
log_rkey,
log_rval,
]
not_search_count = len(logs_list) - 1
insert_logs(logs_list)
token = get_token(email=USER_ADMIN_EMAIL, password=USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(seconds=20)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
# ── helpers ───────────────────────────────────────────────────────────────
def _fts(expression: str) -> requests.Response:
resp = make_query_request(
signoz=signoz,
token=token,
start_ms=start_ms,
end_ms=end_ms,
queries=[build_raw_query("A", "logs", filter_expression=expression, step_interval=60)],
request_type="raw",
)
assert resp.status_code == 200, f"FTS({expression!r}): HTTP {resp.status_code}{resp.text}"
return resp
def _only(svc: str):
"""Validate lambda: exactly 1 row for `svc`."""
return lambda r, s=svc: len(get_rows(r)) == 1 and get_rows(r)[0]["data"]["resources_string"].get("service.name") == s
# ── per-context isolation cases ───────────────────────────────────────────
# Free Text Search: bare/quoted tokens route through freeTextColumn (body only).
# Full Text Search: search() fans out across all fields via ftsFieldKeys.
cases = [
# body — Free text search (body column only)
{
"name": "fts.body/quoted",
"expression": f'"{TOK_BVAL}"',
"validate": _only("body-val-svc"),
},
# TOK_SEV lives only in severity_text — free text (body-only) must return 0 rows
{
"name": "fts.free_text/non_body_token_returns_zero",
"expression": f'"{TOK_SEV}"',
"validate": lambda r: len(get_rows(r)) == 0,
},
# ── Full Text Search (search()) ───────────────────────────────────────
# severity_text — only reachable via search(), not bare/quoted Free Text Search
{
"name": "fts.severity_text/search_quoted",
"expression": f'search("{TOK_SEV}")',
"validate": _only("severity-text-svc"),
},
# trace_id (String — only reachable via search())
{
"name": "fts.trace_id/search",
"expression": f'search("{TOK_TID}")',
"validate": _only("trace-id-svc"),
},
# span_id (String — LOWER/match, case-insensitive)
{
"name": "fts.span_id/search",
"expression": f'search("{TOK_SID}")',
"validate": _only("span-id-svc"),
},
# body key (String — LOWER(body)/match, token appears as a key in stringified JSON)
{
"name": "fts.body_key/search",
"expression": f'search("{TOK_BKEY}")',
"validate": _only("body-key-svc"),
},
# body value (String — LOWER(body)/match, token appears as a value in stringified JSON)
{
"name": "fts.body_val/search",
"expression": f'search("{TOK_BVAL}")',
"validate": _only("body-val-svc"),
},
# attributes_string key (Map — arrayExists(match, mapKeys), case-sensitive)
{
"name": "fts.attr_str_key/search",
"expression": f'search("{TOK_ASKEY}")',
"validate": _only("attr-str-key-svc"),
},
# attributes_string value (Map — arrayExists(match, mapValues), case-sensitive)
{
"name": "fts.attr_str_val/search",
"expression": f'search("{TOK_ASVAL}")',
"validate": _only("attr-str-val-svc"),
},
# attributes_number key (Map — arrayExists(match, mapKeys), key is a string)
{
"name": "fts.attr_num_key/search",
"expression": f'search("{TOK_ANKEY}")',
"validate": _only("attr-num-key-svc"),
},
# resources_string key (Map — arrayExists(match, mapKeys), case-sensitive)
{
"name": "fts.resource_key/search",
"expression": f'search("{TOK_RKEY}")',
"validate": _only("resource-key-svc"),
},
# resources_string value (Map — arrayExists(match, mapValues), case-sensitive)
{
"name": "fts.resource_val/search",
"expression": f'search("{TOK_RVAL}")',
"validate": _only("resource-val-svc"),
},
# NOT search: all logs except log_sev
{
"name": "fts.not_search",
"expression": f'NOT search("{TOK_SEV}")',
"validate": lambda r, n=not_search_count: len(get_rows(r)) == n and "severity-text-svc" not in {row["data"]["resources_string"].get("service.name") for row in get_rows(r)},
},
# search combined with attribute key-value filter (AND):
# search("xfts_") matches all 7 logs that carry any xfts_ token;
# ANDing with attribute.some_attr = TOK_ASVAL narrows to the single
# log that has that exact attribute — demonstrating AND reduces row count.
{
"name": "fts.search_and_attr_filter",
"expression": f'search("xfts_") AND attribute.some_attr = "{TOK_ASVAL}"',
"validate": lambda r: len(get_rows(r)) == 1 and get_rows(r)[0]["data"]["resources_string"].get("service.name") == "attr-str-val-svc",
},
# no-match guard: a token that exists nowhere must return 0 rows
{"name": "fts.no_match", "expression": '"xfts_nonexistent_zzz_999"', "validate": lambda r: len(get_rows(r)) == 0},
]
for case in cases:
resp = _fts(case["expression"])
assert case["validate"](resp), f"Validation failed for '{case['name']}': {resp.json()}"
# ── unquoted search guard ────────────────────────────────────────────────
# search(TRACE) — unquoted argument — must be rejected with HTTP 400.
unquoted_resp = make_query_request(
signoz=signoz,
token=token,
start_ms=start_ms,
end_ms=end_ms,
queries=[build_raw_query("A", "logs", filter_expression=f"search({TOK_SEV})", step_interval=60)],
request_type="raw",
)
assert unquoted_resp.status_code == 400, f"Expected 400 for unquoted search(), got {unquoted_resp.status_code}: {unquoted_resp.text}"

View File

@@ -1217,7 +1217,7 @@ def test_message_searches(
"aggregation": "count()",
"validate": lambda r: len(get_rows(r)) == 2 and set(_body_messages(r)) == payment_messages,
},
# Free Text Search — String bare keyword
# FTS — String bare keyword
{
"name": "msg.fts_quoted",
"requestType": "raw",
@@ -1225,6 +1225,7 @@ def test_message_searches(
"aggregation": "count()",
"validate": lambda r: len(get_rows(r)) == 2 and all("Payment" in b.get("message", "") for b in _get_bodies(r)) and r.json().get("data", {}).get("warning") is not None,
},
# FTS — bare keyword
{
"name": "msg.fts_quoted_without_quotes",
"requestType": "raw",
@@ -1232,24 +1233,6 @@ def test_message_searches(
"aggregation": "count()",
"validate": lambda r: len(get_rows(r)) == 2 and all("Payment" in b.get("message", "") for b in _get_bodies(r)) and r.json().get("data", {}).get("warning") is not None,
},
# ── Full Text Search: search() explicit function ──────────────────────────────────────
{
"name": "msg.search_quoted",
"requestType": "raw",
"expression": 'search("Payment")',
"aggregation": "count()",
"validate": lambda r: len(get_rows(r)) == 2 and set(_body_messages(r)) == payment_messages and r.json().get("data", {}).get("warning") is not None,
},
# NOT search("Payment") — inverted FullTextSearch: logs that do NOT have "payment"
# in any field are returned. control_log (db-service) and no_msg_log
# (metrics-service) have no "payment" anywhere → 2 results.
{
"name": "msg.not_search",
"requestType": "raw",
"expression": 'NOT search("Payment")',
"aggregation": "count()",
"validate": lambda r: len(get_rows(r)) == 2 and all("Payment" not in b.get("message", "") for b in _get_bodies(r)) and r.json().get("data", {}).get("warning") is not None,
},
# = operator via body.message — tests exact match path
{
"name": "msg.body_message_exact",

View File

@@ -1,322 +0,0 @@
"""
Text search integration tests.
Validates that Text Search (free text, full text) correctly finds logs
when the search term lives in any one of the intended field contexts:
severity_text — LowCardinality(String), LOWER/match
trace_id — String, LOWER/match
span_id — String, LOWER/match
body JSON — JSON column, LOWER(toString(col))/match
attributes_string — Map(String,String), arrayExists(match, mapKeys/mapValues)
attributes_number — Map(String,Float64), arrayExists(match, mapKeys)
resources_string — Map(String,String), arrayExists(match, mapKeys/mapValues)
Each test log carries a unique token in exactly one context; all other fields
are neutral. Per-context assertions verify:
1. Exactly 1 row is returned (isolation — no cross-context bleed).
2. The returned row belongs to the expected log (service.name check).
3. The FTS warning is always present in the response.
"""
import json
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import build_raw_query, get_rows, make_query_request
def test_fts_across_contexts(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
export_json_types: Callable[[list[Logs]], None],
) -> None:
"""
10 logs, one unique token per log, each token in a different field context.
Every FTS form (bare / quoted / search('')) is exercised per context.
search() requires a quoted argument; unquoted args must return HTTP 400.
"""
now = datetime.now(tz=UTC)
# ── unique tokens ────────────────────────────────────────────────────────
# TOK_SEV: a severity level unused by every other fixture log (all others are INFO).
# TOK_TID / TOK_SID: valid hex-format IDs; unique so no other inserted log matches them.
# The remaining tokens are lowercase so they work for both case-insensitive
# (String/JSON) and case-sensitive (Map) match(). Each token appears in exactly one log.
TOK_SEV = "FATAL" # → severity_text (others use INFO)
TOK_TID = "0af7651916cd43dd8448eb211c80319c" # → trace_id (valid 32-char hex, unique)
TOK_SID = "6e0c63257de34c92" # → span_id (valid 16-char hex, unique)
TOK_BKEY = "xfts_bkey_004" # → body JSON — key name
TOK_BVAL = "xfts_bval_005" # → body JSON — value
TOK_ASKEY = "xfts_askey_006" # → attributes_string key
TOK_ASVAL = "xfts_asval_007" # → attributes_string value
TOK_ANKEY = "xfts_ankey_008" # → attributes_number key (string key of the map)
TOK_RKEY = "xfts_rkey_009" # → resources_string key
TOK_RVAL = "xfts_rval_010" # → resources_string value
TOK_BMSG = "xfts_bmsg_011" # → body_v2.message — for bare/quoted FTS (freeTextColumn path)
# Neutral body — does not contain any xfts_ token.
_N = json.dumps({"message": "neutral log entry"})
# ── log fixtures ─────────────────────────────────────────────────────────
# Each log uses a distinct service.name so assertions can verify identity.
log_sev = Logs(
timestamp=now - timedelta(seconds=10),
resources={"service.name": "severity-text-svc"},
body_v2=_N,
body_promoted="",
severity_text=TOK_SEV, # ← here
)
log_tid = Logs(
timestamp=now - timedelta(seconds=9),
resources={"service.name": "trace-id-svc"},
body_v2=_N,
body_promoted="",
severity_text="INFO",
trace_id=TOK_TID, # ← here
)
log_sid = Logs(
timestamp=now - timedelta(seconds=8),
resources={"service.name": "span-id-svc"},
body_v2=_N,
body_promoted="",
severity_text="INFO",
span_id=TOK_SID, # ← here
)
log_bkey = Logs(
timestamp=now - timedelta(seconds=7),
resources={"service.name": "body-key-svc"},
body_v2=json.dumps({TOK_BKEY: "irrelevant_val"}), # ← token is a JSON key
body_promoted="",
severity_text="INFO",
)
log_bval = Logs(
timestamp=now - timedelta(seconds=6),
resources={"service.name": "body-val-svc"},
body_v2=json.dumps({"some_key": TOK_BVAL}), # ← token is a JSON value
body_promoted="",
severity_text="INFO",
)
log_askey = Logs(
timestamp=now - timedelta(seconds=5),
resources={"service.name": "attr-str-key-svc"},
attributes={TOK_ASKEY: "other_val"}, # ← token is an attr string key
body_v2=_N,
body_promoted="",
severity_text="INFO",
)
log_asval = Logs(
timestamp=now - timedelta(seconds=4),
resources={"service.name": "attr-str-val-svc"},
attributes={"some_attr": TOK_ASVAL}, # ← token is an attr string value
body_v2=_N,
body_promoted="",
severity_text="INFO",
)
log_ankey = Logs(
timestamp=now - timedelta(seconds=3),
resources={"service.name": "attr-num-key-svc"},
attributes={TOK_ANKEY: 42}, # ← token is an attr_number key
body_v2=_N,
body_promoted="",
severity_text="INFO",
)
log_rkey = Logs(
timestamp=now - timedelta(seconds=2),
resources={"service.name": "resource-key-svc", TOK_RKEY: "irrelevant_val"}, # ← token is a resource key
body_v2=_N,
body_promoted="",
severity_text="INFO",
)
log_rval = Logs(
timestamp=now - timedelta(seconds=1),
resources={"service.name": "resource-val-svc", "some_res_key": TOK_RVAL}, # ← token is a resource value
body_v2=_N,
body_promoted="",
severity_text="INFO",
)
# log_bmsg: unique token in body_v2.message — the column targeted by bare/quoted FTS
log_bmsg = Logs(
timestamp=now - timedelta(milliseconds=500),
resources={"service.name": "body-msg-svc"},
body_v2=json.dumps({"message": TOK_BMSG}),
body_promoted="",
severity_text="INFO",
)
logs_list = [
log_sev,
log_tid,
log_sid,
log_bkey,
log_bval,
log_askey,
log_asval,
log_ankey,
log_rkey,
log_rval,
log_bmsg,
]
not_search_count = len(logs_list) - 1
export_json_types(logs_list)
insert_logs(logs_list)
token = get_token(email=USER_ADMIN_EMAIL, password=USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(seconds=20)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
# ── helpers ───────────────────────────────────────────────────────────────
def _fts(expression: str) -> requests.Response:
resp = make_query_request(
signoz=signoz,
token=token,
start_ms=start_ms,
end_ms=end_ms,
queries=[build_raw_query("A", "logs", filter_expression=expression, step_interval=60)],
request_type="raw",
)
assert resp.status_code == 200, f"FTS({expression!r}): HTTP {resp.status_code}{resp.text}"
return resp
def _only(svc: str):
"""Validate lambda: exactly 1 row for `svc`, FTS warning present."""
return lambda r, s=svc: len(get_rows(r)) == 1 and get_rows(r)[0]["data"]["resources_string"].get("service.name") == s and r.json().get("data", {}).get("warning") is not None
# ── per-context isolation cases ───────────────────────────────────────────
# Free Text Search: bare/quoted tokens route through freeTextColumn (body_v2.message only).
# Full Text Search: search() fans out across all fields via ftsFieldKeys.
cases = [
# body_v2.message — Free Text Search
{
"name": "fts.body_msg/bare",
"expression": TOK_BMSG,
"validate": _only("body-msg-svc"),
},
{
"name": "fts.body_msg/quoted",
"expression": f'"{TOK_BMSG}"',
"validate": _only("body-msg-svc"),
},
# TOK_SEV lives only in severity_text — free text (body_v2.message only) must return 0 rows
{
"name": "fts.free_text/non_body_token_returns_zero",
"expression": f'"{TOK_SEV}"',
"validate": lambda r: len(get_rows(r)) == 0,
},
# ── Full Text Search (search()) ───────────────────────────────────────
# severity_text — only reachable via search(), not bare/quoted Free Text Search
{
"name": "fts.severity_text/search_quoted",
"expression": f'search("{TOK_SEV}")',
"validate": _only("severity-text-svc"),
},
# trace_id (String — only reachable via search())
{
"name": "fts.trace_id/search",
"expression": f'search("{TOK_TID}")',
"validate": _only("trace-id-svc"),
},
# span_id (String — LOWER/match, case-insensitive)
{
"name": "fts.span_id/search",
"expression": f'search("{TOK_SID}")',
"validate": _only("span-id-svc"),
},
# body JSON key (JSON col — LOWER(toString(col))/match, full serialised JSON)
{
"name": "fts.body_key/search",
"expression": f'search("{TOK_BKEY}")',
"validate": _only("body-key-svc"),
},
# body JSON value
{
"name": "fts.body_val/search",
"expression": f'search("{TOK_BVAL}")',
"validate": _only("body-val-svc"),
},
# attributes_string key (Map — arrayExists(match, mapKeys), case-sensitive)
{
"name": "fts.attr_str_key/search",
"expression": f'search("{TOK_ASKEY}")',
"validate": _only("attr-str-key-svc"),
},
# attributes_string value (Map — arrayExists(match, mapValues), case-sensitive)
{
"name": "fts.attr_str_val/search",
"expression": f'search("{TOK_ASVAL}")',
"validate": _only("attr-str-val-svc"),
},
# attributes_number key (Map — arrayExists(match, mapKeys), key is a string)
{
"name": "fts.attr_num_key/search",
"expression": f'search("{TOK_ANKEY}")',
"validate": _only("attr-num-key-svc"),
},
# resources_string key (Map — arrayExists(match, mapKeys), case-sensitive)
{
"name": "fts.resource_key/search",
"expression": f'search("{TOK_RKEY}")',
"validate": _only("resource-key-svc"),
},
# resources_string value (Map — arrayExists(match, mapValues), case-sensitive)
{
"name": "fts.resource_val/search",
"expression": f'search("{TOK_RVAL}")',
"validate": _only("resource-val-svc"),
},
# NOT search: all logs except log_sev
{
"name": "fts.not_search",
"expression": f'NOT search("{TOK_SEV}")',
"validate": lambda r, n=not_search_count: len(get_rows(r)) == n and "severity-text-svc" not in {row["data"]["resources_string"].get("service.name") for row in get_rows(r)},
},
# search combined with attribute key-value filter (AND):
# search("xfts_") matches all 8 logs that carry any xfts_ token;
# ANDing with attribute.some_attr = TOK_ASVAL narrows to the single
# log that has that exact attribute — demonstrating AND reduces row count.
{
"name": "fts.search_and_attr_filter",
"expression": f'search("xfts_") AND attribute.some_attr = "{TOK_ASVAL}"',
"validate": lambda r: len(get_rows(r)) == 1 and get_rows(r)[0]["data"]["resources_string"].get("service.name") == "attr-str-val-svc",
},
# no-match guard: a token that exists nowhere must return 0 rows
{"name": "fts.no_match", "expression": '"xfts_nonexistent_zzz_999"', "validate": lambda r: len(get_rows(r)) == 0},
]
for case in cases:
resp = _fts(case["expression"])
assert case["validate"](resp), f"Validation failed for '{case['name']}': {resp.json()}"
# ── 6-hour window guard ──────────────────────────────────────────────────
# FTS over a window wider than 6 hours must be rejected with HTTP 400.
# The statement builder returns an error before ClickHouse is ever reached.
wide_resp = make_query_request(
signoz=signoz,
token=token,
start_ms=int((now - timedelta(hours=7)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
queries=[build_raw_query("A", "logs", filter_expression=f'search("{TOK_SEV}")', step_interval=60)],
request_type="raw",
)
assert wide_resp.status_code == 400, f"Expected 400 for FTS over >6h window, got {wide_resp.status_code}: {wide_resp.text}"
# ── unquoted search guard ────────────────────────────────────────────────
# search(TRACE) — unquoted argument — must be rejected with HTTP 400.
unquoted_resp = make_query_request(
signoz=signoz,
token=token,
start_ms=start_ms,
end_ms=end_ms,
queries=[build_raw_query("A", "logs", filter_expression=f"search({TOK_SEV})", step_interval=60)],
request_type="raw",
)
assert unquoted_resp.status_code == 400, f"Expected 400 for unquoted search(), got {unquoted_resp.status_code}: {unquoted_resp.text}"