Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
73 changes: 70 additions & 3 deletions components/webui/client/src/components/SqlInput/index.tsx
Original file line number Diff line number Diff line change
@@ -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<Nullable<SqlEditorType>>(null);
const decorationsRef = useRef<Nullable<monaco.editor.IEditorDecorationsCollection>>(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 +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 (
<SqlEditor
options={{
Expand All @@ -64,6 +129,7 @@ const SqlInput = (props: SqlEditorProps) => {
top: 6,
bottom: 4,
},
quickSuggestions: false,
renderLineHighlight: "none",
roundedSelection: false,
scrollBeyondLastColumn: 0,
Expand All @@ -76,8 +142,9 @@ const SqlInput = (props: SqlEditorProps) => {
wordWrap: "off",
}}
onEditorReady={handleEditorReady}
{...props}/>
{...editorProps}/>
);
};

export default SqlInput;
export type {SqlInputProps};
13 changes: 13 additions & 0 deletions components/webui/client/src/components/SqlInput/utils.ts
Original file line number Diff line number Diff line change
@@ -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 `<EOF>`.
*
* @param text
* @return Escaped text.
*/
const escapeHoverMarkdown = (text: string) => text.replace(/[<>]/g, (ch) => ("<" === ch ?
"&lt;" :
"&gt;"));

export {escapeHoverMarkdown};
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
95 changes: 67 additions & 28 deletions components/webui/client/src/sql-parser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
CommonTokenStream,
ErrorListener,
Recognizer,
Token,
} from "antlr4";

import SqlLexer from "./generated/SqlLexer";
Expand All @@ -17,16 +18,36 @@ 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,
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;
Comment on lines +40 to +42
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

🧩 Analysis chain

Verify type safety of Token cast and properties.

The cast offendingSymbol as unknown as Token and subsequent access to token.start and token.stop could fail at runtime if the offending symbol is not actually a Token or lacks these properties. ANTLR4's error listener can receive different symbol types depending on the error context.

Consider adding runtime checks:

-        const token = offendingSymbol as unknown as Token;
-        const startColumn = token.start + 1;
-        const endColumn = token.stop + 2;
+        const token = offendingSymbol as unknown as Token;
+        const startColumn = token?.start !== undefined ? token.start + 1 : column;
+        const endColumn = token?.stop !== undefined ? token.stop + 2 : column + 1;

Alternatively, verify with the ANTLR4 documentation that offendingSymbol is always a Token for lexer/parser errors and document this assumption.


I'll verify this review comment by examining the code context and investigating ANTLR4's error listener specifications.


---

🏁 Script executed:

```shell
#!/bin/bash
# Inspect the file around lines 40-42 to understand the context
cat -n components/webui/client/src/sql-parser/index.ts | sed -n '1,60p'

Length of output: 1806


🌐 Web query:

ANTLR4 error listener offendingSymbol type Token properties

💡 Result:

offendingSymbol (the Token passed to ANTLRErrorListener.syntaxError) implements org.antlr.v4.runtime.Token and exposes these useful properties/methods:

  • getText() — token text. [1]
  • getType() — token type (int). [1]
  • getLine() — 1-based line number. [1]
  • getCharPositionInLine() — char offset in line (0-based). [1]
  • getStartIndex() / getStopIndex() — start/end char indexes in input (may return -1 if not available). [1]
  • getTokenIndex() — index of token in token stream (or -1 if synthetic). [1]
  • getChannel() — token channel (DEFAULT/HIDDEN etc.). [1]
  • getTokenSource() / getInputStream() — source lexer/token stream and underlying CharStream. [1]

In an error listener you typically do:

public void syntaxError(Recognizer r, Object offendingSymbol, int line, int pos, String msg, RecognitionException e) {
Token t = (Token) offendingSymbol;
String text = t.getText();
int type = t.getType();
int start = t.getStartIndex();
int stop = t.getStopIndex();
int tokenIndex = t.getTokenIndex();
// ...
}

The listener contract and the meaning of offendingSymbol are documented on the ANTLR error-listener API. [2]

Sources: ANTLR Token API (methods listed above). [1] ANTLR error-listener / BaseErrorListener docs (offendingSymbol description). [2]


I need to verify the JavaScript/TypeScript ANTLR4 Token interface, as the web search returned Java documentation. Let me investigate further.


---

🏁 Script executed:

```shell
#!/bin/bash
# Search for Token interface definition or usage patterns in the codebase
rg -n "Token\." components/webui/client/src/sql-parser/ -A2 -B2

