diff --git a/apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue b/apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue index 54bee5048c..0dcf96b84a 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeItemDialogs.vue @@ -30,6 +30,7 @@ const { confirmDeleteGroup, showRenameObjectDialog, renameObjectName, + renameObjectDialogTitle, renameObjectPreviewSql, renameObjectError, confirmRenameObject, @@ -274,7 +275,7 @@ watch( - {{ t("contextMenu.renameObjectTitle") }} + {{ renameObjectDialogTitle }}
diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 089c7a6c3b..af4d05d881 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -139,7 +139,7 @@ import { type DropObjectSqlOptions, type TableChildObjectType, } from "@/lib/database/dbAdminSql"; -import { buildRenameObjectSql, supportsObjectRename, type RenameableObjectType } from "@/lib/table/objectRenameSql"; +import { buildRenameObjectSql, buildRenameDatabaseSql, buildRenameDatabasePreflightSql, databaseRenameMaintenanceDatabase, supportsDatabaseRename, supportsObjectRename, type RenameableObjectType } from "@/lib/table/objectRenameSql"; import { buildEditableObjectSource, buildRoutineRenameObjectSourceStatements, supportsSourceBackedRoutineRename } from "@/lib/table/objectSourceEditor"; import { loadEditableObjectSourceForEditor } from "@/lib/table/objectSourceLoad"; import { buildViewDdl } from "@/lib/table/viewDdl"; @@ -1218,6 +1218,10 @@ function requestRenameSelectedNode(): boolean { openRenameMongoCollectionDialog(); return true; } + if (canRenameDatabase.value) { + openRenameDatabaseDialog(); + return true; + } if (canRenameObject.value) { openRenameObjectDialog(); return true; @@ -2535,6 +2539,15 @@ function openRenameObjectDialog() { showRenameObjectDialog.value = true; } +function openRenameDatabaseDialog() { + claimTreeItemDialogOwnership(); + routeTreeItemDialogController(); + renameObjectName.value = activeNode.value.label; + renameObjectError.value = ""; + renameObjectPreviewSql.value = ""; + showRenameObjectDialog.value = true; +} + async function executeTreeNodeSqlWithProductionGuard( node: Pick, sql: string, @@ -2544,6 +2557,7 @@ async function executeTreeNodeSqlWithProductionGuard( executeAsScript?: boolean; executionId?: string; isCancelledBeforeDispatch?: () => boolean; + beforeExecute?: () => Promise; markDispatched?: () => void; } = {}, ) { @@ -2562,11 +2576,13 @@ async function executeTreeNodeSqlWithProductionGuard( database, sql, source: t("production.sourceSidebar"), - execute: () => { + execute: async () => { // The caller may have observed a cancel click while we were still // waiting on connection setup or the production-safety confirmation // above — if so, never actually dispatch the SQL to the backend. if (options.isCancelledBeforeDispatch?.()) throw new Error("Operation cancelled before it was sent to the database."); + await options.beforeExecute?.(); + if (options.isCancelledBeforeDispatch?.()) throw new Error("Operation cancelled before it was sent to the database."); options.markDispatched?.(); return options.executeAsScript ? api.executeScript(node.connectionId!, database, sql, options.schema ?? node.schema) : api.executeQuery(node.connectionId!, database, sql, options.schema ?? node.schema, executionId, { timeoutSecs }); }, @@ -2578,9 +2594,27 @@ let renameObjectPreviewRequestId = 0; async function refreshRenameObjectPreviewSql() { const node = activeNode.value; const requestId = ++renameObjectPreviewRequestId; - const objectType = nodeRenameObjectType(); const newName = renameObjectName.value.trim(); - if (!showRenameObjectDialog.value || !objectType || !newName || newName === node.label) { + if (!showRenameObjectDialog.value || !newName || newName === node.label) { + renameObjectPreviewSql.value = ""; + return; + } + // Database rename path + if (node.type === "database") { + try { + const sql = await buildRenameDatabaseSql({ + databaseType: currentDatabaseType(), + oldName: node.label, + newName, + }); + if (requestId === renameObjectPreviewRequestId) renameObjectPreviewSql.value = sql; + } catch { + if (requestId === renameObjectPreviewRequestId) renameObjectPreviewSql.value = ""; + } + return; + } + const objectType = nodeRenameObjectType(); + if (!objectType) { renameObjectPreviewSql.value = ""; return; } @@ -2608,14 +2642,68 @@ watch([showRenameObjectDialog, renameObjectName, () => activeNode.value.label, ( async function confirmRenameObject() { const node = sidebarFormTarget.value ?? activeNode.value; - const objectType = node.type === "table" ? "TABLE" : node.type === "view" ? "VIEW" : node.type === "materialized_view" ? "MATERIALIZED_VIEW" : node.type === "procedure" ? "PROCEDURE" : node.type === "function" ? "FUNCTION" : null; const newName = renameObjectName.value.trim(); - if (!objectType || !newName || newName === node.label || !node.connectionId || !node.database) return; + if (!newName || newName === node.label || !node.connectionId || !node.database) return; renameObjectError.value = ""; let renameApplied = false; try { const dbType = databaseTypeForNode(node); await connectionStore.ensureConnected(node.connectionId); + + // Database rename path + if (node.type === "database") { + const maintenanceDatabase = databaseRenameMaintenanceDatabase(connectionStore.getConfig(node.connectionId)?.database, node.label); + // Preflight: check permissions, prepared transactions, and active connections + const preflightSql = await buildRenameDatabasePreflightSql({ + databaseType: dbType, + databaseName: node.label, + }); + const preflightResult = await api.executeQuery(node.connectionId, maintenanceDatabase, preflightSql); + let activeConnections = 0; + let preparedTransactions = 0; + let isOwner = false; + if (preflightResult && preflightResult.rows.length > 0) { + const row = preflightResult.rows[0] as any[]; + activeConnections = Number(row[0]) || 0; + preparedTransactions = Number(row[1]) || 0; + isOwner = row[2] === true || row[2] === "t" || String(row[2]) === "true"; + } + + if (!isOwner) { + renameObjectError.value = t("contextMenu.renameDatabaseNotOwner", { name: node.label }); + return; + } + if (preparedTransactions > 0) { + renameObjectError.value = t("contextMenu.renameDatabasePreparedTransactions", { name: node.label, count: preparedTransactions }); + return; + } + + const sql = await buildRenameDatabaseSql({ + databaseType: dbType, + oldName: node.label, + newName, + terminateConnections: activeConnections > 0, + }); + const executed = await executeTreeNodeSqlWithProductionGuard(node, sql, { + database: maintenanceDatabase, + executeAsScript: true, + beforeExecute: () => connectionStore.closeDatabaseConnection(node.connectionId!, node.label), + }); + if (executed === undefined) return; + renameApplied = true; + toast(t("contextMenu.renameObjectSuccess", { oldName: node.label, newName }), 3000); + showRenameObjectDialog.value = false; + const liveNode = findSidebarActionTarget(connectionStore.treeNodes, createSidebarActionTarget(node)) ?? node; + liveNode.label = newName; + liveNode.database = newName; + const renamedNode: TreeNode = { ...liveNode, label: newName, database: newName }; + connectionStore.replacePinnedTreeNode(liveNode, renamedNode); + void connectionStore.refreshTreeNode(liveNode); + return; + } + + const objectType = node.type === "table" ? "TABLE" : node.type === "view" ? "VIEW" : node.type === "materialized_view" ? "MATERIALIZED_VIEW" : node.type === "procedure" ? "PROCEDURE" : node.type === "function" ? "FUNCTION" : null; + if (!objectType) return; if (supportsSourceBackedRoutineRename(dbType, objectType as any)) { const schema = node.schema || node.database; const source = await api.getObjectSource(node.connectionId, node.database, schema, node.objectName || node.label, objectType as any, node.signature); @@ -2954,6 +3042,18 @@ const canEditDatabaseCharsetCollation = computed(() => databasePropertyGroups.va const canEditDatabaseComment = computed(() => databasePropertyGroups.value.includes("databaseComment")); +const canRenameDatabase = computed(() => { + const node = activeNode.value; + if (node.type !== "database" || !node.database) return false; + const config = node.connectionId ? connectionStore.getConfig(node.connectionId) : undefined; + return !!config && !config.read_only && supportsDatabaseRename(config.db_type); +}); + +const renameObjectDialogTitle = computed(() => { + if (activeNode.value.type === "database") return t("contextMenu.renameDatabaseTitle"); + return t("contextMenu.renameObjectTitle"); +}); + const canCreateSchema = computed(() => { const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined; return canCreateDatabaseNodeNamespace(config, activeNode.value) && !isSqlServerLinkedNode(activeNode.value) && !connectionUsesDatabaseObjectTreeMode(config); @@ -4388,6 +4488,7 @@ function objectDialogCapabilities() { return { showRenameObjectDialog, renameObjectName, + renameObjectDialogTitle, renameObjectPreviewSql, renameObjectError, confirmRenameObject, @@ -4917,6 +5018,14 @@ function buildDatabaseSidebarMenu(context: SidebarMenuFactoryContext): boolean { if (canEditDatabaseProperties.value) { items.push({ label: t("contextMenu.editDatabaseProperties"), action: openEditDatabasePropertiesDialog, icon: SquarePen }); } + if (canRenameDatabase.value) { + items.push({ + label: t("contextMenu.renameDatabase"), + action: openRenameDatabaseDialog, + icon: Pencil, + shortcut: shortcutRename, + }); + } if (canCreateTable.value) { items.push({ label: t("contextMenu.createTable"), action: createTable, icon: Plus }); } diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 7e18ae8567..b48d036e0c 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2880,6 +2880,10 @@ export default { renameObjectTitle: "Rename Object", renameObjectNamePlaceholder: "New name", renameObjectSuccess: '"{oldName}" renamed to "{newName}"', + renameDatabase: "Rename Database", + renameDatabaseTitle: "Rename Database", + renameDatabaseNotOwner: 'Only the database owner can rename "{name}".', + renameDatabasePreparedTransactions: 'Database "{name}" has {count} pending prepared transaction(s). Please COMMIT or ROLLBACK them first.', confirmDropTableTitle: "Drop Table", confirmDropTableMessage: 'Are you sure you want to drop "{name}"? This will permanently delete the table and all its data.', dropTableCascade: "Use CASCADE", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 455cdcb054..da50ec6ab5 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2790,6 +2790,10 @@ export default withEnglishFallback({ renameObjectTitle: "Renombrar objeto", renameObjectNamePlaceholder: "Nuevo nombre", renameObjectSuccess: '"{oldName}" renombrado a "{newName}"', + renameDatabase: "Renombrar base de datos", + renameDatabaseTitle: "Renombrar base de datos", + renameDatabaseNotOwner: 'Solo el propietario puede renombrar "{name}".', + renameDatabasePreparedTransactions: 'La base de datos "{name}" tiene {count} transacciones preparadas pendientes. Ejecute COMMIT o ROLLBACK primero.', confirmDropTableTitle: "Eliminar tabla", confirmDropTableMessage: '¿Estás seguro de que deseas eliminar "{name}"? Esto borrará permanentemente la tabla y todos sus datos.', dropTableCascade: "Usar CASCADE", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index a28bf2f35b..afedec294a 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2788,6 +2788,10 @@ export default withEnglishFallback({ renameObjectTitle: "Rinomina Oggetto", renameObjectNamePlaceholder: "Nuovo nome", renameObjectSuccess: '"{oldName}" rinominato in "{newName}"', + renameDatabase: "Rinomina Database", + renameDatabaseTitle: "Rinomina Database", + renameDatabaseNotOwner: 'Solo il proprietario può rinominare "{name}".', + renameDatabasePreparedTransactions: 'Il database "{name}" ha {count} transazioni preparate in sospeso. Eseguire COMMIT o ROLLBACK prima.', confirmDropTableTitle: "Elimina Tabella", confirmDropTableMessage: 'Sei sicuro di voler eliminare (DROP) la tabella "{name}"? Questa operazione eliminerà permanentemente la tabella e tutti i suoi dati.', dropTableCascade: "Usa CASCADE", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 1058760b5f..47935d8e29 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2812,6 +2812,10 @@ export default withEnglishFallback({ renameObjectTitle: "オブジェクト名を変更", renameObjectNamePlaceholder: "新しい名前", renameObjectSuccess: "「{oldName}」の名前を「{newName}」に変更しました", + renameDatabase: "データベース名を変更", + renameDatabaseTitle: "データベース名を変更", + renameDatabaseNotOwner: "データベース「{name}」の所有者のみが名前を変更できます。", + renameDatabasePreparedTransactions: "データベース「{name}」には {count} 件の保留中のプリペアドトランザクションがあります。先に COMMIT または ROLLBACK してください。", confirmDropTableTitle: "テーブルを削除", confirmDropTableMessage: "本当にテーブル「{name}」をドロップ(削除)しますか?テーブルとすべてのデータが永久に削除されます。", dropTableCascade: "CASCADE を使用", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index bd9a66b42f..a085b019b7 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -3015,6 +3015,10 @@ export default withEnglishFallback({ closeRightTabs: "Fechar abas à direita", compileObject: "Compilar", compileObjectSuccess: '"{name}" compilado com sucesso', + renameDatabase: "Renomear banco de dados", + renameDatabaseTitle: "Renomear banco de dados", + renameDatabaseNotOwner: "Somente o proprietário do banco de dados pode renomear «{name}».", + renameDatabasePreparedTransactions: "Há {count} transações preparadas pendentes no banco de dados «{name}». Execute COMMIT ou ROLLBACK antes de renomear.", }, 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 ba77948900..57275e7e0a 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2804,6 +2804,10 @@ export default withEnglishFallback({ renameObjectTitle: "重命名对象", renameObjectNamePlaceholder: "新名称", renameObjectSuccess: "已将「{oldName}」重命名为「{newName}」", + renameDatabase: "重命名数据库", + renameDatabaseTitle: "重命名数据库", + renameDatabaseNotOwner: "只有数据库所有者才能重命名「{name}」。", + renameDatabasePreparedTransactions: "数据库「{name}」中有 {count} 个未完成的预备事务,请先 COMMIT 或 ROLLBACK 后再重命名。", confirmDropTableTitle: "删除表", confirmDropTableMessage: "确定要删除「{name}」吗?这将永久删除表及其所有数据。", dropTableCascade: "使用 CASCADE 强制删除", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 81f9cabe3d..0cc0761ea1 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2788,6 +2788,10 @@ export default withEnglishFallback({ renameObjectTitle: "重新命名物件", renameObjectNamePlaceholder: "新名稱", renameObjectSuccess: "已將「{oldName}」重新命名為「{newName}」", + renameDatabase: "重新命名資料庫", + renameDatabaseTitle: "重新命名資料庫", + renameDatabaseNotOwner: "只有資料庫擁有者才能重新命名「{name}」。", + renameDatabasePreparedTransactions: "資料庫「{name}」中有 {count} 個未完成的預備事務,請先 COMMIT 或 ROLLBACK 後再重新命名。", confirmDropTableTitle: "刪除資料表", confirmDropTableMessage: "確定要刪除「{name}」嗎?這將永久刪除資料表及其所有資料。", dropTableCascade: "使用 CASCADE 強制刪除", diff --git a/apps/desktop/src/lib/__tests__/table/objectRenameSql.spec.ts b/apps/desktop/src/lib/__tests__/table/objectRenameSql.spec.ts new file mode 100644 index 0000000000..668adb18e7 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/table/objectRenameSql.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { databaseRenameMaintenanceDatabase, supportsDatabaseRename } from "@/lib/table/objectRenameSql"; + +describe("database rename", () => { + it("chooses a maintenance database outside the rename target", () => { + expect(databaseRenameMaintenanceDatabase("admin", "application")).toBe("admin"); + expect(databaseRenameMaintenanceDatabase("application", "application")).toBe("postgres"); + expect(databaseRenameMaintenanceDatabase(undefined, "application")).toBe("postgres"); + expect(databaseRenameMaintenanceDatabase("postgres", "postgres")).toBe("template1"); + expect(databaseRenameMaintenanceDatabase("template1", "postgres")).toBe("template1"); + }); + + it("does not expose database rename for YashanDB", () => { + expect(supportsDatabaseRename("postgres")).toBe(true); + expect(supportsDatabaseRename("yashandb")).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index d98a70b16b..530a21c8ac 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -227,6 +227,8 @@ export const buildTableSelectSql = forward("buildTableSelectSql"); export const buildDatabaseSearchSql = forward("buildDatabaseSearchSql"); export const buildSearchResultWhere = forward("buildSearchResultWhere"); export const buildRenameObjectSql = forward("buildRenameObjectSql"); +export const buildRenameDatabaseSql = forward("buildRenameDatabaseSql"); +export const buildRenameDatabasePreflightSql = forward("buildRenameDatabasePreflightSql"); export const buildCreateDatabaseSql = forward("buildCreateDatabaseSql"); export const buildDuckDbAttachDatabaseSql = forward("buildDuckDbAttachDatabaseSql"); export const buildSqliteAttachDatabaseSql = forward("buildSqliteAttachDatabaseSql"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 8e3a548665..b688e77405 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -1316,6 +1316,14 @@ export async function buildRenameObjectSql(options: BuildRenameObjectSqlOptions) return post("/api/query/build-rename-object-sql", { options }); } +export async function buildRenameDatabaseSql(options: { databaseType?: DatabaseType; oldName: string; newName: string; terminateConnections: boolean }): Promise { + return post("/api/query/build-rename-database-sql", options); +} + +export async function buildRenameDatabasePreflightSql(options: { databaseType?: DatabaseType; databaseName: string }): Promise { + return post("/api/query/build-rename-database-preflight-sql", options); +} + export async function buildCreateDatabaseSql(options: CreateDatabaseSqlOptions): Promise { return post("/api/query/build-create-database-sql", { options }); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index e276d722f4..5e09d242c3 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -1494,6 +1494,22 @@ export async function buildRenameObjectSql(options: BuildRenameObjectSqlOptions) return invoke("build_rename_object_sql", { options }); } +export async function buildRenameDatabaseSql(options: { databaseType?: DatabaseType; oldName: string; newName: string; terminateConnections: boolean }): Promise { + return invoke("build_rename_database_sql", { + databaseType: options.databaseType, + oldName: options.oldName, + newName: options.newName, + terminateConnections: options.terminateConnections, + }); +} + +export async function buildRenameDatabasePreflightSql(options: { databaseType?: DatabaseType; databaseName: string }): Promise { + return invoke("build_rename_database_preflight_sql", { + databaseType: options.databaseType, + databaseName: options.databaseName, + }); +} + export async function buildCreateDatabaseSql(options: CreateDatabaseSqlOptions): Promise { return invoke("build_create_database_sql", { options }); } diff --git a/apps/desktop/src/lib/table/objectRenameSql.ts b/apps/desktop/src/lib/table/objectRenameSql.ts index c20fd4d87b..1e08465353 100644 --- a/apps/desktop/src/lib/table/objectRenameSql.ts +++ b/apps/desktop/src/lib/table/objectRenameSql.ts @@ -32,3 +32,36 @@ export function supportsObjectRename(databaseType: DatabaseType | undefined, obj export function buildRenameObjectSql(options: BuildRenameObjectSqlOptions): Promise { return api.buildRenameObjectSql(options); } + +// ── Database rename (PostgreSQL family) ── + +const DATABASE_RENAME_TYPES = new Set(["postgres", "redshift", "gaussdb", "kwdb", "kingbase", "highgo", "uxdb", "vastbase", "opengauss"]); + +export function supportsDatabaseRename(databaseType?: DatabaseType): boolean { + return !!databaseType && DATABASE_RENAME_TYPES.has(databaseType); +} + +export function databaseRenameMaintenanceDatabase(configuredDatabase: string | undefined, targetDatabase: string): string { + if (configuredDatabase && configuredDatabase !== targetDatabase) return configuredDatabase; + return targetDatabase === "postgres" ? "template1" : "postgres"; +} + +export interface BuildRenameDatabaseSqlOptions { + databaseType?: DatabaseType; + oldName: string; + newName: string; + terminateConnections?: boolean; +} + +export function buildRenameDatabaseSql(options: BuildRenameDatabaseSqlOptions): Promise { + return api.buildRenameDatabaseSql({ ...options, terminateConnections: options.terminateConnections ?? false }); +} + +export interface BuildRenameDatabasePreflightSqlOptions { + databaseType?: DatabaseType; + databaseName: string; +} + +export function buildRenameDatabasePreflightSql(options: BuildRenameDatabasePreflightSqlOptions): Promise { + return api.buildRenameDatabasePreflightSql(options); +} diff --git a/crates/dbx-core/src/db_admin_sql.rs b/crates/dbx-core/src/db_admin_sql.rs index 5cfd209e25..546a77e02a 100644 --- a/crates/dbx-core/src/db_admin_sql.rs +++ b/crates/dbx-core/src/db_admin_sql.rs @@ -909,6 +909,65 @@ pub fn build_rename_object_sql(options: RenameObjectSqlOptions) -> Result) -> bool { + matches!( + database_type, + Some( + DatabaseType::Postgres + | DatabaseType::Redshift + | DatabaseType::Gaussdb + | DatabaseType::Kwdb + | DatabaseType::Kingbase + | DatabaseType::Highgo + | DatabaseType::Uxdb + | DatabaseType::Vastbase + | DatabaseType::OpenGauss + ) + ) +} + +pub fn build_rename_database_sql( + database_type: Option, + old_name: &str, + new_name: &str, + terminate_connections: bool, +) -> Result { + if !supports_database_rename(database_type) { + return Err(format!("Renaming databases is not supported for {}.", database_label(database_type))); + } + let mut parts = Vec::new(); + if terminate_connections { + let escaped = old_name.replace('\'', "''"); + parts.push(format!( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{escaped}' AND pid <> pg_backend_pid();" + )); + } + parts.push(format!( + "ALTER DATABASE {} RENAME TO {};", + quote_table_identifier(database_type, old_name), + quote_rename_identifier(database_type, new_name) + )); + Ok(parts.join("\n")) +} + +/// Generates a preflight SQL query that checks whether the database can be renamed. +/// Returns a single-row result with: +/// - active_connections: number of connections to the database (excluding self) +/// - prepared_transactions: number of prepared transactions in the database +/// - is_owner: whether the current user owns the database +pub fn build_rename_database_preflight_sql( + database_type: Option, + database_name: &str, +) -> Result { + if !supports_database_rename(database_type) { + return Err(format!("Renaming databases is not supported for {}.", database_label(database_type))); + } + let escaped = database_name.replace('\'', "''"); + Ok(format!( + "SELECT (SELECT count(*) FROM pg_stat_activity WHERE datname = '{escaped}' AND pid <> pg_backend_pid()) AS active_connections, (SELECT count(*) FROM pg_prepared_xacts WHERE database = '{escaped}') AS prepared_transactions, (SELECT pg_catalog.pg_get_userbyid(datdba) = current_user AS is_owner FROM pg_database WHERE datname = '{escaped}') AS is_owner;" + )) +} + fn is_postgres_like_rename(database_type: DatabaseType) -> bool { // openGauss 兼容 PG 的 ALTER TABLE/VIEW ... RENAME TO 语法,与 gaussdb 等同开放重命名能力。 matches!( @@ -2430,4 +2489,53 @@ mod tests { .unwrap_err() .contains("Renaming PROCEDURE is not supported")); } + + #[test] + fn builds_postgres_database_rename_sql() { + assert!(supports_database_rename(Some(DatabaseType::Postgres))); + assert_eq!( + build_rename_database_sql(Some(DatabaseType::Postgres), "old_db", "new db", false).unwrap(), + "ALTER DATABASE \"old_db\" RENAME TO \"new db\";" + ); + // With terminate_connections + let sql = build_rename_database_sql(Some(DatabaseType::Postgres), "old_db", "new db", true).unwrap(); + assert!(sql.contains("pg_terminate_backend"), "should include terminate"); + assert!(sql.contains("ALTER DATABASE"), "should include rename"); + } + + #[test] + fn builds_rename_database_preflight_sql() { + let sql = build_rename_database_preflight_sql(Some(DatabaseType::Postgres), "my_db").unwrap(); + assert!(sql.contains("active_connections"), "should check active connections"); + assert!(sql.contains("prepared_transactions"), "should check prepared transactions"); + assert!(sql.contains("is_owner"), "should check ownership"); + assert!(sql.contains("pg_stat_activity"), "should query pg_stat_activity"); + assert!(sql.contains("pg_prepared_xacts"), "should query pg_prepared_xacts"); + assert!(sql.contains("pg_database"), "should query pg_database"); + } + + #[test] + fn rejects_unsupported_database_rename() { + assert!(!supports_database_rename(Some(DatabaseType::Mysql))); + assert!(!supports_database_rename(Some(DatabaseType::Sqlite))); + assert!(build_rename_database_sql(Some(DatabaseType::Mysql), "old_db", "new_db", false).is_err()); + assert!(build_rename_database_preflight_sql(Some(DatabaseType::Mysql), "old_db").is_err()); + } + + #[test] + fn builds_various_postgres_family_database_rename_sql() { + for db_type in [ + DatabaseType::Gaussdb, + DatabaseType::Kingbase, + DatabaseType::Highgo, + DatabaseType::Vastbase, + DatabaseType::OpenGauss, + DatabaseType::Redshift, + ] { + assert!(supports_database_rename(Some(db_type)), "{db_type:?} should support database rename"); + let sql = build_rename_database_sql(Some(db_type), "old_db", "new_db", false).unwrap(); + assert!(sql.contains("ALTER DATABASE"), "{db_type:?}: missing ALTER DATABASE"); + assert!(sql.contains("RENAME TO"), "{db_type:?}: missing RENAME TO"); + } + } } diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 9a33c6f2fd..fcbe72835f 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -479,6 +479,8 @@ async fn main() { .route("/query/build-database-search-sql", post(routes::query::build_database_search_sql)) .route("/query/build-search-result-where", post(routes::query::build_search_result_where)) .route("/query/build-rename-object-sql", post(routes::query::build_rename_object_sql)) + .route("/query/build-rename-database-sql", post(routes::query::build_rename_database_sql)) + .route("/query/build-rename-database-preflight-sql", post(routes::query::build_rename_database_preflight_sql)) .route("/query/build-create-database-sql", post(routes::query::build_create_database_sql)) .route("/query/build-sqlite-attach-database-sql", post(routes::query::build_sqlite_attach_database_sql)) .route("/query/build-drop-object-sql", post(routes::query::build_drop_object_sql)) diff --git a/crates/dbx-web/src/routes/query.rs b/crates/dbx-web/src/routes/query.rs index 42d7810cfb..9996042fb7 100644 --- a/crates/dbx-web/src/routes/query.rs +++ b/crates/dbx-web/src/routes/query.rs @@ -148,6 +148,22 @@ pub struct BuildRenameObjectSqlRequest { pub options: dbx_core::db_admin_sql::RenameObjectSqlOptions, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildRenameDatabaseSqlRequest { + pub database_type: Option, + pub old_name: String, + pub new_name: String, + pub terminate_connections: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildRenameDatabasePreflightSqlRequest { + pub database_type: Option, + pub database_name: String, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct BuildCreateDatabaseSqlRequest { @@ -801,6 +817,27 @@ pub async fn build_rename_object_sql(Json(req): Json, +) -> Result, AppError> { + dbx_core::db_admin_sql::build_rename_database_sql( + req.database_type, + &req.old_name, + &req.new_name, + req.terminate_connections, + ) + .map(Json) + .map_err(AppError::from) +} + +pub async fn build_rename_database_preflight_sql( + Json(req): Json, +) -> Result, AppError> { + dbx_core::db_admin_sql::build_rename_database_preflight_sql(req.database_type, &req.database_name) + .map(Json) + .map_err(AppError::from) +} + pub async fn build_create_database_sql( Json(req): Json, ) -> Result, AppError> { diff --git a/packages/app-tests/sidebarMutationRuntime.test.ts b/packages/app-tests/sidebarMutationRuntime.test.ts index fdea85cd7c..16a93ef3a9 100644 --- a/packages/app-tests/sidebarMutationRuntime.test.ts +++ b/packages/app-tests/sidebarMutationRuntime.test.ts @@ -98,3 +98,24 @@ test("all locales define the object drop refresh warning with its message placeh assert.match(source, /objectDropRefreshFailed\s*:\s*["'][^\n]*\{message\}/, `${locale} must preserve the message placeholder`); } }); + +test("database rename uses a maintenance connection and mutates state only after guarded script execution", () => { + const rename = functionBody("confirmRenameObject"); + assert.ok(rename.includes("databaseRenameMaintenanceDatabase(connectionStore.getConfig(node.connectionId)?.database, node.label)")); + assert.ok(rename.includes("api.executeQuery(node.connectionId, maintenanceDatabase, preflightSql)")); + assert.ok(rename.includes("database: maintenanceDatabase")); + assert.ok(rename.includes("executeAsScript: true")); + assert.ok(rename.includes("beforeExecute: () => connectionStore.closeDatabaseConnection(node.connectionId!, node.label)")); + assert.ok(rename.includes("if (executed === undefined) return")); + + const executionIndex = rename.indexOf("const executed = await executeTreeNodeSqlWithProductionGuard"); + const cancellationIndex = rename.indexOf("if (executed === undefined) return"); + const mutationIndex = rename.indexOf("renameApplied = true"); + assert.ok(executionIndex >= 0 && cancellationIndex > executionIndex && mutationIndex > cancellationIndex); + + assert.ok(runtimeHost.includes("beforeExecute?: () => Promise")); + const guardedExecuteIndex = runtimeHost.indexOf("execute: async () => {"); + const beforeExecuteIndex = runtimeHost.indexOf("await options.beforeExecute?.()", guardedExecuteIndex); + const markDispatchedIndex = runtimeHost.indexOf("options.markDispatched?.()", guardedExecuteIndex); + assert.ok(guardedExecuteIndex >= 0 && beforeExecuteIndex > guardedExecuteIndex && markDispatchedIndex > beforeExecuteIndex); +}); diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index 0bb2dc28ec..558595eb8a 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -562,6 +562,24 @@ pub fn build_rename_object_sql(options: dbx_core::db_admin_sql::RenameObjectSqlO dbx_core::db_admin_sql::build_rename_object_sql(options) } +#[tauri::command] +pub fn build_rename_database_sql( + database_type: Option, + old_name: String, + new_name: String, + terminate_connections: bool, +) -> Result { + dbx_core::db_admin_sql::build_rename_database_sql(database_type, &old_name, &new_name, terminate_connections) +} + +#[tauri::command] +pub fn build_rename_database_preflight_sql( + database_type: Option, + database_name: String, +) -> Result { + dbx_core::db_admin_sql::build_rename_database_preflight_sql(database_type, &database_name) +} + #[tauri::command] pub fn build_create_database_sql(options: dbx_core::db_admin_sql::CreateDatabaseSqlOptions) -> Result { dbx_core::db_admin_sql::build_create_database_sql(options) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b1ca3451df..57b6e319da 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1730,6 +1730,8 @@ pub fn run() { commands::query::build_database_search_sql, commands::query::build_search_result_where, commands::query::build_rename_object_sql, + commands::query::build_rename_database_sql, + commands::query::build_rename_database_preflight_sql, commands::query::build_create_database_sql, #[cfg(feature = "duckdb-sidecar")] commands::query::build_duckdb_attach_database_sql,