From 2cadb92f15a0e0d06c5cd1a49a2c84c46560688f Mon Sep 17 00:00:00 2001
From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com>
Date: Thu, 28 May 2026 02:01:48 +0000
Subject: [PATCH 1/4] fix(row-panel): emit Map['key'] subscript for Map columns
in mergePath
mergePath converted numeric path segments to 1-based array subscripts
([N+1]) regardless of whether the parent column was a Map or an Array.
On a Map(String, String) column this produced SQL like LogAttributes[2],
which ClickHouse rejects with "Illegal types of arguments:
Map(String, String), UInt8 for function arrayElement". The grid row
expand view failed for any row whose attribute path included a
numeric-looking key under a Map column.
The function now accepts a mapColumns argument alongside jsonColumns.
For Map-typed parents, all sub-keys render as string-key subscripts
(Map['1']) regardless of whether the key looks numeric. Array columns
keep their existing 1-based array-index behavior.
The three call sites (useAutoCompleteOptions, DBRowJsonViewer via
DBRowOverviewPanel and DBRowDataPanel, DBSearchPageFilters) now thread
Map-column names from the result-set metadata or source schema. A new
useMapColumns hook mirrors useJsonColumns. A new getMapColumnNames
helper mirrors getJSONColumnNames for callers that work directly off
ResponseJSON meta.
Fixes HDX-4369.
---
.../hdx-4369-mergepath-map-subscript.md | 23 ++++++
packages/app/src/__tests__/utils.test.ts | 74 +++++++++++++++++++
.../app/src/components/DBRowDataPanel.tsx | 22 +++++-
.../app/src/components/DBRowJsonViewer.tsx | 8 +-
.../app/src/components/DBRowOverviewPanel.tsx | 9 ++-
.../src/components/DBSearchPageFilters.tsx | 5 +-
.../app/src/hooks/useAutoCompleteOptions.tsx | 19 ++++-
packages/app/src/hooks/useMetadata.tsx | 29 ++++++++
packages/app/src/utils.ts | 62 +++++++++++-----
9 files changed, 227 insertions(+), 24 deletions(-)
create mode 100644 .changeset/hdx-4369-mergepath-map-subscript.md
diff --git a/.changeset/hdx-4369-mergepath-map-subscript.md b/.changeset/hdx-4369-mergepath-map-subscript.md
new file mode 100644
index 0000000000..00c07cd9ba
--- /dev/null
+++ b/.changeset/hdx-4369-mergepath-map-subscript.md
@@ -0,0 +1,23 @@
+---
+"@hyperdx/app": patch
+---
+
+fix(row-panel): mergePath now emits string-key subscripts for Map columns,
+preventing a crash when expanding rows with numeric-looking attribute keys
+
+`mergePath` converted numeric path segments to 1-based array subscripts
+(`[N+1]`) regardless of whether the parent column was a Map or an Array.
+On a `Map(String, String)` column this produced SQL like `LogAttributes[2]`,
+which ClickHouse rejects with `Illegal types of arguments:
+Map(String, String), UInt8 for function arrayElement`. The grid row
+"expand" view failed for any row whose attribute path included a
+numeric-looking key under a Map column.
+
+`mergePath` now accepts a `mapColumns` argument alongside `jsonColumns`.
+For Map-typed parents, sub-keys always render as string subscripts
+(`Map['1']`) regardless of whether the key looks numeric. The three
+callers (`useAutoCompleteOptions`, `DBRowJsonViewer` via the row panels,
+`DBSearchPageFilters`) now thread Map-column names from the source
+schema. A new `useMapColumns` hook mirrors `useJsonColumns`.
+
+Fixes HDX-4369.
diff --git a/packages/app/src/__tests__/utils.test.ts b/packages/app/src/__tests__/utils.test.ts
index d10d1f7b75..79c20d6001 100644
--- a/packages/app/src/__tests__/utils.test.ts
+++ b/packages/app/src/__tests__/utils.test.ts
@@ -12,6 +12,7 @@ import {
getAllMetricTables,
getMetricTableName,
mapKeyBy,
+ mergePath,
orderByStringToSortingState,
parseTimestampToMs,
sortingStateToOrderByString,
@@ -1202,3 +1203,76 @@ describe('formatDurationMsCompact', () => {
expect(formatDurationMsCompact(7_200_000)).toBe('2h');
});
});
+
+describe('mergePath', () => {
+ describe('default (Array / unknown column)', () => {
+ it('returns the bare key for a single-segment path', () => {
+ expect(mergePath(['Body'])).toBe('Body');
+ });
+
+ it('numeric sub-segment becomes 1-based array index', () => {
+ // ClickHouse arrays are 1-based but flattened data uses 0-based indices.
+ expect(mergePath(['SomeArray', '0'])).toBe('SomeArray[1]');
+ expect(mergePath(['SomeArray', '4'])).toBe('SomeArray[5]');
+ });
+
+ it('non-numeric sub-segment becomes string-key subscript', () => {
+ expect(mergePath(['SomeColumn', 'service.name'])).toBe(
+ "SomeColumn['service.name']",
+ );
+ });
+
+ it('mixed numeric and string segments chain', () => {
+ expect(mergePath(['Outer', '1', 'inner'])).toBe("Outer[2]['inner']");
+ });
+ });
+
+ describe('JSON column', () => {
+ it('emits dotted backtick-quoted accessor', () => {
+ expect(mergePath(['BodyJson', 'service', 'name'], ['BodyJson'])).toBe(
+ 'BodyJson.`service`.`name`',
+ );
+ });
+ });
+
+ describe('Map column (HDX-4369)', () => {
+ // Failing reproducer from the issue body: on a Map(String, String), a
+ // numeric-looking sub-key must NOT collapse into array-index syntax.
+ // ClickHouse rejects `LogAttributes[2]` against a Map column with
+ // "Illegal types of arguments: Map(String, String), UInt8 for function
+ // arrayElement". The fix adds a `mapColumns` parameter that forces the
+ // bracketed string-key form regardless of whether the key parses as a
+ // non-negative integer.
+ it('numeric sub-key on a Map renders as string subscript, not array index', () => {
+ const result = mergePath(['LogAttributes', '1'], [], ['LogAttributes']);
+ expect(result).not.toBe('LogAttributes[2]');
+ expect(result).not.toMatch(/\[\d+\]$/);
+ expect(result).toBe("LogAttributes['1']");
+ });
+
+ it('non-numeric Map sub-key keeps string subscript (unchanged)', () => {
+ expect(
+ mergePath(['LogAttributes', 'service.name'], [], ['LogAttributes']),
+ ).toBe("LogAttributes['service.name']");
+ });
+
+ it('multi-segment Map path chains string subscripts', () => {
+ expect(
+ mergePath(['LogAttributes', '1', 'foo'], [], ['LogAttributes']),
+ ).toBe("LogAttributes['1']['foo']");
+ });
+
+ it('Array column with numeric key still uses array-index syntax', () => {
+ // Inverse case: keep existing behavior for non-Map parents.
+ expect(mergePath(['SomeArray', '1'], [], ['LogAttributes'])).toBe(
+ 'SomeArray[2]',
+ );
+ });
+
+ it('JSON column wins over Map column when both lists contain the key', () => {
+ // Caller can't currently configure the same column as both; the order
+ // is deterministic if they did.
+ expect(mergePath(['Body', '1'], ['Body'], ['Body'])).toBe('Body.`1`');
+ });
+ });
+});
diff --git a/packages/app/src/components/DBRowDataPanel.tsx b/packages/app/src/components/DBRowDataPanel.tsx
index 2a38f2d105..789e688352 100644
--- a/packages/app/src/components/DBRowDataPanel.tsx
+++ b/packages/app/src/components/DBRowDataPanel.tsx
@@ -200,6 +200,21 @@ export function getJSONColumnNames(meta: ResponseJSON['meta'] | undefined) {
);
}
+// Returns the names of Map-typed columns in the result metadata. Used by
+// `mergePath` to keep numeric-looking sub-keys on a Map(String, ...) from
+// collapsing into ClickHouse array-index syntax (`Map[2]`), which the
+// server rejects with
+// `Illegal types of arguments: Map(String, ...), UInt8 for function
+// arrayElement`. HDX-4369.
+export function getMapColumnNames(meta: ResponseJSON['meta'] | undefined) {
+ return (
+ meta
+ // Match both `Map(K, V)` and the bare `Map` (rare; defensive).
+ ?.filter(m => m.type === 'Map' || m.type.startsWith('Map('))
+ .map(m => m.name) ?? []
+ );
+}
+
export function RowDataPanel({
source,
rowId,
@@ -222,11 +237,16 @@ export function RowDataPanel({
}, [data]);
const jsonColumns = getJSONColumnNames(data?.meta);
+ const mapColumns = getMapColumnNames(data?.meta);
return (
-
+
);
diff --git a/packages/app/src/components/DBRowJsonViewer.tsx b/packages/app/src/components/DBRowJsonViewer.tsx
index e6aea39c5f..385d72c549 100644
--- a/packages/app/src/components/DBRowJsonViewer.tsx
+++ b/packages/app/src/components/DBRowJsonViewer.tsx
@@ -333,9 +333,14 @@ function HyperJsonMenu({ rowData }: { rowData: any }) {
export function DBRowJsonViewer({
data,
jsonColumns,
+ mapColumns,
}: {
data: any;
jsonColumns?: string[];
+ // Map column names from the result-set metadata. Threaded into
+ // `mergePath` so numeric-looking sub-keys on a Map render as
+ // `Map['key']` instead of the array `Map[N+1]`. HDX-4369.
+ mapColumns?: string[];
}) {
const {
onPropertyAddClick,
@@ -370,7 +375,7 @@ export function DBRowJsonViewer({
const getLineActions = useCallback(
({ keyPath, value, isInParsedJson, parsedJsonRootPath }) => {
const actions: LineAction[] = [];
- const fieldPath = mergePath(keyPath, jsonColumns);
+ const fieldPath = mergePath(keyPath, jsonColumns, mapColumns);
// Add to Filters action (strings only)
// FIXME: TOTAL HACK To disallow adding timestamp to filters
@@ -621,6 +626,7 @@ export function DBRowJsonViewer({
rowData,
toggleColumn,
jsonColumns,
+ mapColumns,
],
);
diff --git a/packages/app/src/components/DBRowOverviewPanel.tsx b/packages/app/src/components/DBRowOverviewPanel.tsx
index ab372d031b..5610ad4efc 100644
--- a/packages/app/src/components/DBRowOverviewPanel.tsx
+++ b/packages/app/src/components/DBRowOverviewPanel.tsx
@@ -8,7 +8,11 @@ import { WithClause } from '@/hooks/useRowWhere';
import { getEventBody } from '@/source';
import { getHighlightedAttributesFromData } from '@/utils/highlightedAttributes';
-import { getJSONColumnNames, useRowData } from './DBRowDataPanel';
+import {
+ getJSONColumnNames,
+ getMapColumnNames,
+ useRowData,
+} from './DBRowDataPanel';
import { DBRowJsonViewer } from './DBRowJsonViewer';
import { RowSidePanelContext } from './DBRowSidePanel';
import DBRowSidePanelHeader from './DBRowSidePanelHeader';
@@ -52,6 +56,7 @@ export function RowOverviewPanel({
}, [source, data]);
const jsonColumns = getJSONColumnNames(data?.meta);
+ const mapColumns = getMapColumnNames(data?.meta);
const eventAttributesExpr =
source.kind === SourceKind.Log || source.kind === SourceKind.Trace
@@ -277,6 +282,7 @@ export function RowOverviewPanel({
@@ -294,6 +300,7 @@ export function RowOverviewPanel({
diff --git a/packages/app/src/components/DBSearchPageFilters.tsx b/packages/app/src/components/DBSearchPageFilters.tsx
index ba5de2b41c..dfbc7024e0 100644
--- a/packages/app/src/components/DBSearchPageFilters.tsx
+++ b/packages/app/src/components/DBSearchPageFilters.tsx
@@ -64,6 +64,7 @@ import {
useGetKeyValues,
useGetValuesDistribution,
useJsonColumns,
+ useMapColumns,
useTableMetadata,
} from '@/hooks/useMetadata';
import { useMetadataWithSettings } from '@/hooks/useMetadata';
@@ -1141,6 +1142,7 @@ const DBSearchPageFiltersComponent = ({
const { data: source } = useSource({ id: sourceId });
const sourceTableConnection = tcFromSource(source);
const { data: jsonColumns } = useJsonColumns(sourceTableConnection);
+ const { data: mapColumns } = useMapColumns(sourceTableConnection);
const filterMode = showAllValues ? ('all' as const) : ('exact' as const);
const hasMVs = !!sourceTableConnection.metadataMVs;
@@ -1237,7 +1239,7 @@ const DBSearchPageFiltersComponent = ({
.map(({ path, type }) => {
return {
type,
- path: mergePath(path, jsonColumns ?? []),
+ path: mergePath(path, jsonColumns ?? [], mapColumns ?? []),
isMapSubField: path.length > 1,
};
})
@@ -1259,6 +1261,7 @@ const DBSearchPageFiltersComponent = ({
}, [
allFields,
jsonColumns,
+ mapColumns,
filterState,
showMoreFields,
isFieldPinned,
diff --git a/packages/app/src/hooks/useAutoCompleteOptions.tsx b/packages/app/src/hooks/useAutoCompleteOptions.tsx
index 1e1b10ad17..08dbbf7208 100644
--- a/packages/app/src/hooks/useAutoCompleteOptions.tsx
+++ b/packages/app/src/hooks/useAutoCompleteOptions.tsx
@@ -234,9 +234,24 @@ export function useAutoCompleteOptions(
[tcs, effectiveDateRange, source?.timestampValueExpression],
);
+ // Map columns from the field list, so a path like `['LogAttributes', '1']`
+ // on a Map(String, ...) renders as `LogAttributes['1']` instead of the
+ // illegal array `LogAttributes[2]`. HDX-4369.
+ const mapColumns = useMemo(
+ () =>
+ (fields ?? [])
+ .filter(
+ f =>
+ f.path.length === 1 &&
+ (f.type === 'Map' || f.type.startsWith('Map(')),
+ )
+ .map(f => f.path[0]),
+ [fields],
+ );
+
const searchKeys = useMemo(
- () => (searchField ? [mergePath(searchField.path)] : []),
- [searchField],
+ () => (searchField ? [mergePath(searchField.path, [], mapColumns)] : []),
+ [searchField, mapColumns],
);
const metadataMVs = tcs[0]?.metadataMVs;
diff --git a/packages/app/src/hooks/useMetadata.tsx b/packages/app/src/hooks/useMetadata.tsx
index 2f170d980f..b2dbdeb34c 100644
--- a/packages/app/src/hooks/useMetadata.tsx
+++ b/packages/app/src/hooks/useMetadata.tsx
@@ -126,6 +126,35 @@ export function useJsonColumns(
});
}
+// Mirrors `useJsonColumns` for Map-typed columns. Used by `mergePath`
+// callers (notably `DBSearchPageFilters`) so numeric-looking sub-keys on a
+// Map render as `Map['key']` instead of the illegal array `Map[N+1]`.
+// HDX-4369.
+export function useMapColumns(
+ tableConnection: TableConnection | undefined,
+ options?: Partial>,
+) {
+ const metadata = useMetadataWithSettings();
+ return useQuery({
+ queryKey: ['useMetadata.useMapColumns', tableConnection],
+ queryFn: async () => {
+ if (!tableConnection) return [];
+ const columns = await metadata.getColumns(tableConnection);
+ return (
+ filterColumnMetaByType(columns, [JSDataType.Map])?.map(
+ column => column.name,
+ ) ?? []
+ );
+ },
+ enabled:
+ tableConnection &&
+ !!tableConnection.databaseName &&
+ !!tableConnection.tableName &&
+ !!tableConnection.connectionId,
+ ...options,
+ });
+}
+
export function useMultipleAllFields(
tableConnections: TableConnection[],
options?: Partial> & {
diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts
index 294f31946f..4a045771da 100644
--- a/packages/app/src/utils.ts
+++ b/packages/app/src/utils.ts
@@ -933,28 +933,54 @@ export const formatUptime = (seconds: number) => {
// FIXME: eventually we want to separate metric name into two fields
// Date formatting
-export const mergePath = (path: string[], jsonColumns: string[] = []) => {
+//
+// `mergePath` rebuilds a column-access expression for a nested path that the
+// row table flattened during display. It has three column-type modes:
+//
+// - JSON column: dotted backtick-quoted accessor, e.g. `Body.\`key\``
+// - Map column: bracketed string-key subscript, e.g. `LogAttributes['1']`
+// - Array column (default): numeric segments get 1-based array subscripts
+// (`Array[N+1]`); string segments get bracketed string-key subscripts.
+//
+// Without the Map-column branch, a Map(String, String) like `LogAttributes`
+// with a numeric-looking key (`"1"`) collapses into the array branch and
+// ClickHouse rejects the resulting `LogAttributes[2]` with
+// `Illegal types of arguments: Map(String, String), UInt8 for function
+// arrayElement`. HDX-4369.
+export const mergePath = (
+ path: string[],
+ jsonColumns: string[] = [],
+ mapColumns: string[] = [],
+) => {
const [key, ...rest] = path;
if (rest.length === 0) {
return key;
}
- return jsonColumns.includes(key)
- ? `${key}.${rest
- .map(v =>
- v
- .split('.')
- .map(v => (v.startsWith('`') && v.endsWith('`') ? v : `\`${v}\``))
- .join('.'),
- )
- .join('.')}`
- : `${key}${rest
- .map(v => {
- const asNumber = Number(v);
- const isArrayIndex = Number.isInteger(asNumber) && asNumber >= 0;
- // ClickHouse arrays are 1-based, but flattened data uses 0-based indices
- return isArrayIndex ? `[${asNumber + 1}]` : `['${v}']`;
- })
- .join('')}`;
+ if (jsonColumns.includes(key)) {
+ return `${key}.${rest
+ .map(v =>
+ v
+ .split('.')
+ .map(v => (v.startsWith('`') && v.endsWith('`') ? v : `\`${v}\``))
+ .join('.'),
+ )
+ .join('.')}`;
+ }
+ if (mapColumns.includes(key)) {
+ // Map columns always take string-key subscripts; ClickHouse's Map index
+ // operator is keyed by the Map's key type, not by integer position. A
+ // numeric-looking sub-key like `"1"` on a Map(String, ...) must still
+ // render as `Map['1']`.
+ return `${key}${rest.map(v => `['${v}']`).join('')}`;
+ }
+ return `${key}${rest
+ .map(v => {
+ const asNumber = Number(v);
+ const isArrayIndex = Number.isInteger(asNumber) && asNumber >= 0;
+ // ClickHouse arrays are 1-based, but flattened data uses 0-based indices
+ return isArrayIndex ? `[${asNumber + 1}]` : `['${v}']`;
+ })
+ .join('')}`;
};
const _useTry = (fn: () => T): [null | Error | unknown, null | T] => {
From 3d73166b705625fc938e26896f53eaeb0681c0e2 Mon Sep 17 00:00:00 2001
From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com>
Date: Fri, 29 May 2026 02:53:20 +0000
Subject: [PATCH 2/4] chore(lint): collapse DBRowDataPanel test import to
single line
The merge-into-main resolution at cf880540 introduced the import as
three lines; prettier wants one. CI lint was failing on this single
formatting issue.
---
packages/app/src/components/__tests__/DBRowDataPanel.test.ts | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/packages/app/src/components/__tests__/DBRowDataPanel.test.ts b/packages/app/src/components/__tests__/DBRowDataPanel.test.ts
index 959bc9e018..513653646f 100644
--- a/packages/app/src/components/__tests__/DBRowDataPanel.test.ts
+++ b/packages/app/src/components/__tests__/DBRowDataPanel.test.ts
@@ -1,7 +1,4 @@
-import {
- getJSONColumnNames,
- getMapColumnNames,
-} from '../DBRowDataPanel';
+import { getJSONColumnNames, getMapColumnNames } from '../DBRowDataPanel';
describe('DBRowDataPanel', () => {
describe('getJSONColumnNames', () => {
From a584d1812f255435b0b3b3e5908acfd613a124fe Mon Sep 17 00:00:00 2001
From: Alex Fedotyev
Date: Fri, 29 May 2026 04:37:18 +0000
Subject: [PATCH 3/4] fix(row-panel): address review feedback on mergePath Map
handling
Thread mapColumns into toClickHouseKeyExpression so dot-form filterState
keys with numeric-looking sub-keys (e.g. LogAttributes.1) render as
LogAttributes['1'] on the Load-more SQL path. parseMapFieldName already
guarantees the base column is a Map, so the third mergePath argument is
just the parsed baseName.
Escape backslash and single-quote inside Map and string-key subscripts
in mergePath. Keys carry user-controlled text; an unescaped quote
produces malformed SQL like Map['it's'].
Switch the Map-column derivation in useAutoCompleteOptions from raw
type-string matching to the canonical jsType === JSDataType.Map check.
convertCHDataTypeToJSType unwraps LowCardinality(...) and Nullable(...)
wrappers before classifying, so the new check covers wrapped Map types
that the raw pattern was missing. Extract deriveMapColumnsFromFields as
an exported helper so a regression here trips a unit test.
Annotate the mergePath call in buildJSONExtractQuery with an invariant
comment: the helper only runs under a parsed-JSON root, so mapColumns is
intentionally omitted. Future callers that point it at a Map column will
need to thread mapColumns through.
Tests:
- New mergePath SQL-escaping cases (single quote, backslash, both).
- New toClickHouseKeyExpression cases for numeric-looking Map sub-keys.
- New deriveMapColumnsFromFields cases covering wrapped Map types,
nested fields, non-Map columns, and empty input.
---
packages/app/src/__tests__/utils.test.ts | 37 +++++++++
.../app/src/components/DBRowJsonViewer.tsx | 5 ++
.../DBSearchPageFilters/utils.test.ts | 17 ++++
.../components/DBSearchPageFilters/utils.ts | 12 ++-
.../__tests__/useAutoCompleteOptions.test.tsx | 81 ++++++++++++++++++-
.../app/src/hooks/useAutoCompleteOptions.tsx | 28 +++++--
packages/app/src/utils.ts | 13 ++-
7 files changed, 180 insertions(+), 13 deletions(-)
diff --git a/packages/app/src/__tests__/utils.test.ts b/packages/app/src/__tests__/utils.test.ts
index 7e83f12af1..6288bea30a 100644
--- a/packages/app/src/__tests__/utils.test.ts
+++ b/packages/app/src/__tests__/utils.test.ts
@@ -1277,6 +1277,43 @@ describe('mergePath', () => {
expect(mergePath(['Body', '1'], ['Body'], ['Body'])).toBe('Body.`1`');
});
});
+
+ describe('SQL escaping of single quotes and backslashes', () => {
+ // Keys can contain user-controlled characters (Map sub-keys carry
+ // arbitrary text). An unescaped single quote produces malformed SQL like
+ // `Map['it's']`, which ClickHouse parses as the broken token sequence
+ // `Map['it']s']`. Backslash must escape first so the quote-escape
+ // backslash is not itself doubled.
+ it('escapes single quotes in Map sub-keys', () => {
+ expect(mergePath(['LogAttributes', "it's"], [], ['LogAttributes'])).toBe(
+ "LogAttributes['it\\'s']",
+ );
+ });
+
+ it('escapes backslashes in Map sub-keys', () => {
+ expect(
+ mergePath(['LogAttributes', 'back\\slash'], [], ['LogAttributes']),
+ ).toBe("LogAttributes['back\\\\slash']");
+ });
+
+ it('escapes a key containing both a backslash and a quote', () => {
+ expect(
+ mergePath(['LogAttributes', "a\\b'c"], [], ['LogAttributes']),
+ ).toBe("LogAttributes['a\\\\b\\'c']");
+ });
+
+ it('escapes single quotes in default-branch string subscripts', () => {
+ // The default Array / unknown column branch also takes string-key
+ // subscripts when the segment is non-numeric. Same escape applies.
+ expect(mergePath(['SomeColumn', "it's"])).toBe("SomeColumn['it\\'s']");
+ });
+
+ it('leaves numeric segments untouched in the default branch', () => {
+ // Numeric path collapses to bracketed integer index; escape is a
+ // no-op because Number.isInteger(asNumber) succeeds. Sanity check.
+ expect(mergePath(['SomeArray', '0'])).toBe('SomeArray[1]');
+ });
+ });
});
describe('getColorFromCSSToken', () => {
diff --git a/packages/app/src/components/DBRowJsonViewer.tsx b/packages/app/src/components/DBRowJsonViewer.tsx
index 385d72c549..b0296df2ea 100644
--- a/packages/app/src/components/DBRowJsonViewer.tsx
+++ b/packages/app/src/components/DBRowJsonViewer.tsx
@@ -51,6 +51,11 @@ export function buildJSONExtractQuery(
return null; // No nested path to extract
}
+ // The mergePath call here intentionally omits `mapColumns`. This helper
+ // only runs under a parsed-JSON root (callers gate on `isInParsedJson &&
+ // parsedJsonRootPath`), so `parsedJsonRootPath[0]` is always a JSON column.
+ // A future caller that points this at a Map column would need to thread
+ // mapColumns through; see HDX-4369.
const baseColumn = mergePath(parsedJsonRootPath, jsonColumns);
const jsonPathArgs = nestedPath.map(p => `'${p}'`).join(', ');
return `${jsonExtractFn}(${baseColumn}, ${jsonPathArgs})`;
diff --git a/packages/app/src/components/DBSearchPageFilters/utils.test.ts b/packages/app/src/components/DBSearchPageFilters/utils.test.ts
index c851f8d026..56f07094f7 100644
--- a/packages/app/src/components/DBSearchPageFilters/utils.test.ts
+++ b/packages/app/src/components/DBSearchPageFilters/utils.test.ts
@@ -141,4 +141,21 @@ describe('toClickHouseKeyExpression', () => {
it('leaves plain column names unchanged', () => {
expect(toClickHouseKeyExpression('Timestamp')).toBe('Timestamp');
});
+
+ // HDX-4369: parseMapFieldName proves the base is a Map, so a numeric-
+ // looking sub-key must NOT collapse into array-index syntax. Without
+ // mergePath's third argument the result was `LogAttributes[2]`, which
+ // ClickHouse rejects with "Illegal types of arguments: Map(String,
+ // String), UInt8 for function arrayElement" on the "Load more" path.
+ it('rewrites a numeric-looking map sub-key to bracket form', () => {
+ expect(toClickHouseKeyExpression('LogAttributes.1')).toBe(
+ "LogAttributes['1']",
+ );
+ });
+
+ it('preserves a multi-segment property path that starts with a numeric segment', () => {
+ expect(toClickHouseKeyExpression('LogAttributes.42.foo')).toBe(
+ "LogAttributes['42.foo']",
+ );
+ });
});
diff --git a/packages/app/src/components/DBSearchPageFilters/utils.ts b/packages/app/src/components/DBSearchPageFilters/utils.ts
index 7ccdd44874..c8558c8166 100644
--- a/packages/app/src/components/DBSearchPageFilters/utils.ts
+++ b/packages/app/src/components/DBSearchPageFilters/utils.ts
@@ -150,6 +150,12 @@ export function getFilterStateEntry(
// caller (e.g. "Load more" via metadata.getKeyValues), since `setFilterValue`
// normalizes Map sub-keys to dot form which ClickHouse cannot resolve as map
// access.
+//
+// `parseMapFieldName` already guarantees `parsed.baseName` is a Map (its only
+// callers are the dot-form facet keys that originate from Map columns), so
+// `mergePath` must treat it as one. Without the third argument, a numeric-
+// looking sub-key like `LogAttributes.1` collapses into the Array branch and
+// emits the illegal `LogAttributes[2]`. HDX-4369.
export function toClickHouseKeyExpression(key: string): string {
if (
key.includes("['") ||
@@ -161,5 +167,9 @@ export function toClickHouseKeyExpression(key: string): string {
}
const parsed = parseMapFieldName(key);
if (!parsed) return key;
- return mergePath([parsed.baseName, parsed.propertyPath]);
+ return mergePath(
+ [parsed.baseName, parsed.propertyPath],
+ [],
+ [parsed.baseName],
+ );
}
diff --git a/packages/app/src/hooks/__tests__/useAutoCompleteOptions.test.tsx b/packages/app/src/hooks/__tests__/useAutoCompleteOptions.test.tsx
index 67b88ac86b..ce8cebe785 100644
--- a/packages/app/src/hooks/__tests__/useAutoCompleteOptions.test.tsx
+++ b/packages/app/src/hooks/__tests__/useAutoCompleteOptions.test.tsx
@@ -3,8 +3,11 @@ import { Field } from '@hyperdx/common-utils/dist/core/metadata';
import { renderHook } from '@testing-library/react';
import { LuceneLanguageFormatter } from '../../components/SearchInput/SearchInputV2';
-import { useAutoCompleteOptions } from '../useAutoCompleteOptions';
-import { tokenizeAtCursor } from '../useAutoCompleteOptions';
+import {
+ deriveMapColumnsFromFields,
+ tokenizeAtCursor,
+ useAutoCompleteOptions,
+} from '../useAutoCompleteOptions';
import { useGetKeyValues, useMultipleAllFields } from '../useMetadata';
// Mock dependencies
@@ -364,3 +367,77 @@ describe('tokenizeAtCursor', () => {
});
});
});
+
+// HDX-4369: pins the threading from "field list" -> "mapColumns" inside
+// useAutoCompleteOptions. The hook uses the derived array as the third
+// argument to mergePath when computing `searchKeys`, so a regression here
+// silently re-introduces the illegal `Map[N+1]` SQL.
+describe('deriveMapColumnsFromFields', () => {
+ it('returns top-level Map column names', () => {
+ const fields: Field[] = [
+ { path: ['LogAttributes'], jsType: JSDataType.Map, type: 'map' },
+ { path: ['ResourceAttributes'], jsType: JSDataType.Map, type: 'map' },
+ {
+ path: ['ServiceName'],
+ jsType: JSDataType.String,
+ type: 'String',
+ },
+ ];
+ expect(deriveMapColumnsFromFields(fields)).toEqual([
+ 'LogAttributes',
+ 'ResourceAttributes',
+ ]);
+ });
+
+ it('matches wrapped Map types via the canonical jsType', () => {
+ // convertCHDataTypeToJSType peels off LowCardinality(...) and
+ // Nullable(...) before classifying, so jsType is the canonical signal.
+ // A raw-string check on f.type would miss these wrappers and silently
+ // fall through to the array-index path in mergePath.
+ const fields: Field[] = [
+ {
+ path: ['LowCardMap'],
+ jsType: JSDataType.Map,
+ type: 'LowCardinality(Map(String, String))',
+ },
+ {
+ path: ['NullableMap'],
+ jsType: JSDataType.Map,
+ type: 'Nullable(Map(String, UInt8))',
+ },
+ ];
+ expect(deriveMapColumnsFromFields(fields)).toEqual([
+ 'LowCardMap',
+ 'NullableMap',
+ ]);
+ });
+
+ it('excludes nested fields (path.length > 1)', () => {
+ // Sub-keys under a Map (e.g. ResourceAttributes.service.name) are not
+ // themselves Map-typed parents; including them would change mergePath's
+ // semantics for the outer column.
+ const fields: Field[] = [
+ { path: ['ResourceAttributes'], jsType: JSDataType.Map, type: 'Map' },
+ {
+ path: ['ResourceAttributes', 'service.name'],
+ jsType: JSDataType.String,
+ type: 'String',
+ },
+ ];
+ expect(deriveMapColumnsFromFields(fields)).toEqual(['ResourceAttributes']);
+ });
+
+ it('excludes non-Map columns even when path.length === 1', () => {
+ const fields: Field[] = [
+ { path: ['BodyJson'], jsType: JSDataType.JSON, type: 'JSON' },
+ { path: ['Timestamp'], jsType: JSDataType.Date, type: 'DateTime64(9)' },
+ { path: ['Body'], jsType: JSDataType.String, type: 'String' },
+ ];
+ expect(deriveMapColumnsFromFields(fields)).toEqual([]);
+ });
+
+ it('handles undefined and empty inputs without throwing', () => {
+ expect(deriveMapColumnsFromFields(undefined)).toEqual([]);
+ expect(deriveMapColumnsFromFields([])).toEqual([]);
+ });
+});
diff --git a/packages/app/src/hooks/useAutoCompleteOptions.tsx b/packages/app/src/hooks/useAutoCompleteOptions.tsx
index 08dbbf7208..0dbde75e0e 100644
--- a/packages/app/src/hooks/useAutoCompleteOptions.tsx
+++ b/packages/app/src/hooks/useAutoCompleteOptions.tsx
@@ -1,4 +1,5 @@
import { useMemo } from 'react';
+import { JSDataType } from '@hyperdx/common-utils/dist/clickhouse';
import {
Field,
parseKeyPath,
@@ -15,6 +16,24 @@ import {
import { useSource } from '@/source';
import { mergePath, toArray, useDebounce } from '@/utils';
+// Derive top-level Map column names from a fields list. Matches on the
+// canonical `JSDataType.Map` rather than the raw ClickHouse type string so
+// wrapped Map types (e.g. `LowCardinality(Map(...))`, `Nullable(Map(...))`)
+// are detected too: `convertCHDataTypeToJSType` unwraps those wrappers before
+// classifying. Top-level only (path.length === 1) since nested Map sub-keys
+// surface as deeper path segments and are not themselves Map-typed parents.
+//
+// Exported separately so a regression in `useAutoCompleteOptions`'s Map
+// derivation trips a unit test: dropping or breaking this filter would
+// silently re-introduce the array-index emission HDX-4369 fixed.
+export function deriveMapColumnsFromFields(
+ fields: readonly Field[] | undefined,
+): string[] {
+ return (fields ?? [])
+ .filter(f => f.path.length === 1 && f.jsType === JSDataType.Map)
+ .map(f => f.path[0]);
+}
+
export type TokenInfo = {
/** The full token at the cursor position */
token: string;
@@ -238,14 +257,7 @@ export function useAutoCompleteOptions(
// on a Map(String, ...) renders as `LogAttributes['1']` instead of the
// illegal array `LogAttributes[2]`. HDX-4369.
const mapColumns = useMemo(
- () =>
- (fields ?? [])
- .filter(
- f =>
- f.path.length === 1 &&
- (f.type === 'Map' || f.type.startsWith('Map(')),
- )
- .map(f => f.path[0]),
+ () => deriveMapColumnsFromFields(fields),
[fields],
);
diff --git a/packages/app/src/utils.ts b/packages/app/src/utils.ts
index 96ad204398..8d925a60ca 100644
--- a/packages/app/src/utils.ts
+++ b/packages/app/src/utils.ts
@@ -1014,6 +1014,13 @@ export const formatUptime = (seconds: number) => {
// ClickHouse rejects the resulting `LogAttributes[2]` with
// `Illegal types of arguments: Map(String, String), UInt8 for function
// arrayElement`. HDX-4369.
+// Escape backslash and single-quote inside a key before interpolating it
+// into a single-quoted SQL string. Keys can contain user-controlled
+// characters (Map sub-keys, JSON field names from the row data) and an
+// unescaped quote produces malformed SQL.
+const escapeSqlSingleQuoted = (v: string): string =>
+ v.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
+
export const mergePath = (
path: string[],
jsonColumns: string[] = [],
@@ -1038,14 +1045,16 @@ export const mergePath = (
// operator is keyed by the Map's key type, not by integer position. A
// numeric-looking sub-key like `"1"` on a Map(String, ...) must still
// render as `Map['1']`.
- return `${key}${rest.map(v => `['${v}']`).join('')}`;
+ return `${key}${rest.map(v => `['${escapeSqlSingleQuoted(v)}']`).join('')}`;
}
return `${key}${rest
.map(v => {
const asNumber = Number(v);
const isArrayIndex = Number.isInteger(asNumber) && asNumber >= 0;
// ClickHouse arrays are 1-based, but flattened data uses 0-based indices
- return isArrayIndex ? `[${asNumber + 1}]` : `['${v}']`;
+ return isArrayIndex
+ ? `[${asNumber + 1}]`
+ : `['${escapeSqlSingleQuoted(v)}']`;
})
.join('')}`;
};
From f4a1ac74bb67e4ef2059634b712582862d3753a1 Mon Sep 17 00:00:00 2001
From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com>
Date: Fri, 29 May 2026 14:49:46 +0000
Subject: [PATCH 4/4] fix(row-panel): thread mapColumns through
buildJSONExtractQuery
The deep-review pass on the previous revision flagged a residual leak
of HDX-4369 through the JSON-extract path. When a Map column has a
sub-value that is itself a JSON-parseable string, HyperJson promotes
the child context to `isInParsedJson=true` with
parsedJsonRootPath=[MapCol, key]. The inner mergePath call in
buildJSONExtractQuery did not receive mapColumns, so a numeric-looking
sub-key fell through the default branch and emitted Map[N+1] array
syntax. ClickHouse rejects that with the same "Illegal types of
arguments: Map(String, String), UInt8 for function arrayElement"
the row-panel "expand" path was already failing on.
Thread mapColumns through buildJSONExtractQuery so the four line
actions in the parsed-JSON arm (Add to Filters, Search, Chart,
Toggle Column) render Map['1'] instead of Map[2]. The four call sites
already had mapColumns in scope via the DBRowJsonViewer prop and the
getLineActions dep array.
Rewrote the comment on buildJSONExtractQuery to describe what
parsedJsonRootPath[0] actually is (JSON column OR Map column with a
parsed-JSON child) instead of asserting an invariant the gate does
not enforce.
Tests in DBRowJsonViewer.test.tsx:
- emits Map['1'] for Map column with numeric sub-key holding parsed JSON
- emits Map['42'] for deeply nested Map column with numeric sub-key
- keeps non-numeric Map sub-key unchanged when mapColumns is threaded
- falls back to array index when mapColumns is empty (pins the
pre-HDX-4369 default for the non-Map case, e.g. an Array(JSON))
---
.../src/components/DBRowJsonViewer.test.tsx | 52 +++++++++++++++++++
.../app/src/components/DBRowJsonViewer.tsx | 21 +++++---
2 files changed, 67 insertions(+), 6 deletions(-)
diff --git a/packages/app/src/components/DBRowJsonViewer.test.tsx b/packages/app/src/components/DBRowJsonViewer.test.tsx
index e889a6df69..c6e1c23c45 100644
--- a/packages/app/src/components/DBRowJsonViewer.test.tsx
+++ b/packages/app/src/components/DBRowJsonViewer.test.tsx
@@ -292,5 +292,57 @@ describe('DBRowJsonViewer', () => {
),
).toBe("JSONExtractString(LogAttributes.`config`, 'host')");
});
+
+ // HDX-4369. HyperJson promotes a Map sub-value that is itself a
+ // JSON-parseable string to `isInParsedJson=true` with
+ // parsedJsonRootPath=[MapCol, key] (see HyperJson.tsx:227-234). When that
+ // key is numeric, the inner `mergePath` used to emit `MapCol[N+1]` array
+ // syntax, which ClickHouse rejects with "Illegal types of arguments:
+ // Map(String, String), UInt8 for function arrayElement". Threading
+ // `mapColumns` keeps the Map[\'1\'] subscript.
+ it("emits Map['1'] for Map column with numeric sub-key holding parsed JSON", () => {
+ expect(
+ buildJSONExtractQuery(
+ ['LogAttributes', '1', 'foo'],
+ ['LogAttributes', '1'],
+ [], // jsonColumns
+ 'JSONExtractString',
+ ['LogAttributes'], // mapColumns
+ ),
+ ).toBe("JSONExtractString(LogAttributes['1'], 'foo')");
+ });
+
+ it("emits Map['42'] for deeply nested Map column with numeric sub-key holding parsed JSON", () => {
+ expect(
+ buildJSONExtractQuery(
+ ['LogAttributes', '42', 'bar', 'baz'],
+ ['LogAttributes', '42'],
+ [],
+ 'JSONExtractString',
+ ['LogAttributes'],
+ ),
+ ).toBe("JSONExtractString(LogAttributes['42'], 'bar', 'baz')");
+ });
+
+ it('keeps non-numeric Map sub-key unchanged when mapColumns is threaded', () => {
+ expect(
+ buildJSONExtractQuery(
+ ['LogAttributes', 'config', 'host'],
+ ['LogAttributes', 'config'],
+ [],
+ 'JSONExtractString',
+ ['LogAttributes'],
+ ),
+ ).toBe("JSONExtractString(LogAttributes['config'], 'host')");
+ });
+
+ it('falls back to array index when mapColumns is empty (unchanged behavior)', () => {
+ // Without mapColumns, a numeric segment still gets the array-index
+ // treatment. This pins the pre-HDX-4369 default for the non-Map case
+ // (e.g. an Array(JSON) column whose element holds a parsed JSON value).
+ expect(
+ buildJSONExtractQuery(['SomeArray', '0', 'id'], ['SomeArray', '0']),
+ ).toBe("JSONExtractString(SomeArray[1], 'id')");
+ });
});
});
diff --git a/packages/app/src/components/DBRowJsonViewer.tsx b/packages/app/src/components/DBRowJsonViewer.tsx
index b0296df2ea..df856023a8 100644
--- a/packages/app/src/components/DBRowJsonViewer.tsx
+++ b/packages/app/src/components/DBRowJsonViewer.tsx
@@ -45,18 +45,20 @@ export function buildJSONExtractQuery(
parsedJsonRootPath: string[],
jsonColumns: string[] = [],
jsonExtractFn: JSONExtractFn = 'JSONExtractString',
+ mapColumns: string[] = [],
): string | null {
const nestedPath = keyPath.slice(parsedJsonRootPath.length);
if (nestedPath.length === 0) {
return null; // No nested path to extract
}
- // The mergePath call here intentionally omits `mapColumns`. This helper
- // only runs under a parsed-JSON root (callers gate on `isInParsedJson &&
- // parsedJsonRootPath`), so `parsedJsonRootPath[0]` is always a JSON column.
- // A future caller that points this at a Map column would need to thread
- // mapColumns through; see HDX-4369.
- const baseColumn = mergePath(parsedJsonRootPath, jsonColumns);
+ // `parsedJsonRootPath[0]` is the column the parsed-JSON view is anchored on.
+ // It can be a JSON column (auto-detected by ClickHouse JSON type) OR a Map
+ // column whose sub-value is a JSON-parseable string (HyperJson promotes those
+ // to `isInParsedJson=true`, see HyperJson.tsx:227). Thread `mapColumns` so a
+ // numeric-looking Map sub-key renders as `Map['1']` instead of the array
+ // `Map[2]`. See HDX-4369.
+ const baseColumn = mergePath(parsedJsonRootPath, jsonColumns, mapColumns);
const jsonPathArgs = nestedPath.map(p => `'${p}'`).join(', ');
return `${jsonExtractFn}(${baseColumn}, ${jsonPathArgs})`;
}
@@ -408,6 +410,8 @@ export function DBRowJsonViewer({
keyPath,
parsedJsonRootPath,
jsonColumns,
+ 'JSONExtractString',
+ mapColumns,
);
if (jsonQuery) {
filterFieldPath = jsonQuery;
@@ -452,6 +456,7 @@ export function DBRowJsonViewer({
parsedJsonRootPath,
jsonColumns,
jsonExtractFn,
+ mapColumns,
);
if (jsonQuery) {
@@ -495,6 +500,8 @@ export function DBRowJsonViewer({
keyPath,
parsedJsonRootPath,
jsonColumns,
+ 'JSONExtractString',
+ mapColumns,
);
if (jsonQuery) {
chartFieldPath = jsonQuery;
@@ -522,6 +529,8 @@ export function DBRowJsonViewer({
keyPath,
parsedJsonRootPath,
jsonColumns,
+ 'JSONExtractString',
+ mapColumns,
);
if (jsonQuery) {
columnFieldPath = jsonQuery;