-
Notifications
You must be signed in to change notification settings - Fork 421
fix: keep parsed-JSON "Add to Filters" keys as valid SQL #2561
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
d18ba95
1d07b22
dd6c858
8e3145f
c055770
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@hyperdx/app': patch | ||
| --- | ||
|
|
||
| Fix "Add to Filters" on a value inside parsed JSON from a String column (for example `Body`) building invalid SQL. The `JSONExtractString(...)` expression the JSON viewer produces is now passed through unchanged instead of being mis-parsed as a dot-form Map sub-key and mangled into a query ClickHouse rejects. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| import { | ||
| type FilterState, | ||
| filtersToQuery, | ||
| isValidFilterCondition, | ||
| } from '@hyperdx/common-utils/dist/filters'; | ||
|
|
||
| import { buildJSONExtractQuery } from '@/components/DBRowJsonViewer'; | ||
| import { escapeFilterStateKeys, parseQuery } from '@/searchFilters'; | ||
|
|
||
| import { cleanClickHouseExpression } from './utils'; | ||
|
|
||
| // End-to-end coverage for the "Add to Filters" -> WHERE-clause path, driven | ||
| // through the real producer (buildJSONExtractQuery) and the real serialization | ||
| // (escapeFilterStateKeys -> filtersToQuery). This is the regression net for | ||
| // HDX-4427: a filter key that is a raw ClickHouse expression must serialize to | ||
| // valid SQL and survive a parseQuery round-trip. Asserting validity (rather | ||
| // than an exact string) catches this bug class for any JSON/Map key shape, not | ||
| // just the one the user happened to hit. | ||
|
|
||
| // Real top-level columns on a typical OTel logs table. | ||
| const knownColumns = new Set(['Body', 'LogAttributes', 'ServiceName']); | ||
|
|
||
| // buildJSONExtractQuery returns string | null; in every case here a nested path | ||
| // exists, so null is a test-setup error rather than an expected branch. | ||
| const jsonKey = ( | ||
| keyPath: string[], | ||
| parsedJsonRootPath: string[], | ||
| jsonColumns: string[] = [], | ||
| jsonExtractFn: | ||
| | 'JSONExtractString' | ||
| | 'JSONExtractFloat' | ||
| | 'JSONExtractBool' = 'JSONExtractString', | ||
| mapColumns: string[] = [], | ||
| ): string => { | ||
| const out = buildJSONExtractQuery( | ||
| keyPath, | ||
| parsedJsonRootPath, | ||
| jsonColumns, | ||
| jsonExtractFn, | ||
| mapColumns, | ||
| ); | ||
| if (out == null) { | ||
| throw new Error('buildJSONExtractQuery returned null for a nested path'); | ||
| } | ||
| return out; | ||
| }; | ||
|
|
||
| const runAddToFilter = ( | ||
| key: string, | ||
| values: { included?: string[]; excluded?: string[] }, | ||
| ) => { | ||
| const state: FilterState = { | ||
| [key]: { | ||
| included: new Set<string | boolean>(values.included ?? []), | ||
| excluded: new Set<string | boolean>(values.excluded ?? []), | ||
| }, | ||
| }; | ||
| // Mirror updateFilterQuery: escape keys, then serialize to the query. | ||
| return filtersToQuery(escapeFilterStateKeys(state, knownColumns)); | ||
| }; | ||
|
|
||
| describe('parsed-JSON "Add to Filters" -> valid SQL pipeline (HDX-4427)', () => { | ||
| const cases: { | ||
| desc: string; | ||
| key: string; | ||
| values: { included?: string[]; excluded?: string[] }; | ||
| }[] = [ | ||
| { | ||
| desc: 'flat dotted key in a String column (the reported play-clickstack case)', | ||
| key: jsonKey(['Body', 'app.user.currency'], ['Body']), | ||
| values: { included: ['USD'] }, | ||
| }, | ||
| { | ||
| desc: 'nested path in a String column', | ||
| key: jsonKey(['Body', 'app', 'user', 'id'], ['Body']), | ||
| values: { included: ['u-1'] }, | ||
| }, | ||
| { | ||
| desc: 'simple top-level key in a String column', | ||
| key: jsonKey(['Body', 'level'], ['Body']), | ||
| values: { included: ['error'], excluded: ['debug'] }, | ||
| }, | ||
| { | ||
| desc: 'numeric metric via JSONExtractFloat with a dotted path', | ||
| key: jsonKey( | ||
| ['Body', 'metrics.latency'], | ||
| ['Body'], | ||
| [], | ||
| 'JSONExtractFloat', | ||
| ), | ||
| values: { included: ['200'] }, | ||
| }, | ||
| { | ||
| desc: 'boolean flag via JSONExtractBool with a dotted path', | ||
| key: jsonKey(['Body', 'flags.enabled'], ['Body'], [], 'JSONExtractBool'), | ||
| values: { included: ['true'] }, | ||
| }, | ||
| { | ||
| desc: 'Map sub-value holding JSON (bracketed base column)', | ||
| key: jsonKey( | ||
| ['LogAttributes', 'config', 'db.host'], | ||
| ['LogAttributes', 'config'], | ||
| [], | ||
| 'JSONExtractString', | ||
| ['LogAttributes'], | ||
| ), | ||
| values: { included: ['localhost'] }, | ||
| }, | ||
| { | ||
| desc: 'value containing a single quote (SQL escaping)', | ||
| key: jsonKey(['Body', 'user.name'], ['Body']), | ||
| values: { included: ["O'Brien"] }, | ||
| }, | ||
| ]; | ||
|
|
||
| it.each(cases)( | ||
| 'emits only valid ClickHouse SQL for $desc', | ||
| ({ key, values }) => { | ||
| const query = runAddToFilter(key, values); | ||
|
|
||
| expect(query.length).toBeGreaterThan(0); | ||
| for (const filter of query) { | ||
| expect(filter.type).toBe('sql'); | ||
| if (filter.type === 'sql') { | ||
| expect(isValidFilterCondition(filter.condition, 'sql')).toBe(true); | ||
| } | ||
| } | ||
| }, | ||
| ); | ||
|
|
||
| it('emits the corrected, valid condition for the reported case', () => { | ||
| const key = jsonKey(['Body', 'app.user.currency'], ['Body']); | ||
| expect(runAddToFilter(key, { included: ['USD'] })).toEqual([ | ||
| { | ||
| type: 'sql', | ||
| condition: "JSONExtractString(Body, 'app.user.currency') IN ('USD')", | ||
| }, | ||
| ]); | ||
| }); | ||
|
|
||
| it('round-trips included and excluded values through parseQuery', () => { | ||
| const key = jsonKey(['Body', 'app.user.currency'], ['Body']); | ||
| const query = runAddToFilter(key, { | ||
| included: ['USD', 'EUR'], | ||
| excluded: ['JPY'], | ||
| }); | ||
|
|
||
| const back = parseQuery(query).filters; | ||
| // parseQuery keys are the canonical (escaped) form; clean them the same way | ||
| // unescapeFilterStateKeys does before comparing to the in-memory key. | ||
| const restoredKeys = Object.keys(back).map(cleanClickHouseExpression); | ||
| expect(restoredKeys).toEqual([key]); | ||
|
|
||
| const entry = back[Object.keys(back)[0]]; | ||
| expect(entry.included).toEqual(new Set(['USD', 'EUR'])); | ||
| expect(entry.excluded).toEqual(new Set(['JPY'])); | ||
| }); | ||
|
|
||
| it('round-trips a value containing a single quote', () => { | ||
| const key = jsonKey(['Body', 'user.name'], ['Body']); | ||
| const back = parseQuery( | ||
| runAddToFilter(key, { included: ["O'Brien"] }), | ||
| ).filters; | ||
| const entry = back[Object.keys(back)[0]]; | ||
| expect(entry.included).toEqual(new Set(["O'Brien"])); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import { SearchPage } from '../../page-objects/SearchPage'; | ||
| import { JSON_BODY_LOG } from '../../seed-clickhouse'; | ||
| import { expect, test } from '../../utils/base-test'; | ||
| import { DEFAULT_LOGS_SOURCE_NAME } from '../../utils/constants'; | ||
|
|
||
| // Re-running the search query after a filter change can take longer than | ||
| // Playwright's 5s assertion default on slow CI runners. | ||
| const QUERY_TIMEOUT = 20_000; | ||
|
|
||
| // HDX-4427: "Add to Filters" on a value inside parsed JSON from a String column | ||
| // (Body) builds a JSONExtractString(...) expression as the filter key. Before | ||
| // the fix this serialized to invalid SQL and ClickHouse rejected the query, so | ||
| // the whole search errored. This drives the real UI path end to end against | ||
| // ClickHouse: add the filter from the row side panel, then confirm the query | ||
| // runs without error and still returns the matching row. | ||
| test.describe( | ||
| 'Search: parsed-JSON "Add to Filters"', | ||
| { tag: ['@search', '@full-stack'] }, | ||
| () => { | ||
| test('filtering on a nested JSON Body value runs without a ClickHouse error', async ({ | ||
| page, | ||
| }) => { | ||
| const searchPage = new SearchPage(page); | ||
| await searchPage.goto(); | ||
| await searchPage.selectSource(DEFAULT_LOGS_SOURCE_NAME); | ||
| await searchPage.timePicker.selectRelativeTime('Last 1 hour'); | ||
|
|
||
| // Isolate the seeded JSON-body row so the side panel opens on it. | ||
| await searchPage.performSearch( | ||
| `ServiceName:"${JSON_BODY_LOG.serviceName}"`, | ||
| ); | ||
| await expect(searchPage.getTableError()).toHaveCount(0); | ||
| await expect(searchPage.table.getRows()).toHaveCount(1, { | ||
|
Check failure on line 33 in packages/app/tests/e2e/features/search/json-add-to-filter.spec.ts
|
||
| timeout: QUERY_TIMEOUT, | ||
| }); | ||
|
|
||
| // Open the row, then add a filter on the nested JSON value from the | ||
| // parsed tab. This builds JSONExtractString(Body, 'app.user.currency'). | ||
| await searchPage.table.clickFirstRow(); | ||
| await searchPage.sidePanel.addParsedJsonFieldToFilter( | ||
| 'Body', | ||
| JSON_BODY_LOG.jsonKey, | ||
| ); | ||
|
|
||
| // The filter is built from the nested key as a JSONExtractString | ||
| // expression and persisted to the URL. | ||
| await expect(page).toHaveURL(/JSONExtractString/); | ||
| // The generated SQL must be valid: no table error and the matching row | ||
| // still comes back. Before the fix the mangled key produced invalid SQL | ||
| // that ClickHouse rejected, so the query errored and returned no rows. | ||
| await expect(searchPage.getTableError()).toHaveCount(0, { | ||
| timeout: QUERY_TIMEOUT, | ||
| }); | ||
| await expect(searchPage.table.getRows()).toHaveCount(1, { | ||
| timeout: QUERY_TIMEOUT, | ||
| }); | ||
| }); | ||
| }, | ||
| ); | ||
Uh oh!
There was an error while loading. Please reload this page.