Skip to content

Commit 13daa88

Browse files
SpencerTorresalyssabullbossinc
authored
Add SQL Formatter (#1205)
Co-authored-by: Alyssa (Bull) Joyner <[email protected]> Co-authored-by: Andrew Hackmann <[email protected]>
1 parent 31cea11 commit 13daa88

File tree

7 files changed

+145
-13
lines changed

7 files changed

+145
-13
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
"react-dom": "18.3.1",
8585
"react-router-dom": "5.3.4",
8686
"semver": "7.7.1",
87+
"sql-formatter": "^15.5.1",
8788
"tslib": "2.7.0"
8889
},
8990
"packageManager": "[email protected]",

src/components/QueryToolbox.tsx

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import React, { useMemo } from 'react';
2+
import { css } from '@emotion/css';
3+
4+
import { Icon, IconButton, Stack, Tooltip, useTheme2 } from '@grafana/ui';
5+
6+
interface QueryToolboxProps {
7+
showTools?: boolean;
8+
onFormatCode?: () => void;
9+
}
10+
11+
export function QueryToolbox({ showTools, onFormatCode }: QueryToolboxProps) {
12+
const theme = useTheme2();
13+
14+
const styles = useMemo(() => {
15+
return {
16+
container: css({
17+
border: `1px solid ${theme.colors.border.medium}`,
18+
borderTop: 'none',
19+
padding: theme.spacing(0.5, 0.5, 0.5, 0.5),
20+
display: 'flex',
21+
flexGrow: 1,
22+
justifyContent: 'space-between',
23+
fontSize: theme.typography.bodySmall.fontSize,
24+
}),
25+
error: css({
26+
color: theme.colors.error.text,
27+
fontSize: theme.typography.bodySmall.fontSize,
28+
fontFamily: theme.typography.fontFamilyMonospace,
29+
}),
30+
valid: css({
31+
color: theme.colors.success.text,
32+
}),
33+
info: css({
34+
color: theme.colors.text.secondary,
35+
}),
36+
hint: css({
37+
color: theme.colors.text.disabled,
38+
whiteSpace: 'nowrap',
39+
cursor: 'help',
40+
}),
41+
};
42+
}, [theme]);
43+
44+
let style = {};
45+
46+
if (!showTools) {
47+
style = { height: 0, padding: 0, visibility: 'hidden' };
48+
}
49+
50+
return (
51+
<div className={styles.container} style={style}>
52+
{showTools && (
53+
<div>
54+
<Stack>
55+
{onFormatCode && (
56+
<IconButton
57+
onClick={() => {
58+
onFormatCode();
59+
}}
60+
name="brackets-curly"
61+
size="xs"
62+
tooltip="Format query"
63+
/>
64+
)}
65+
<Tooltip content="Hit CTRL/CMD+Return to run query">
66+
<Icon className={styles.hint} name="keyboard" />
67+
</Tooltip>
68+
</Stack>
69+
</div>
70+
)}
71+
</div>
72+
);
73+
}

src/components/SqlEditor.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const mockMonaco = {
2020
register: jest.fn(),
2121
setMonarchTokensProvider: jest.fn(),
2222
registerCompletionItemProvider: jest.fn(),
23+
registerDocumentFormattingEditProvider: jest.fn(),
2324
}
2425
};
2526

@@ -121,7 +122,7 @@ describe('SQL Editor', () => {
121122
]);
122123

123124
// Trigger the action
124-
registeredAction.run();
125+
registeredAction.run(mockEditorInstance);
125126

126127
// Verify mockOnRunQuery was called once
127128
expect(mockOnRunQuery).toHaveBeenCalledTimes(1);

src/components/SqlEditor.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react';
1+
import React, { useRef } from 'react';
22
import { QueryEditorProps } from '@grafana/data';
33
import { CodeEditor, monacoTypes } from '@grafana/ui';
44
import { Datasource } from 'data/CHDatasource';
@@ -13,6 +13,7 @@ import { QueryType } from 'types/queryBuilder';
1313
import { QueryTypeSwitcher } from 'components/queryBuilder/QueryTypeSwitcher';
1414
import { pluginVersion } from 'utils/version';
1515
import { useSchemaSuggestionsProvider } from 'hooks/useSchemaSuggestionsProvider';
16+
import { QueryToolbox } from './QueryToolbox';
1617

1718
type SqlEditorProps = QueryEditorProps<Datasource, CHQuery, CHConfig>;
1819

