From 352403d79e501c9ee28baacb28182c1ca9a24f41 Mon Sep 17 00:00:00 2001 From: Elias <45232878+mapan0424@users.noreply.github.com> Date: Fri, 7 Aug 2026 03:30:52 +0800 Subject: [PATCH] feat(xugu): add programmable object compile actions * feat(xugu): add programmable object compile actions * chore(i18n): autofill new translations --------- Co-authored-by: mapan Co-authored-by: dbx-i18n-bot --- .../src/components/objects/ObjectBrowser.vue | 19 ++++++++ .../sidebar/SidebarTreeRuntimeHost.vue | 27 ++++++++++++ apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/ja.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + .../src/lib/__tests__/xuguCompileSql.spec.ts | 33 ++++++++++++++ .../src/lib/database/xuguCompileSql.ts | 43 +++++++++++++++++++ 11 files changed, 136 insertions(+) create mode 100644 apps/desktop/src/lib/__tests__/xuguCompileSql.spec.ts create mode 100644 apps/desktop/src/lib/database/xuguCompileSql.ts diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 425889ebc..92e6f9930 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -44,6 +44,7 @@ import { Trash2, WrapText, X, + Wrench, } from "@lucide/vue"; import { useI18n } from "vue-i18n"; import i18n from "@/i18n"; @@ -94,6 +95,7 @@ import QueryEditor from "@/components/editor/QueryEditor.vue"; import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter"; import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts"; import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard"; +import { buildXuguCompileSql } from "@/lib/database/xuguCompileSql"; import { formatShortcut } from "@/lib/editor/shortcutRegistry"; import { batchTableEmptyFeedback, buildBatchTableEmptyPlan, runBatchTableEmpty, type BatchTableEmptyPlanItem } from "@/lib/sidebar/batchTableEmpty"; import { runBatchTableDrop } from "@/lib/table/batchTableDrop"; @@ -2199,6 +2201,21 @@ async function executeObjectBrowserSqlWithProductionGuard(sql: string, execut }); } +async function compileXuguObject(row: ObjectBrowserRow) { + if (effectiveDatabaseType.value !== "xugu") return; + const sql = buildXuguCompileSql({ objectType: row.type, schema: row.schema || selectedSchema.value, name: row.name }); + if (!sql) return; + try { + const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql, row.schema || selectedSchema.value)); + if (!executed) return; + toast(t("contextMenu.compileObjectSuccess", { name: row.name })); + await reload(); + await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, row.schema || selectedSchema.value); + } catch (e: any) { + toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); + } +} + async function refreshTruncatePreviewSql(row: ObjectBrowserRow) { truncatePreviewSql.value = ""; truncatePreviewSql.value = await buildTruncateTableSql(tableAdminSqlOptions(row, { cascade: canTruncateTargetCascade.value && truncateTableCascade.value })).catch(() => ""); @@ -2704,6 +2721,7 @@ function getViewMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { function getProcFuncMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { return [ ...(item.type === "PROCEDURE" ? [{ label: t("contextMenu.executeProcedure"), action: () => openProcedureExecution(item), icon: Play }] : []), + ...(effectiveDatabaseType.value === "xugu" && buildXuguCompileSql({ objectType: item.type, schema: item.schema || selectedSchema.value, name: item.name }) ? [{ label: t("contextMenu.compileObject"), action: () => compileXuguObject(item), icon: Wrench }] : []), { label: t("contextMenu.viewSource"), action: () => openSource(item), icon: Code2 }, ...(canRename(item) ? [{ label: t("contextMenu.renameObject"), action: () => requestRename(item), icon: Pencil }] : []), { label: "", separator: true }, @@ -2720,6 +2738,7 @@ function getProcFuncMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { function getPackageMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { return [ + ...(effectiveDatabaseType.value === "xugu" && buildXuguCompileSql({ objectType: item.type, schema: item.schema || selectedSchema.value, name: item.name }) ? [{ label: t("contextMenu.compileObject"), action: () => compileXuguObject(item), icon: Wrench }] : []), { label: t("contextMenu.viewSource"), action: () => openSource(item), icon: Code2 }, { label: "", separator: true }, { label: t("contextMenu.copyName"), action: () => copyName(item), icon: Copy }, diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 8e5171bbb..50a5c633b 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -40,6 +40,7 @@ import { Plus, ScrollText, Code2, + Wrench, ListFilter, Clipboard, UsersRound, @@ -154,6 +155,7 @@ import { isSqlServerLinkedNode } from "@/lib/database/sqlServerLinkedServers"; import { flattenTree } from "@/composables/useFlatTree"; import { createDatabaseCollationOptionsForCharset, nextCreateDatabaseCollation, normalizeCreateDatabaseCharset, parseCreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions"; import { executeWithProductionContextGuard, executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard"; +import { buildXuguCompileSql } from "@/lib/database/xuguCompileSql"; import type { SidebarDataOpenRequest } from "@/lib/sidebar/sidebarDataOpenCoordinator"; import { createSidebarActionTarget, findSidebarActionTarget, releaseRemovedSidebarActionTarget, type SidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget"; import { createSidebarMenuContext, normalizeSidebarMenuDescriptors } from "@/lib/sidebar/sidebarTreeMenuDescriptors"; @@ -1684,6 +1686,22 @@ function openProcedureExecution() { emit("open-procedure", node); } +async function compileXuguObject() { + const node = activeNode.value; + if (currentDatabaseType() !== "xugu" || !node.connectionId || !node.database) return; + const sql = buildXuguCompileSql({ objectType: node.type, schema: node.schema, name: node.objectName || node.label }); + if (!sql) return; + try { + await connectionStore.ensureConnected(node.connectionId); + const executed = await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema }); + if (!executed) return; + toast(t("contextMenu.compileObjectSuccess", { name: node.label }), 3000); + await connectionStore.refreshTreeNode(node); + } catch (e: any) { + toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000); + } +} + function requestDropObject() { void refreshDropObjectPreviewSql(); showDropObjectConfirm.value = true; @@ -4545,6 +4563,9 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean { if (node.type === "index" || node.type === "fkey" || (node.type === "trigger" && !!node.tableName)) { items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value }); + if (currentDatabaseType() === "xugu" && buildXuguCompileSql({ objectType: node.type, schema: node.schema, name: node.objectName || node.label })) { + items.push({ label: t("contextMenu.compileObject"), action: compileXuguObject, icon: Wrench }); + } if (node.type === "index" && canOpenStructureEditor.value) { items.push({ label: "", separator: true }); items.push({ label: t("contextMenu.editIndex"), action: openStructureEditor, icon: PencilRuler }); @@ -4576,6 +4597,9 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean { if (node.type === "procedure") { items.push({ label: t("contextMenu.executeProcedure"), action: openProcedureExecution, icon: Play }); } + if (currentDatabaseType() === "xugu" && buildXuguCompileSql({ objectType: node.type, schema: node.schema, name: node.objectName || node.label })) { + items.push({ label: t("contextMenu.compileObject"), action: compileXuguObject, icon: Wrench }); + } items.push({ label: t("contextMenu.viewSource"), action: () => openObjectSourceDialog(false), icon: Code2 }); if (canRenameObject.value) { items.push({ @@ -4606,6 +4630,9 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean { } if (node.type === "trigger" || node.type === "package" || node.type === "package-body" || node.type === "type" || node.type === "type-body") { + if (currentDatabaseType() === "xugu" && buildXuguCompileSql({ objectType: node.type, schema: node.schema, name: node.objectName || node.label })) { + items.push({ label: t("contextMenu.compileObject"), action: compileXuguObject, icon: Wrench }); + } items.push({ label: t("contextMenu.viewSource"), action: () => openObjectSourceDialog(false), icon: Code2 }); items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value }); items.push({ label: t("contextMenu.changeOpenMode"), action: () => emit("open-settings", "navigation"), icon: Settings2 }); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 30342d5e7..dde8a373f 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2326,6 +2326,8 @@ export default { batchTruncate: "Truncate selected ({count})", batchDropIndexes: "Drop Indexes ({count})", executeProcedure: "Execute Procedure", + compileObject: "Compile", + compileObjectSuccess: '"{name}" compiled successfully', confirmExecuteProcedureTitle: "Execute Procedure", confirmExecuteProcedureMessage: 'Execute procedure "{name}"? You can fill or adjust parameter values first.', loadingProcedureParameters: "Loading parameters...", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 71717f262..3c2dc5735 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2415,6 +2415,8 @@ export default withEnglishFallback({ createTrigger: "Nuevo disparador", changeOpenMode: "Modificar modo de apertura", closeRightTabs: "Cerrar pestañas a la derecha", + compileObject: "Compilar", + compileObjectSuccess: "\"{name}\" compilado con éxito", }, 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 eccc0c52a..ea5ec212f 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2413,6 +2413,8 @@ export default withEnglishFallback({ createTrigger: "Nuovo trigger", changeOpenMode: "Modifica modalità apertura", closeRightTabs: "Chiudi le schede a destra", + compileObject: "Compila", + compileObjectSuccess: "\"{name}\" compilato con successo", }, visibleDatabases: { title: "Database Visibili", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 1dcd1edbd..ac45c6718 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2440,6 +2440,8 @@ export default withEnglishFallback({ createTrigger: "新規トリガー", changeOpenMode: "開き方を変更", closeRightTabs: "右側のタブを閉じる", + compileObject: "コンパイル", + compileObjectSuccess: "\"{name}\" のコンパイルに成功しました", }, visibleDatabases: { title: "表示するデータベース", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index a5053e751..ddd3120f0 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2415,6 +2415,8 @@ export default withEnglishFallback({ createTrigger: "Novo gatilho", changeOpenMode: "Alterar modo de abertura", closeRightTabs: "Fechar abas à direita", + compileObject: "Compilar", + compileObjectSuccess: "\"{name}\" compilado com sucesso", }, 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 19aa8d5f9..58c67e348 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2327,6 +2327,8 @@ export default withEnglishFallback({ batchTruncate: "截断所选({count})", batchDropIndexes: "删除索引({count})", executeProcedure: "执行过程", + compileObject: "编译", + compileObjectSuccess: '"{name}" 编译成功', confirmExecuteProcedureTitle: "执行存储过程", confirmExecuteProcedureMessage: "确认执行存储过程「{name}」?可先补充或调整参数值。", loadingProcedureParameters: "正在读取参数...", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 225e9f47d..c62aec579 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2414,6 +2414,8 @@ export default withEnglishFallback({ createTrigger: "新增觸發器", changeOpenMode: "修改開啟方式", closeRightTabs: "關閉右側標籤頁", + compileObject: "編譯", + compileObjectSuccess: "\"{name}\" 編譯成功", }, visibleDatabases: { title: "顯示資料庫", diff --git a/apps/desktop/src/lib/__tests__/xuguCompileSql.spec.ts b/apps/desktop/src/lib/__tests__/xuguCompileSql.spec.ts new file mode 100644 index 000000000..8743178d1 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/xuguCompileSql.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { buildXuguCompileSql, xuguCompileKeyword } from "@/lib/database/xuguCompileSql"; + +describe("xuguCompileSql", () => { + it.each([ + ["procedure", "PROCEDURE"], + ["function", "FUNCTION"], + ["trigger", "TRIGGER"], + ["package", "PACKAGE"], + ["package-body", "PACKAGE"], + ["type", "TYPE"], + ["type-body", "TYPE"], + ])("maps %s to %s", (objectType, keyword) => { + expect(xuguCompileKeyword(objectType)).toBe(keyword); + }); + + it("quotes schema and object names while preserving mixed case", () => { + expect(buildXuguCompileSql({ objectType: "procedure", schema: "AppSchema", name: "spCreateOrder" })).toBe('ALTER PROCEDURE "AppSchema"."spCreateOrder" RECOMPILE;'); + }); + + it("accepts object-browser uppercase kinds", () => { + expect(buildXuguCompileSql({ objectType: "FUNCTION", name: "f_get_count" })).toBe('ALTER FUNCTION "f_get_count" RECOMPILE;'); + }); + + it("escapes embedded double quotes", () => { + expect(buildXuguCompileSql({ objectType: "trigger", schema: 'A"Schema', name: 'T"1' })).toBe('ALTER TRIGGER "A""Schema"."T""1" RECOMPILE;'); + }); + + it("returns null for unsupported object kinds or blank names", () => { + expect(buildXuguCompileSql({ objectType: "sequence", schema: "SYSDBA", name: "SEQ_1" })).toBeNull(); + expect(buildXuguCompileSql({ objectType: "procedure", schema: "SYSDBA", name: " " })).toBeNull(); + }); +}); diff --git a/apps/desktop/src/lib/database/xuguCompileSql.ts b/apps/desktop/src/lib/database/xuguCompileSql.ts new file mode 100644 index 000000000..90cc9d558 --- /dev/null +++ b/apps/desktop/src/lib/database/xuguCompileSql.ts @@ -0,0 +1,43 @@ +/** + * Xugu object kinds that support an explicit recompilation statement. + * Keep this list intentionally narrow: the caller must still gate the action + * to Xugu connections so other database dialects are not affected. + */ +const XUGU_COMPILE_KEYWORDS: Record = { + procedure: "PROCEDURE", + function: "FUNCTION", + trigger: "TRIGGER", + package: "PACKAGE", + "package-body": "PACKAGE", + type: "TYPE", + "type-body": "TYPE", + PROCEDURE: "PROCEDURE", + FUNCTION: "FUNCTION", + TRIGGER: "TRIGGER", + PACKAGE: "PACKAGE", + PACKAGE_BODY: "PACKAGE", + TYPE: "TYPE", + TYPE_BODY: "TYPE", +}; + +export interface XuguCompileSqlInput { + objectType: string; + name: string; + schema?: string; +} + +export function xuguCompileKeyword(objectType: string): string | null { + return XUGU_COMPILE_KEYWORDS[objectType] ?? null; +} + +export function buildXuguCompileSql(input: XuguCompileSqlInput): string | null { + const keyword = xuguCompileKeyword(input.objectType); + const name = input.name.trim(); + if (!keyword || !name) return null; + const qualifiedName = input.schema?.trim() ? `${quoteXuguIdentifier(input.schema.trim())}.${quoteXuguIdentifier(name)}` : quoteXuguIdentifier(name); + return `ALTER ${keyword} ${qualifiedName} RECOMPILE;`; +} + +function quoteXuguIdentifier(name: string): string { + return `"${name.replaceAll('"', '""')}"`; +}