diff --git a/components/webui/client/src/components/SqlInput/index.tsx b/components/webui/client/src/components/SqlInput/index.tsx index 540ab5d124..50de8d0667 100644 --- a/components/webui/client/src/components/SqlInput/index.tsx +++ b/components/webui/client/src/components/SqlInput/index.tsx @@ -1,22 +1,40 @@ // 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"; +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 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 +60,41 @@ const SqlInput = (props: SqlEditorProps) => { }); }, []); + // Validate SQL and update markers whenever value changes + useEffect(() => { + const editor = editorRef.current; + if (null === editor) { + return; + } + + const model = editor.getModel(); + if (null === model) { + return; + } + + const value = editorProps.value ?? ""; + + // Clear markers if no validation function or empty/whitespace-only input + if ("undefined" === typeof validateFn || "" === value.trim()) { + monaco.editor.setModelMarkers(model, "sql-parser", []); + + return; + } + + const errors = validateFn(value); + const markers: monaco.editor.IMarkerData[] = errors.map((error) => ({ + endColumn: error.endColumn, + endLineNumber: error.line, + message: error.message, + severity: monaco.MarkerSeverity.Error, + startColumn: error.startColumn, + startLineNumber: error.line, + })); + + monaco.editor.setModelMarkers(model, "sql-parser", markers); + }, [editorProps.value, + validateFn]); + return ( { wordWrap: "off", }} onEditorReady={handleEditorReady} - {...props}/> + {...editorProps}/> ); }; export default SqlInput; +export type {SqlInputProps}; 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..3b00896adc 100644 --- a/components/webui/client/src/sql-parser/index.ts +++ b/components/webui/client/src/sql-parser/index.ts @@ -3,6 +3,7 @@ import { CharStream, CommonTokenStream, ErrorListener, + RecognitionException, Recognizer, } from "antlr4"; @@ -17,16 +18,41 @@ 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, line: number, column: number, msg: string, + e: RecognitionException | undefined, ) { - throw new SyntaxError(`line ${line}:${column}: ${msg}`); + let startColumn = column + 1; + let endColumn = column + 2; + + if (e?.offendingToken) { + startColumn = e.offendingToken.start + 1; + endColumn = e.offendingToken.stop + 2; + } + + this.errors.push({ + column: column, + endColumn: endColumn, + line: line, + message: msg, + startColumn: startColumn, + }); } } @@ -51,31 +77,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 +148,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 +174,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 +189,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 +236,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 +243,13 @@ export { buildSearchQuery, buildTimelineQuery, SyntaxError, - validate, + validateBooleanExpression, + validateSelectItemList, + validateSortItemList, }; export type { BuildSearchQueryProps, BuildTimelineQueryProps, + ValidationError, };