feat(mysql): add routine and trigger creation templates

This commit is contained in:
zipg 2026-07-20 11:03:08 +08:00 committed by GitHub
parent bec6514fee
commit 71ce46e5fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 200 additions and 1 deletions

View File

@ -66,6 +66,7 @@ import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
import { canTreeNodePin, canTreeNodeShowExpander } from "@/lib/sidebar/sidebarTreeItemLayout";
import { objectTypesForGroupNode } from "@/lib/table/tableTree";
import { loadSidebarObjectGroup } from "@/lib/sidebar/sidebarObjectGroupRouting";
import { mysqlObjectTemplateForGroup } from "@/lib/sidebar/mysqlObjectTemplates";
import { buildTableDeleteTemplate, buildTableInsertTemplate, buildTableSelectTemplate, buildTableUpdateTemplate } from "@/lib/table/tableSqlTemplates";
import { driverStoreFocusForInstallError } from "@/lib/connection/agentDriverInstallHint";
import {
@ -2865,6 +2866,16 @@ function createView() {
});
}
function createMysqlObjectTemplate() {
const node = activeNode.value;
if (!node.connectionId || !node.database) return;
const template = mysqlObjectTemplateForGroup(connectionStore.getConfig(node.connectionId), node);
if (!template) return;
connectionStore.activeConnectionId = node.connectionId;
const tabId = queryStore.createTab(node.connectionId, node.database, t(template.titleKey), "query", node.schema);
queryStore.updateSql(tabId, template.sql);
}
const canExpand = computed(() =>
canTreeNodeShowExpander({
type: activeNode.value.type,
@ -4024,7 +4035,8 @@ function buildObjectGroupSidebarMenu(context: SidebarMenuFactoryContext): boolea
const { node, items } = context;
// 9. Group Labels (group-columns, group-tables, etc.)
if (isGroupLabel(node)) {
const hasGroupCreateAction = (node.type === "group-tables" && canCreateTable.value) || (node.type === "group-views" && !!node.connectionId && !!node.database);
const mysqlObjectTemplate = node.connectionId ? mysqlObjectTemplateForGroup(connectionStore.getConfig(node.connectionId), node) : null;
const hasGroupCreateAction = (node.type === "group-tables" && canCreateTable.value) || (node.type === "group-views" && !!node.connectionId && !!node.database) || !!mysqlObjectTemplate;
const canLoadAllObjectGroup = node.type === "group-tables" || node.type === "group-views" || node.type === "group-materialized-views";
if (node.type === "group-tables" && canCreateTable.value) {
items.push({ label: t("contextMenu.createTable"), action: createTable, icon: Plus });
@ -4038,6 +4050,9 @@ function buildObjectGroupSidebarMenu(context: SidebarMenuFactoryContext): boolea
if (node.type === "group-views" && node.connectionId && node.database) {
items.push({ label: t("contextMenu.createView"), action: createView, icon: Plus });
}
if (mysqlObjectTemplate) {
items.push({ label: t(mysqlObjectTemplate.titleKey), action: createMysqlObjectTemplate, icon: Plus });
}
if (hasGroupCreateAction) {
items.push({ label: "", separator: true });
}

View File

@ -1644,6 +1644,9 @@ export default {
exportStructureCopied: "Structure copied",
createTable: "Create Table",
createView: "Create View",
createProcedure: "Create Procedure",
createFunction: "Create Function",
createTrigger: "Create Trigger",
dropTable: "Drop Table",
emptyTable: "Empty Table",
truncateTable: "Truncate Table",

View File

@ -1761,6 +1761,9 @@ export default withEnglishFallback({
dropExtension: "Eliminar extensión",
processList: "Lista de procesos",
serverDashboard: "Panel de control del servidor",
createProcedure: "Nuevo procedimiento almacenado",
createFunction: "Nueva función",
createTrigger: "Nuevo disparador",
},
visibleDatabases: {
title: "Bases de datos visibles",

View File

@ -1759,6 +1759,9 @@ export default withEnglishFallback({
dropExtension: "Elimina estensione",
processList: "Elenco processi",
serverDashboard: "Dashboard del server",
createProcedure: "Nuovo stored procedure",
createFunction: "Nuova funzione",
createTrigger: "Nuovo trigger",
},
visibleDatabases: {
title: "Database Visibili",

View File

@ -1760,6 +1760,9 @@ export default withEnglishFallback({
dropExtension: "拡張機能を削除",
processList: "プロセス一覧",
serverDashboard: "サーバーダッシュボード",
createProcedure: "新規ストアドプロシージャ",
createFunction: "新規関数",
createTrigger: "新規トリガー",
},
visibleDatabases: {
title: "表示するデータベース",

View File

@ -1761,6 +1761,9 @@ export default withEnglishFallback({
dropExtension: "Remover extensão",
processList: "Lista de Processos",
serverDashboard: "Painel do Servidor",
createProcedure: "Novo procedimento armazenado",
createFunction: "Nova função",
createTrigger: "Novo gatilho",
},
visibleDatabases: {
title: "Bancos de dados visíveis",

View File

@ -1644,6 +1644,9 @@ export default withEnglishFallback({
exportStructureCopied: "表结构已复制",
createTable: "新建表",
createView: "新建视图",
createProcedure: "新建存储过程",
createFunction: "新建函数",
createTrigger: "新建触发器",
dropTable: "删除表",
emptyTable: "清空数据",
truncateTable: "截断表",

View File

@ -1761,6 +1761,9 @@ export default withEnglishFallback({
dropExtension: "刪除擴展",
processList: "處理程序清單",
serverDashboard: "伺服器儀表板",
createProcedure: "新增儲存程序",
createFunction: "新增函數",
createTrigger: "新增觸發器",
},
visibleDatabases: {
title: "顯示資料庫",

View File

@ -0,0 +1,89 @@
import type { ConnectionConfig, TreeNode, TreeNodeType } from "@/types/database";
export type MysqlObjectTemplateKind = "procedure" | "function" | "trigger";
export interface MysqlObjectTemplate {
kind: MysqlObjectTemplateKind;
titleKey: "contextMenu.createProcedure" | "contextMenu.createFunction" | "contextMenu.createTrigger";
sql: string;
}
type MysqlTemplateConnection = Pick<ConnectionConfig, "db_type" | "driver_profile">;
type MysqlTemplateNode = Pick<TreeNode, "type" | "database" | "tableName">;
const templateKindByGroup: Partial<Record<TreeNodeType, MysqlObjectTemplateKind>> = {
"group-procedures": "procedure",
"group-functions": "function",
"group-triggers": "trigger",
};
export function supportsMysqlObjectTemplates(connection?: MysqlTemplateConnection): boolean {
if (connection?.db_type !== "mysql") return false;
const profile = connection.driver_profile?.trim().toLowerCase();
return !profile || profile === "mysql" || profile === "custom_mysql";
}
export function mysqlObjectTemplateForGroup(connection: MysqlTemplateConnection | undefined, node: MysqlTemplateNode): MysqlObjectTemplate | null {
if (!supportsMysqlObjectTemplates(connection) || !node.database) return null;
const kind = templateKindByGroup[node.type];
if (!kind) return null;
return buildMysqlObjectTemplate(kind, node.database, node.tableName);
}
export function buildMysqlObjectTemplate(kind: MysqlObjectTemplateKind, database: string, tableName?: string): MysqlObjectTemplate {
const qualifiedDatabase = quoteMysqlIdentifier(database);
if (kind === "procedure") {
const name = "new_procedure";
return {
kind,
titleKey: "contextMenu.createProcedure",
sql: `DELIMITER $$
CREATE PROCEDURE ${qualifiedDatabase}.${quoteMysqlIdentifier(name)}()
BEGIN
SELECT 1;
END$$
DELIMITER ;`,
};
}
if (kind === "function") {
const name = "new_function";
return {
kind,
titleKey: "contextMenu.createFunction",
sql: `DELIMITER $$
CREATE FUNCTION ${qualifiedDatabase}.${quoteMysqlIdentifier(name)}()
RETURNS INT
DETERMINISTIC
BEGIN
RETURN 0;
END$$
DELIMITER ;`,
};
}
const name = "new_trigger";
return {
kind,
titleKey: "contextMenu.createTrigger",
sql: `DELIMITER $$
CREATE TRIGGER ${qualifiedDatabase}.${quoteMysqlIdentifier(name)}
BEFORE INSERT ON ${qualifiedDatabase}.${quoteMysqlIdentifier(tableName || "table_name")}
FOR EACH ROW
BEGIN
SET @dbx_trigger_placeholder = 1;
END$$
DELIMITER ;`,
};
}
function quoteMysqlIdentifier(identifier: string): string {
return `\`${identifier.replaceAll("`", "``")}\``;
}

View File

@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import { buildMysqlObjectTemplate, mysqlObjectTemplateForGroup, supportsMysqlObjectTemplates } from "../../apps/desktop/src/lib/sidebar/mysqlObjectTemplates.ts";
import { executableStatementRanges } from "../../apps/desktop/src/lib/sql/sqlStatementRanges.ts";
import type { ConnectionConfig, TreeNode } from "../../apps/desktop/src/types/database.ts";
function connection(dbType: ConnectionConfig["db_type"], driverProfile?: string): ConnectionConfig {
return {
id: "conn-1",
name: "Test",
db_type: dbType,
driver_profile: driverProfile,
host: "127.0.0.1",
port: 3306,
username: "root",
password: "",
};
}
function group(type: TreeNode["type"], overrides: Partial<TreeNode> = {}): TreeNode {
return {
id: `conn-1:app:${type}`,
label: type,
type,
connectionId: "conn-1",
database: "app",
children: [],
...overrides,
};
}
test("MySQL object templates are limited to native and custom MySQL profiles", () => {
assert.equal(supportsMysqlObjectTemplates(connection("mysql")), true);
assert.equal(supportsMysqlObjectTemplates(connection("mysql", "mysql")), true);
assert.equal(supportsMysqlObjectTemplates(connection("mysql", "CUSTOM_MYSQL")), true);
for (const profile of ["doris", "selectdb", "starrocks", "tidb", "oceanbase", "mariadb"]) {
assert.equal(supportsMysqlObjectTemplates(connection("mysql", profile)), false, profile);
}
assert.equal(supportsMysqlObjectTemplates(connection("starrocks", "starrocks")), false);
assert.equal(supportsMysqlObjectTemplates(connection("jdbc", "mysql")), false);
});
test("only supported MySQL object groups expose creation templates", () => {
const mysql = connection("mysql", "mysql");
assert.equal(mysqlObjectTemplateForGroup(mysql, group("group-procedures"))?.kind, "procedure");
assert.equal(mysqlObjectTemplateForGroup(mysql, group("group-functions"))?.kind, "function");
assert.equal(mysqlObjectTemplateForGroup(mysql, group("group-triggers"))?.kind, "trigger");
assert.equal(mysqlObjectTemplateForGroup(mysql, group("group-views")), null);
assert.equal(mysqlObjectTemplateForGroup(mysql, group("group-functions", { database: "" })), null);
});
test("procedure and function templates are executable and database-qualified", () => {
assert.equal(buildMysqlObjectTemplate("procedure", "db`x").sql, "DELIMITER $$\n\nCREATE PROCEDURE `db``x`.`new_procedure`()\nBEGIN\n SELECT 1;\nEND$$\n\nDELIMITER ;");
assert.equal(buildMysqlObjectTemplate("function", "app").sql, "DELIMITER $$\n\nCREATE FUNCTION `app`.`new_function`()\nRETURNS INT\nDETERMINISTIC\nBEGIN\n RETURN 0;\nEND$$\n\nDELIMITER ;");
});
test("DBX parses every template as one executable MySQL statement", () => {
for (const kind of ["procedure", "function", "trigger"] as const) {
const ranges = executableStatementRanges(buildMysqlObjectTemplate(kind, "app", "orders").sql, "mysql");
assert.equal(ranges.length, 1, kind);
assert.match(ranges[0].sql, new RegExp(`^CREATE ${kind.toUpperCase()}`));
assert.doesNotMatch(ranges[0].sql, /DELIMITER/i);
}
});
test("trigger templates use table context when available and otherwise keep an editable placeholder", () => {
assert.match(buildMysqlObjectTemplate("trigger", "app", "order`items").sql, /BEFORE INSERT ON `app`\.`order``items`/);
assert.match(buildMysqlObjectTemplate("trigger", "app").sql, /BEFORE INSERT ON `app`\.`table_name`/);
const template = mysqlObjectTemplateForGroup(connection("mysql", "mysql"), group("group-triggers", { tableName: "orders" }));
assert.equal(template?.kind, "trigger");
assert.match(template?.sql || "", /BEFORE INSERT ON `app`\.`orders`/);
});