From 71ce46e5fdfe4cc07496c475416cc1b0b5c5d119 Mon Sep 17 00:00:00 2001 From: zipg Date: Mon, 20 Jul 2026 11:03:08 +0800 Subject: [PATCH] feat(mysql): add routine and trigger creation templates --- .../sidebar/SidebarTreeRuntimeHost.vue | 17 +++- apps/desktop/src/i18n/locales/en.ts | 3 + apps/desktop/src/i18n/locales/es.ts | 3 + apps/desktop/src/i18n/locales/it.ts | 3 + apps/desktop/src/i18n/locales/ja.ts | 3 + apps/desktop/src/i18n/locales/pt-BR.ts | 3 + apps/desktop/src/i18n/locales/zh-CN.ts | 3 + apps/desktop/src/i18n/locales/zh-TW.ts | 3 + .../src/lib/sidebar/mysqlObjectTemplates.ts | 89 +++++++++++++++++++ .../app-tests/mysqlObjectTemplates.test.ts | 74 +++++++++++++++ 10 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/lib/sidebar/mysqlObjectTemplates.ts create mode 100644 packages/app-tests/mysqlObjectTemplates.test.ts diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 5472693fd..4dd9eaa76 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -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 }); } diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 46ea3e933..584d13386 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index ae3eb9d71..a5be2cfc0 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index bc4b901ec..1cba3e52b 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index b60bc7c9a..59af8b835 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1760,6 +1760,9 @@ export default withEnglishFallback({ dropExtension: "拡張機能を削除", processList: "プロセス一覧", serverDashboard: "サーバーダッシュボード", + createProcedure: "新規ストアドプロシージャ", + createFunction: "新規関数", + createTrigger: "新規トリガー", }, visibleDatabases: { title: "表示するデータベース", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 7d487edd5..bf7998a06 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 70fb11f89..46ead7fbd 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1644,6 +1644,9 @@ export default withEnglishFallback({ exportStructureCopied: "表结构已复制", createTable: "新建表", createView: "新建视图", + createProcedure: "新建存储过程", + createFunction: "新建函数", + createTrigger: "新建触发器", dropTable: "删除表", emptyTable: "清空数据", truncateTable: "截断表", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index afe85f808..68be723e9 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1761,6 +1761,9 @@ export default withEnglishFallback({ dropExtension: "刪除擴展", processList: "處理程序清單", serverDashboard: "伺服器儀表板", + createProcedure: "新增儲存程序", + createFunction: "新增函數", + createTrigger: "新增觸發器", }, visibleDatabases: { title: "顯示資料庫", diff --git a/apps/desktop/src/lib/sidebar/mysqlObjectTemplates.ts b/apps/desktop/src/lib/sidebar/mysqlObjectTemplates.ts new file mode 100644 index 000000000..5014d2d64 --- /dev/null +++ b/apps/desktop/src/lib/sidebar/mysqlObjectTemplates.ts @@ -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; +type MysqlTemplateNode = Pick; + +const templateKindByGroup: Partial> = { + "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("`", "``")}\``; +} diff --git a/packages/app-tests/mysqlObjectTemplates.test.ts b/packages/app-tests/mysqlObjectTemplates.test.ts new file mode 100644 index 000000000..509f58a35 --- /dev/null +++ b/packages/app-tests/mysqlObjectTemplates.test.ts @@ -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 { + 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`/); +});