diff --git a/components/webui/client/src/components/SqlEditor/monaco-loader.ts b/components/webui/client/src/components/SqlEditor/monaco-loader.ts index 5623d7d94d..729326525f 100644 --- a/components/webui/client/src/components/SqlEditor/monaco-loader.ts +++ b/components/webui/client/src/components/SqlEditor/monaco-loader.ts @@ -4,6 +4,8 @@ import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"; import "monaco-editor/esm/vs/basic-languages/sql/sql.contribution.js"; +import "monaco-editor/esm/vs/editor/contrib/hover/browser/hoverContribution.js"; +import "monaco-editor/esm/vs/editor/contrib/hover/browser/markerHoverParticipant.js"; /* eslint-enable import/default */ diff --git a/components/webui/client/src/components/SqlInput/index.tsx b/components/webui/client/src/components/SqlInput/index.tsx index 540ab5d124..dffcc7a548 100644 --- a/components/webui/client/src/components/SqlInput/index.tsx +++ b/components/webui/client/src/components/SqlInput/index.tsx @@ -1,22 +1,42 @@ // Reference: https://github.com/vikyd/vue-monaco-singleline -import {useCallback} from "react"; +import { + useCallback, + useEffect, + useRef, +} from "react"; +import {Nullable} from "@webui/common/utility-types"; import * as monaco from "monaco-editor/esm/vs/editor/editor.api.js"; +import {ValidationError} from "../../sql-parser"; import SqlEditor, { SqlEditorProps, SqlEditorType, } from "../SqlEditor"; +import {escapeHoverMarkdown} from "./utils"; +type SqlInputProps = SqlEditorProps & { + /** + * Validation function to check SQL syntax and return errors. + */ + validateFn?: (sqlString: string) => ValidationError[]; +}; + /** * Single-line SQL input. * * @param props * @return */ -const SqlInput = (props: SqlEditorProps) => { +const SqlInput = (props: SqlInputProps) => { + const {validateFn, ...editorProps} = props; + const editorRef = useRef>(null); + const decorationsRef = useRef>(null); + const handleEditorReady = useCallback((editor: SqlEditorType) => { + editorRef.current = editor; + // Prevent multi-line input by repositioning cursor and replacing newlines with empty // string. editor.onDidChangeCursorPosition((e) => { @@ -42,6 +62,51 @@ const SqlInput = (props: SqlEditorProps) => { }); }, []); + // Validate SQL and update markers whenever value changes + useEffect(() => { + const editor = editorRef.current; + if (null === editor) { + return; + } + + if (null === decorationsRef.current) { + decorationsRef.current = editor.createDecorationsCollection(); + } + + const value = editorProps.value ?? ""; + + if ("undefined" === typeof validateFn || "" === value.trim()) { + decorationsRef.current.clear(); + + return; + } + + const errors = validateFn(value); + const decorations: monaco.editor.IModelDeltaDecoration[] = errors.map((error) => ({ + range: new monaco.Range(error.line, error.startColumn, error.line, error.endColumn), + options: { + className: "squiggly-error", + hoverMessage: {value: escapeHoverMarkdown(error.message), isTrusted: false}, + minimap: { + color: {id: "minimap.errorHighlight"}, + position: monaco.editor.MinimapPosition.Inline, + }, + + overviewRuler: { + color: {id: "editorOverviewRuler.errorForeground"}, + position: monaco.editor.OverviewRulerLane.Right, + }, + showIfCollapsed: true, + stickiness: monaco.editor.TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, + zIndex: 30, + }, + } + )); + + decorationsRef.current.set(decorations); + }, [editorProps.value, + validateFn]); + return ( { top: 6, bottom: 4, }, + quickSuggestions: false, renderLineHighlight: "none", roundedSelection: false, scrollBeyondLastColumn: 0, @@ -76,8 +142,9 @@ const SqlInput = (props: SqlEditorProps) => { wordWrap: "off", }} onEditorReady={handleEditorReady} - {...props}/> + {...editorProps}/> ); }; export default SqlInput; +export type {SqlInputProps}; diff --git a/components/webui/client/src/components/SqlInput/utils.ts b/components/webui/client/src/components/SqlInput/utils.ts new file mode 100644 index 0000000000..93f76a092f --- /dev/null +++ b/components/webui/client/src/components/SqlInput/utils.ts @@ -0,0 +1,13 @@ +/** + * Escapes angle brackets in text for safe display in Monaco editor hover tooltips. + * Monaco uses Markdown for hover messages, so raw `<` and `>` characters are + * interpreted as HTML tags and must be escaped to display literals like ``. + * + * @param text + * @return Escaped text. + */ +const escapeHoverMarkdown = (text: string) => text.replace(/[<>]/g, (ch) => ("<" === ch ? + "<" : + ">")); + +export {escapeHoverMarkdown}; diff --git a/components/webui/client/src/pages/SearchPage/SearchControls/Presto/GuidedControls/OrderBy.tsx b/components/webui/client/src/pages/SearchPage/SearchControls/Presto/GuidedControls/OrderBy.tsx index f941e6747b..df49d36ce5 100644 --- a/components/webui/client/src/pages/SearchPage/SearchControls/Presto/GuidedControls/OrderBy.tsx +++ b/components/webui/client/src/pages/SearchPage/SearchControls/Presto/GuidedControls/OrderBy.tsx @@ -1,5 +1,6 @@ import InputLabel from "../../../../../components/InputLabel"; import SqlInput from "../../../../../components/SqlInput"; +import {validateSortItemList} from "../../../../../sql-parser"; import useSearchStore from "../../../SearchState/index"; import usePrestoSearchState from "../../../SearchState/Presto"; import {SEARCH_UI_STATE} from "../../../SearchState/typings"; @@ -24,6 +25,7 @@ const OrderBy = () => { { updateOrderBy(value || ""); diff --git a/components/webui/client/src/pages/SearchPage/SearchControls/Presto/GuidedControls/Select.tsx b/components/webui/client/src/pages/SearchPage/SearchControls/Presto/GuidedControls/Select.tsx index 7699bc8408..a39e4721eb 100644 --- a/components/webui/client/src/pages/SearchPage/SearchControls/Presto/GuidedControls/Select.tsx +++ b/components/webui/client/src/pages/SearchPage/SearchControls/Presto/GuidedControls/Select.tsx @@ -1,5 +1,6 @@ import InputLabel from "../../../../../components/InputLabel"; import SqlInput from "../../../../../components/SqlInput"; +import {validateSelectItemList} from "../../../../../sql-parser"; import useSearchStore from "../../../SearchState/index"; import usePrestoSearchState from "../../../SearchState/Presto"; import {SEARCH_UI_STATE} from "../../../SearchState/typings"; @@ -24,6 +25,7 @@ const Select = () => { { updateSelect(value || ""); diff --git a/components/webui/client/src/pages/SearchPage/SearchControls/Presto/GuidedControls/Where.tsx b/components/webui/client/src/pages/SearchPage/SearchControls/Presto/GuidedControls/Where.tsx index 557dc6a6c9..946941690b 100644 --- a/components/webui/client/src/pages/SearchPage/SearchControls/Presto/GuidedControls/Where.tsx +++ b/components/webui/client/src/pages/SearchPage/SearchControls/Presto/GuidedControls/Where.tsx @@ -1,5 +1,6 @@ import InputLabel from "../../../../../components/InputLabel"; import SqlInput from "../../../../../components/SqlInput"; +import {validateBooleanExpression} from "../../../../../sql-parser"; import useSearchStore from "../../../SearchState/index"; import usePrestoSearchState from "../../../SearchState/Presto"; import {SEARCH_UI_STATE} from "../../../SearchState/typings"; @@ -24,6 +25,7 @@ const Where = () => { { updateWhere(value || ""); diff --git a/components/webui/client/src/sql-parser/index.ts b/components/webui/client/src/sql-parser/index.ts index 012c8c5b5c..3513289cdc 100644 --- a/components/webui/client/src/sql-parser/index.ts +++ b/components/webui/client/src/sql-parser/index.ts @@ -4,6 +4,7 @@ import { CommonTokenStream, ErrorListener, Recognizer, + Token, } from "antlr4"; import SqlLexer from "./generated/SqlLexer"; @@ -17,16 +18,36 @@ import { class SyntaxError extends Error { } +interface ValidationError { + line: number; + column: number; + message: string; + startColumn: number; + endColumn: number; +} + class SyntaxErrorListener extends ErrorListener { - // eslint-disable-next-line max-params, class-methods-use-this + errors: ValidationError[] = []; + + // eslint-disable-next-line max-params override syntaxError ( _recognizer: Recognizer, - _offendingSymbol: TSymbol, + offendingSymbol: TSymbol, line: number, column: number, msg: string, ) { - throw new SyntaxError(`line ${line}:${column}: ${msg}`); + const token = offendingSymbol as unknown as Token; + const startColumn = token.start + 1; + const endColumn = token.stop + 2; + + this.errors.push({ + column: column, + endColumn: endColumn, + line: line, + message: msg, + startColumn: startColumn, + }); } } @@ -51,31 +72,60 @@ class UpperCaseCharStream extends CharStream { } /** - * Creates a SQL parser for a given input string. + * Helper function to set up parser with error listener. * - * @param input The SQL query string to be parsed. - * @return The configured SQL parser instance ready to parse the input. + * @param sqlString + * @return Object containing parser and error listener */ -const buildParser = (input: string): SqlParser => { +const setupParser = (sqlString: string) => { const syntaxErrorListener = new SyntaxErrorListener(); - const lexer = new SqlLexer(new UpperCaseCharStream(input)); + const lexer = new SqlLexer(new UpperCaseCharStream(sqlString)); lexer.removeErrorListeners(); lexer.addErrorListener(syntaxErrorListener); const parser = new SqlParser(new CommonTokenStream(lexer)); parser.removeErrorListeners(); parser.addErrorListener(syntaxErrorListener); - return parser; + return {parser, syntaxErrorListener}; +}; + +/** + * Validate a SELECT item list and return any syntax errors found. + * + * @param sqlString + * @return Array of validation errors, empty if valid + */ +const validateSelectItemList = (sqlString: string): ValidationError[] => { + const {parser, syntaxErrorListener} = setupParser(sqlString); + parser.standaloneSelectItemList(); + + return syntaxErrorListener.errors; }; /** - * Validate a SQL string for syntax errors. + * Validate a boolean expression and return any syntax errors found. * * @param sqlString - * @throws {SyntaxError} with line, column, and message details if a syntax error is found. + * @return Array of validation errors, empty if valid */ -const validate = (sqlString: string) => { - buildParser(sqlString).singleStatement(); +const validateBooleanExpression = (sqlString: string): ValidationError[] => { + const {parser, syntaxErrorListener} = setupParser(sqlString); + parser.standaloneBooleanExpression(); + + return syntaxErrorListener.errors; +}; + +/** + * Validate a sort item list and return any syntax errors found. + * + * @param sqlString + * @return Array of validation errors, empty if valid + */ +const validateSortItemList = (sqlString: string): ValidationError[] => { + const {parser, syntaxErrorListener} = setupParser(sqlString); + parser.standaloneSortItemList(); + + return syntaxErrorListener.errors; }; const MILLISECONDS_PER_SECOND = 1000; @@ -93,7 +143,6 @@ const MILLISECONDS_PER_SECOND = 1000; * @param props.endTimestamp * @param props.timestampKey * @return - * @throws {Error} if the constructed SQL string is not valid. */ const buildSearchQuery = ({ selectItemList, @@ -120,12 +169,6 @@ AND ${endTimestamp.valueOf() / MILLISECONDS_PER_SECOND}`; queryString += `\nLIMIT ${limitValue}`; } - try { - validate(queryString); - } catch (err: unknown) { - console.error(`The constructed SQL is not valid: ${queryString}`, err); - } - return queryString; }; @@ -141,7 +184,6 @@ AND ${endTimestamp.valueOf() / MILLISECONDS_PER_SECOND}`; * @param props.timestampKey * @param props.booleanExpression * @return - * @throws {Error} if the constructed SQL string is not valid. */ const buildTimelineQuery = ({ databaseName, @@ -189,12 +231,6 @@ LEFT JOIN timestamps ON buckets.idx = timestamps.idx ORDER BY timestamps.idx `; - try { - validate(queryString); - } catch (err: unknown) { - console.error(`The constructed SQL is not valid: ${queryString}`, err); - } - return queryString; }; @@ -202,10 +238,13 @@ export { buildSearchQuery, buildTimelineQuery, SyntaxError, - validate, + validateBooleanExpression, + validateSelectItemList, + validateSortItemList, }; export type { BuildSearchQueryProps, BuildTimelineQueryProps, + ValidationError, };