feat(postgres): support safe database renaming

This commit is contained in:
Tara authored and GitHub committed 2026-08-22 19:30:38 +08:00
1 parent 3118e0d617
commit bf029cd342
20 files changed
+410 -7

No files matched your search

@@ -30,6 +30,7 @@ const {
confirmDeleteGroup,
showRenameObjectDialog,
renameObjectName,
renameObjectDialogTitle,
renameObjectPreviewSql,
renameObjectError,
confirmRenameObject,
@@ -274,7 +275,7 @@ watch(
<Dialog v-model:open="showRenameObjectDialog">
<DialogContent class="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>{{ t("contextMenu.renameObjectTitle") }}</DialogTitle>
<DialogTitle>{{ renameObjectDialogTitle }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3">
<Input v-model="renameObjectName" :placeholder="t('contextMenu.renameObjectNamePlaceholder')" @keydown.enter.prevent="confirmRenameObject" />
@@ -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<TreeNode, "connectionId" | "database" | "schema">,
sql: string,
@@ -2544,6 +2557,7 @@ async function executeTreeNodeSqlWithProductionGuard(
executeAsScript?: boolean;
executionId?: string;
isCancelledBeforeDispatch?: () => boolean;
beforeExecute?: () => Promise<void>;
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 });
}