fix(sql): avoid O(n^2) re-tokenization in MySQL routine editing
This commit is contained in:
1 parent
2f47a23c4c
commit
8187ef9baf
1 file changed
+140
-39
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user