@@ -33,6 +34,7 @@ function setupAutoSize(editor: monacoTypes.editor.IStandaloneCodeEditor) {
3334

3435
export const SqlEditor = (props: SqlEditorProps) => {
3536
const { query, onChange, datasource } = props;
37+
const editorRef = useRef<monacoTypes.editor.IStandaloneCodeEditor | null>(null);
3638
const sqlQuery = query as CHSqlQuery;
3739
const queryType = sqlQuery.queryType || QueryType.Table;
3840

@@ -74,6 +76,7 @@ export const SqlEditor = (props: SqlEditorProps) => {
7476
};
7577

7678
const handleMount = (editor: monacoTypes.editor.IStandaloneCodeEditor, monaco: typeof monacoTypes) => {
79+
editorRef.current = editor;
7780
const me = registerSQL('sql', editor, _getSuggestions);
7881
setupAutoSize(editor);
7982
editor.onKeyUp((e: any) => {
@@ -82,18 +85,29 @@ export const SqlEditor = (props: SqlEditorProps) => {
8285
validateSql(sql, editor.getModel(), me);
8386
}
8487
});
88+
8589
editor.addAction({
8690
id: 'run-query',
8791
label: 'Run Query',
8892
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter],
8993
contextMenuGroupId: 'navigation',
9094
contextMenuOrder: 1.5,
91-
run: function() {
95+
run: (editor: monacoTypes.editor.IStandaloneCodeEditor) => {
96+
saveChanges({ rawSql: editor.getValue() });
9297
props.onRunQuery();
9398
},
9499
});
95100
};
96101

102+
const onEditorWillUnmount = () => {
103+
editorRef.current = null
104+
};
105+
const triggerFormat = () => {
106+
if (editorRef.current !== null) {
107+
editorRef.current.trigger("editor", "editor.action.formatDocument", "");
108+
}
109+
};
110+
97111
return (
98112
<>
99113
<div className={'gf-form ' + styles.QueryEditor.queryType}>
@@ -109,6 +123,11 @@ export const SqlEditor = (props: SqlEditorProps) => {
109123
showLineNumbers={true}
110124
onBlur={(sql) => saveChanges({ rawSql: sql })}
111125
onEditorDidMount={handleMount}
126+
onEditorWillUnmount={onEditorWillUnmount}
127+
/>
128+
<QueryToolbox
129+
showTools
130+
onFormatCode={triggerFormat}
112131
/>
113132
</div>
114133
</>

src/components/sqlProvider.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { formatSql } from "./sqlProvider"
2+
3+
describe('SQL Formatter', () => {
4+
it('formats SQL', () => {
5+
const input = 'SELECT 1, 2, 3 FROM test LIMIT 1';
6+
const expected = 'SELECT\n 1,\n 2,\n 3\nFROM\n test\nLIMIT\n 1';
7+
8+
const actual = formatSql(input, 1);
9+
expect(actual).toBe(expected);
10+
});
11+
});

src/components/sqlProvider.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Monaco, MonacoEditor, monacoTypes } from '@grafana/ui'
1+
import { Monaco, MonacoEditor, monacoTypes } from '@grafana/ui';
2+
import { format } from 'sql-formatter';
23

34
declare const monaco: Monaco;
45

@@ -39,8 +40,22 @@ export type Fetcher = {
3940
(text: string, range: Range, cursorPosition: number): Promise<SuggestionResponse>;
4041
};
4142

43+
export function formatSql(rawSql: string, tabWidth = 4): string {
44+
// The default formatter doesn't like the $, so we swap it out
45+
const macroPrefix = '$';
46+
const swapIdentifier = 'GRAFANA_DOLLAR_TOKEN';
47+
const removedVariables = rawSql.replaceAll(macroPrefix, swapIdentifier);
48+
const formattedRaw = format(removedVariables, {
49+
language: 'postgresql',
50+
tabWidth
51+
});
52+
53+
const formatted = formattedRaw.replaceAll(swapIdentifier, macroPrefix);
54+
return formatted;
55+
}
56+
4257
export function registerSQL(lang: string, editor: MonacoEditor, fetchSuggestions: Fetcher) {
43-
// so options are visible outside query editor
58+
// show options outside query editor
4459
editor.updateOptions({ fixedOverflowWidgets: true, scrollBeyondLastLine: false });
4560

4661
// const registeredLang = monaco.languages.getLanguages().find((l: Lang) => l.id === lang);
@@ -55,13 +70,6 @@ export function registerSQL(lang: string, editor: MonacoEditor, fetchSuggestions
5570
triggerCharacters: [' ', '.', '$'],
5671
provideCompletionItems: async (model: Model, position: Position) => {
5772
const word = model.getWordUntilPosition(position);
58-
// const textUntilPosition = model.getValueInRange({
59-
// startLineNumber: 1,
60-
// startColumn: 1,
61-
// endLineNumber: position.lineNumber,
62-
// endColumn: position.column,
63-
// });
64-
6573
const range: Range = {
6674
startLineNumber: position.lineNumber,
6775
endLineNumber: position.lineNumber,
@@ -73,5 +81,16 @@ export function registerSQL(lang: string, editor: MonacoEditor, fetchSuggestions
7381
},
7482
});
7583

84+
monaco.languages.registerDocumentFormattingEditProvider('sql', {
85+
provideDocumentFormattingEdits(model, options) {
86+
return [
87+
{
88+
range: model.getFullModelRange(),
89+
text: formatSql(model.getValue(), options.tabSize)
90+
}
91+
];
92+
}
93+
});
94+
7695
return monaco.editor;
7796
}

yarn.lock

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6500,7 +6500,7 @@ natural-compare@^1.4.0:
65006500
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
65016501
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
65026502

6503-
nearley@^2.19.5:
6503+
nearley@^2.19.5, nearley@^2.20.1:
65046504
version "2.20.1"
65056505
resolved "https://registry.yarnpkg.com/nearley/-/nearley-2.20.1.tgz#246cd33eff0d012faf197ff6774d7ac78acdd474"
65066506
integrity sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==
@@ -8056,6 +8056,14 @@ sprintf-js@~1.0.2:
80568056
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
80578057
integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==
80588058

8059+
sql-formatter@^15.5.1:
8060+
version "15.5.1"
8061+
resolved "https://registry.yarnpkg.com/sql-formatter/-/sql-formatter-15.5.1.tgz#ce5602e2dab6438a05afaaecb6424dec7e62ff25"
8062+
integrity sha512-H3XfFRpK8LybkU2mD2Vj3AF35YcfviwUuy3yl98Xp7DTneJJVB40L654DpHYIRctASNIQPoLo7+yCboOfkOpWA==
8063+
dependencies:
8064+
argparse "^2.0.1"
8065+
nearley "^2.20.1"
8066+
80598067
stack-generator@^2.0.5:
80608068
version "2.0.10"
80618069
resolved "https://registry.yarnpkg.com/stack-generator/-/stack-generator-2.0.10.tgz#8ae171e985ed62287d4f1ed55a1633b3fb53bb4d"

0 commit comments

Comments
 (0)