feat: Improve auto-completion for SQLEditor (#1893)

## Summary

This PR

1. Refactors the SQLEditor and SQLInlineEditor to share code (and a directory) where possible.
2. Adds auto-complete to SQLEditor, for SQL Keywords, table names, database names, columns, map keys, and functions

The SQL editor autocompletion values are pulled from any tables that are registered in HyperDX as a source (or a materialized view on a source).

The PR is broken into one commit which is just file relocation and a second where the other changes are included.

### Screenshots or video

<img width="777" height="923" alt="Screenshot 2026-03-12 at 2 26 01 PM" src="https://github.com/user-attachments/assets/238bb46d-c459-4722-837c-b1682d971c32" />

### How to test locally or on Vercel

This can be tested in the preview environment.

Things to test include the `SQLEditor` for Raw SQL Charting and the `SQLInlineEditor` (used for WHERE and ORDER BY inputs, as well as in the SourceForm.

### References



- Linear Issue: Closes HDX-3620
- Related PRs:
This commit is contained in:
Drew Davis 2026-03-12 17:23:56 -04:00 committed by GitHub
parent 25a3291f57
commit 33edc7e5be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1901 additions and 293 deletions

View file

@ -0,0 +1,6 @@
---
"@hyperdx/app": patch
---
feat: Improve auto-completion for SQLEditor\

View file

@ -22,11 +22,11 @@ import {
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { useClickhouseClient } from '@/clickhouse';
import { SQLEditorControlled } from '@/components/SQLEditor/SQLEditor';
import { ConnectionSelectControlled } from './components/ConnectionSelect';
import DBTableChart from './components/DBTableChart';
import { DBTimeChart } from './components/DBTimeChart';
import { SQLEditorControlled } from './components/SQLEditor';
function useBenchmarkQueryIds({
queries,

View file

@ -85,11 +85,11 @@ import OnboardingModal from '@/components/OnboardingModal';
import SearchWhereInput, {
getStoredLanguage,
} from '@/components/SearchInput/SearchWhereInput';
import { SQLInlineEditorControlled } from '@/components/SearchInput/SQLInlineEditor';
import SearchPageActionBar from '@/components/SearchPageActionBar';
import SearchTotalCountChart from '@/components/SearchTotalCountChart';
import { TableSourceForm } from '@/components/Sources/SourceForm';
import { SourceSelectControlled } from '@/components/SourceSelect';
import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor';
import { Tags } from '@/components/Tags';
import { TimePicker } from '@/components/TimePicker';
import { IS_LOCAL_MODE } from '@/config';

View file

@ -37,6 +37,7 @@ import {
} from '@tabler/icons-react';
import { useQueryClient } from '@tanstack/react-query';
import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor';
import { useCreateSavedSearch } from '@/savedSearch';
import { useSavedSearch } from '@/savedSearch';
import { useSource } from '@/source';
@ -53,7 +54,6 @@ import { AlertPreviewChart } from './components/AlertPreviewChart';
import { AlertChannelForm } from './components/Alerts';
import { AlertScheduleFields } from './components/AlertScheduleFields';
import { getStoredLanguage } from './components/SearchInput/SearchWhereInput';
import { SQLInlineEditorControlled } from './components/SearchInput/SQLInlineEditor';
import { getWebhookChannelIcon } from './utils/webhookIcons';
import api from './api';
import { AlertWithCreatedBy, SearchConfig } from './types';

View file

@ -31,7 +31,8 @@ import {
IconTrash,
} from '@tabler/icons-react';
import { SQLInlineEditorControlled } from './components/SearchInput/SQLInlineEditor';
import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor';
import SourceSchemaPreview from './components/SourceSchemaPreview';
import { SourceSelectControlled } from './components/SourceSelect';
import { useSource, useSources } from './source';

View file

@ -7,6 +7,7 @@ import * as utils from '../utils';
import {
formatAttributeClause,
formatNumber,
getAllMetricTables,
getMetricTableName,
mapKeyBy,
orderByStringToSortingState,
@ -120,6 +121,117 @@ describe('getMetricTableName', () => {
});
});
describe('getAllMetricTables', () => {
const createMetricSource = (metricTables: Record<string, string>): TSource =>
({
kind: 'metric' as const,
from: { databaseName: 'test_db', tableName: '' },
connection: 'test-conn',
id: 'test-id',
name: 'test',
timestampValueExpression: 'timestamp',
metricTables,
}) as unknown as TSource;
it('returns empty array for non-metric source', () => {
const source = {
kind: 'log' as const,
from: { databaseName: 'test_db', tableName: 'logs' },
connection: 'test-conn',
id: 'test-id',
name: 'test',
timestampValueExpression: 'timestamp',
} as unknown as TSource;
expect(getAllMetricTables(source)).toEqual([]);
});
it('returns empty array when metricTables is undefined', () => {
const source = {
kind: 'metric' as const,
from: { databaseName: 'test_db', tableName: '' },
connection: 'test-conn',
id: 'test-id',
name: 'test',
timestampValueExpression: 'timestamp',
} as unknown as TSource;
expect(getAllMetricTables(source)).toEqual([]);
});
it('returns TableConnection for each populated metric table', () => {
const source = createMetricSource({
Gauge: 'gauge_table',
Sum: 'sum_table',
});
const result = getAllMetricTables(source);
expect(result).toEqual(
expect.arrayContaining([
{
tableName: 'gauge_table',
databaseName: 'test_db',
connectionId: 'test-conn',
},
{
tableName: 'sum_table',
databaseName: 'test_db',
connectionId: 'test-conn',
},
]),
);
expect(result).toHaveLength(2);
});
it('filters out metric types with no table name', () => {
const source = createMetricSource({
Gauge: 'gauge_table',
Histogram: '',
});
const result = getAllMetricTables(source);
expect(result).toEqual([
{
tableName: 'gauge_table',
databaseName: 'test_db',
connectionId: 'test-conn',
},
]);
});
it('returns all four metric types when all are populated', () => {
const source = createMetricSource({
Gauge: 'gauge_t',
Histogram: 'histogram_t',
Sum: 'sum_t',
Summary: 'summary_t',
});
const result = getAllMetricTables(source);
expect(result).toHaveLength(4);
expect(result.map(t => t.tableName).sort()).toEqual([
'gauge_t',
'histogram_t',
'sum_t',
'summary_t',
]);
// All should share the same database and connection
for (const tc of result) {
expect(tc.databaseName).toBe('test_db');
expect(tc.connectionId).toBe('test-conn');
}
});
it('returns empty array when all metric table values are falsy', () => {
const source = createMetricSource({
Gauge: '',
Histogram: '',
});
expect(getAllMetricTables(source)).toEqual([]);
});
});
describe('formatNumber', () => {
it('handles undefined/null values', () => {
expect(formatNumber(undefined)).toBe('N/A');

View file

@ -1,13 +1,18 @@
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { Control, UseFormSetValue, useWatch } from 'react-hook-form';
import { DisplayType } from '@hyperdx/common-utils/dist/types';
import {
TableConnection,
tcFromSource,
} from '@hyperdx/common-utils/dist/core/metadata';
import { DisplayType, SourceKind } from '@hyperdx/common-utils/dist/types';
import { Box, Button, Group, Stack, Text } from '@mantine/core';
import { SQLEditorControlled } from '@/components/SQLEditor/SQLEditor';
import useResizable from '@/hooks/useResizable';
import { useSources } from '@/source';
import { getAllMetricTables } from '@/utils';
import { ConnectionSelectControlled } from '../ConnectionSelect';
import { SQLEditorControlled } from '../SQLEditor';
import { SQL_PLACEHOLDERS } from './constants';
import { RawSqlChartInstructions } from './RawSqlChartInstructions';
@ -46,6 +51,31 @@ export default function RawSqlChartEditor({
const placeholderSQl = SQL_PLACEHOLDERS[displayType ?? DisplayType.Table];
const tableConnections: TableConnection[] = useMemo(() => {
if (!sources) return [];
return sources
.filter(s => s.connection === connection)
.flatMap(source => {
const tables: TableConnection[] = getAllMetricTables(source);
if (source.kind !== SourceKind.Metric) {
tables.push(tcFromSource(source));
}
if (source.materializedViews) {
tables.push(
...source.materializedViews.map(mv => ({
databaseName: mv.databaseName,
tableName: mv.tableName,
connectionId: source.connection,
})),
);
}
return tables;
});
}, [sources, connection]);
return (
<Stack>
<Group align="center">
@ -66,6 +96,7 @@ export default function RawSqlChartEditor({
height={`${size}vh`}
enableLineWrapping
placeholder={placeholderSQl}
tableConnections={tableConnections}
/>
<div className={resizeStyles.resizeYHandle} onMouseDown={startResize} />
</Box>

View file

@ -64,6 +64,7 @@ export function RawSqlChartInstructions({
const [helpOpened, setHelpOpened] = useAtom(helpOpenedAtom);
const toggleHelp = () => setHelpOpened(v => !v);
const availableParams = QUERY_PARAMS_BY_DISPLAY_TYPE[displayType];
const exampleClipboard = useClipboard({ timeout: 1500 });
return (
<Paper
@ -110,9 +111,36 @@ export function RawSqlChartInstructions({
<Text size="xs" fw="bold">
Example:
</Text>
<Code fz="xs" block>
{QUERY_PARAM_EXAMPLES[displayType]}
</Code>
<div style={{ position: 'relative' }}>
<Tooltip
label={exampleClipboard.copied ? 'Copied!' : 'Copy'}
withArrow
>
<ActionIcon
variant="subtle"
size="xs"
color={exampleClipboard.copied ? 'green' : 'gray'}
onClick={() =>
exampleClipboard.copy(QUERY_PARAM_EXAMPLES[displayType])
}
style={{
position: 'absolute',
top: 4,
right: 4,
zIndex: 1,
}}
>
{exampleClipboard.copied ? (
<IconCheck size={10} />
) : (
<IconCopy size={10} />
)}
</ActionIcon>
</Tooltip>
<Code fz="xs" block>
{QUERY_PARAM_EXAMPLES[displayType]}
</Code>
</div>
</Stack>
</Collapse>
</Stack>

View file

@ -86,7 +86,7 @@ import { DBTimeChart } from '@/components/DBTimeChart';
import SearchWhereInput, {
getStoredLanguage,
} from '@/components/SearchInput/SearchWhereInput';
import { SQLInlineEditorControlled } from '@/components/SearchInput/SQLInlineEditor';
import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor';
import { TimePicker } from '@/components/TimePicker';
import { IS_LOCAL_MODE } from '@/config';
import { GranularityPickerControlled } from '@/GranularityPicker';

View file

@ -16,7 +16,7 @@ import {
import { IconPencil } from '@tabler/icons-react';
import { DBTraceWaterfallChartContainer } from '@/components/DBTraceWaterfallChart';
import { SQLInlineEditorControlled } from '@/components/SearchInput/SQLInlineEditor';
import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor';
import { WithClause } from '@/hooks/useRowWhere';
import { useSource, useUpdateSource } from '@/source';
import TabBar from '@/TabBar';

View file

@ -1,113 +0,0 @@
import { useRef } from 'react';
import { useController, UseControllerProps } from 'react-hook-form';
import { startCompletion } from '@codemirror/autocomplete';
import { sql } from '@codemirror/lang-sql';
import { Paper, useMantineColorScheme } from '@mantine/core';
import CodeMirror, {
Compartment,
EditorView,
ReactCodeMirrorRef,
} from '@uiw/react-codemirror';
type SQLInlineEditorProps = {
value: string;
onChange: (value: string) => void;
placeholder?: string;
height?: string;
enableLineWrapping?: boolean;
};
const styleTheme = EditorView.baseTheme({
'&.cm-editor.cm-focused': {
outline: '0px solid transparent',
},
'&.cm-editor': {
background: 'transparent !important',
},
'& .cm-scroller': {
overflowX: 'hidden',
},
});
export default function SQLEditor({
onChange,
placeholder,
value,
height,
enableLineWrapping = false,
}: SQLInlineEditorProps) {
const { colorScheme } = useMantineColorScheme();
const ref = useRef<ReactCodeMirrorRef>(null);
const compartmentRef = useRef<Compartment>(new Compartment());
return (
<Paper
flex="auto"
shadow="none"
style={{
bg: 'var(--color-bg-field)',
display: 'flex',
alignItems: 'center',
}}
ps="4px"
>
<div style={{ width: '100%' }}>
<CodeMirror
indentWithTab={false}
ref={ref}
value={value}
onChange={onChange}
theme={colorScheme === 'dark' ? 'dark' : 'light'}
height={height}
minHeight={'100px'}
extensions={[
styleTheme,
// eslint-disable-next-line react-hooks/refs
compartmentRef.current.of(
sql({
upperCaseKeywords: true,
}),
),
...(enableLineWrapping ? [EditorView.lineWrapping] : []),
]}
onUpdate={update => {
// Always open completion window as much as possible
if (
update.focusChanged &&
update.view.hasFocus &&
ref.current?.view
) {
startCompletion(ref.current?.view);
}
}}
basicSetup={{
lineNumbers: false,
foldGutter: false,
highlightActiveLine: false,
highlightActiveLineGutter: false,
}}
placeholder={placeholder}
/>
</div>
</Paper>
);
}
export function SQLEditorControlled({
placeholder,
height,
...props
}: Omit<SQLInlineEditorProps, 'value' | 'onChange'> & UseControllerProps<any>) {
const { field } = useController(props);
return (
<SQLEditor
onChange={field.onChange}
placeholder={placeholder}
value={field.value}
height={height}
{...props}
/>
);
}

View file

@ -0,0 +1,124 @@
import { useCallback, useEffect, useRef } from 'react';
import { useController, UseControllerProps } from 'react-hook-form';
import { acceptCompletion } from '@codemirror/autocomplete';
import { sql } from '@codemirror/lang-sql';
import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata';
import { Paper, useMantineColorScheme } from '@mantine/core';
import CodeMirror, {
Compartment,
EditorView,
keymap,
ReactCodeMirrorRef,
} from '@uiw/react-codemirror';
import { useMultipleAllFields } from '@/hooks/useMetadata';
import {
createCodeMirrorSqlDialect,
createCodeMirrorStyleTheme,
DEFAULT_CODE_MIRROR_BASIC_SETUP,
} from './utils';
type SQLEditorProps = {
value: string;
onChange: (value: string) => void;
placeholder?: string;
height?: string;
enableLineWrapping?: boolean;
tableConnections?: TableConnection[];
};
export default function SQLEditor({
onChange,
placeholder,
value,
height,
enableLineWrapping = false,
tableConnections,
}: SQLEditorProps) {
const { colorScheme } = useMantineColorScheme();
const ref = useRef<ReactCodeMirrorRef>(null);
const compartmentRef = useRef<Compartment>(new Compartment());
const { data: fields } = useMultipleAllFields(tableConnections ?? []);
const updateAutocompleteColumns = useCallback(
(viewRef: EditorView) => {
const identifiers: string[] = [
// Suggest database and table names for autocompletion
...new Set(tableConnections?.map(tc => tc.tableName) ?? []),
...new Set(tableConnections?.map(tc => tc.databaseName) ?? []),
...new Set(
tableConnections?.map(tc => `${tc.databaseName}.${tc.tableName}`) ??
[],
),
// Suggest column names for autocompletion, including Map keys
...(fields?.map(column => {
if (column.path.length > 1) {
return `${column.path[0]}['${column.path[1]}']`;
}
return column.path[0];
}) ?? []),
];
viewRef.dispatch({
effects: compartmentRef.current.reconfigure(
createCodeMirrorSqlDialect({
identifiers,
includeAggregateFunctions: true,
includeRegularFunctions: true,
}),
),
});
},
[fields, tableConnections],
);
useEffect(() => {
if (ref.current != null && ref.current.view != null) {
updateAutocompleteColumns(ref.current.view);
}
}, [updateAutocompleteColumns]);
return (
<Paper style={{ width: '100%' }}>
<CodeMirror
indentWithTab={false}
ref={ref}
value={value}
onChange={onChange}
onCreateEditor={updateAutocompleteColumns}
theme={colorScheme === 'dark' ? 'dark' : 'light'}
height={height}
minHeight={'100px'}
extensions={[
createCodeMirrorStyleTheme(),
// eslint-disable-next-line react-hooks/refs
compartmentRef.current.of(
sql({
upperCaseKeywords: true,
}),
),
keymap.of([
{
key: 'Tab',
run: acceptCompletion,
},
]),
...(enableLineWrapping ? [EditorView.lineWrapping] : []),
]}
basicSetup={DEFAULT_CODE_MIRROR_BASIC_SETUP}
placeholder={placeholder}
/>
</Paper>
);
}
export function SQLEditorControlled({
...props
}: Omit<SQLEditorProps, 'value' | 'onChange'> & UseControllerProps<any>) {
const { field } = useController(props);
return <SQLEditor onChange={field.onChange} value={field.value} {...props} />;
}

View file

@ -9,7 +9,7 @@ import {
Completion,
startCompletion,
} from '@codemirror/autocomplete';
import { sql, SQLDialect } from '@codemirror/lang-sql';
import { sql } from '@codemirror/lang-sql';
import {
Field,
TableConnectionChoice,
@ -31,86 +31,20 @@ import CodeMirror, {
tooltips,
} from '@uiw/react-codemirror';
import InputLanguageSwitch from '@/components/SearchInput/InputLanguageSwitch';
import { useMultipleAllFields } from '@/hooks/useMetadata';
import { useQueryHistory } from '@/utils';
import InputLanguageSwitch from './InputLanguageSwitch';
import { KEYWORDS_FOR_WHERE_OR_ORDER_BY } from './constants';
import {
createCodeMirrorSqlDialect,
createCodeMirrorStyleTheme,
DEFAULT_CODE_MIRROR_BASIC_SETUP,
} from './utils';
import styles from './SQLInlineEditor.module.scss';
const AUTOCOMPLETE_LIST_FOR_SQL_FUNCTIONS = [
// used with WHERE
'AND',
'OR',
'NOT',
'IN',
'LIKE',
'ILIKE',
'BETWEEN',
'ASC',
'DESC',
// regular functions - arithmetic
'intDiv',
'intDivOrZero',
'isNaN',
'moduloOrZero',
'abs',
// regular functions - array
'empty',
'notEmpty',
'length',
'arrayConcat',
'has',
'hasAll',
'hasAny',
'indexOf',
'arrayCount',
'countEqual',
'arrayUnion',
'arrayIntersect',
'arrayMap',
'arrayFilter',
'arraySort',
'flatten',
'arrayCompact',
'arrayMin',
'arrayMax',
'arraySum',
'arrayAvg',
// regular functions - conditional
'if',
'multiIf',
// regular functions - rounding
'floor',
'ceiling',
'truncate',
'round',
// regular functions - dates and times
'timestamp',
'toTimeZone',
'toYear',
'toMonth',
'toWeek',
'toDayOfYear',
'toDayOfMonth',
'toDayOfWeek',
'toUnixTimestamp',
'toTime',
// regular functions - string
'lower',
'upper',
'substring',
'trim',
// regular functions - dictionaries
'dictGet',
'dictGetOrDefault',
'dictGetOrNull',
];
const AUTOCOMPLETE_LIST_STRING = ` ${AUTOCOMPLETE_LIST_FOR_SQL_FUNCTIONS.join(' ')}`;
type SQLInlineEditorProps = {
autoCompleteFields?: Field[];
filterField?: (field: Field) => boolean;
value: string;
onChange: (value: string) => void;
@ -132,78 +66,6 @@ type SQLInlineEditorProps = {
const MAX_EDITOR_HEIGHT = '150px';
const createStyleTheme = () =>
EditorView.baseTheme({
'&.cm-editor.cm-focused': {
outline: '0px solid transparent',
},
'&.cm-editor': {
background: 'transparent !important',
},
'.cm-editor-multiline &.cm-editor': {
maxHeight: MAX_EDITOR_HEIGHT,
},
'& .cm-tooltip-autocomplete': {
whiteSpace: 'nowrap',
wordWrap: 'break-word',
maxWidth: '100%',
backgroundColor: 'var(--color-bg-surface) !important',
border: '1px solid var(--color-border) !important',
borderRadius: '8px',
boxShadow:
'0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
padding: '4px',
},
'& .cm-tooltip-autocomplete > ul': {
fontFamily: 'inherit',
maxHeight: '300px',
},
'& .cm-tooltip-autocomplete > ul > li': {
padding: '4px 8px',
borderRadius: '4px',
cursor: 'pointer',
color: 'var(--color-text)',
},
'& .cm-tooltip-autocomplete > ul > li[aria-selected]': {
backgroundColor: 'var(--color-bg-highlighted) !important',
color: 'var(--color-text-muted) !important',
},
'& .cm-tooltip-autocomplete .cm-completionLabel': {
color: 'var(--color-text)',
},
'& .cm-tooltip-autocomplete .cm-completionDetail': {
color: 'var(--color-text-muted)',
fontStyle: 'normal',
marginLeft: '8px',
},
'& .cm-tooltip-autocomplete .cm-completionInfo': {
backgroundColor: 'var(--color-bg-field)',
border: '1px solid var(--color-border)',
borderRadius: '4px',
padding: '8px',
color: 'var(--color-text)',
},
'& .cm-completionIcon': {
width: '16px',
marginRight: '6px',
opacity: 0.7,
},
'& .cm-scroller': {
overflowX: 'hidden',
},
'.cm-editor-multiline & .cm-scroller': {
maxHeight: MAX_EDITOR_HEIGHT,
overflowY: 'auto',
},
});
const cmBasicSetup = {
lineNumbers: false,
foldGutter: false,
highlightActiveLine: false,
highlightActiveLineGutter: false,
};
export default function SQLInlineEditor({
tableConnection,
tableConnections,
@ -220,7 +82,7 @@ export default function SQLInlineEditor({
disableKeywordAutocomplete,
enableHotkey,
tooltipText,
additionalSuggestions = [],
additionalSuggestions,
queryHistoryType,
parentRef,
allowMultiline = true,
@ -285,23 +147,22 @@ export default function SQLInlineEditor({
const updateAutocompleteColumns = useCallback(
(viewRef: EditorView) => {
const currentText = viewRef.state.doc.toString();
const keywords = [
const identifiers = [
...(filteredFields?.map(column => {
if (column.path.length > 1) {
return `${column.path[0]}['${column.path[1]}']`;
}
return column.path[0];
}) ?? []),
...additionalSuggestions,
...(additionalSuggestions ?? []),
];
const auto = sql({
dialect: SQLDialect.define({
keywords:
keywords.join(' ') +
(disableKeywordAutocomplete ? '' : AUTOCOMPLETE_LIST_STRING),
}),
const auto = createCodeMirrorSqlDialect({
identifiers,
keywords: KEYWORDS_FOR_WHERE_OR_ORDER_BY,
includeRegularFunctions: !disableKeywordAutocomplete,
});
const queryHistoryList = autocompletion({
compareCompletions: (a: any, b: any) => {
return 0;
@ -374,20 +235,24 @@ export default function SQLInlineEditor({
const cmExtensions = useMemo(
() => [
...tooltipExt,
createStyleTheme(),
createCodeMirrorStyleTheme(MAX_EDITOR_HEIGHT),
// Enable line wrapping when multiline is allowed (regardless of focus)
...(allowMultiline ? [EditorView.lineWrapping] : []),
// eslint-disable-next-line react-hooks/refs
compartmentRef.current.of(
sql({
upperCaseKeywords: true,
}),
),
// Configure Enter key to submit search, and Shift + Enter to insert new line when multiline is allowed
Prec.highest(
keymap.of([
{
key: 'Enter',
run: view => {
run: () => {
if (onSubmit == null) {
return false;
}
@ -490,7 +355,7 @@ export default function SQLInlineEditor({
}, [setIsFocused])}
extensions={cmExtensions}
onCreateEditor={updateAutocompleteColumns}
basicSetup={cmBasicSetup}
basicSetup={DEFAULT_CODE_MIRROR_BASIC_SETUP}
placeholder={placeholder}
onClick={onClickCodeMirror}
/>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,149 @@
import {
autocompletion,
Completion,
CompletionContext,
} from '@codemirror/autocomplete';
import { sql } from '@codemirror/lang-sql';
import { EditorView } from '@uiw/react-codemirror';
import {
AGGREGATE_FUNCTIONS,
ALL_KEYWORDS,
REGULAR_FUNCTIONS,
} from './constants';
/**
* Creates a custom CodeMirror completion source for SQL identifiers (column names, table
* names, functions, etc.) that inserts them verbatim, without quoting.
*/
function createIdentifierCompletionSource(completions: Completion[]) {
return (context: CompletionContext) => {
// Match word characters, dots, single quotes, and brackets to support
// identifiers like `ResourceAttributes['service.name']`
const prefix = context.matchBefore(/[\w.'[\]]+/);
if (!prefix && !context.explicit) return null;
return {
from: prefix?.from ?? context.pos,
options: completions,
validFor: /^[\w.'[\]]*$/,
};
};
}
export const createCodeMirrorSqlDialect = ({
identifiers,
keywords = ALL_KEYWORDS,
includeRegularFunctions = false,
includeAggregateFunctions = false,
}: {
identifiers: string[];
keywords?: string[];
includeRegularFunctions?: boolean;
includeAggregateFunctions?: boolean;
}) => {
const completions: Completion[] = [
...identifiers.map(id => ({ label: id, type: 'variable' as const })),
...keywords.map(kw => ({
label: kw,
type: 'keyword' as const,
})),
...(includeRegularFunctions
? REGULAR_FUNCTIONS.map(fn => ({
label: fn,
type: 'function' as const,
apply: `${fn}(`,
}))
: []),
...(includeAggregateFunctions
? AGGREGATE_FUNCTIONS.map(fn => ({
label: fn,
type: 'function' as const,
apply: `${fn}(`,
}))
: []),
];
return [
// SQL language for syntax highlighting (completions are overridden below)
sql({ upperCaseKeywords: true }),
// Override built-in SQL completions with our custom source
autocompletion({
override: [createIdentifierCompletionSource(completions)],
}),
];
};
export const DEFAULT_CODE_MIRROR_BASIC_SETUP = {
lineNumbers: false,
foldGutter: false,
highlightActiveLine: false,
highlightActiveLineGutter: false,
};
export const createCodeMirrorStyleTheme = (maxEditorHeight?: string) =>
EditorView.baseTheme({
'&.cm-editor.cm-focused': {
outline: '0px solid transparent',
},
'&.cm-editor': {
background: 'transparent !important',
},
'& .cm-tooltip-autocomplete': {
whiteSpace: 'nowrap',
wordWrap: 'break-word',
maxWidth: '100%',
backgroundColor: 'var(--color-bg-surface) !important',
border: '1px solid var(--color-border) !important',
borderRadius: '8px',
boxShadow:
'0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
padding: '4px',
},
'& .cm-tooltip-autocomplete > ul': {
fontFamily: 'inherit',
maxHeight: '300px',
},
'& .cm-tooltip-autocomplete > ul > li': {
padding: '4px 8px',
borderRadius: '4px',
cursor: 'pointer',
color: 'var(--color-text)',
},
'& .cm-tooltip-autocomplete > ul > li[aria-selected]': {
backgroundColor: 'var(--color-bg-highlighted) !important',
color: 'var(--color-text-muted) !important',
},
'& .cm-tooltip-autocomplete .cm-completionLabel': {
color: 'var(--color-text)',
},
'& .cm-tooltip-autocomplete .cm-completionDetail': {
color: 'var(--color-text-muted)',
fontStyle: 'normal',
marginLeft: '8px',
},
'& .cm-tooltip-autocomplete .cm-completionInfo': {
backgroundColor: 'var(--color-bg-field)',
border: '1px solid var(--color-border)',
borderRadius: '4px',
padding: '8px',
color: 'var(--color-text)',
},
'& .cm-completionIcon': {
width: '16px',
marginRight: '6px',
opacity: 0.7,
},
'& .cm-scroller': {
overflowX: 'hidden',
},
...(maxEditorHeight
? {
'.cm-editor-multiline &.cm-editor': {
maxHeight: maxEditorHeight,
},
'.cm-editor-multiline & .cm-scroller': {
maxHeight: maxEditorHeight,
overflowY: 'auto',
},
}
: {}),
});

View file

@ -18,7 +18,7 @@ import { Center } from '@mantine/core';
import { Text } from '@mantine/core';
import { IconPlayerPlay } from '@tabler/icons-react';
import { SQLInlineEditorControlled } from '@/components/SearchInput/SQLInlineEditor';
import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor';
import { getDurationMsExpression } from '@/source';
import type { AddFilterFn } from '../DBDeltaChart';

View file

@ -2,9 +2,10 @@ import { FieldPath, useController, UseControllerProps } from 'react-hook-form';
import { TableConnectionChoice } from '@hyperdx/common-utils/dist/core/metadata';
import { Box, Flex, Kbd } from '@mantine/core';
import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor';
import InputLanguageSwitch from './InputLanguageSwitch';
import SearchInputV2 from './SearchInputV2';
import { SQLInlineEditorControlled } from './SQLInlineEditor';
import styles from './SearchWhereInput.module.scss';

View file

@ -6,7 +6,3 @@ export {
default as SearchWhereInput,
type SearchWhereInputProps,
} from './SearchWhereInput';
export {
default as SQLInlineEditor,
SQLInlineEditorControlled,
} from './SQLInlineEditor';

View file

@ -51,8 +51,8 @@ import {
IconTrash,
} from '@tabler/icons-react';
import { SQLInlineEditorControlled } from '@/components/SearchInput/SQLInlineEditor';
import { SourceSelectControlled } from '@/components/SourceSelect';
import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor';
import { IS_METRICS_ENABLED, IS_SESSIONS_ENABLED } from '@/config';
import { useConnections } from '@/connection';
import { useExplainQuery } from '@/hooks/useExplainQuery';

View file

@ -130,7 +130,7 @@ jest.mock('../MaterializedViews/MVOptimizationIndicator', () => ({
default: () => <div>MV Indicator</div>,
}));
jest.mock('../SearchInput/SQLInlineEditor', () => ({
jest.mock('../SQLEditor/SQLInlineEditor', () => ({
SQLInlineEditorControlled: () => <div>SQL Editor</div>,
}));

View file

@ -1,7 +1,9 @@
import React from 'react';
import * as metadataModule from '@hyperdx/app/src/metadata';
import { JSDataType } from '@hyperdx/common-utils/dist/clickhouse';
import { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse/browser';
import {
Field,
Metadata,
MetadataCache,
} from '@hyperdx/common-utils/dist/core/metadata';
@ -9,11 +11,13 @@ import { BuilderChartConfigWithDateRange } from '@hyperdx/common-utils/dist/type
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import api from '@/api';
import { useSources } from '@/source';
import {
deduplicate2dArray,
useGetKeyValues,
useMultipleAllFields,
useMultipleGetKeyValues,
} from '../useMetadata';
@ -272,6 +276,136 @@ describe('useGetKeyValues', () => {
});
});
describe('useMultipleAllFields', () => {
let queryClient: QueryClient;
let wrapper: React.ComponentType<{ children: any }>;
let mockMetadata: Metadata;
const fieldsA: Field[] = [
{ path: ['col_a'], type: 'string', jsType: JSDataType.String },
{ path: ['col_shared'], type: 'number', jsType: JSDataType.Number },
];
const fieldsB: Field[] = [
{ path: ['col_b'], type: 'string', jsType: JSDataType.String },
{ path: ['col_shared'], type: 'number', jsType: JSDataType.Number },
];
const tcA = {
databaseName: 'db',
tableName: 'table_a',
connectionId: 'conn1',
};
const tcB = {
databaseName: 'db',
tableName: 'table_b',
connectionId: 'conn1',
};
beforeEach(() => {
jest.clearAllMocks();
mockMetadata = new Metadata({} as ClickhouseClient, {} as MetadataCache);
jest.spyOn(metadataModule, 'getMetadata').mockReturnValue(mockMetadata);
jest.spyOn(api, 'useMe').mockReturnValue({
data: { team: {} },
isFetched: true,
} as any);
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
});
it('should return fields from successful connections and empty array for failed ones', async () => {
jest
.spyOn(mockMetadata, 'getAllFields')
.mockResolvedValueOnce(fieldsA)
.mockRejectedValueOnce(new Error('connection refused'));
const { result } = renderHook(() => useMultipleAllFields([tcA, tcB]), {
wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
// Should contain only fieldsA since fieldsB failed
expect(result.current.data).toEqual(fieldsA);
});
it('should deduplicate fields across successful connections', async () => {
jest
.spyOn(mockMetadata, 'getAllFields')
.mockResolvedValueOnce(fieldsA)
.mockResolvedValueOnce(fieldsB);
const { result } = renderHook(() => useMultipleAllFields([tcA, tcB]), {
wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
// col_shared appears in both but should be deduplicated
expect(result.current.data).toEqual([
{ path: ['col_a'], type: 'string', jsType: JSDataType.String },
{ path: ['col_shared'], type: 'number', jsType: JSDataType.Number },
{ path: ['col_b'], type: 'string', jsType: JSDataType.String },
]);
});
it('should return empty array when all connections fail', async () => {
jest
.spyOn(mockMetadata, 'getAllFields')
.mockRejectedValueOnce(new Error('fail 1'))
.mockRejectedValueOnce(new Error('fail 2'));
const { result } = renderHook(() => useMultipleAllFields([tcA, tcB]), {
wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
it('should log a warning for each failed connection', async () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
jest
.spyOn(mockMetadata, 'getAllFields')
.mockResolvedValueOnce(fieldsA)
.mockRejectedValueOnce(new Error('timeout'));
const { result } = renderHook(() => useMultipleAllFields([tcA, tcB]), {
wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(warnSpy).toHaveBeenCalledWith(
'Failed to fetch fields for table connection',
expect.any(Error),
);
warnSpy.mockRestore();
});
it('should skip deduplication for a single connection', async () => {
jest.spyOn(mockMetadata, 'getAllFields').mockResolvedValueOnce(fieldsA);
const { result } = renderHook(() => useMultipleAllFields([tcA]), {
wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(fieldsA);
});
});
describe('deduplicate2dArray', () => {
// Test basic deduplication
it('should remove duplicate objects across 2D array', () => {

View file

@ -137,10 +137,21 @@ export function useMultipleAllFields(
return [];
}
const fields2d = await Promise.all(
const promiseResults = await Promise.allSettled(
tableConnections.map(tc => metadata.getAllFields(tc)),
);
const fields2d: Field[][] = promiseResults.map(result => {
if (result.status === 'rejected') {
console.warn(
'Failed to fetch fields for table connection',
result.reason,
);
return [];
}
return result.value;
});
// skip deduplication if not needed
if (fields2d.length === 1) return fields2d[0];

View file

@ -3,7 +3,8 @@ import { useRouter } from 'next/router';
import { formatDistanceToNowStrict } from 'date-fns';
import numbro from 'numbro';
import type { MutableRefObject, SetStateAction } from 'react';
import { TSource } from '@hyperdx/common-utils/dist/types';
import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata';
import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
import { SortingState } from '@tanstack/react-table';
import { dateRangeToString } from './timeQuery';
@ -894,6 +895,27 @@ export function getMetricTableName(
];
}
export function getAllMetricTables(source: TSource): TableConnection[] {
if (source.kind !== SourceKind.Metric || !source.metricTables) return [];
return Object.values(MetricsDataType)
.filter(
metricType =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
!!source.metricTables![metricType as keyof TSource['metricTables']],
)
.map(
metricType =>
({
tableName:
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
source.metricTables![metricType as keyof TSource['metricTables']],
databaseName: source.from.databaseName,
connectionId: source.connection,
}) satisfies TableConnection,
);
}
/**
* Converts (T | T[]) to T[]. If undefined, empty array
*/