mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(editor): complete mysql routine params and local variables
This commit is contained in:
+15
-1
@@ -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()];
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user