Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 58 additions & 3 deletions components/webui/client/src/components/SqlInput/index.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,41 @@
// Reference: https://github.com/vikyd/vue-monaco-singleline
import {useCallback} from "react";
import {
useCallback,
useEffect,
useRef,
} from "react";
Comment on lines +2 to +6
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix import sorting to resolve pipeline failure.

The pipeline reports an import sorting violation that must be resolved before merge.

Run the autofix command to sort the imports:

npm run lint:fix

Or manually reorder if needed.

🧰 Tools
🪛 GitHub Actions: clp-lint

[warning] 2-2: Run autofix to sort these imports! simple-import-sort/imports

🤖 Prompt for AI Agents
In components/webui/client/src/components/SqlInput/index.tsx around lines 2 to
6, import statements are not sorted which triggers the lint pipeline; run the
autofix to reorder imports (npm run lint:fix) or manually reorder the imports to
match the project's ESLint/organize-imports rules (group external packages, then
local imports, alphabetize within groups), save the file, and commit the change
to clear the import-sorting violation.


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<Nullable<SqlEditorType>>(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) => {
Expand All @@ -42,6 +61,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]);
Comment on lines +63 to +96
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

LGTM with optional error-handling suggestion.

The validation logic correctly applies Monaco markers and follows the coding guidelines with explicit comparisons. The useEffect dependencies are accurate.

Optionally, consider wrapping the validator call in a try-catch to handle unexpected parse errors gracefully:

     const errors = validateFn(value);
+    try {
+        errors = validateFn(value);
+    } catch (e) {
+        console.error("Validation failed:", e);
+        return;
+    }
     const markers: monaco.editor.IMarkerData[] = errors.map((error) => ({

This would prevent the component from crashing if the validator throws an unexpected error.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In components/webui/client/src/components/SqlInput/index.tsx around lines 64 to
97, the call to validateFn(value) is not guarded against runtime exceptions
which could crash the component; wrap the validator invocation in a try-catch,
on error log or report the exception (e.g., console.error or a telemetry call),
clear any existing markers via monaco.editor.setModelMarkers(model,
"sql-parser", []) and return so the component remains stable, and keep the
existing marker-setting logic in the try block for successful validations.


return (
<SqlEditor
options={{
Expand Down Expand Up @@ -76,8 +130,9 @@ const SqlInput = (props: SqlEditorProps) => {
wordWrap: "off",
}}
onEditorReady={handleEditorReady}
{...props}/>
{...editorProps}/>
);
};

export default SqlInput;
export type {SqlInputProps};
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -24,6 +25,7 @@ const OrderBy = () => {
<SqlInput
className={guidedGrid["noLeftBorderRadius"] || ""}
disabled={disabled}
validateFn={validateSortItemList}
value={orderBy}
onChange={(value) => {
updateOrderBy(value || "");
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -24,6 +25,7 @@ const Select = () => {
<SqlInput
className={guidedGrid["noLeftBorderRadius"] || ""}
disabled={disabled}
validateFn={validateSelectItemList}
value={select}
onChange={(value) => {
updateSelect(value || "");
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -24,6 +25,7 @@ const Where = () => {
<SqlInput
className={guidedGrid["noLeftBorderRadius"] || ""}
disabled={disabled}
validateFn={validateBooleanExpression}
value={where}
onChange={(value) => {
updateWhere(value || "");
Expand Down
98 changes: 71 additions & 27 deletions components/webui/client/src/sql-parser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
CharStream,
CommonTokenStream,
ErrorListener,
RecognitionException,
Recognizer,
} from "antlr4";

Expand All @@ -17,16 +18,41 @@ import {
class SyntaxError extends Error {
}

interface ValidationError {
line: number;
column: number;
message: string;
startColumn: number;
endColumn: number;
}

class SyntaxErrorListener<TSymbol> extends ErrorListener<TSymbol> {
// eslint-disable-next-line max-params, class-methods-use-this
errors: ValidationError[] = [];

// eslint-disable-next-line max-params
override syntaxError (
_recognizer: Recognizer<TSymbol>,
_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,
});
}
}

Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -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;
};

Expand All @@ -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,
Expand Down Expand Up @@ -189,23 +236,20 @@ 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;
};

export {
buildSearchQuery,
buildTimelineQuery,
SyntaxError,
validate,
validateBooleanExpression,
validateSelectItemList,
validateSortItemList,
};

export type {
BuildSearchQueryProps,
BuildTimelineQueryProps,
ValidationError,
};
Loading