diff --git a/.changeset/row-panel-materialized-alias-columns.md b/.changeset/row-panel-materialized-alias-columns.md new file mode 100644 index 0000000000..d14af7b81d --- /dev/null +++ b/.changeset/row-panel-materialized-alias-columns.md @@ -0,0 +1,15 @@ +--- +'@hyperdx/app': patch +--- + +feat: add an option to show MATERIALIZED and ALIAS columns in the row details panel + +ClickHouse leaves MATERIALIZED and ALIAS columns out of `SELECT *`, so the row +details panel never showed them. A new "Show materialized and alias columns" +item in the properties view options menu, off by default, adds +`asterisk_include_materialized_columns` and `asterisk_include_alias_columns` to +the row query. It has no effect on a source with a Known Columns List, and a +value that the source's query settings give for either setting wins. If the row +fails to load while the option is on (for example, because the connection's user +is `readonly = 1`, or an ALIAS column cannot be evaluated), the error state +offers to turn the option off. diff --git a/packages/app/src/components/DBRowDataPanel.tsx b/packages/app/src/components/DBRowDataPanel.tsx index 6a60cf5c0d..8540e06c5a 100644 --- a/packages/app/src/components/DBRowDataPanel.tsx +++ b/packages/app/src/components/DBRowDataPanel.tsx @@ -10,6 +10,11 @@ import { import { Box } from '@mantine/core'; import { useQueriedChartConfig } from '@/hooks/useChartConfig'; +import { + getKnownColumnsList, + getSelectAllColumnsQuerySettings, + useSelectAllColumnsQuerySettings, +} from '@/hooks/useMaterializedAliasColumnsOption'; import { WithClause } from '@/hooks/useRowWhere'; import { getDisplayedTimestampValueExpression, @@ -90,10 +95,7 @@ export function useRowData({ // target tables declare different column sets. When the source declares a // "known columns" list (columns known to exist across all target tables) we // select that instead of `*` when fetching full row data. - const knownColumns = - isLogSource(source) || isTraceSource(source) - ? source.knownColumnsListExpression?.trim() - : undefined; + const knownColumns = getKnownColumnsList(source); const baseConfig = { source: source.id, @@ -210,6 +212,8 @@ export function useRowData({ ...(aliasWith && aliasWith.length > 0 ? { with: aliasWith } : {}), }; + const additionalQuerySettings = useSelectAllColumnsQuerySettings(source); + const baseQueryKey = ['row_side_panel', rowId, aliasWith, source]; // Both halves of the filter are needed for `renderChartConfig` to emit one, so // a source with no usable timestamp expression can't be bounded at all. @@ -225,6 +229,7 @@ export function useRowData({ { queryKey: [...baseQueryKey, dateRange], enabled: rowId != null && hasWindow, + additionalQuerySettings, }, ); @@ -245,6 +250,7 @@ export function useRowData({ const fallbackResult = useQueriedChartConfig(baseConfig, { queryKey: [...baseQueryKey, undefined], enabled: rowId != null && isFallbackActive, + additionalQuerySettings, }); const queryResult = isFallbackActive ? fallbackResult : boundedResult; @@ -379,6 +385,9 @@ export function RowDataPanel({ data={firstRow} jsonColumns={jsonColumns} mapColumns={mapColumns} + showMaterializedAliasColumnsOption={ + getSelectAllColumnsQuerySettings(source) != null + } /> diff --git a/packages/app/src/components/DBRowJsonViewer.test.tsx b/packages/app/src/components/DBRowJsonViewer.test.tsx index 32ef25dca2..4fc26a7a32 100644 --- a/packages/app/src/components/DBRowJsonViewer.test.tsx +++ b/packages/app/src/components/DBRowJsonViewer.test.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { fireEvent, screen, within } from '@testing-library/react'; +import { useMaterializedAliasColumnsOption } from '@/hooks/useMaterializedAliasColumnsOption'; + import { buildJSONExtractQuery, DBRowJsonViewer } from './DBRowJsonViewer'; import { RowSidePanelContext } from './DBRowSidePanel'; @@ -186,6 +188,46 @@ describe('DBRowJsonViewer', () => { expect(mockToggleColumn).toHaveBeenCalledWith("LogAttributes['field1']"); }); + it('turns the materialized and alias columns option on and off from the menu', async () => { + const OptionValue = () => ( + + {String(useMaterializedAliasColumnsOption()[0])} + + ); + const { container } = renderWithMantine( + + + + , + ); + const option = screen.getByTestId('materialized-alias-option'); + const toggle = async () => { + fireEvent.click(container.querySelector('[aria-haspopup="menu"]')!); + fireEvent.click( + await screen.findByTestId('json-viewer-materialized-alias-toggle'), + ); + }; + + expect(option).toHaveTextContent('false'); + await toggle(); + expect(option).toHaveTextContent('true'); + expect( + localStorage.getItem('hdx-row-show-materialized-alias-columns'), + ).toBe('true'); + await toggle(); + expect(option).toHaveTextContent('false'); + }); + + it('hides the materialized and alias columns option unless asked to show it', async () => { + const { container } = renderComponent(logData); + fireEvent.click(container.querySelector('[aria-haspopup="menu"]')!); + + expect(await screen.findByText('Hide blank values')).toBeInTheDocument(); + expect( + screen.queryByTestId('json-viewer-materialized-alias-toggle'), + ).not.toBeInTheDocument(); + }); + describe('timestamp fields', () => { it('displays Timestamp using the same formatter as the results table', () => { renderComponent({ diff --git a/packages/app/src/components/DBRowJsonViewer.tsx b/packages/app/src/components/DBRowJsonViewer.tsx index f8e2ec2eaf..70a3ae6ada 100644 --- a/packages/app/src/components/DBRowJsonViewer.tsx +++ b/packages/app/src/components/DBRowJsonViewer.tsx @@ -32,6 +32,7 @@ import HyperJson, { GetLineActions, LineAction, } from '@/components/HyperJson'; +import { useMaterializedAliasColumnsOption } from '@/hooks/useMaterializedAliasColumnsOption'; import { useFormatTime } from '@/useFormatTime'; import { isColumnInSelect, mergePath } from '@/utils'; import { @@ -227,8 +228,16 @@ const viewerOptionsAtom = atomWithStorage( viewerOptionsStorage, ); -function HyperJsonMenu({ rowData }: { rowData: any }) { +function HyperJsonMenu({ + rowData, + showMaterializedAliasColumnsOption, +}: { + rowData: any; + showMaterializedAliasColumnsOption: boolean; +}) { const [jsonOptions, setJsonOptions] = useAtom(viewerOptionsAtom); + const [showMaterializedAliasColumns, setShowMaterializedAliasColumns] = + useMaterializedAliasColumnsOption(); const effectiveWhiteSpace = jsonOptions.whiteSpace ?? 'pre-wrap'; return ( @@ -335,6 +344,23 @@ function HyperJsonMenu({ rowData }: { rowData: any }) { > Hide blank values + {showMaterializedAliasColumnsOption && ( + + ) : null + } + onClick={() => + setShowMaterializedAliasColumns(!showMaterializedAliasColumns) + } + > + Show materialized and alias columns + + )} @@ -345,6 +371,7 @@ export function DBRowJsonViewer({ data, jsonColumns, mapColumns, + showMaterializedAliasColumnsOption = false, }: { data: any; jsonColumns?: string[]; @@ -352,6 +379,9 @@ export function DBRowJsonViewer({ // `mergePath` so numeric-looking sub-keys on a Map render as // `Map['key']` instead of the array `Map[N+1]`. HDX-4369. mapColumns?: string[]; + // Only a viewer of the whole row from `useRowData` is affected by the option, + // and only when the source's row query would change. + showMaterializedAliasColumnsOption?: boolean; }) { const formatTime = useFormatTime(); const { @@ -699,7 +729,12 @@ export function DBRowJsonViewer({ )}
- + diff --git a/packages/app/src/components/DBRowSidePanelErrorState.tsx b/packages/app/src/components/DBRowSidePanelErrorState.tsx index 19ed40599b..673df7879e 100644 --- a/packages/app/src/components/DBRowSidePanelErrorState.tsx +++ b/packages/app/src/components/DBRowSidePanelErrorState.tsx @@ -14,6 +14,10 @@ import { useDisclosure } from '@mantine/hooks'; import { IconAlertTriangle } from '@tabler/icons-react'; import { IS_LOCAL_MODE } from '@/config'; +import { + useMaterializedAliasColumnsOption, + useSelectAllColumnsQuerySettings, +} from '@/hooks/useMaterializedAliasColumnsOption'; import { useTableMetadata } from '@/hooks/useMetadata'; import { useBrandDisplayName } from '@/theme/ThemeProvider'; @@ -82,6 +86,30 @@ function KnownColumnsListHint({ ); } +// The option lives in the Column Values menu, which a failed row does not show. +function MaterializedAliasColumnsHint({ onHide }: { onHide: () => void }) { + return ( + } + title="Materialized and alias columns are on" + data-testid="materialized-alias-columns-hint" + > + + + The row query includes MATERIALIZED and ALIAS columns. The whole row + fails to load if the connection's user cannot change query + settings (for example, a readonly user) or if one of these columns + cannot be evaluated. + + + + + ); +} + export function DBRowSidePanelErrorState({ error, source, @@ -94,6 +122,9 @@ export function DBRowSidePanelErrorState({ const showHint = isMissingColumnError(error) && !!tableMetadata?.isPointerTable; + const selectAllColumnsSettings = useSelectAllColumnsQuerySettings(source); + const [, setShowMaterializedAliasColumns] = + useMaterializedAliasColumnsOption(); return ( @@ -103,6 +134,12 @@ export function DBRowSidePanelErrorState({ )} + {selectAllColumnsSettings != null && ( + setShowMaterializedAliasColumns(false)} + /> + )} + Error Message: diff --git a/packages/app/src/components/__tests__/DBRowDataPanel.test.ts b/packages/app/src/components/__tests__/DBRowDataPanel.test.ts index 66152de309..94a5de0e2e 100644 --- a/packages/app/src/components/__tests__/DBRowDataPanel.test.ts +++ b/packages/app/src/components/__tests__/DBRowDataPanel.test.ts @@ -1,3 +1,4 @@ +import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse'; import { SourceKind, TLogSource } from '@hyperdx/common-utils/dist/types'; import { renderHook } from '@testing-library/react'; @@ -13,6 +14,8 @@ jest.mock('@/hooks/useChartConfig', () => ({ })); const mockUseQueriedChartConfig = useQueriedChartConfig as jest.Mock; +const showMaterializedAliasColumns = () => + localStorage.setItem('hdx-row-show-materialized-alias-columns', 'true'); describe('DBRowDataPanel', () => { const source: TLogSource = { @@ -28,6 +31,7 @@ describe('DBRowDataPanel', () => { beforeEach(() => { jest.clearAllMocks(); + localStorage.clear(); mockUseQueriedChartConfig.mockReturnValue({ data: { data: [], @@ -93,6 +97,114 @@ describe('DBRowDataPanel', () => { expect(config.select).not.toContainEqual({ valueExpression: '*' }); }); + it('adds no query settings while the viewer option is off', () => { + renderHook(() => useRowData({ source, rowId: "id='abc123'" })); + + const [bounded, fallback] = mockUseQueriedChartConfig.mock.calls; + for (const [, options] of [bounded, fallback]) { + expect(options.additionalQuerySettings).toBeUndefined(); + expect(options.retry).toBeUndefined(); + } + }); + + it('includes MATERIALIZED and ALIAS columns when the viewer option is on', () => { + showMaterializedAliasColumns(); + renderHook(() => useRowData({ source, rowId: "id='abc123'" })); + + const [bounded, fallback] = mockUseQueriedChartConfig.mock.calls; + for (const [, options] of [bounded, fallback]) { + expect(options.additionalQuerySettings).toEqual([ + { setting: 'asterisk_include_materialized_columns', value: '1' }, + { setting: 'asterisk_include_alias_columns', value: '1' }, + ]); + } + }); + + it('adds only the settings that the source does not set itself', () => { + showMaterializedAliasColumns(); + const sourceWithSetting: TLogSource = { + ...source, + querySettings: [ + { setting: 'asterisk_include_alias_columns', value: '0' }, + ], + }; + + renderHook(() => + useRowData({ source: sourceWithSetting, rowId: "id='abc123'" }), + ); + + const [[, options]] = mockUseQueriedChartConfig.mock.calls; + expect(options.additionalQuerySettings).toEqual([ + { setting: 'asterisk_include_materialized_columns', value: '1' }, + ]); + }); + + it('adds no query settings when the source sets both itself', () => { + showMaterializedAliasColumns(); + const sourceWithSettings: TLogSource = { + ...source, + querySettings: [ + { setting: 'asterisk_include_materialized_columns', value: '0' }, + { setting: 'asterisk_include_alias_columns', value: '0' }, + ], + }; + + renderHook(() => + useRowData({ source: sourceWithSettings, rowId: "id='abc123'" }), + ); + + const [[, options]] = mockUseQueriedChartConfig.mock.calls; + expect(options.additionalQuerySettings).toBeUndefined(); + }); + + it('adds no query settings when the source has a Known Columns List', () => { + showMaterializedAliasColumns(); + const sourceWithKnownColumns: TLogSource = { + ...source, + knownColumnsListExpression: 'Timestamp, Body, ServiceName', + }; + + renderHook(() => + useRowData({ source: sourceWithKnownColumns, rowId: "id='abc123'" }), + ); + + const [bounded, fallback] = mockUseQueriedChartConfig.mock.calls; + for (const [, options] of [bounded, fallback]) { + expect(options.additionalQuerySettings).toBeUndefined(); + } + }); + + it('reports the error and does not drop the settings when the connection rejects them', () => { + showMaterializedAliasColumns(); + // The client's parsed error, wrapped the way the row query sees it. + const message = + "Cannot modify 'asterisk_include_materialized_columns' setting in readonly mode. "; + const readonlyError = new ClickHouseQueryError(message, 'SELECT * LIMIT 1'); + readonlyError.cause = Object.assign(new Error(message), { + code: '164', + type: 'READONLY', + }); + mockUseQueriedChartConfig.mockReturnValue({ + data: undefined, + error: readonlyError, + isLoading: false, + isPending: false, + isError: true, + isSuccess: false, + }); + + const { result } = renderHook(() => + useRowData({ source, rowId: "id='abc123'" }), + ); + + expect(result.current.isError).toBe(true); + expect(result.current.error).toBe(readonlyError); + expect(result.current.isLoading).toBe(false); + for (const [, options] of mockUseQueriedChartConfig.mock.calls) { + expect(options.additionalQuerySettings).toBeDefined(); + } + }); + describe('time filtering', () => { // A row id synthesized from ids alone (e.g. "View Trace" builds // TraceId + SpanId) has no timestamp of its own, so without a dateRange the diff --git a/packages/app/src/components/__tests__/DBRowSidePanelErrorState.test.tsx b/packages/app/src/components/__tests__/DBRowSidePanelErrorState.test.tsx index f67795ff90..9e06654849 100644 --- a/packages/app/src/components/__tests__/DBRowSidePanelErrorState.test.tsx +++ b/packages/app/src/components/__tests__/DBRowSidePanelErrorState.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse'; import { SourceKind, TLogSource } from '@hyperdx/common-utils/dist/types'; import { MantineProvider } from '@mantine/core'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { DBRowSidePanelErrorState } from '@/components/DBRowSidePanelErrorState'; @@ -129,4 +129,41 @@ describe('DBRowSidePanelErrorState', () => { ), ).toBeNull(); }); + + describe('materialized and alias columns hint', () => { + const OPTION_KEY = 'hdx-row-show-materialized-alias-columns'; + // The hint does not depend on the error, so any error shows it. + const error = new ClickHouseQueryError( + 'Timeout exceeded: elapsed 30 seconds', + 'SELECT * FROM logs LIMIT 1', + ); + + beforeEach(() => { + localStorage.clear(); + }); + + it('offers to hide the columns while the option is on', () => { + localStorage.setItem(OPTION_KEY, 'true'); + renderErrorState(error); + + fireEvent.click( + screen.getByRole('button', { + name: 'Hide materialized and alias columns', + }), + ); + + expect(localStorage.getItem(OPTION_KEY)).toBe('false'); + expect( + screen.queryByTestId('materialized-alias-columns-hint'), + ).toBeNull(); + }); + + it('is not shown while the option is off', () => { + renderErrorState(error); + + expect( + screen.queryByTestId('materialized-alias-columns-hint'), + ).toBeNull(); + }); + }); }); diff --git a/packages/app/src/hooks/__tests__/useChartConfig.test.tsx b/packages/app/src/hooks/__tests__/useChartConfig.test.tsx index 0793af11e0..a94296ca30 100644 --- a/packages/app/src/hooks/__tests__/useChartConfig.test.tsx +++ b/packages/app/src/hooks/__tests__/useChartConfig.test.tsx @@ -21,6 +21,7 @@ import { appendChunk, getGranularityAlignedTimeWindows, getMinGranularitySeconds, + mergeQuerySettings, useQueriedChartConfig, } from '@/hooks/useChartConfig'; import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation'; @@ -1155,6 +1156,73 @@ describe('useChartConfig', () => { expect(result.current.isPending).toBe(false); }); + it('passes additionalQuerySettings to the query', async () => { + const config = createMockChartConfig({ + dateRange: undefined, + granularity: undefined, + }); + const additionalQuerySettings = [ + { setting: 'asterisk_include_alias_columns', value: '1' }, + ]; + + mockClickhouseClient.queryChartConfig.mockResolvedValue( + createMockQueryResponse([]), + ); + + const { result } = renderHook( + () => useQueriedChartConfig(config, { additionalQuerySettings }), + { + wrapper, + }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(mockClickhouseClient.queryChartConfig).toHaveBeenCalledTimes(1); + expect(mockClickhouseClient.queryChartConfig).toHaveBeenCalledWith({ + config, + metadata: expect.any(Object), + opts: { + abort_signal: expect.any(AbortSignal), + }, + querySettings: additionalQuerySettings, + }); + }); + + it('adds additionalQuerySettings to a queryKey that the caller passes', async () => { + const config = createMockChartConfig({ + dateRange: undefined, + granularity: undefined, + }); + const additionalQuerySettings = [ + { setting: 'asterisk_include_alias_columns', value: '1' }, + ]; + + mockClickhouseClient.queryChartConfig.mockResolvedValue( + createMockQueryResponse([]), + ); + + const { result } = renderHook( + () => + useQueriedChartConfig(config, { + queryKey: ['caller-key'], + additionalQuerySettings, + }), + { + wrapper, + }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect( + queryClient.getQueryCache().find({ + queryKey: ['caller-key', additionalQuerySettings], + exact: true, + }), + ).toBeDefined(); + }); + it('fetches data without chunking when no granularity is provided', async () => { const config = createMockChartConfig({ dateRange: [new Date('2025-10-01'), new Date('2025-10-02')], @@ -2304,6 +2372,43 @@ describe('useChartConfig', () => { }); }); + describe('mergeQuerySettings', () => { + const sourceSettings = [{ setting: 'max_threads', value: '4' }]; + + it('returns the source settings unchanged when there is nothing to add', () => { + expect(mergeQuerySettings(sourceSettings, undefined)).toBe( + sourceSettings, + ); + expect(mergeQuerySettings(undefined, [])).toBeUndefined(); + }); + + it('adds the additional settings after the source settings', () => { + expect( + mergeQuerySettings(sourceSettings, [ + { setting: 'asterisk_include_alias_columns', value: '1' }, + ]), + ).toEqual([ + { setting: 'max_threads', value: '4' }, + { setting: 'asterisk_include_alias_columns', value: '1' }, + ]); + }); + + it('adds only the settings that the source does not define', () => { + expect( + mergeQuerySettings( + [{ setting: 'asterisk_include_alias_columns', value: '0' }], + [ + { setting: 'asterisk_include_materialized_columns', value: '1' }, + { setting: 'asterisk_include_alias_columns', value: '1' }, + ], + ), + ).toEqual([ + { setting: 'asterisk_include_alias_columns', value: '0' }, + { setting: 'asterisk_include_materialized_columns', value: '1' }, + ]); + }); + }); + describe('appendChunk', () => { const empty = { data: [], meta: [], rows: 0, isComplete: false }; diff --git a/packages/app/src/hooks/useChartConfig.tsx b/packages/app/src/hooks/useChartConfig.tsx index ea96708564..6cd74bda85 100644 --- a/packages/app/src/hooks/useChartConfig.tsx +++ b/packages/app/src/hooks/useChartConfig.tsx @@ -72,6 +72,12 @@ interface AdditionalUseQueriedChartConfigOptions { * replaces the key the hook builds from the config. */ queryKeyPrefix?: string; + /** + * Query settings for this query only, added after the source's own query + * settings. A setting that the source already defines keeps its value. + * They are added to the query key, also when the caller passes `queryKey`. + */ + additionalQuerySettings?: QuerySettings; } type TimeWindow = { @@ -284,6 +290,28 @@ async function* fetchDataInChunks({ } } +/** + * Adds per-query settings after the source's query settings. A setting that the + * source already defines keeps the source's value. + */ +export function mergeQuerySettings( + sourceSettings: QuerySettings | undefined, + additionalSettings: QuerySettings | undefined, +): QuerySettings | undefined { + if (!additionalSettings?.length) { + return sourceSettings; + } + const sourceSettingNames = new Set( + (sourceSettings ?? []).map(({ setting }) => setting), + ); + return [ + ...(sourceSettings ?? []), + ...additionalSettings.filter( + ({ setting }) => !sourceSettingNames.has(setting), + ), + ]; +} + /** Append the given chunk to the given accumulated result. Exported for tests. */ export function appendChunk( accumulated: TQueryFnData, @@ -371,6 +399,9 @@ export function useQueriedChartConfig( options?.enableQueryChunking ?? false, options?.enableParallelQueries ?? false, minGranularitySeconds, + ...(options?.additionalQuerySettings?.length + ? [options.additionalQuerySettings] + : []), ], // TODO: Replace this with `streamedQuery` when it is no longer experimental. Use 'replace' refetch mode. // https://tanstack.com/query/latest/docs/reference/streamedQuery @@ -403,7 +434,10 @@ export function useQueriedChartConfig( enableQueryChunking: options?.enableQueryChunking, enableParallelQueries: options?.enableParallelQueries, metadata, - querySettings: source?.querySettings, + querySettings: mergeQuerySettings( + source?.querySettings, + options?.additionalQuerySettings, + ), }); let accumulatedChunks: TQueryFnData = emptyValue; @@ -437,6 +471,9 @@ export function useQueriedChartConfig( retry: 1, refetchOnWindowFocus: false, ...options, + ...(options?.queryKey && options.additionalQuerySettings?.length + ? { queryKey: [...options.queryKey, options.additionalQuerySettings] } + : {}), enabled: enabled && !isLoadingMVOptimization && !isSourceLoading, }); diff --git a/packages/app/src/hooks/useMaterializedAliasColumnsOption.ts b/packages/app/src/hooks/useMaterializedAliasColumnsOption.ts new file mode 100644 index 0000000000..ba70435379 --- /dev/null +++ b/packages/app/src/hooks/useMaterializedAliasColumnsOption.ts @@ -0,0 +1,53 @@ +import { + isLogSource, + isTraceSource, + QuerySettings, + TSource, +} from '@hyperdx/common-utils/dist/types'; + +import { useLocalStorage } from '@/utils'; + +// ClickHouse leaves MATERIALIZED and ALIAS columns out of `SELECT *` unless +// these settings are on. +const SELECT_ALL_COLUMNS_QUERY_SETTINGS: QuerySettings = [ + { setting: 'asterisk_include_materialized_columns', value: '1' }, + { setting: 'asterisk_include_alias_columns', value: '1' }, +]; + +export function useMaterializedAliasColumnsOption() { + return useLocalStorage('hdx-row-show-materialized-alias-columns', false); +} + +// A Known Columns List replaces `SELECT *` in the row query. +export function getKnownColumnsList(source: TSource): string | undefined { + return isLogSource(source) || isTraceSource(source) + ? source.knownColumnsListExpression?.trim() || undefined + : undefined; +} + +// The settings that the row query adds for this source while the option is on, +// if any. A setting that the source's own query settings define keeps the +// source's value. +export function getSelectAllColumnsQuerySettings( + source: TSource, +): QuerySettings | undefined { + if (getKnownColumnsList(source)) { + return undefined; + } + const added = SELECT_ALL_COLUMNS_QUERY_SETTINGS.filter( + ({ setting }) => + !source.querySettings?.some( + sourceSetting => sourceSetting.setting === setting, + ), + ); + return added.length > 0 ? added : undefined; +} + +export function useSelectAllColumnsQuerySettings( + source: TSource, +): QuerySettings | undefined { + const [showMaterializedAliasColumns] = useMaterializedAliasColumnsOption(); + return showMaterializedAliasColumns + ? getSelectAllColumnsQuerySettings(source) + : undefined; +} diff --git a/packages/common-utils/src/__tests__/queryChartConfig.int.test.ts b/packages/common-utils/src/__tests__/queryChartConfig.int.test.ts index 892b24a59a..427f7f6f76 100644 --- a/packages/common-utils/src/__tests__/queryChartConfig.int.test.ts +++ b/packages/common-utils/src/__tests__/queryChartConfig.int.test.ts @@ -2856,4 +2856,72 @@ describe('queryChartConfig Integration Tests', () => { expect(Number(col(rows[0], 'Error rate'))).toBeCloseTo(40, 5); }); }); + + // The row details panel selects `*` with these settings (see useRowData). + describe('SELECT * with MATERIALIZED and ALIAS columns', () => { + const ROW_TABLE = 'select_all_columns_int_test'; + const config: ChartConfigWithOptDateRange = { + connection: 'test-connection', + from: { databaseName: DATABASE, tableName: ROW_TABLE }, + select: [{ valueExpression: '*' }], + where: 'Id = 1', + limit: { limit: 1 }, + }; + + beforeAll(async () => { + await client.command({ + query: `CREATE OR REPLACE TABLE ${DATABASE}.${ROW_TABLE} ( + Id UInt32, + StatusCode UInt16, + StatusClass String MATERIALIZED if(StatusCode >= 500, '5xx', 'other'), + IsServerError Bool ALIAS StatusCode >= 500 + ) + ENGINE = MergeTree + ORDER BY Id`, + }); + await client.insert({ + table: `${DATABASE}.${ROW_TABLE}`, + values: [{ Id: 1, StatusCode: 503 }], + format: 'JSONEachRow', + }); + }); + + afterAll(async () => { + await client.command({ + query: `DROP TABLE IF EXISTS ${DATABASE}.${ROW_TABLE}`, + }); + }); + + it('leaves MATERIALIZED and ALIAS columns out of `*` by default', async () => { + const result = await hdxClient.queryChartConfig({ + config, + metadata, + querySettings: undefined, + }); + + expect(result.meta?.map(m => m.name)).toEqual(['Id', 'StatusCode']); + }); + + it('returns MATERIALIZED and ALIAS columns with the asterisk_include_* settings', async () => { + const result = await hdxClient.queryChartConfig({ + config, + metadata, + querySettings: [ + { setting: 'asterisk_include_materialized_columns', value: '1' }, + { setting: 'asterisk_include_alias_columns', value: '1' }, + ], + }); + + expect(result.meta?.map(m => m.name)).toEqual([ + 'Id', + 'StatusCode', + 'StatusClass', + 'IsServerError', + ]); + expect(result.data[0]).toMatchObject({ + StatusClass: '5xx', + IsServerError: true, + }); + }); + }); });