sql: extract frontend code into separate package (#81109)

* sql: extract frontend code into separate package

* updated package version
This commit is contained in:
Gábor Farkas
2024-01-26 11:38:29 +01:00
committed by GitHub
parent 2febbec758
commit 29e8a355cb
87 changed files with 187 additions and 104 deletions
@@ -0,0 +1,46 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { LanguageDefinition, SQLEditor } from '@grafana/experimental';
import { SQLQuery } from '../../types';
type Props = {
query: SQLQuery;
onChange: (value: SQLQuery, processQuery: boolean) => void;
children?: (props: { formatQuery: () => void }) => React.ReactNode;
width?: number;
height?: number;
editorLanguageDefinition: LanguageDefinition;
};
export function QueryEditorRaw({ children, onChange, query, width, height, editorLanguageDefinition }: Props) {
// We need to pass query via ref to SQLEditor as onChange is executed via monacoEditor.onDidChangeModelContent callback, not onChange property
const queryRef = useRef<SQLQuery>(query);
useEffect(() => {
queryRef.current = query;
}, [query]);
const onRawQueryChange = useCallback(
(rawSql: string, processQuery: boolean) => {
const newQuery = {
...queryRef.current,
rawQuery: true,
rawSql,
};
onChange(newQuery, processQuery);
},
[onChange]
);
return (
<SQLEditor
width={width}
height={height}
query={query.rawSql!}
onChange={onRawQueryChange}
language={editorLanguageDefinition}
>
{children}
</SQLEditor>
);
}
@@ -0,0 +1,109 @@
import { css } from '@emotion/css';
import React, { useMemo, useState } from 'react';
import { reportInteraction } from '@grafana/runtime';
import { HorizontalGroup, Icon, IconButton, Tooltip, useTheme2 } from '@grafana/ui';
import { QueryValidator, QueryValidatorProps } from './QueryValidator';
interface QueryToolboxProps extends Omit<QueryValidatorProps, 'onValidate'> {
showTools?: boolean;
isExpanded?: boolean;
onFormatCode?: () => void;
onExpand?: (expand: boolean) => void;
onValidate?: (isValid: boolean) => void;
}
export function QueryToolbox({ showTools, onFormatCode, onExpand, isExpanded, ...validatorProps }: QueryToolboxProps) {
const theme = useTheme2();
const [validationResult, setValidationResult] = useState<boolean>();
const styles = useMemo(() => {
return {
container: css`
border: 1px solid ${theme.colors.border.medium};
border-top: none;
padding: ${theme.spacing(0.5, 0.5, 0.5, 0.5)};
display: flex;
flex-grow: 1;
justify-content: space-between;
font-size: ${theme.typography.bodySmall.fontSize};
`,
error: css`
color: ${theme.colors.error.text};
font-size: ${theme.typography.bodySmall.fontSize};
font-family: ${theme.typography.fontFamilyMonospace};
`,
valid: css`
color: ${theme.colors.success.text};
`,
info: css`
color: ${theme.colors.text.secondary};
`,
hint: css`
color: ${theme.colors.text.disabled};
white-space: nowrap;
cursor: help;
`,
};
}, [theme]);
let style = {};
if (!showTools && validationResult === undefined) {
style = { height: 0, padding: 0, visibility: 'hidden' };
}
return (
<div className={styles.container} style={style}>
<div>
{validatorProps.onValidate && (
<QueryValidator
{...validatorProps}
onValidate={(result: boolean) => {
setValidationResult(result);
validatorProps.onValidate!(result);
}}
/>
)}
</div>
{showTools && (
<div>
<HorizontalGroup spacing="sm">
{onFormatCode && (
<IconButton
onClick={() => {
reportInteraction('grafana_sql_query_formatted', {
datasource: validatorProps.query.datasource?.type,
});
onFormatCode();
}}
name="brackets-curly"
size="xs"
tooltip="Format query"
/>
)}
{onExpand && (
<IconButton
onClick={() => {
reportInteraction('grafana_sql_editor_expand', {
datasource: validatorProps.query.datasource?.type,
expanded: !isExpanded,
});
onExpand(!isExpanded);
}}
name={isExpanded ? 'angle-up' : 'angle-down'}
size="xs"
tooltip={isExpanded ? 'Collapse editor' : 'Expand editor'}
/>
)}
<Tooltip content="Hit CTRL/CMD+Return to run query">
<Icon className={styles.hint} name="keyboard" />
</Tooltip>
</HorizontalGroup>
</div>
)}
</div>
);
}
@@ -0,0 +1,110 @@
import { css } from '@emotion/css';
import React, { useState, useMemo, useEffect } from 'react';
import { useAsyncFn } from 'react-use';
import useDebounce from 'react-use/lib/useDebounce';
import { formattedValueToString, getValueFormat, TimeRange } from '@grafana/data';
import { Icon, Spinner, useTheme2 } from '@grafana/ui';
import { DB, SQLQuery, ValidationResults } from '../../types';
export interface QueryValidatorProps {
db: DB;
query: SQLQuery;
range?: TimeRange;
onValidate: (isValid: boolean) => void;
}
export function QueryValidator({ db, query, onValidate, range }: QueryValidatorProps) {
const [validationResult, setValidationResult] = useState<ValidationResults | null>();
const theme = useTheme2();
const valueFormatter = useMemo(() => getValueFormat('bytes'), []);
const styles = useMemo(() => {
return {
error: css`
color: ${theme.colors.error.text};
font-size: ${theme.typography.bodySmall.fontSize};
font-family: ${theme.typography.fontFamilyMonospace};
`,
valid: css`
color: ${theme.colors.success.text};
`,
info: css`
color: ${theme.colors.text.secondary};
`,
};
}, [theme]);
const [state, validateQuery] = useAsyncFn(
async (q: SQLQuery) => {
if (q.rawSql?.trim() === '') {
return null;
}
return await db.validateQuery(q, range);
},
[db]
);
const [,] = useDebounce(
async () => {
const result = await validateQuery(query);
if (result) {
setValidationResult(result);
}
return null;
},
1000,
[query, validateQuery]
);
useEffect(() => {
if (validationResult?.isError) {
onValidate(false);
}
if (validationResult?.isValid) {
onValidate(true);
}
}, [validationResult, onValidate]);
if (!state.value && !state.loading) {
return null;
}
const error = state.value?.error ? processErrorMessage(state.value.error) : '';
return (
<>
{state.loading && (
<div className={styles.info}>
<Spinner inline={true} size="xs" /> Validating query...
</div>
)}
{!state.loading && state.value && (
<>
<>
{state.value.isValid && state.value.statistics && (
<div className={styles.valid}>
<Icon name="check" /> This query will process{' '}
<strong>{formattedValueToString(valueFormatter(state.value.statistics.TotalBytesProcessed))}</strong>{' '}
when run.
</div>
)}
</>
<>{state.value.isError && <div className={styles.error}>{error}</div>}</>
</>
)}
</>
);
}
function processErrorMessage(error: string) {
const splat = error.split(':');
if (splat.length > 2) {
return splat.slice(2).join(':');
}
return error;
}
@@ -0,0 +1,135 @@
## SQLEditor
### Core concepts
- `SuggestionKind` - a descriptive string representing a type of a suggestion, i.e. `SelectKeyword`, `Tables`, `LogicalOperators` etc.
- `LinkedToken` - linked list element representing each individual token with a query. Allows traversing the query back and forth. Used by `StatementPositionResolver`(see below)
- `StatementPosition` - a desctiptive string representing cursor/token position within the query. Each statement position is defined together with `StatementPositionResolver` that, given some position context, returns a boolean value indicating whether or not we are in a given `StatementPosition` position.
```ts
export type StatementPositionResolver = (
currentToken: LinkedToken | null,
previousKeyword: LinkedToken | null,
previousNonWhiteSpace: LinkedToken | null,
previousIsSlash: Boolean // To be removed as it's CloudWatch specific
) => Boolean;
```
- `SuggestionKind` and `StatementPosition` are glued together via suggestions kind registry (language specific!). This registry contains items of `SuggestionKindRegistyItem` type of the following interface:
```ts
export interface SuggestionKindRegistyItem extends RegistryItem {
id: StatementPosition;
kind: SuggestionKind[];
}
```
This item defines what kinds of suggestions should be provided in a given statement position
- Registries. There are couple of different registries used that drive the autocomplete mechanism.
- **Language specific**: functions registry, operators registry, suggestion kinds registries and statement position resolvers registires. Those registires contain SQL defaults as well as allow extension per language type.
- **Instance specific**: Registry of `SuggestionsRegistyItem` items that glue particular `SuggestionKind` with an async function that provides completion items for it.
```ts
export interface SuggestionsRegistyItem extends RegistryItem {
id: SuggestionKind;
suggestions: (position: PositionContext, m: typeof monacoTypes) => Promise<CustomSuggestion[]>;
}
```
Think about instance-specific registry as having i.e. mixed data source with multiple query editors for the same type of data source and you wish to provide only table suggestions that are valid for particular query row.
### SQLEditor component
Goals
- [ ] Allow providing suggestions for standard-ish SQL syntax (THIS PR)
- [ ] Allow providing custom SQL dialects and suggestions for them (TODO - CloudWatch implementation sets a good base for how to provide custom dialect definition)
`SQLEditor` component builds on top of `CodeEditor` component, but we may want to base it on `ReactMonacoEditor` component instead to be less prone to `CodeEditor` API changes and have full control over the Monaco API. For now the `CodeEditor` is good enough for a simplification.
`SQLEditor` API:
```ts
interface SQLEditorProps {
query: string;
onChange: (q: string) => void;
language?: LanguageDefinition;
}
```
The important part is the `LanguageDefinition` interface which provides way to customize the completion both on a language and instance level:
```ts
interface LanguageDefinition extends monacoTypes.languages.ILanguageExtensionPoint {
// TODO: Will allow providing a custom language definition.
loadLanguage?: (module: any) => Promise<void>;
// Provides API for customizing the autocomplete
completionProvider?: (m: Monaco) => SQLCompletionItemProvider;
}
```
The `completionProvider` function is the core of the autocomplete customization. `SQLEditor` comes with standard SQL completion items, but this function allows:
- providing dynamic suggestions: tables, columns
- providing custom `StatementPositionResolvers` that are specific for a given dialect or not implemented yet for standard SQL
- providing custom `SuggestionKind` and resolvers for this kind of suggestions.
```ts
export interface SQLCompletionItemProvider
extends Omit<monacoTypes.languages.CompletionItemProvider, 'provideCompletionItems'> {
/**
* Allows dialect specific functions to be added to the completion list.
* @alpha
*/
supportedFunctions?: () => Array<{
id: string;
name: string;
}>;
/**
* Allows dialect specific operators to be added to the completion list.
* @alpha
*/
supportedOperators?: () => Array<{
id: string;
operator: string;
type: OperatorType;
}>;
/**
* Allows adding macros that are available in the dialect datasource.
* @alpha
*/
supportedMacros?: () => Array<{
id: string;
name: string;
type: MacroType;
args: Array<string>;
}>;
/**
* Allows custom suggestion kinds to be defined and correlate them with <Custom>StatementPosition.
* @alpha
*/
customSuggestionKinds?: () => CustomSuggestionKind[];
/**
* Allows custom statement placement definition.
* @alpha
*/
customStatementPlacement?: () => CustomStatementPlacement[];
/**
* Allows providing a custom function for resolving db tables.
* It's up to the consumer to decide whether the columns are resolved via API calls or preloaded in the query editor(i.e. full db schema is preloades loaded).
* @alpha
*/
tables?: {
resolve: () => Promise<TableDefinition[]>;
// Allows providing a custom function for calculating the table name from the query. If not specified a default implemnentation is used. I.e. BigQuery requires the table name to be fully qualified name: <project>.<dataset>.<table>
parseName?: (t: LinkedToken) => string;
};
/**
* Allows providing a custom function for resolving table.
* It's up to the consumer to decide whether the columns are resolved via API calls or preloaded in the query editor(i.e. full db schema is preloades loaded).
* @alpha
*/
columns?: {
resolve: (table: string) => Promise<ColumnDefinition[]>;
};
}
```
@@ -0,0 +1,126 @@
import { css } from '@emotion/css';
import React, { useMemo, useState } from 'react';
import { useMeasure } from 'react-use';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2 } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { Modal, useStyles2, useTheme2 } from '@grafana/ui';
import { SQLQuery, QueryEditorProps } from '../../types';
import { QueryEditorRaw } from './QueryEditorRaw';
import { QueryToolbox } from './QueryToolbox';
interface RawEditorProps extends Omit<QueryEditorProps, 'onChange'> {
onRunQuery: () => void;
onChange: (q: SQLQuery, processQuery: boolean) => void;
onValidate: (isValid: boolean) => void;
queryToValidate: SQLQuery;
}
export function RawEditor({ db, query, onChange, onRunQuery, onValidate, queryToValidate, range }: RawEditorProps) {
const theme = useTheme2();
const styles = useStyles2(getStyles);
const [isExpanded, setIsExpanded] = useState(false);
const [toolboxRef, toolboxMeasure] = useMeasure<HTMLDivElement>();
const [editorRef, editorMeasure] = useMeasure<HTMLDivElement>();
const editorLanguageDefinition = useMemo(() => db.getEditorLanguageDefinition(), [db]);
const renderQueryEditor = (width?: number, height?: number) => {
return (
<QueryEditorRaw
editorLanguageDefinition={editorLanguageDefinition}
query={query}
width={width}
height={height ? height - toolboxMeasure.height : undefined}
onChange={onChange}
>
{({ formatQuery }) => {
return (
<div ref={toolboxRef}>
<QueryToolbox
db={db}
query={queryToValidate}
onValidate={onValidate}
onFormatCode={formatQuery}
showTools
range={range}
onExpand={setIsExpanded}
isExpanded={isExpanded}
/>
</div>
);
}}
</QueryEditorRaw>
);
};
const renderEditor = (standalone = false) => {
return standalone ? (
<AutoSizer>
{({ width, height }) => {
return renderQueryEditor(width, height);
}}
</AutoSizer>
) : (
<div ref={editorRef}>{renderQueryEditor()}</div>
);
};
const renderPlaceholder = () => {
return (
<div
style={{
width: editorMeasure.width,
height: editorMeasure.height,
background: theme.colors.background.primary,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
Editing in expanded code editor
</div>
);
};
return (
<>
{isExpanded ? renderPlaceholder() : renderEditor()}
{isExpanded && (
<Modal
title={`Query ${query.refId}`}
closeOnBackdropClick={false}
closeOnEscape={false}
className={styles.modal}
contentClassName={styles.modalContent}
isOpen={isExpanded}
onDismiss={() => {
reportInteraction('grafana_sql_editor_expand', {
datasource: query.datasource?.type,
expanded: false,
});
setIsExpanded(false);
}}
>
{renderEditor(true)}
</Modal>
)}
</>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
modal: css`
width: 95vw;
height: 95vh;
`,
modalContent: css`
height: 100%;
padding-top: 0;
`,
};
}