fix(editor): complete mysql routine params and local variables

This commit is contained in:
zipg
2026-10-17 15:18:52 +08:00
committed by GitHub
parent 1a88b640f6
commit 44905e021b
7 changed files with 234 additions and 5 deletions
@@ -6,7 +6,7 @@ import { createI18n } from "vue-i18n";
import { language, syntaxTree } from "@codemirror/language";
import { EditorView, showTooltip } from "@codemirror/view";
import { toggleLineComment, undo } from "@codemirror/commands";
import { completionStatus, currentCompletions, startCompletion } from "@codemirror/autocomplete";
import { acceptCompletion, setSelectedCompletion, completionStatus, currentCompletions, startCompletion } from "@codemirror/autocomplete";
import { afterEach, describe, expect, it, vi } from "vitest";
import QueryEditor from "../QueryEditor.vue";
import type { QueryEditorProps } from "../queryEditorTypes";
@@ -69,6 +69,20 @@ async function expectSqlCompletion(view: EditorView) {
}
describe("QueryEditor language capabilities", () => {
it.each([
["p_", "p_input"],
["v_", "v_local"],
])("completes and inserts MySQL routine value %s (#12293)", async (prefix, expected) => {
const sql = `CREATE PROCEDURE p(IN p_input INT)\nBEGIN\nDECLARE v_local INT;\nSELECT ${prefix}`;
const { view } = await mountEditor({ databaseType: "mysql", modelValue: sql, initialSelection: { anchor: sql.length, head: sql.length } });
expect(startCompletion(view)).toBe(true);
await vi.waitFor(() => expect(completionStatus(view.state)).toBe("active"));
expect(currentCompletions(view.state)[0]?.label).toBe(expected);
view.dispatch({ effects: setSelectedCompletion(0) });
await vi.waitFor(() => expect(acceptCompletion(view)).toBe(true));
expect(view.state.doc.toString()).toBe(sql.slice(0, -prefix.length) + expected);
});
it("does not install SQL grammar or expose SQL completions for Qdrant", async () => {
const { view } = await mountEditor();
expectPlainText(view);
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { buildSqlCompletionItems } from "@/lib/sql/sqlCompletion";
const variables = (source: string, databaseType: "mysql" | "postgres" = "mysql") => {
const cursor = source.indexOf("|");
const sql = source.replace("|", "");
return buildSqlCompletionItems(sql, cursor < 0 ? sql.length : cursor, { databaseType, dialect: databaseType, tables: [], columnsByTable: new Map() }).filter((item) => item.type === "variable");
};
const procedure = (body: string) => `CREATE DEFINER=\`root\`@\`localhost\` PROCEDURE db.p(IN p_input INT, OUT p_output DECIMAL(10,2), INOUT p_count INT) BEGIN ${body}`;
describe("MySQL routine variable completion (#12293)", () => {
it("suggests IN, OUT and INOUT parameters without treating datatype commas as parameters", () => {
expect(variables(procedure("SELECT p_")).map((item) => item.label)).toEqual(expect.arrayContaining(["p_input", "p_output", "p_count"]));
});
it("suggests multiple local declarations and excludes the declaration after the cursor", () => {
const names = variables(procedure("DECLARE v_first, v_second INT DEFAULT 1; DECLARE v_third VARCHAR(10); SELECT v_|; DECLARE v_later INT;")).map((item) => item.label);
expect(names).toEqual(expect.arrayContaining(["v_first", "v_second", "v_third"]));
expect(names).not.toContain("v_later");
});
it("filters candidates by prefix and preserves identifier casing", () => {
expect(variables(procedure("DECLARE v_Mixed INT; SELECT v_mi"))).toEqual([expect.objectContaining({ label: "v_Mixed", apply: "v_Mixed" })]);
});
it("supports function parameters in a simple RETURN body", () => {
expect(variables("CREATE FUNCTION f(p_value INT) RETURNS INT DETERMINISTIC RETURN p_v")).toEqual([expect.objectContaining({ label: "p_value" })]);
});
it("does not leak a completed routine into another statement or routine", () => {
expect(variables(procedure("SELECT 1; END; SELECT p_"))).toEqual([]);
expect(variables("CREATE FUNCTION f(p_old INT) RETURNS INT RETURN p_old; SELECT p_")).toEqual([]);
expect(variables(procedure("SELECT 1; END; CREATE PROCEDURE second(p_other INT) BEGIN SELECT p_")).map((item) => item.label)).toEqual(["p_other"]);
});
it("restores outer scope after a nested block and handles shadowing case-insensitively", () => {
expect(variables(procedure("DECLARE v_outer INT; BEGIN DECLARE v_inner INT; SELECT v_")).map((item) => item.label)).toEqual(expect.arrayContaining(["v_outer", "v_inner"]));
expect(variables(procedure("DECLARE v_outer INT; BEGIN DECLARE v_inner INT; SELECT 1; END; SELECT v_")).map((item) => item.label)).toEqual(["v_outer"]);
expect(variables(procedure("DECLARE P_INPUT INT; SELECT p_i")).map((item) => item.label)).toEqual(["P_INPUT"]);
});
it.each([
"IF p_input > 0 THEN SELECT 1; END IF;",
"WHILE p_input > 0 DO SET p_input = p_input - 1; END WHILE;",
"my_loop: LOOP LEAVE my_loop; END LOOP my_loop;",
"REPEAT SET p_input = p_input - 1; UNTIL p_input = 0 END REPEAT;",
"CASE p_input WHEN 1 THEN SELECT 1; ELSE SELECT 2; END CASE;",
"SELECT CASE WHEN p_input = 1 THEN 2 ELSE 3 END;",
"SELECT (CASE WHEN p_input = 1 THEN 2 ELSE 3 END);",
"SELECT IF(p_input = 1, 2, 3);",
])("keeps the routine scope after control flow or expressions: %s", (body) => {
expect(variables(procedure(`${body} SELECT p_i`)).map((item) => item.label)).toEqual(["p_input"]);
});
it("does not interpret qualified columns as block keywords", () => {
expect(variables(procedure("SELECT t.begin, t.end, t.case FROM t; SELECT p_i")).map((item) => item.label)).toEqual(["p_input"]);
});
it("ignores strings, comments, cursors, conditions and handlers", () => {
const names = variables(
procedure(`DECLARE v_real INT; DECLARE v_condition CONDITION FOR SQLSTATE '45000'; DECLARE v_cursor CURSOR FOR SELECT 1; DECLARE CONTINUE HANDLER FOR NOT FOUND SET p_count = 0; -- DECLARE v_comment INT;\n SELECT 'BEGIN DECLARE v_string INT; END', "DECLARE v_double INT;"; SELECT v_`),
).map((item) => item.label);
expect(names).toEqual(["v_real"]);
});
it("quotes identifiers when required by MySQL", () => {
expect(variables("CREATE PROCEDURE p(IN `p value` INT) BEGIN SELECT p")).toContainEqual(expect.objectContaining({ label: "p value", apply: "`p value`" }));
});
it.each(["SELECT t.p_", "SELECT @p_", "SELECT @@p_", "SELECT * FROM p_", "CALL p_", "INSERT INTO t(p_"])("does not offer local values in an object or qualified context: %s", (tail) => {
expect(variables(procedure(tail))).toEqual([]);
});
it.each(["SET p_output = p_i", "IF p_i", "SELECT COALESCE(p_i", "SELECT * FROM t WHERE id = p_i"])("suggests values in expression contexts: %s", (tail) => {
expect(variables(procedure(tail)).map((item) => item.label)).toEqual(["p_input"]);
});
it.each(["$$", "//"])("respects DELIMITER %s scripts", (delimiter) => {
const first = `DELIMITER ${delimiter}\nCREATE PROCEDURE old(p_old INT) BEGIN SELECT 1; END${delimiter}\n`;
expect(variables(`${first}CREATE PROCEDURE current(p_current INT) BEGIN SELECT p_`).map((item) => item.label)).toEqual(["p_current"]);
expect(variables(`${first}DELIMITER ;\nSELECT p_`)).toEqual([]);
expect(variables(`DELIMITER ${delimiter}\nCREATE PROCEDURE current(p_input INT) BEGIN SELECT '${delimiter}'; SELECT p_i`).map((item) => item.label)).toEqual(["p_input"]);
});
it("does not collect values outside a MySQL routine", () => {
expect(variables("DECLARE v_outside INT; SELECT v_")).toEqual([]);
expect(variables(procedure("SELECT p_"), "postgres")).toEqual([]);
});
});
@@ -0,0 +1,124 @@
import { splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges";
import { tokenizeSqlSemantic } from "@/lib/sql/semantic/tokens";
import type { SqlSemanticToken } from "@/lib/sql/semantic/types";
type Scope = { kind: string; depth: number; variables: string[] };
const CONTROL_BLOCKS = new Set(["if", "loop", "while", "repeat"]);
const OTHER_CREATE_OBJECTS = new Set(["table", "view", "trigger", "event", "database", "schema", "index", "user", "role"]);
function identifier(token: SqlSemanticToken | undefined): string | undefined {
if (!token || (token.kind !== "word" && !(token.kind === "quoted_identifier" && token.quote === "`"))) return undefined;
if (token.text.startsWith("@")) return undefined;
return token.kind === "quoted_identifier" ? token.text.slice(1, -1).replaceAll("``", "`") : token.text;
}
function declarationNames(tokens: SqlSemanticToken[], start: number): string[] {
const names: string[] = [];
let index = start;
while (index < tokens.length) {
const name = identifier(tokens[index]);
if (!name) return [];
names.push(name);
index += 1;
if (tokens[index]?.text !== ",") break;
index += 1;
}
const type = tokens[index];
// Cursor, condition and handler declarations share DECLARE's prefix, but do
// not introduce scalar values that can be used in SQL expressions.
if (type?.kind !== "word" || ["condition", "cursor", "handler"].includes(type.normalized) || ["continue", "exit", "undo"].includes(tokens[start]?.normalized ?? "")) return [];
return names;
}
/** Values visible at the cursor in a MySQL procedure or function definition. */
export function collectMysqlRoutineVariables(sql: string, cursor: number): string[] {
let beforeCursor = sql.slice(0, Math.max(0, Math.min(cursor, sql.length)));
if (!/\b(?:procedure|function)\b/i.test(beforeCursor)) return [];
// Client delimiters such as END$$ are not SQL words. Let the execution
// splitter isolate the current definition instead of tokenizing its suffix.
if (/^\s*delimiter(?:[ \t]|;)/im.test(beforeCursor)) {
const statements = splitSqlStatementRanges(beforeCursor, "mysql");
const statement = statements[statements.length - 1];
if (!statement || beforeCursor.slice(statement.to).trim()) return [];
beforeCursor = beforeCursor.slice(statement.from, statement.to);
}
const tokens = tokenizeSqlSemantic(beforeCursor, "mysql", { mysqlDoubleQuoteIsString: true }).filter((token) => token.kind !== "comment");
let parameters: string[] | undefined;
const scopes: Scope[] = [];
let startedBody = false;
let statementStart = false;
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index]!;
const word = token.kind === "word" && tokens[index - 1]?.text !== "." ? token.normalized : "";
if (!parameters && token.depth === 0 && word === "create") {
let routine = index + 1;
while (routine < tokens.length) {
const current = tokens[routine]!;
if (current.text === ";" || (current.kind === "word" && OTHER_CREATE_OBJECTS.has(current.normalized))) break;
if (current.depth === 0 && current.kind === "word" && ["procedure", "function"].includes(current.normalized)) break;
routine += 1;
}
if (!["procedure", "function"].includes(tokens[routine]?.normalized ?? "")) continue;
let open = routine + 1;
if (!identifier(tokens[open])) continue;
open += 1;
if (tokens[open]?.text === ".") {
if (!identifier(tokens[open + 1])) continue;
open += 2;
}
if (tokens[open]?.text !== "(") continue;
let close = open + 1;
while (close < tokens.length && !(tokens[close]?.text === ")" && tokens[close]?.depth === 0)) close += 1;
if (close === tokens.length) continue;
parameters = [];
for (let parameter = open + 1; parameter < close; parameter += 1) {
if (parameter !== open + 1 && !(tokens[parameter - 1]?.text === "," && tokens[parameter - 1]?.depth === 1)) continue;
if (["in", "out", "inout"].includes(tokens[parameter]?.normalized ?? "")) parameter += 1;
const name = identifier(tokens[parameter]);
if (name && tokens[parameter + 1]?.kind === "word") parameters.push(name);
}
startedBody = false;
statementStart = true;
index = close;
continue;
}
if (!parameters) continue;
if (word === "end") {
const scope = scopes[scopes.length - 1];
if (scope?.depth === token.depth) {
scopes.pop();
if (scopes.length === 0 && startedBody) parameters = undefined;
// END IF / END CASE / END LOOP are one closing construct; their second
// keyword must not open another block. Expression CASE uses bare END.
const suffix = tokens[index + 1];
if (suffix?.kind === "word" && suffix.normalized === scope.kind) index += 1;
}
statementStart = false;
continue;
}
if ((word === "begin" && token.depth === 0) || word === "case" || (statementStart && token.depth === 0 && CONTROL_BLOCKS.has(word))) {
scopes.push({ kind: word, depth: token.depth, variables: [] });
if (word === "begin") startedBody = true;
statementStart = word === "begin" || word === "loop" || word === "repeat";
continue;
}
const declarationScope = scopes[scopes.length - 1];
if (statementStart && word === "declare" && declarationScope?.kind === "begin") {
declarationScope.variables.push(...declarationNames(tokens, index + 1));
}
if (token.text === ";" && token.depth === 0) {
// A routine with a single RETURN / SELECT body has no BEGIN..END pair.
if (!startedBody) parameters = undefined;
statementStart = true;
} else {
statementStart = token.depth === 0 && (token.text === ":" || ["then", "else", "do"].includes(word));
}
}
if (!parameters) return [];
// Innermost declarations shadow outer names, including routine parameters.
const visible = new Map<string, string>();
for (const name of [...parameters, ...scopes.flatMap((scope) => scope.variables)]) visible.set(name.toLowerCase(), name);
return [...visible.values()];
}
+7 -4
View File
@@ -1,3 +1,4 @@
import { collectMysqlRoutineVariables } from "@/lib/sql/mysqlRoutineVariables";
import { Cassandra, MariaSQL, MSSQL, MySQL, PLSQL, PostgreSQL, SQLite, StandardSQL } from "@codemirror/lang-sql";
import type { Completion, CompletionInfo } from "@codemirror/autocomplete";
import type { DatabaseType, SqlSnippet } from "@/types/database";
@@ -1855,7 +1856,7 @@ class SqlCompletionProvider {
if (!context.dataTypeContext) {
this.items.push(...customSnippetItems);
}
this.items.push(...buildSqlServerLocalVariableItems(context));
this.items.push(...buildLocalVariableItems(context));
if (!pendingJoinKeyword && !context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && !context.exclusiveRoutineSuggestions) {
if (!preferReferencedColumns && !context.dataTypeContext) {
this.items.push(...buildSnippetItems(context.prefix, snippets.filter(shouldFormatBuiltinSnippet), this.input.keywordCase, this.databaseType));
@@ -2332,7 +2333,7 @@ function getSqlLexicalContext(sql: string, cursor: number, options: SqlSemanticB
inBacktick = true;
} else if (ch === "[") {
inBracket = true;
} else if (ch === "$") {
} else if (ch === "$" && options.databaseType !== "mysql" && options.dialect !== "mysql") {
const marker = matchDollarQuoteTag(sql, index);
if (marker) {
dollarTag = marker;
@@ -2813,7 +2814,8 @@ export function getSqlCompletionContext(sql: string, cursor: number, options: Sq
const statementKind = detectStatementKind(beforeCursor || fullStatement);
const selectListWildcardAfterCursor = isStandaloneSelectWildcard(rawStatement, cursor - statementSpan.start, selectListColumnContext, statementKind, resolveSqlDialectId(options));
const preferredValueKeywords = sqlServerDatepartCompletionValues(beforeCursor, options.databaseType);
const localVariables = prefix.startsWith("@") ? collectSqlServerLocalVariables(sql, cursor, options) : undefined;
const mysqlVariablesAllowed = (options.databaseType === "mysql" || (!options.databaseType && options.dialect === "mysql")) && !prefix.startsWith("@") && !qualifier && !dataTypeContext && !afterTableTrigger && !insertInfo && !inCallRoutineContext;
const localVariables = prefix.startsWith("@") ? collectSqlServerLocalVariables(sql, cursor, options) : mysqlVariablesAllowed ? collectMysqlRoutineVariables(sql, cursor) : undefined;
const preferredKeywords = qualifier ? [] : preferredKeywordsForCompletion(beforeCursor, beforeToken, selectListColumnContext, exclusiveTableSuggestions, updateInfo, deleteInfo, options.databaseType);
const contextKind = detectCompletionContextKind({
qualifier,
@@ -5099,11 +5101,12 @@ function buildPreferredKeywordItems(prefix: string, keywords: string[], keywordC
}));
}
function buildSqlServerLocalVariableItems(context: SqlCompletionContext): SqlCompletionItem[] {
function buildLocalVariableItems(context: SqlCompletionContext): SqlCompletionItem[] {
return (context.localVariables ?? [])
.filter((variable) => matchesPrefix(variable, context.prefix))
.map((variable) => ({
label: variable,
apply: variable.startsWith("@") ? variable : quoteCompletionApplyIdentifier(variable, "mysql"),
type: "variable" as const,
detail: "local variable",
boost: computeBoost(variable, context.prefix) + 4_000,
+11
View File
@@ -0,0 +1,11 @@
# #12293 修复实测
两张截图均来自独立本地页面内的生产 `QueryEditor.vue`,使用 MySQL 方言、手动触发补全,未连接远程服务:
- `parameter-completion.png`:输入 `p_` 后出现 `p_input`,回车插入正确参数名。
- `local-variable-completion.png`:输入 `v_` 后出现 `v_local`,回车插入正确变量名。
另外在本机 Docker MySQL 8.4.6 中创建独立临时库,实测了过程参数、局部变量、嵌套 BEGIN..END、IF 和单 RETURN 函数。
过程输入 7,计算 7 + 1 + 3 + 1,结果 12;函数输入 7,结果 8。实测结束后清理了该临时测试库。
相关行为测试还覆盖方向参数、逗号声明、嵌套作用域、大小写和同名变量、DELIMITER 脚本、注释/字符串、条件和游标声明过滤,以及其他方言的兼容性。
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB