fix(sql): avoid O(n^2) re-tokenization in MySQL routine editing

This commit is contained in:
Melody Ruch authored and GitHub committed 2026-08-24 13:28:41 +08:00
1 parent 2f47a23c4c
commit 8187ef9baf
1 file changed
+140 -39
+140 -39
View File
@@ -312,6 +312,36 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType
let oraclePlSqlStatementEnd: number | null | undefined;
let i = 0;
// Incremental cache for the MySQL routine-block check below: without it, every
// semicolon inside a CREATE PROCEDURE/FUNCTION/TRIGGER body (which, unlike a
// DELIMITER-wrapped body, still contains ordinary internal semicolons) would
// re-tokenize the whole prefix back to statementStart from scratch, and re-walk
// the whole BEGIN/CASE/END nesting from scratch, turning a single edit to an
// N-statement routine body into O(N^2) work (see mysqlRoutineBlockCompleteness).
let mysqlRoutineScan: {
from: number;
scannedTo: number;
lexState: MysqlRoutineLexState;
tokens: MysqlRoutineToken[];
completeness: MysqlRoutineBlockCompleteness;
} | null = null;
const mysqlRoutineTokensUpTo = (to: number): MysqlRoutineToken[] => {
if (!mysqlRoutineScan || mysqlRoutineScan.from !== statementStart) {
mysqlRoutineScan = { from: statementStart, scannedTo: statementStart, lexState: "none", tokens: [], completeness: newMysqlRoutineBlockCompleteness() };
}
if (to > mysqlRoutineScan.scannedTo) {
const { tokens: newTokens, endState } = mysqlRoutineTokens(sql, parameterOptions, mysqlRoutineScan.scannedTo, to, mysqlRoutineScan.lexState);
mysqlRoutineScan.tokens.push(...newTokens);
mysqlRoutineScan.lexState = endState;
mysqlRoutineScan.scannedTo = to;
}
return mysqlRoutineScan.tokens;
};
const mysqlRoutineBlockIsCompleteUpTo = (to: number): boolean => {
const tokens = mysqlRoutineTokensUpTo(to);
return advanceMysqlRoutineBlockCompleteness(mysqlRoutineScan!.completeness, tokens);
};
const isWhitespace = (ch: string) => ch === " " || ch === "\t" || ch === "\r" || ch === "\n";
const nextLineBreak = (from: number) => {
const carriageReturn = sql.indexOf("\r", from);
@@ -355,6 +385,7 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType
pendingMysqlDirectiveLineEnd = -1;
postgresDollarQuotedRoutine = false;
oraclePlSqlStatementEnd = undefined;
mysqlRoutineScan = null;
};
while (i < len) {
@@ -545,9 +576,10 @@ export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType
continue;
}
} else if (ch === ";") {
const isMysqlRoutineBlock = isMysqlRoutineBlockDatabase(databaseType) && statementStart !== -1 && startsWithMysqlRoutineBlock(sql.slice(statementStart, i), parameterOptions);
const routineTokensBeforeSemicolon = isMysqlRoutineBlockDatabase(databaseType) && statementStart !== -1 ? mysqlRoutineTokensUpTo(i) : null;
const isMysqlRoutineBlock = routineTokensBeforeSemicolon !== null && isMysqlRoutineDdlStartFromWords(mysqlRoutineDdlStartWords(routineTokensBeforeSemicolon)) && mysqlRoutineTokensContainBegin(routineTokensBeforeSemicolon);
if (isMysqlRoutineBlock) {
if (!mysqlRoutineBlockIsComplete(sql.slice(statementStart, i + 1), parameterOptions)) {
if (!mysqlRoutineBlockIsCompleteUpTo(i + 1)) {
markContent(i);
i += 1;
continue;
@@ -1528,11 +1560,30 @@ function isMysqlRoutineBlockDatabase(databaseType?: DatabaseType): boolean {
}
function startsWithMysqlRoutineBlock(sql: string, parameterOptions?: SqlParameterOptions): boolean {
return isMysqlRoutineDdlStart(sql, parameterOptions) && mysqlRoutineTokens(sql, parameterOptions).some((token) => token.kind === "word" && token.value === "BEGIN");
return isMysqlRoutineDdlStart(sql, parameterOptions) && mysqlRoutineTokensContainBegin(mysqlRoutineTokens(sql, parameterOptions).tokens);
}
function mysqlRoutineTokensContainBegin(tokens: readonly MysqlRoutineToken[]): boolean {
return tokens.some((token) => token.kind === "word" && token.value === "BEGIN");
}
function isMysqlRoutineDdlStart(sql: string, parameterOptions?: SqlParameterOptions): boolean {
const words = mysqlRoutineWords(sql, parameterOptions).slice(0, 16);
return isMysqlRoutineDdlStartFromWords(mysqlRoutineWords(sql, parameterOptions).slice(0, 16));
}
/** First 16 word tokens, scanned without ever visiting the tail of `tokens` -
* the routine-detection check only ever needs this small fixed-size prefix. */
function mysqlRoutineDdlStartWords(tokens: readonly MysqlRoutineToken[], limit = 16): string[] {
const words: string[] = [];
for (const token of tokens) {
if (token.kind !== "word") continue;
words.push(token.value);
if (words.length >= limit) break;
}
return words;
}
function isMysqlRoutineDdlStartFromWords(words: readonly string[]): boolean {
if (words[0] !== "CREATE") return false;
for (const word of words.slice(1)) {
@@ -1542,53 +1593,102 @@ function isMysqlRoutineDdlStart(sql: string, parameterOptions?: SqlParameterOpti
return false;
}
function mysqlRoutineBlockIsComplete(sql: string, parameterOptions?: SqlParameterOptions): boolean {
if (!startsWithMysqlRoutineBlock(sql, parameterOptions)) return false;
/**
* Incremental, resumable equivalent of walking `mysqlRoutineTokens(...)` with a
* fresh `blockStack`/`sawBegin` on every call. Driven token-by-token from
* `mysqlRoutineBlockIsCompleteUpTo` in splitSqlStatementRanges, so a routine body
* with N internal semicolons costs O(N) total instead of O(N^2) (re-walking the
* whole BEGIN/CASE/END nesting from the top on every semicolon while typing).
*
* Semantics mirror the non-incremental token walk exactly: `lastWordToken` plays
* the role of `previousWordToken(tokens, index)` (both reset to null once a
* semicolon is crossed), and `pendingEndAwaitingSuffix` defers resolving an "END"
* token's effect on `blockStack` until the following token arrives, which plays
* the role of `nextWordToken(tokens, index)`'s lookahead without needing to
* revisit already-processed tokens.
*/
interface MysqlRoutineBlockCompleteness {
processedTokenCount: number;
blockStack: Array<"BEGIN" | "CASE">;
sawBegin: boolean;
lastWordToken: string | null;
pendingEndAwaitingSuffix: boolean;
lastTokenKind: "word" | "semicolon" | null;
}
const tokens = mysqlRoutineTokens(sql, parameterOptions);
const blockStack: Array<"BEGIN" | "CASE"> = [];
let sawBegin = false;
function newMysqlRoutineBlockCompleteness(): MysqlRoutineBlockCompleteness {
return { processedTokenCount: 0, blockStack: [], sawBegin: false, lastWordToken: null, pendingEndAwaitingSuffix: false, lastTokenKind: null };
}
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (token.kind !== "word") continue;
if (token.value === "BEGIN") {
if (previousWordToken(tokens, index) === "END") continue;
sawBegin = true;
blockStack.push("BEGIN");
continue;
}
if (token.value === "CASE") {
if (previousWordToken(tokens, index) === "END") continue;
blockStack.push("CASE");
continue;
}
if (token.value === "END" && sawBegin) {
const suffix = nextWordToken(tokens, index) ?? "";
if (suffix === "CASE") {
if (blockStack[blockStack.length - 1] === "CASE") blockStack.pop();
continue;
/** Folds any `tokens` not yet seen into `state` and returns whether the routine body is complete so far. */
function advanceMysqlRoutineBlockCompleteness(state: MysqlRoutineBlockCompleteness, tokens: readonly MysqlRoutineToken[]): boolean {
while (state.processedTokenCount < tokens.length) {
const token = tokens[state.processedTokenCount];
state.processedTokenCount += 1;
if (token.kind === "semicolon") {
if (state.pendingEndAwaitingSuffix) {
state.blockStack.pop();
state.pendingEndAwaitingSuffix = false;
}
if (MYSQL_CONTROL_BLOCK_SUFFIXES.has(suffix)) continue;
blockStack.pop();
state.lastWordToken = null;
state.lastTokenKind = "semicolon";
continue;
}
const value = token.value;
if (state.pendingEndAwaitingSuffix) {
if (value === "CASE") {
if (state.blockStack[state.blockStack.length - 1] === "CASE") state.blockStack.pop();
} else if (!MYSQL_CONTROL_BLOCK_SUFFIXES.has(value)) {
state.blockStack.pop();
}
state.pendingEndAwaitingSuffix = false;
}
if (value === "BEGIN") {
if (state.lastWordToken !== "END") {
state.sawBegin = true;
state.blockStack.push("BEGIN");
}
} else if (value === "CASE") {
if (state.lastWordToken !== "END") {
state.blockStack.push("CASE");
}
} else if (value === "END" && state.sawBegin) {
state.pendingEndAwaitingSuffix = true;
}
state.lastWordToken = value;
state.lastTokenKind = "word";
}
return sawBegin && blockStack.length === 0 && tokens[tokens.length - 1]?.kind === "semicolon";
return state.sawBegin && state.blockStack.length === 0 && state.lastTokenKind === "semicolon";
}
function mysqlRoutineWords(sql: string, parameterOptions?: SqlParameterOptions): string[] {
return mysqlRoutineTokens(sql, parameterOptions)
.filter((token): token is { kind: "word"; value: string } => token.kind === "word")
.tokens.filter((token): token is { kind: "word"; value: string } => token.kind === "word")
.map((token) => token.value);
}
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;
type MysqlRoutineToken = { kind: "word" | "semicolon"; value: string };
type MysqlRoutineLexState = QuoteState | "lineComment" | "blockComment";
while (i < sql.length) {
const MYSQL_ROUTINE_WORD_RE = /[A-Za-z_][\w$]*/y;
/**
* Tokenizes `sql` starting at `fromIndex` (resuming from `initialState`, the lexer
* state left over from any earlier chunk) and stops at `toIndex`. Callers that
* re-check an ever-growing prefix on every semicolon (see splitSqlStatementRanges)
* rely on this being resumable so they only re-scan the newly typed suffix instead
* of the whole prefix from scratch every time.
*/
function mysqlRoutineTokens(sql: string, parameterOptions?: SqlParameterOptions, fromIndex = 0, toIndex = sql.length, initialState: MysqlRoutineLexState = "none"): { tokens: MysqlRoutineToken[]; endState: MysqlRoutineLexState } {
const tokens: MysqlRoutineToken[] = [];
let state: MysqlRoutineLexState = initialState;
let i = fromIndex;
while (i < toIndex) {
const ch = sql[i];
const next = sql[i + 1] ?? "";
@@ -1678,7 +1778,8 @@ function mysqlRoutineTokens(sql: string, parameterOptions?: SqlParameterOptions)
continue;
}
const word = /^[A-Za-z_][\w$]*/.exec(sql.slice(i))?.[0];
MYSQL_ROUTINE_WORD_RE.lastIndex = i;
const word = MYSQL_ROUTINE_WORD_RE.exec(sql)?.[0];
if (word) {
tokens.push({ kind: "word", value: word.toUpperCase() });
i += word.length;
@@ -1687,7 +1788,7 @@ function mysqlRoutineTokens(sql: string, parameterOptions?: SqlParameterOptions)
i += 1;
}
return tokens;
return { tokens, endState: state };
}
function startsWithOraclePlSqlBlock(sql: string): boolean {