Length of output: 1108


🏁 Script executed:

#!/bin/bash
# Check if there are other error listener implementations or Token property accesses
rg -n "\.start|\.stop" components/webui/client/src/sql-parser/ -B3 -A1

Length of output: 3932


🏁 Script executed:

#!/bin/bash
# Look for package.json to see antlr4 version
find components/webui/client -name "package.json" -exec cat {} \; | grep -A5 -B5 antlr4 || echo "antlr4 not in found package.json"

Length of output: 350


🌐 Web query:

antlr4 npm JavaScript Token interface properties start stop

💡 Result:

Short answer: In the JS/TypeScript ANTLR runtimes tokens expose startIndex and stopIndex (character offsets); parser contexts expose start and stop Token objects. Use token.startIndex / token.stopIndex for character positions and ctx.start / ctx.stop for the tokens that bracket a rule. [1][2][3]

Sources

  • CommonToken / Token definitions in the antlr4/ANTLR4‑TS TypeScript typings (start/stop → startIndex/stopIndex). [1]
  • ParserRuleContext API: contexts have start and stop Token properties. [2]
  • Note: rule $stop may be null in embedded actions (see discussion). [3]

Use correct Token property names: startIndex and stopIndex.

The review comment correctly identifies a type safety concern, but the underlying issue is more critical than suggested. In the JavaScript ANTLR4 runtime (version 4.13.2), Token objects expose startIndex and stopIndex properties, not start and stop. The current code will fail at runtime attempting to access undefined properties.

Update lines 41–42 to use:

const startColumn = token.startIndex + 1;
const endColumn = token.stopIndex + 2;

The double cast bypasses TypeScript but does not prevent the runtime failure. No optional chaining fallback is needed if you correct the property names.

🤖 Prompt for AI Agents
In components/webui/client/src/sql-parser/index.ts around lines 40 to 42, the
code casts offendingSymbol to Token and accesses non-existent properties
token.start and token.stop which will be undefined at runtime; replace those
property accesses with the correct ANTLR Token properties token.startIndex and
token.stopIndex and remove the unnecessary double-cast so that you compute
startColumn as token.startIndex + 1 and endColumn as token.stopIndex + 2.


this.errors.push({
column: column,
endColumn: endColumn,
line: line,
message: msg,
startColumn: startColumn,
});
}
}

Expand All @@ -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;
};
Comment on lines +98 to 103
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

Consider adding error handling to validation functions.

The validation functions don't have try-catch blocks. If the parser encounters an unexpected error (beyond syntax errors), it will propagate to the caller and potentially crash the UI.

Consider wrapping validation logic:

 const validateSelectItemList = (sqlString: string): ValidationError[] => {
+    try {
         const {parser, syntaxErrorListener} = setupParser(sqlString);
         parser.standaloneSelectItemList();
         return syntaxErrorListener.errors;
+    } catch (error) {
+        console.error("Unexpected validation error:", error);
+        return [{
+            line: 1,
+            column: 0,
+            startColumn: 0,
+            endColumn: sqlString.length,
+            message: "Unexpected validation error occurred"
+        }];
+    }
 };

Apply similar pattern to validateBooleanExpression and validateSortItemList.

Also applies to: 111-116, 124-129

🤖 Prompt for AI Agents
components/webui/client/src/sql-parser/index.ts lines 98-103 (and similarly
111-116, 124-129): the validation functions call the parser directly and can
throw unexpected exceptions; wrap the parser invocation in a try-catch, keep the
existing setupParser and return of syntaxErrorListener.errors in the normal
path, and in the catch block log the caught error (or forward it to the app
logger) and return a ValidationError[] containing a single ValidationError
describing an internal parser/validation failure so the UI doesn't crash; apply
the same try-catch pattern to validateBooleanExpression and
validateSortItemList.


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

Expand All @@ -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,
Expand Down Expand Up @@ -189,23 +231,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