Skip to content
14 changes: 14 additions & 0 deletions .changeset/map-attribute-column-alias.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@hyperdx/app': patch
---

fix: label Map attribute columns added from the search sidebars by their key

Adding a Map attribute such as `ResourceAttributes['service.name']` as a column
from the filters sidebar or the row side panel now writes it into the SELECT as
`ResourceAttributes['service.name'] AS "service.name"`, so the results column
reads `service.name` instead of
`arrayElement(ResourceAttributes, 'service.name')`. The alias is visible and
editable in the SELECT, and queries typed by hand are not changed. A key named
like a table column, or one already used as a name in the SELECT, is added
without an alias.
38 changes: 33 additions & 5 deletions packages/app/src/DBSearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,12 @@ import {
} from '@/timeQuery';
import {
formatDurationMs,
orderByAfterRemovingSelectItem,
QUERY_LOCAL_STORAGE,
selectItemExpression,
useLocalStorage,
usePrevious,
withMapKeyAlias,
} from '@/utils';

import ChartSQLPreview, { SQLPreview } from './components/ChartSQLPreview';
Expand Down Expand Up @@ -1104,7 +1107,7 @@ export function DBSearchPage() {
[sources, lastSelectedSourceId],
);

const { control, setValue, reset, handleSubmit, formState } =
const { control, setValue, getValues, reset, handleSubmit, formState } =
useForm<SearchConfigFromSchema>({
values: {
select: searchedConfig.select || '',
Expand Down Expand Up @@ -1737,13 +1740,38 @@ export function DBSearchPage() {

const toggleColumn = useCallback(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — The new toggleColumn wiring has no test

The helpers are unit-tested, but nothing checks toggleColumn itself. No test removes an aliased item when it is given the bare expression, which is what the sidebar, side panel and header all send, or resets the form's orderBy to defaultSearchConfig.orderBy. Add a DBSearchPage test that toggles ResourceAttributes['service.name'] on, sorts by "service.name", toggles it off, and asserts the resulting select and orderBy.

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

(column: string) => {
const newSelectArray = displayedColumns.includes(column)
? displayedColumns.filter(s => s !== column)
: [...displayedColumns, column];
// A column added from the UI can carry an alias, so match the expression too
const selected = displayedColumns.find(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — toggleColumn repeats the alias-aware match that isColumnInSelect already does

Add a findSelectItem(displayedColumns, column) helper to packages/app/src/utils.ts that returns the matching item, build isColumnInSelect on top of it, and call it here. As written, the "is it shown?" check in the sidebars (isColumnInSelect) and the "which item do I remove?" check here are two copies of s === column || selectItemExpression(s) === column. If one copy changes, a sidebar button can read "Remove" while clicking it adds a duplicate column.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — toggleColumn re-implements the isColumnInSelect match inline

The s === column || selectItemExpression(s) === column check is the same predicate as the new isColumnInSelect in utils.ts. Add a findColumnInSelect helper in utils.ts and have both toggleColumn and isColumnInSelect use it, so the button state and the add/remove action can't drift apart.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — toggleColumn copies the matching rule that isColumnInSelect already implements

The s === column || selectItemExpression(s) === column rule now exists in two places: here and in isColumnInSelect (utils.ts). Move it into one exported finder, e.g. findSelectItem(displayedColumns, column), which returns the item. Build isColumnInSelect on top of it and use it here, so the side panel and sidebar "is shown" check can never disagree with what toggleColumn removes.

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

s => s === column || selectItemExpression(s) === column,
);
const newSelectArray = selected
? displayedColumns.filter(s => s !== selected)
: [
...displayedColumns,
withMapKeyAlias(column, displayedColumns, knownColumns),
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 major — Sorting an aliased Map column, then removing it, leaves a dangling ORDER BY "key" and breaks the query

Reachable with UI clicks only: add ResourceAttributes['service.name'] from the sidebar (SELECT becomes … AS "service.name"), click that column header to sort, then click the header's remove button. DBRowTable.tsx:610 gives an aliased column the id "service.name" (because aliasMap now has that key), onSortingChange (DBSearchPage.tsx:1943) persists orderBy: '"service.name" DESC' into the searched config/URL, and the removal branch here only rewrites select — so the next query is SELECT Timestamp, … FROM … ORDER BY "service.name" DESC with no such alias or column, and ClickHouse fails with Unknown identifier. Before this change the same column was sorted by its derived name arrayElement(ResourceAttributes, 'service.name'), a self-contained expression that survived removal. Fix in the removal branch: when the item being dropped carries an alias, clear the sort if searchedConfig.orderBy references that alias (fall back to defaultSearchConfig.orderBy) — or make DBRowTable's sort id the underlying expression (aliasMap[column]) instead of the quoted alias.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. The form's orderBy is bound to searchedConfig, so removing the column resubmitted "service.name" DESC. Fixed in cdc3047: removing a SELECT item whose alias the current sort is on resets the sort to the source default (orderByAfterRemovingSelectItem, with tests). It also covers a hand-typed alias removed from the header.

];
setValue('select', newSelectArray.join(', '));
if (selected) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — The new toggleColumn logic (removing an aliased item, resetting the sort) has no test

Add a DBSearchPage or DBRowJsonViewer test that adds a Map subscript, checks the SELECT gets AS "key", sorts by "key", then toggles the column again. It should assert the aliased item is removed and orderBy goes back to the default. The unit tests in utils.test.ts only cover the helpers, so they would still pass if toggleColumn used an exact === match or skipped orderByAfterRemovingSelectItem.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — New toggleColumn orderBy reset and aliasing have no test

The tests only cover the pure helpers. Nothing checks that removing an aliased column that is currently sorted resets the orderBy form value to defaultSearchConfig.orderBy, or that adding a column writes the aliased item into select. A wrong getValues/setValue wiring would still pass. Add a DBSearchPage/toggleColumn test for both paths.

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

const orderBy = getValues('orderBy') ?? '';
const nextOrderBy = orderByAfterRemovingSelectItem(
selected,
orderBy,
defaultSearchConfig.orderBy ?? '',
);
if (nextOrderBy !== orderBy) {
setValue('orderBy', nextOrderBy);
}
}
onSubmit();
},
[displayedColumns, setValue, onSubmit],
[
displayedColumns,
knownColumns,
setValue,
getValues,
defaultSearchConfig.orderBy,
onSubmit,
],
);

const generateSearchUrl = useCallback(
Expand Down
115 changes: 115 additions & 0 deletions packages/app/src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,17 @@ import {
getAllMetricTables,
getColorFromCSSToken,
getMetricTableName,
isColumnInSelect,
mapKeyBy,
mergePath,
orderByAfterRemovingSelectItem,
orderByStringToSortingState,
parseTimestampToMs,
resolveConditionalColor,
sortingStateToOrderByString,
stripTrailingSlash,
useQueryHistory,
withMapKeyAlias,
} from '@/utils';

describe('formatAttributeClause', () => {
Expand Down Expand Up @@ -1420,6 +1423,118 @@ describe('mergePath', () => {
});
});

describe('withMapKeyAlias', () => {
const noColumns = new Set(['Timestamp', 'Body']);

it('labels a Map subscript with its key', () => {
expect(
withMapKeyAlias("ResourceAttributes['service.name']", [], noColumns),
).toBe(`ResourceAttributes['service.name'] AS "service.name"`);
expect(withMapKeyAlias("`LogAttributes`['k8s.pod']", [], noColumns)).toBe(
'`LogAttributes`[\'k8s.pod\'] AS "k8s.pod"',
);
});

it('unescapes the key the way mergePath escaped it', () => {
expect(withMapKeyAlias("LogAttributes['it\\'s']", [], noColumns)).toBe(
`LogAttributes['it\\'s'] AS "it's"`,
);
});

it('leaves anything that is not a bare Map subscript alone', () => {
[
'Body',
'lower(Body)',
"LogAttributes['a']['b']",
"JSONExtractString(Body, 'a')",
"LogAttributes['a'] AS a",
].forEach(column => {
expect(withMapKeyAlias(column, [], noColumns)).toBe(column);
});
});

it('keeps the derived name when the alias could change the query', () => {
// An alias shadows a column of the same name in WHERE and ORDER BY
expect(
withMapKeyAlias(
"ResourceAttributes['ServiceName']",
[],
new Set(['ServiceName']),
),
).toBe("ResourceAttributes['ServiceName']");
// Two selected columns cannot share a name
expect(
withMapKeyAlias(
"LogAttributes['http.method']",
[`SpanAttributes['http.method'] AS "http.method"`],
noColumns,
),
).toBe("LogAttributes['http.method']");
// Keys that would need escaping inside the quoted alias
expect(withMapKeyAlias(`LogAttributes['a"b']`, [], noColumns)).toBe(
`LogAttributes['a"b']`,
);
// Table columns not loaded yet: a clash with one cannot be ruled out
expect(
withMapKeyAlias("ResourceAttributes['ServiceName']", [], new Set()),
).toBe("ResourceAttributes['ServiceName']");
});
});

describe('isColumnInSelect', () => {
it('matches a column whether or not it was aliased', () => {
const select = [
'Timestamp',
`ResourceAttributes['service.name'] AS "service.name"`,
];
expect(isColumnInSelect(select, 'Timestamp')).toBe(true);
expect(isColumnInSelect(select, "ResourceAttributes['service.name']")).toBe(
true,
);
expect(isColumnInSelect(select, 'Body')).toBe(false);
expect(isColumnInSelect(undefined, 'Body')).toBe(false);
});

it('matches a hand-typed alias too', () => {
expect(isColumnInSelect(['Body AS b'], 'Body')).toBe(true);
expect(isColumnInSelect(['lower(Body) AS `b`'], 'lower(Body)')).toBe(true);
});

it('does not mistake a cast for an alias', () => {
expect(isColumnInSelect(['CAST(x AS String)'], 'CAST(x')).toBe(false);
});
});

describe('orderByAfterRemovingSelectItem', () => {
const item = `ResourceAttributes['service.name'] AS "service.name"`;

it('drops a sort on the alias of the removed column', () => {
expect(
orderByAfterRemovingSelectItem(
item,
'"service.name" DESC',
'Timestamp DESC',
),
).toBe('Timestamp DESC');
expect(orderByAfterRemovingSelectItem(item, '"service.name" ASC', '')).toBe(
'',
);
});

it('keeps any other sort', () => {
expect(
orderByAfterRemovingSelectItem(item, 'Timestamp ASC', 'Timestamp DESC'),
).toBe('Timestamp ASC');
expect(orderByAfterRemovingSelectItem(item, '"http.method" DESC', '')).toBe(
'"http.method" DESC',
);
// A column without an alias sorts by its own expression, which is not affected
expect(
orderByAfterRemovingSelectItem('Body', 'Body DESC', 'Timestamp DESC'),
).toBe('Body DESC');
});
});

describe('getColorFromCSSToken', () => {
afterEach(() => {
jest.restoreAllMocks();
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/components/DBRowJsonViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import HyperJson, {
LineAction,
} from '@/components/HyperJson';
import { useFormatTime } from '@/useFormatTime';
import { mergePath } from '@/utils';
import { isColumnInSelect, mergePath } from '@/utils';
import {
CLIPBOARD_ERROR_MESSAGE,
copyTextToClipboard,
Expand Down Expand Up @@ -584,7 +584,7 @@ export function DBRowJsonViewer({
}
}

const isIncluded = displayedColumns?.includes(columnFieldPath);
const isIncluded = isColumnInSelect(displayedColumns, columnFieldPath);
actions.push({
key: 'toggle-column',
label: isIncluded ? <IconMinus size={14} /> : <IconPlus size={14} />,
Expand Down
7 changes: 5 additions & 2 deletions packages/app/src/components/DBSearchPageFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ import {
usePinnedFilters,
} from '@/searchFilters';
import { useSource } from '@/source';
import { useLocalStorage } from '@/utils';
import { isColumnInSelect, useLocalStorage } from '@/utils';

import { FilterSettingsPanel } from './DBSearchPageFilters/FilterSettingsPopover';
import { useFetchFacets } from './DBSearchPageFilters/hooks';
Expand Down Expand Up @@ -1554,7 +1554,10 @@ const DBSearchPageFiltersComponent = ({
onColumnToggle={
onColumnToggle ? () => onColumnToggle(facetSqlKey) : undefined
}
isColumnDisplayed={displayedColumns?.includes(facetSqlKey)}
isColumnDisplayed={isColumnInSelect(
displayedColumns,
facetSqlKey,
)}
onLoadMore={loadMoreFacetsForKey}
loadMoreLoading={loadMoreLoadingKeys.has(facet.key)}
hasLoadedMore={extraFacetKeys.has(facet.key)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Accordion, Group, Text, Tooltip, UnstyledButton } from '@mantine/core';
import { useVirtualizer } from '@tanstack/react-virtual';

import { FilterGroup } from '@/components/DBSearchPageFilters';
import { isColumnInSelect } from '@/utils';

import classes from '@styles/SearchPage.module.scss';

Expand Down Expand Up @@ -243,7 +244,8 @@ export const NestedFilterGroup = ({
onColumnToggle(child.sqlKey ?? child.key)
: undefined
}
isColumnDisplayed={displayedColumns?.includes(
isColumnDisplayed={isColumnInSelect(
displayedColumns,
child.sqlKey ?? child.key,
)}
onLoadMore={() => onLoadMore(child.key)}
Expand Down
92 changes: 91 additions & 1 deletion packages/app/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { formatDistanceToNowStrict } from 'date-fns';
import numbro from 'numbro';
import type { SetStateAction } from 'react';
import TimestampNano from 'timestamp-nano';
import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata';
import {
TableConnection,
unquoteIdentifier,
} from '@hyperdx/common-utils/dist/core/metadata';
import {
CATEGORICAL_PALETTE_TOKENS,
ChartPaletteToken,
Expand Down Expand Up @@ -1201,6 +1204,93 @@ export const mergePath = (
.join('')}`;
};

// A bare Map subscript as the UI builds it: `LogAttributes['key']` or `` `LogAttributes`['key'] ``
const MAP_SUBSCRIPT_REGEX =
/^(`[^`]+`|[A-Za-z_][A-Za-z0-9_]*)\['((?:[^'\\]|\\.)*)'\]$/;
// A trailing `AS alias` on a SELECT item, with the alias quoted or bare
const SELECT_ALIAS_REGEX =
/\s+AS\s+("(?:[^"\\]|\\.)*"|`[^`]*`|[A-Za-z_][A-Za-z0-9_]*)\s*$/i;

/**
* The expression of a SELECT item, without its `AS alias`
*/
export const selectItemExpression = (item: string): string =>
item.replace(SELECT_ALIAS_REGEX, '').trim();

/**
* The alias of a SELECT item, unquoted, or undefined if it has none
*/
const selectItemAlias = (item: string): string | undefined => {
const alias = SELECT_ALIAS_REGEX.exec(item)?.[1];
return alias == null ? undefined : unquoteIdentifier(alias);
};

/**
* Whether a column picked in the UI is in the SELECT list, aliased or not
*/
export const isColumnInSelect = (
displayedColumns: string[] | undefined,
column: string,
): boolean =>
displayedColumns?.some(
item => item === column || selectItemExpression(item) === column,
) ?? false;

/**
* Label a Map subscript picked in the UI with its key, so the results column
* reads `service.name` instead of `arrayElement(LogAttributes, 'service.name')`.
* The alias is left off whenever it could change the query.
*/
export const withMapKeyAlias = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Header labelling is done by rewriting persisted SQL when a presentational rename already exists

DBRowTable already takes a columnNameMap keyed by the ClickHouse result column name and uses it for the header and CSV name (DBRowTable.tsx:513, :614; PatternSidePanel.tsx:55 uses it for exactly this purpose). Deriving that map in DBSqlRowTable from columnMap/aliasMap would relabel arrayElement(ResourceAttributes, 'service.name') → service.name without touching the query, which (a) removes the need for the three skip rules and the three alias-aware isColumnInSelect call sites, (b) keeps a format out of the URL and saved searches that stays valid forever once written, and (c) also fixes hand-typed SELECTs and a source's defaultTableSelectExpression, which this approach deliberately leaves showing arrayElement(...).

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

@DevShoaib78 DevShoaib78 Sep 24, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A fair alternative, but @pulpdrew asked specifically for the alias to be written into the SELECT so it's visible and editable (#3163 (comment)), so I've kept that. Happy to switch to a presentational columnNameMap if the maintainers prefer it.

column: string,
selectItems: string[],
tableColumns: Set<string>,
): string => {
const match = MAP_SUBSCRIPT_REGEX.exec(column);
if (!match) {
return column;
}
// Inverse of escapeSqlSingleQuoted, which mergePath uses to build the subscript
const key = match[2].replace(/\\(.)/g, '$1');
const selectedNames = selectItems.map(
item => selectItemAlias(item) ?? item.trim(),
);
if (
// Nothing to label
key === '' ||
// Would need escaping inside the quoted alias
/["\\]/.test(key) ||
// Table columns not loaded yet, so a clash with one cannot be ruled out
tableColumns.size === 0 ||
// ClickHouse resolves an alias before a column of the same name
tableColumns.has(key) ||
// Two selected columns cannot share a name
selectedNames.includes(key)
) {
return column;
}
return `${column} AS "${key}"`;
};

/**
* The sort to keep once a SELECT item is removed. Sorting on an aliased column
* from the table header orders by the alias itself (`"service.name" DESC`),
* which stops resolving once the item is gone, so that falls back to the
* default sort.
*/
export const orderByAfterRemovingSelectItem = (
item: string,
orderBy: string,
defaultOrderBy: string,
): string => {
const alias = selectItemAlias(item);
if (alias == null) {
return orderBy;
}
const sortedBy = orderBy.trim().replace(/\s+(ASC|DESC)$/i, '');
return sortedBy === `"${alias}"` ? defaultOrderBy : orderBy;
};

const _useTry = <T>(fn: () => T): [null | Error | unknown, null | T] => {
let output = null;
let error = null;
Expand Down
Loading