Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/add-to-filter-json-column-toString.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 17 additions & 2 deletions packages/app/src/components/DBRowJsonViewer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@ describe('DBRowJsonViewer', () => {
});

// Helper to render component
const renderComponent = (data: any) => {
const renderComponent = (data: any, jsonColumns?: string[]) => {
return renderWithMantine(
<RowSidePanelContext.Provider value={defaultContext}>
<DBRowJsonViewer data={data} />
<DBRowJsonViewer data={data} jsonColumns={jsonColumns} />
</RowSidePanelContext.Provider>,
);
};
Expand Down Expand Up @@ -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');
Expand Down
19 changes: 4 additions & 15 deletions packages/app/src/components/DBRowJsonViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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`,
});
},
});
Expand Down
6 changes: 5 additions & 1 deletion packages/app/src/components/EventTag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -105,7 +106,10 @@ export default function EventTag({
size="xs"
rightSection={<IconCirclePlus size={14} />}
onClick={() => {
onPropertyAddClick(sqlExpression, value);
onPropertyAddClick(
cleanClickHouseExpression(sqlExpression),
value,
);
setOpened(false);
}}
>
Expand Down
16 changes: 16 additions & 0 deletions packages/common-utils/src/__tests__/filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | boolean>(['grpc://thingy:8888/Verify']),
excluded: new Set<string | boolean>(),
},
};
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: {
Expand Down
13 changes: 13 additions & 0 deletions packages/common-utils/src/__tests__/queryParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading