fix(editor): validate MyBatis statement placeholders

This commit is contained in:
zipg authored and GitHub committed 2026-07-29 20:29:13 +08:00
1 parent 9e5dfa3396
commit 5309170bf7
7 files changed
+141 -74

No files matched your search

+49 -43
View File
@@ -1,5 +1,6 @@
import type { SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget";
import { splitMongoCommandRanges } from "@/lib/mongo/mongoShellCommand";
import { readSqlBracedParameterAt, type SqlParameterOptions } from "@/lib/sql/sqlParameters";
import type { DatabaseType } from "@/types/database";
/**
@@ -18,11 +19,11 @@ export function supportsExecutionTargetPicker(databaseType?: DatabaseType): bool
return !!databaseType && (databaseType === "redis" || databaseType === "elasticsearch" || !NON_SQL_EXECUTION_TARGET_TYPES.has(databaseType));
}
export function hasMultipleExecutionTargets(sql: string, databaseType?: DatabaseType): boolean {
export function hasMultipleExecutionTargets(sql: string, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): boolean {
if (databaseType === "redis") {
return redisExecutableCommandCount(sql) > 1;
}
return splitSqlStatementRanges(sql, databaseType).length > 1;
return splitSqlStatementRanges(sql, databaseType, parameterOptions).length > 1;
}
interface RawStatement {
@@ -272,7 +273,7 @@ const SAP_HANA_SCRIPT_BLOCK_TERMINATORS = new Set(["IF", "FOR", "WHILE"]);
* only the statement text (the trailing semicolon and inter-statement
* whitespace are excluded so editor highlights stay tight).
*/
export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType): RawStatement[] {
export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): RawStatement[] {
if (databaseType === "elasticsearch") {
const requests = splitElasticsearchRestRequestRanges(sql);
if (requests) return requests;
@@ -423,7 +424,7 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType
i = newline === -1 ? len : newline + 1;
continue;
}
if (ch === "#") {
if (startsHashLineComment(sql, i, parameterOptions)) {
const newline = sql.indexOf("\n", i);
i = newline === -1 ? len : newline + 1;
continue;
@@ -491,9 +492,9 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType
continue;
}
} else if (ch === ";") {
const isMysqlRoutineBlock = isMysqlRoutineBlockDatabase(databaseType) && statementStart !== -1 && startsWithMysqlRoutineBlock(sql.slice(statementStart, i));
const isMysqlRoutineBlock = isMysqlRoutineBlockDatabase(databaseType) && statementStart !== -1 && startsWithMysqlRoutineBlock(sql.slice(statementStart, i), parameterOptions);
if (isMysqlRoutineBlock) {
if (!mysqlRoutineBlockIsComplete(sql.slice(statementStart, i + 1))) {
if (!mysqlRoutineBlockIsComplete(sql.slice(statementStart, i + 1), parameterOptions)) {
markContent(i);
i += 1;
continue;
@@ -544,14 +545,14 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType
* The returned range covers only the statement's own text (no trailing `;`),
* which lets the editor highlight a tight preview range.
*/
export function statementRangeAtCursor(sql: string, cursorPos: number, databaseType?: DatabaseType): SqlTextRange | null {
export function statementRangeAtCursor(sql: string, cursorPos: number, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): SqlTextRange | null {
const pos = clampCursor(sql, cursorPos);
if (isCursorOnBlankLine(sql, pos)) return null;
const statements = splitSqlStatementRanges(sql, databaseType);
const statements = splitSqlStatementRanges(sql, databaseType, parameterOptions);
for (let index = 0; index < statements.length; index += 1) {
const statement = statements[index];
const softRanges = splitStatementRangeAtSoftStarts(sql, statement, databaseType);
const softRanges = splitStatementRangeAtSoftStarts(sql, statement, databaseType, parameterOptions);
// Cursor inside the statement body, including the exact start/end.
if (pos >= statement.from && pos <= statement.to) {
return rangeForCursorInSoftRanges(sql, softRanges, pos) ?? rangeFor(statement, sql);
@@ -569,7 +570,7 @@ export function statementRangeAtCursor(sql: string, cursorPos: number, databaseT
if (pos >= statement.hitFrom && pos < statement.from && (sql.slice(pos, statement.from).trim() === "" || (databaseType === "elasticsearch" && isElasticsearchRequestPreamble(sql.slice(statement.hitFrom, statement.from))))) {
const previous = statements[index - 1];
if (previous && isCursorInSameLineDelimiterGap(sql, previous.to, pos)) {
const previousSoftRanges = splitStatementRangeAtSoftStarts(sql, previous, databaseType);
const previousSoftRanges = splitStatementRangeAtSoftStarts(sql, previous, databaseType, parameterOptions);
return rangeForCursorInSoftRanges(sql, previousSoftRanges, pos) ?? rangeFor(previous, sql);
}
return rangeForCursorInSoftRanges(sql, softRanges, pos) ?? rangeFor(statement, sql);
@@ -631,13 +632,13 @@ function rangeForCursorInSoftRanges(sql: string, ranges: RawStatement[], pos: nu
return null;
}
function splitStatementRangeAtSoftStarts(sql: string, statement: RawStatement, databaseType?: DatabaseType): RawStatement[] {
function splitStatementRangeAtSoftStarts(sql: string, statement: RawStatement, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): RawStatement[] {
if (isOraclePlSqlStatement(statement.sql, databaseType)) return [statement];
if (isSapHanaScriptBlockStatement(statement.sql, databaseType)) return [statement];
// Routine bodies contain top-level-looking SET/INSERT/SELECT lines that are not independent statements.
if (isMysqlRoutineBlockDatabase(databaseType) && startsWithMysqlRoutineBlock(statement.sql)) return [statement];
if (isMysqlRoutineBlockDatabase(databaseType) && startsWithMysqlRoutineBlock(statement.sql, parameterOptions)) return [statement];
const lineStarts = topLevelSoftStatementLineStarts(sql, statement, databaseType);
const lineStarts = topLevelSoftStatementLineStarts(sql, statement, databaseType, parameterOptions);
if (lineStarts.length <= 1) return [statement];
const boundaries: Array<{ hitFrom: number; from: number; keyword: string }> = [];
@@ -661,7 +662,7 @@ function splitStatementRangeAtSoftStarts(sql: string, statement: RawStatement, d
continue;
}
if (isSetOperationQueryContinuation(sql, statement.from, lineStart.from, lineStart.keyword)) {
if (isSetOperationQueryContinuation(sql, statement.from, lineStart.from, lineStart.keyword, parameterOptions)) {
continue;
}
@@ -719,7 +720,7 @@ function splitStatementRangeAtSoftStarts(sql: string, statement: RawStatement, d
for (let index = 0; index < boundaries.length; index += 1) {
const boundary = boundaries[index];
const next = boundaries[index + 1];
const to = next ? trimRangeEndBeforeNextBoundary(sql, boundary.from, next.from) : trimRangeEnd(sql, boundary.from, statement.to);
const to = next ? trimRangeEndBeforeNextBoundary(sql, boundary.from, next.from, parameterOptions) : trimRangeEnd(sql, boundary.from, statement.to);
if (to > boundary.from) {
ranges.push({
hitFrom: boundary.hitFrom,
@@ -733,7 +734,7 @@ function splitStatementRangeAtSoftStarts(sql: string, statement: RawStatement, d
return ranges.length > 0 ? ranges : [statement];
}
function topLevelSoftStatementLineStarts(sql: string, statement: RawStatement, databaseType?: DatabaseType): Array<{ hitFrom: number; from: number; keyword: string }> {
function topLevelSoftStatementLineStarts(sql: string, statement: RawStatement, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): Array<{ hitFrom: number; from: number; keyword: string }> {
const starts: Array<{ hitFrom: number; from: number; keyword: string }> = [];
const len = statement.to;
const explainOptionsStart = explainOptionsParenAt(sql, statement.from);
@@ -751,7 +752,7 @@ function topLevelSoftStatementLineStarts(sql: string, statement: RawStatement, d
const ch = sql[i];
const next = sql[i + 1] ?? "";
if (state === "none" && firstNonWhitespaceOnLine === -1 && ch !== "\n" && ch !== "\r" && !isSqlWhitespace(ch) && !startsLineComment(sql, i) && !startsBlockComment(sql, i)) {
if (state === "none" && firstNonWhitespaceOnLine === -1 && ch !== "\n" && ch !== "\r" && !isSqlWhitespace(ch) && !startsLineComment(sql, i, parameterOptions) && !startsBlockComment(sql, i)) {
firstNonWhitespaceOnLine = i;
if (parenDepth === 0) {
const keyword = softStatementKeywordAt(sql, i, databaseType);
@@ -850,7 +851,7 @@ function topLevelSoftStatementLineStarts(sql: string, statement: RawStatement, d
i += 2;
continue;
}
if (ch === "#") {
if (startsHashLineComment(sql, i, parameterOptions)) {
state = "lineComment";
i += 1;
continue;
@@ -915,9 +916,9 @@ function softStatementStartKeywords(databaseType?: DatabaseType): Set<string> {
return new Set([...COMMON_SOFT_STATEMENT_START_KEYWORDS, ...(databaseType ? (DATABASE_SOFT_STATEMENT_KEYWORDS[databaseType] ?? []) : [])]);
}
function isSetOperationQueryContinuation(sql: string, from: number, to: number, keyword: string): boolean {
function isSetOperationQueryContinuation(sql: string, from: number, to: number, keyword: string, parameterOptions?: SqlParameterOptions): boolean {
if (keyword !== "SELECT" && keyword !== "WITH") return false;
const words = topLevelWordsBefore(sql, from, to, 3);
const words = topLevelWordsBefore(sql, from, to, 3, parameterOptions);
const last = words[words.length - 1];
if (last && SET_OPERATION_KEYWORDS.has(last)) return true;
if (last && SET_OPERATION_MODIFIER_KEYWORDS.has(last)) {
@@ -952,7 +953,7 @@ function startsWithMysqlCreateTable(sql: string, statementFrom: number): boolean
return /^CREATE\s+(?:TEMPORARY\s+)?TABLE\b/i.test(text);
}
function topLevelWordsBefore(sql: string, from: number, to: number, limit: number): string[] {
function topLevelWordsBefore(sql: string, from: number, to: number, limit: number, parameterOptions?: SqlParameterOptions): string[] {
const words: string[] = [];
let state: QuoteState | "lineComment" | "blockComment" = "none";
let dollarTag = "";
@@ -1044,7 +1045,7 @@ function topLevelWordsBefore(sql: string, from: number, to: number, limit: numbe
i += 2;
continue;
}
if (ch === "#") {
if (startsHashLineComment(sql, i, parameterOptions)) {
state = "lineComment";
i += 1;
continue;
@@ -1267,8 +1268,13 @@ function skipBalancedParens(sql: string, pos: number): number | null {
return null;
}
function startsLineComment(sql: string, pos: number): boolean {
return (sql[pos] === "-" && sql[pos + 1] === "-") || sql[pos] === "#";
function startsLineComment(sql: string, pos: number, parameterOptions?: SqlParameterOptions): boolean {
return (sql[pos] === "-" && sql[pos + 1] === "-") || startsHashLineComment(sql, pos, parameterOptions);
}
function startsHashLineComment(sql: string, pos: number, parameterOptions?: SqlParameterOptions): boolean {
if (sql[pos] !== "#") return false;
return readSqlBracedParameterAt(sql, pos, parameterOptions)?.syntax !== "mybatis";
}
function startsBlockComment(sql: string, pos: number): boolean {
@@ -1283,7 +1289,7 @@ function trimRangeEnd(sql: string, from: number, to: number): number {
return end;
}
function trimRangeEndBeforeNextBoundary(sql: string, from: number, nextBoundaryFrom: number): number {
function trimRangeEndBeforeNextBoundary(sql: string, from: number, nextBoundaryFrom: number, parameterOptions?: SqlParameterOptions): number {
let state: QuoteState | "lineComment" | "blockComment" = "none";
let dollarTag = "";
let lastContentEnd = from;
@@ -1384,7 +1390,7 @@ function trimRangeEndBeforeNextBoundary(sql: string, from: number, nextBoundaryF
i += 2;
continue;
}
if (ch === "#") {
if (startsHashLineComment(sql, i, parameterOptions)) {
state = "lineComment";
i += 1;
continue;
@@ -1462,12 +1468,12 @@ function isMysqlRoutineBlockDatabase(databaseType?: DatabaseType): boolean {
return !!databaseType && MYSQL_ROUTINE_BLOCK_DATABASES.has(databaseType);
}
function startsWithMysqlRoutineBlock(sql: string): boolean {
return isMysqlRoutineDdlStart(sql) && mysqlRoutineTokens(sql).some((token) => token.kind === "word" && token.value === "BEGIN");
function startsWithMysqlRoutineBlock(sql: string, parameterOptions?: SqlParameterOptions): boolean {
return isMysqlRoutineDdlStart(sql, parameterOptions) && mysqlRoutineTokens(sql, parameterOptions).some((token) => token.kind === "word" && token.value === "BEGIN");
}
function isMysqlRoutineDdlStart(sql: string): boolean {
const words = mysqlRoutineWords(sql).slice(0, 16);
function isMysqlRoutineDdlStart(sql: string, parameterOptions?: SqlParameterOptions): boolean {
const words = mysqlRoutineWords(sql, parameterOptions).slice(0, 16);
if (words[0] !== "CREATE") return false;
for (const word of words.slice(1)) {
@@ -1477,10 +1483,10 @@ function isMysqlRoutineDdlStart(sql: string): boolean {
return false;
}
function mysqlRoutineBlockIsComplete(sql: string): boolean {
if (!startsWithMysqlRoutineBlock(sql)) return false;
function mysqlRoutineBlockIsComplete(sql: string, parameterOptions?: SqlParameterOptions): boolean {
if (!startsWithMysqlRoutineBlock(sql, parameterOptions)) return false;
const tokens = mysqlRoutineTokens(sql);
const tokens = mysqlRoutineTokens(sql, parameterOptions);
let beginDepth = 0;
let sawBegin = false;
@@ -1502,13 +1508,13 @@ function mysqlRoutineBlockIsComplete(sql: string): boolean {
return sawBegin && beginDepth === 0 && tokens[tokens.length - 1]?.kind === "semicolon";
}
function mysqlRoutineWords(sql: string): string[] {
return mysqlRoutineTokens(sql)
function mysqlRoutineWords(sql: string, parameterOptions?: SqlParameterOptions): string[] {
return mysqlRoutineTokens(sql, parameterOptions)
.filter((token): token is { kind: "word"; value: string } => token.kind === "word")
.map((token) => token.value);
}
function mysqlRoutineTokens(sql: string): Array<{ kind: "word" | "semicolon"; value: string }> {
function mysqlRoutineTokens(sql: string, parameterOptions?: SqlParameterOptions): Array<{ kind: "word" | "semicolon"; value: string }> {
const tokens: Array<{ kind: "word" | "semicolon"; value: string }> = [];
let state: QuoteState | "lineComment" | "blockComment" = "none";
let i = 0;
@@ -1572,7 +1578,7 @@ function mysqlRoutineTokens(sql: string): Array<{ kind: "word" | "semicolon"; va
i += 2;
continue;
}
if (ch === "#") {
if (startsHashLineComment(sql, i, parameterOptions)) {
state = "lineComment";
i += 1;
continue;
@@ -1975,21 +1981,21 @@ function normalizeSql(sql: string): string {
* cursor statement and the full document are effectively the same SQL — in
* that case only a single candidate is returned to avoid duplicates.
*/
export function executableStatementRanges(sql: string, databaseType?: DatabaseType): SqlTextRange[] {
export function executableStatementRanges(sql: string, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): SqlTextRange[] {
if (databaseType === "redis") return redisExecutableCommandRanges(sql);
if (databaseType === "mongodb") return splitMongoCommandRanges(sql).map(({ from, to, text }) => ({ from, to, sql: text }));
return splitSqlStatementRanges(sql, databaseType).flatMap((statement) => splitStatementRangeAtSoftStarts(sql, statement, databaseType).map((range) => rangeFor(range, sql)));
return splitSqlStatementRanges(sql, databaseType, parameterOptions).flatMap((statement) => splitStatementRangeAtSoftStarts(sql, statement, databaseType, parameterOptions).map((range) => rangeFor(range, sql)));
}
export function currentExecutableStatementRange(sql: string, cursorPos: number, databaseType?: DatabaseType): SqlTextRange | null {
export function currentExecutableStatementRange(sql: string, cursorPos: number, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): SqlTextRange | null {
if (databaseType === "redis") return redisCommandRangeAtCursor(sql, cursorPos);
if (databaseType === "mongodb") return null;
return statementRangeAtCursor(sql, cursorPos, databaseType);
return statementRangeAtCursor(sql, cursorPos, databaseType, parameterOptions);
}
export function buildExecutionCandidates(sql: string, cursorPos: number, databaseType?: DatabaseType): SqlExecutionCandidate[] {
export function buildExecutionCandidates(sql: string, cursorPos: number, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): SqlExecutionCandidate[] {
const full = fullSqlRange(sql);
const cursorStatement = currentExecutableStatementRange(sql, cursorPos, databaseType);
const cursorStatement = currentExecutableStatementRange(sql, cursorPos, databaseType, parameterOptions);
if (!full && !cursorStatement) return [];
if (!full) {