diff --git a/.changeset/thin-pets-relate.md b/.changeset/thin-pets-relate.md new file mode 100644 index 0000000000..71fe2ca5b1 --- /dev/null +++ b/.changeset/thin-pets-relate.md @@ -0,0 +1,6 @@ +--- +"@hyperdx/common-utils": patch +"@hyperdx/app": patch +--- + +fix: make filters aware of map column return arrayElement diff --git a/packages/app/src/components/DBRowTable.tsx b/packages/app/src/components/DBRowTable.tsx index 9d2c8c31e6..53779e8d1d 100644 --- a/packages/app/src/components/DBRowTable.tsx +++ b/packages/app/src/components/DBRowTable.tsx @@ -29,6 +29,7 @@ import { isJSDataTypeJSONStringifiable, JSDataType, } from '@hyperdx/common-utils/dist/clickhouse'; +import { parseKeyPath } from '@hyperdx/common-utils/dist/core/metadata'; import { splitAndTrimWithBracket } from '@hyperdx/common-utils/dist/core/utils'; import { BuilderChartConfigWithDateRange, @@ -1508,6 +1509,18 @@ function selectColumnMapWithoutAdditionalKeys( ); } +export function addMapAliasesToSelect(select: string): string { + const selects = splitAndTrimWithBracket(select); + for (let i = 0; i < selects.length; i++) { + const key = selects[i]; + const path = parseKeyPath(key); + if (path.length === 2) { + selects[i] = `${key} as "${path[0]}['${path[1]}']"`; + } + } + return selects.join(','); +} + export type DBRowTableVariant = 'default' | 'muted'; function DBSqlRowTableComponent({ @@ -1602,6 +1615,9 @@ function DBSqlRowTableComponent({ ...searchChartConfigDefaults(me?.team), ...config, }; + if (typeof base.select === 'string') { + base.select = addMapAliasesToSelect(base.select); + } if (orderByArray.length) { base.orderBy = orderByArray.map(o => { return { diff --git a/packages/app/src/components/__tests__/DBRowTable.test.tsx b/packages/app/src/components/__tests__/DBRowTable.test.tsx index ffb459b2f5..03e8a80db6 100644 --- a/packages/app/src/components/__tests__/DBRowTable.test.tsx +++ b/packages/app/src/components/__tests__/DBRowTable.test.tsx @@ -2,6 +2,7 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { + addMapAliasesToSelect, appendSelectWithAdditionalKeys, RawLogTable, } from '@/components/DBRowTable'; @@ -416,3 +417,90 @@ describe('appendSelectWithAdditionalKeys', () => { }); }); }); + +describe('addMapAliasesToSelect', () => { + it('leaves plain columns unchanged and collapses ", " to ","', () => { + expect(addMapAliasesToSelect('Timestamp, ServiceName, SeverityText')).toBe( + 'Timestamp,ServiceName,SeverityText', + ); + }); + + it('aliases a single-quoted bracket-form map key', () => { + expect(addMapAliasesToSelect("ResourceAttributes['service.name']")).toBe( + `ResourceAttributes['service.name'] as "ResourceAttributes['service.name']"`, + ); + }); + + it('aliases only the map keys in a mixed multi-column input', () => { + expect( + addMapAliasesToSelect( + "Timestamp, ResourceAttributes['service.name'], SeverityText", + ), + ).toBe( + `Timestamp,ResourceAttributes['service.name'] as "ResourceAttributes['service.name']",SeverityText`, + ); + }); + + it('preserves function-call expressions whose arguments contain commas', () => { + expect( + addMapAliasesToSelect( + "Timestamp, toDateTime(now64(3, 'UTC')), ServiceName", + ), + ).toBe(`Timestamp,toDateTime(now64(3, 'UTC')),ServiceName`); + }); + + it('aliases an arrayElement(Col, key) Map subscript', () => { + expect( + addMapAliasesToSelect("arrayElement(ResourceAttributes, 'service.name')"), + ).toBe( + `arrayElement(ResourceAttributes, 'service.name') as "ResourceAttributes['service.name']"`, + ); + }); + + it('passes already-aliased plain columns through unchanged', () => { + expect(addMapAliasesToSelect('Timestamp as ts, ServiceName as svc')).toBe( + 'Timestamp as ts,ServiceName as svc', + ); + }); + + it('does NOT re-alias a bracket-form map subscript that already carries an "as " clause', () => { + expect( + addMapAliasesToSelect("ResourceAttributes['service.name'] as svc"), + ).toBe("ResourceAttributes['service.name'] as svc"); + }); + + it('does NOT re-alias an already-aliased map subscript within a multi-column input', () => { + expect( + addMapAliasesToSelect( + "Timestamp, ResourceAttributes['service.name'] as svc, SeverityText", + ), + ).toBe("Timestamp,ResourceAttributes['service.name'] as svc,SeverityText"); + }); + + it('does NOT alias unbracketed dot-notation tokens', () => { + expect(addMapAliasesToSelect('user_data.name')).toBe('user_data.name'); + expect(addMapAliasesToSelect('user_data.address.city')).toBe( + 'user_data.address.city', + ); + }); + + it('does NOT alias multi-segment dotted native columns', () => { + expect(addMapAliasesToSelect('SpanAttributes.k8s.resource.name')).toBe( + 'SpanAttributes.k8s.resource.name', + ); + }); + + it('does NOT alias toString(x).y attribute-access expressions', () => { + expect(addMapAliasesToSelect('toString(SpanAttributes).k8s')).toBe( + 'toString(SpanAttributes).k8s', + ); + }); + + it('does NOT alias backtick-quoted dotted JSON paths', () => { + expect(addMapAliasesToSelect('`host`.`arch`')).toBe('`host`.`arch`'); + }); + + it('returns an empty string for empty input', () => { + expect(addMapAliasesToSelect('')).toBe(''); + }); +}); diff --git a/packages/common-utils/src/__tests__/metadata.test.ts b/packages/common-utils/src/__tests__/metadata.test.ts index bbd40781ef..083c9005a2 100644 --- a/packages/common-utils/src/__tests__/metadata.test.ts +++ b/packages/common-utils/src/__tests__/metadata.test.ts @@ -1471,4 +1471,28 @@ describe('parseKeyPath', () => { "ResourceAttributes['service.name", ]); }); + + it('parses single-quoted arrayElement function-call form', () => { + expect( + parseKeyPath("arrayElement(ResourceAttributes, 'service.name')"), + ).toEqual(['ResourceAttributes', 'service.name']); + }); + + it('parses arrayElement keys with dots in the map key', () => { + expect( + parseKeyPath("arrayElement(SpanAttributes, 'http.request.method')"), + ).toEqual(['SpanAttributes', 'http.request.method']); + }); + + it('tolerates whitespace inside arrayElement', () => { + expect( + parseKeyPath("arrayElement( ResourceAttributes , 'service.name' )"), + ).toEqual(['ResourceAttributes', 'service.name']); + }); + + it('does not parse arrayElement with a missing closing paren', () => { + expect( + parseKeyPath("arrayElement(ResourceAttributes, 'service.name'"), + ).toEqual(["arrayElement(ResourceAttributes, 'service.name'"]); + }); }); diff --git a/packages/common-utils/src/core/metadata.ts b/packages/common-utils/src/core/metadata.ts index a80feb1fad..59688cef15 100644 --- a/packages/common-utils/src/core/metadata.ts +++ b/packages/common-utils/src/core/metadata.ts @@ -1768,9 +1768,17 @@ export type Field = { }; /** - * Parses a bracket-notation key string into a path array. - * e.g. `ResourceAttributes['service.name']` → `['ResourceAttributes', 'service.name']` - * `ServiceName` → `['ServiceName']` + * Parses a Map-access key string into a path array. + * + * Handles both ClickHouse Map access forms: + * - Bracket form (canonical input): + * `ResourceAttributes['service.name']` → `['ResourceAttributes', 'service.name']` + * `ResourceAttributes["service.name"]` → `['ResourceAttributes', 'service.name']` + * - Function-call form (what ClickHouse renders in result column names): + * `arrayElement(ResourceAttributes, 'service.name')` → `['ResourceAttributes', 'service.name']` + * + * Native columns return a single-element path: + * `ServiceName` → `['ServiceName']` */ export function parseKeyPath(key: string): string[] { const singleIdx = key.indexOf("['"); @@ -1781,6 +1789,15 @@ export function parseKeyPath(key: string): string[] { if (doubleIdx !== -1 && key.endsWith('"]')) { return [key.slice(0, doubleIdx), key.slice(doubleIdx + 2, -2)]; } + // ClickHouse Map access function-call form. ClickHouse renders Map + // subscript expressions (e.g. `SpanAttributes['http.route']`) as + // `arrayElement(SpanAttributes, 'http.route')` in result column names. + const ARRAY_ELEMENT_PREFIX = 'arrayElement('; + if (key.startsWith(ARRAY_ELEMENT_PREFIX) && key.endsWith(')')) { + const inner = key.slice(ARRAY_ELEMENT_PREFIX.length, -1); + const parts = inner.split(',').map(e => e.trim()); + return [parts[0], parts[1].replaceAll("'", '')]; + } return [key]; }