mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
25a3291f57
commit
33edc7e5be
26 changed files with 1901 additions and 293 deletions
6
.changeset/poor-walls-occur.md
Normal file
6
.changeset/poor-walls-occur.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Improve auto-completion for SQLEditor\
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
124
packages/app/src/components/SQLEditor/SQLEditor.tsx
Normal file
124
packages/app/src/components/SQLEditor/SQLEditor.tsx
Normal 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} />;
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
1241
packages/app/src/components/SQLEditor/constants.ts
Normal file
1241
packages/app/src/components/SQLEditor/constants.ts
Normal file
File diff suppressed because it is too large
Load diff
149
packages/app/src/components/SQLEditor/utils.ts
Normal file
149
packages/app/src/components/SQLEditor/utils.ts
Normal 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',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,3 @@ export {
|
|||
default as SearchWhereInput,
|
||||
type SearchWhereInputProps,
|
||||
} from './SearchWhereInput';
|
||||
export {
|
||||
default as SQLInlineEditor,
|
||||
SQLInlineEditorControlled,
|
||||
} from './SQLInlineEditor';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue