Repository navigation
fix: handle native JSON search filters #2551
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
base: main
Are you sure you want to change the base?
Changes from all commits
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,6 @@ | ||
| --- | ||
| '@hyperdx/app': patch | ||
| '@hyperdx/common-utils': patch | ||
| --- | ||
|
|
||
| Fix native JSON search filters and metadata value queries for string, numeric, boolean, and mixed-type paths, while preserving Map-column bracket access. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -57,7 +57,7 @@ import { IS_CLICKHOUSE_BUILD } from '@/config'; | |
| import { | ||
| useColumns, | ||
| useGetValuesDistribution, | ||
| useJsonColumns, | ||
| useJsonColumnNames, | ||
| useTableMetadata, | ||
| } from '@/hooks/useMetadata'; | ||
| import useResizable from '@/hooks/useResizable'; | ||
|
|
@@ -1197,7 +1197,6 @@ const DBSearchPageFiltersComponent = ({ | |
|
|
||
| const { data: source } = useSource({ id: sourceId }); | ||
| const sourceTableConnection = tcFromSource(source); | ||
| const { data: jsonColumns } = useJsonColumns(sourceTableConnection); | ||
|
|
||
| // Special case for live tail | ||
| const [dateRange, setDateRange] = useState<[Date, Date]>( | ||
|
|
@@ -1209,6 +1208,8 @@ const DBSearchPageFiltersComponent = ({ | |
| tableName: chartConfig.from.tableName, | ||
| connectionId: chartConfig.connection, | ||
| }); | ||
| const jsonColumns = useJsonColumnNames(columns); | ||
| const jsonColumnSet = useMemo(() => new Set(jsonColumns), [jsonColumns]); | ||
|
|
||
| const { data: tableMetadata } = useTableMetadata(sourceTableConnection); | ||
|
|
||
|
|
@@ -1334,9 +1335,6 @@ const DBSearchPageFiltersComponent = ({ | |
| const _facets: FacetEntry[] = []; | ||
| for (const _facet of facetsWithPinnedValues ?? []) { | ||
| const facet = structuredClone(_facet); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — The only mutation of
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — This diff removed the only mutation of the clone (
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Existing pins for JSON fields, saved under the old Before this PR, Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 major — Pins saved before this PR as The old Nothing blocks merge automatically, but a maintainer will expect this fixed if it is a real defect in code this PR changes. If it is about surrounding code, reply and say so instead of patching. Do not widen the PR. How to respond
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 major — Existing JSON pins are keyed by the old Before this change, JSON facet keys were rewritten to Nothing blocks merge automatically, but a maintainer will expect this fixed if it is a real defect in code this PR changes. If it is about surrounding code, reply and say so instead of patching. Do not widen the PR. How to respond |
||
| if (jsonColumns?.some(col => facet.key.startsWith(col))) { | ||
| facet.key = `toString(${facet.key})`; | ||
| } | ||
|
|
||
| // Skip fields already shown in the Shared Filters section | ||
| if (sharedFilterKeys.has(facet.key)) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Dropping the Before this change
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 major — Dropping the Before this diff |
||
|
|
@@ -1416,7 +1414,6 @@ const DBSearchPageFiltersComponent = ({ | |
| tableMetadata, | ||
| isFieldPinned, | ||
| isSharedFieldPinned, | ||
| jsonColumns, | ||
| sharedFilterKeys, | ||
| ]); | ||
|
|
||
|
|
@@ -1536,6 +1533,7 @@ const DBSearchPageFiltersComponent = ({ | |
| sqlKey: toQuotedClickHouseKeyExpression( | ||
| child.key, | ||
| knownColumns, | ||
| jsonColumnSet, | ||
| ), | ||
| }))} | ||
| selectedValues={group.children.reduce((acc, child) => { | ||
|
|
@@ -1602,6 +1600,7 @@ const DBSearchPageFiltersComponent = ({ | |
| const facetSqlKey = toQuotedClickHouseKeyExpression( | ||
| facet.key, | ||
| knownColumns, | ||
| jsonColumnSet, | ||
| ); | ||
| return ( | ||
| <FilterGroup | ||
|
|
@@ -1684,6 +1683,7 @@ const DBSearchPageFiltersComponent = ({ | |
| setFilterRange, | ||
| tableMetadata, | ||
| knownColumns, | ||
| jsonColumnSet, | ||
| extraFacetKeys, | ||
| ], | ||
| ); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -54,18 +54,23 @@ jest.mock('@/source', () => ({ | |
| useSource: jest.fn(), | ||
| })); | ||
|
|
||
| jest.mock('@/searchFilters', () => ({ | ||
| __esModule: true, | ||
| usePinnedFilters: jest.fn(), | ||
| escapeFilterStateKeys: jest.fn((state: unknown) => state), | ||
| })); | ||
| jest.mock('@/searchFilters', () => { | ||
| const actual = | ||
| jest.requireActual<typeof import('@/searchFilters')>('@/searchFilters'); | ||
| return { | ||
| __esModule: true, | ||
| ...actual, | ||
| usePinnedFilters: jest.fn(), | ||
| escapeFilterStateKeys: jest.fn(actual.escapeFilterStateKeys), | ||
| }; | ||
| }); | ||
|
|
||
| jest.mock('@/hooks/useMetadata', () => ({ | ||
| __esModule: true, | ||
| useMetadataWithSettings: jest.fn(), | ||
| useColumns: jest.fn(), | ||
| useDateTimeColumns: jest.fn(), | ||
| useJsonColumns: jest.fn(), | ||
| useJsonColumnNames: jest.fn(), | ||
| useMapColumns: jest.fn(), | ||
| useAllFields: jest.fn(), | ||
| useGetKeyValues: jest.fn(), | ||
|
|
@@ -80,7 +85,7 @@ const useMetadataWithSettings = jest.mocked( | |
| ); | ||
| const useColumns = jest.mocked(useMetadataModule.useColumns); | ||
| const useDateTimeColumns = jest.mocked(useMetadataModule.useDateTimeColumns); | ||
| const useJsonColumns = jest.mocked(useMetadataModule.useJsonColumns); | ||
| const useJsonColumnNames = jest.mocked(useMetadataModule.useJsonColumnNames); | ||
| const useMapColumns = jest.mocked(useMetadataModule.useMapColumns); | ||
| const useAllFields = jest.mocked(useMetadataModule.useAllFields); | ||
| const useGetKeyValues = jest.mocked(useMetadataModule.useGetKeyValues); | ||
|
|
@@ -166,10 +171,8 @@ function setupDefaultMocks({ withMVs }: { withMVs: boolean }) { | |
| isLoading: false, | ||
| } as any); | ||
|
|
||
| useDateTimeColumns.mockReturnValue([ | ||
| { name: 'Timestamp', type: 'DateTime' }, | ||
| ] as any); | ||
| useJsonColumns.mockReturnValue({ data: [] } as any); | ||
| useDateTimeColumns.mockReturnValue(new Map([['Timestamp', 'DateTime']])); | ||
| useJsonColumnNames.mockReturnValue([]); | ||
| useMapColumns.mockReturnValue({ data: [] } as any); | ||
|
|
||
| useAllFields.mockReturnValue({ | ||
|
|
@@ -570,6 +573,85 @@ describe('useFetchFacets', () => { | |
| }); | ||
|
|
||
| describe('loadMoreFacetsForKey (raw-tables pipeline)', () => { | ||
| it("strips a JSON facet's own selection before escaping load-more filters", async () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Load-more test mocks away the serialization it is meant to protect and never exercises a remaining JSON filter Add a second JSON key to |
||
| setupDefaultMocks({ withMVs: false }); | ||
| useJsonColumnNames.mockReturnValue(['ResourceAttributes']); | ||
| useColumns.mockReturnValue({ | ||
| data: [ | ||
| { name: 'Timestamp', type: 'DateTime' }, | ||
| { | ||
| name: 'ResourceAttributes', | ||
| type: 'JSON(max_dynamic_types=8, max_dynamic_paths=64)', | ||
| }, | ||
| ], | ||
| isLoading: false, | ||
| } as ReturnType<typeof useMetadataModule.useColumns>); | ||
| useMetadataWithSettings.mockReturnValue({ | ||
| getKeyValuesWithMVs: jest.fn().mockResolvedValue([ | ||
| { | ||
| key: "ResourceAttributes['k8s.namespace.name']", | ||
| value: ['production'], | ||
| }, | ||
| ]), | ||
| } as any); | ||
| const filterState: FilterState = { | ||
| 'ResourceAttributes.k8s.namespace.name': { | ||
| included: new Set<string | boolean>(['production']), | ||
| excluded: new Set<string | boolean>(), | ||
| }, | ||
| 'ResourceAttributes.service.name': { | ||
| included: new Set<string | boolean>(['api']), | ||
| excluded: new Set<string | boolean>(), | ||
| }, | ||
| }; | ||
|
|
||
| const { wrapper } = makeWrapper(); | ||
| const { result } = renderHook( | ||
| () => | ||
| useFetchFacets({ | ||
| chartConfig: CHART_CONFIG, | ||
| sourceId: 'source1', | ||
| dateRange: DATE_RANGE, | ||
| mode: 'exact', | ||
| filterState, | ||
| }), | ||
| { wrapper }, | ||
| ); | ||
|
|
||
| await act(async () => { | ||
| await result.current.loadMoreFacetsForKey( | ||
| 'toString(ResourceAttributes.`k8s`.`namespace`.`name`)', | ||
| ); | ||
| }); | ||
|
|
||
| expect(searchFiltersModule.escapeFilterStateKeys).toHaveBeenCalledWith( | ||
| { | ||
| 'ResourceAttributes.service.name': { | ||
| included: new Set(['api']), | ||
| excluded: new Set(), | ||
| }, | ||
| }, | ||
| expect.any(Set), | ||
| new Set(['ResourceAttributes']), | ||
| ); | ||
| expect( | ||
| useMetadataWithSettings.mock.results.at(-1)?.value.getKeyValuesWithMVs, | ||
| ).toHaveBeenCalledWith( | ||
| expect.objectContaining({ | ||
| keys: ["ResourceAttributes['k8s.namespace.name']"], | ||
| chartConfig: expect.objectContaining({ | ||
| filters: [ | ||
| { | ||
| type: 'sql', | ||
| condition: | ||
| "toString(ResourceAttributes.`service`.`name`) IN ('api')", | ||
| }, | ||
| ], | ||
| }), | ||
| }), | ||
| ); | ||
| }); | ||
|
|
||
| it('reports the key as loading while the fetch is in flight, then clears it', async () => { | ||
| setupDefaultMocks({ withMVs: false }); | ||
| useGetKeyValues.mockReturnValue({ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 major — Existing JSON field/value pins saved under the old
toString(...)key stop matchingBefore this PR,
shownFacetsrewrote JSON facet keys totoString(<key>), sotoggleFieldPin/toggleFilterPin/the shared-pin toggles saved those strings to localStorage and to MongoDB (team pins). Nowchild.keyis the unwrappedCol.a.b`` form, andisFieldPinned/`isSharedFieldPinned` in `searchFilters.tsx:565-606` compare with `includes`, which is an exact string match. `groupFacetsByBaseName` folds the legacy pinned key into the queried child, keeping the queried child's key, so existing pins show as unpinned. Re-pinning saves a second entry, and the old one is orphaned and can't be removed from the UI. Fix: normalize keys on read, e.g. run pin keys through `cleanClickHouseExpression` and compare clean forms, or migrate stored pins whose key matches `toString(.…)`.Nothing blocks merge automatically, but a maintainer will expect this fixed if it is a real defect in code this PR changes. If it is about surrounding code, reply and say so instead of patching. Do not widen the PR. How to respond