diff --git a/.changeset/add-to-filter-json-column-toString.md b/.changeset/add-to-filter-json-column-toString.md new file mode 100644 index 0000000000..4b0b247962 --- /dev/null +++ b/.changeset/add-to-filter-json-column-toString.md @@ -0,0 +1,15 @@ +--- +"@hyperdx/app": patch +--- + +fix: "Add to Filters" on a JSON-typed ClickHouse column no longer produces an +unparseable Lucene query + +Previously, clicking "Add to Filters" on a field under a JSON column wrapped +the field path with `toString(...)` before handing it off as a Lucene filter +key. Lucene's grammar forbids parentheses inside field names, so the resulting +condition like `toString(JSONColumn.\`foo\`):"…"` failed to parse with +`Expected … but ":" found.` + +The handler now passes the clean dot-notation path (e.g. `JSONColumn.foo`) +to the filter setter. diff --git a/packages/app/src/components/DBRowJsonViewer.test.tsx b/packages/app/src/components/DBRowJsonViewer.test.tsx index 161a5abfe6..e889a6df69 100644 --- a/packages/app/src/components/DBRowJsonViewer.test.tsx +++ b/packages/app/src/components/DBRowJsonViewer.test.tsx @@ -53,10 +53,10 @@ describe('DBRowJsonViewer', () => { }); // Helper to render component - const renderComponent = (data: any) => { + const renderComponent = (data: any, jsonColumns?: string[]) => { return renderWithMantine( - + , ); }; @@ -133,6 +133,21 @@ describe('DBRowJsonViewer', () => { ); }); + it('adds filters on JSON-typed column as plain dot notation (no toString, no backticks)', () => { + const jsonData = { + Metadata: { + endpoint: 'grpc://thingy:8888/Verify', + }, + }; + renderComponent(jsonData, ['Metadata']); + clickLineButton('endpoint', 'Add to Filters'); + + expect(mockOnPropertyAddClick).toHaveBeenCalledWith( + 'Metadata.endpoint', + 'grpc://thingy:8888/Verify', + ); + }); + it('toggles columns with correct path formatting', () => { renderComponent(logData); clickLineButton('field1', 'Column'); diff --git a/packages/app/src/components/DBRowJsonViewer.tsx b/packages/app/src/components/DBRowJsonViewer.tsx index 65abd56115..e6aea39c5f 100644 --- a/packages/app/src/components/DBRowJsonViewer.tsx +++ b/packages/app/src/components/DBRowJsonViewer.tsx @@ -27,6 +27,7 @@ import { IconTextWrap, } from '@tabler/icons-react'; +import { cleanClickHouseExpression } from '@/components/DBSearchPageFilters/utils'; import HyperJson, { GetLineActions, LineAction } from '@/components/HyperJson'; import { mergePath } from '@/utils'; import { @@ -370,8 +371,6 @@ export function DBRowJsonViewer({ ({ keyPath, value, isInParsedJson, parsedJsonRootPath }) => { const actions: LineAction[] = []; const fieldPath = mergePath(keyPath, jsonColumns); - const isJsonColumn = - keyPath.length > 0 && jsonColumns?.includes(keyPath[0]); // Add to Filters action (strings only) // FIXME: TOTAL HACK To disallow adding timestamp to filters @@ -394,7 +393,6 @@ export function DBRowJsonViewer({ onClick: () => { let filterFieldPath = fieldPath; - // Handle parsed JSON from string columns using JSONExtractString if (isInParsedJson && parsedJsonRootPath) { const jsonQuery = buildJSONExtractQuery( keyPath, @@ -403,23 +401,14 @@ export function DBRowJsonViewer({ ); if (jsonQuery) { filterFieldPath = jsonQuery; - } else { - // We're at the root of the parsed JSON, treat as string - filterFieldPath = isJsonColumn - ? `toString(${fieldPath})` - : fieldPath; } - } else { - // Regular JSON column or non-JSON field - filterFieldPath = isJsonColumn - ? `toString(${fieldPath})` - : fieldPath; } - onPropertyAddClick(filterFieldPath, value); + const cleanedPath = cleanClickHouseExpression(filterFieldPath); + onPropertyAddClick(cleanedPath, value); notifications.show({ color: 'green', - message: `Added "${fieldPath} = ${value}" to filters`, + message: `Added "${cleanedPath} = ${value}" to filters`, }); }, }); diff --git a/packages/app/src/components/EventTag.tsx b/packages/app/src/components/EventTag.tsx index 9cb6177fb0..d2ae465604 100644 --- a/packages/app/src/components/EventTag.tsx +++ b/packages/app/src/components/EventTag.tsx @@ -5,6 +5,7 @@ import { SearchConditionLanguage } from '@hyperdx/common-utils/dist/types'; import { Button, Group, Popover, Stack, Text, Tooltip } from '@mantine/core'; import { IconCirclePlus, IconLink, IconSearch } from '@tabler/icons-react'; +import { cleanClickHouseExpression } from '@/components/DBSearchPageFilters/utils'; import { isLinkableUrl } from '@/utils/highlightedAttributes'; export default function EventTag({ @@ -105,7 +106,10 @@ export default function EventTag({ size="xs" rightSection={} onClick={() => { - onPropertyAddClick(sqlExpression, value); + onPropertyAddClick( + cleanClickHouseExpression(sqlExpression), + value, + ); setOpened(false); }} > diff --git a/packages/common-utils/src/__tests__/filters.test.ts b/packages/common-utils/src/__tests__/filters.test.ts index d2ba8ebb2f..9ba03e7c30 100644 --- a/packages/common-utils/src/__tests__/filters.test.ts +++ b/packages/common-utils/src/__tests__/filters.test.ts @@ -457,6 +457,22 @@ describe('filters', () => { expect(parsed?.[0].included).toEqual(['my-app']); }); + it('round-trips a JSON column dot-path with colons and slashes in value', () => { + const state: FilterState = { + 'Metadata.endpoint': { + included: new Set(['grpc://thingy:8888/Verify']), + excluded: new Set(), + }, + }; + const filters = filtersToQuery(state); + const condition = getCondition(filters[0]); + expect(condition).not.toMatch(/^toString\(/); + expect(condition).not.toMatch(/`/); + const parsed = parseLuceneFilter(condition); + expect(parsed?.[0].key).toBe('Metadata.endpoint'); + expect(parsed?.[0].included).toEqual(['grpc://thingy:8888/Verify']); + }); + it('round-trips mixed included and excluded for same field', () => { const state: FilterState = { env: { diff --git a/packages/common-utils/src/__tests__/queryParser.test.ts b/packages/common-utils/src/__tests__/queryParser.test.ts index dbc79c3e54..b223c6bb69 100644 --- a/packages/common-utils/src/__tests__/queryParser.test.ts +++ b/packages/common-utils/src/__tests__/queryParser.test.ts @@ -493,6 +493,19 @@ describe('CustomSchemaSQLSerializerV2 - json', () => { "((hasToken(lower(concatWithSeparator(';',Body,OtherColumn)), lower('foo'))) AND (hasToken(lower(concatWithSeparator(';',Body,OtherColumn)), lower('bar'))))"; expect(actualSql).toBe(expectedSql); }); + + it('"Add to Filters" repro: JSON column path + value with colons and slashes emits valid SQL', async () => { + const lucene = + 'ResourceAttributesJSON.endpoint:"grpc://auth-svc:50051/Verify"'; + const builder = new SearchQueryBuilder(lucene, serializer); + const actualSql = await builder.build(); + expect(actualSql).toContain( + 'toString(`ResourceAttributesJSON`.`endpoint`)', + ); + expect(actualSql).toContain('grpc://auth-svc:50051/Verify'); + expect(actualSql).not.toContain('http_COLON_'); + expect(actualSql).not.toContain('HDX_COLON'); + }); }); describe('CustomSchemaSQLSerializerV2 - bloom_filter tokens() indices', () => {