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 });
}
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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 を使用",
+4
View File
@@ -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",
+4
View File
@@ -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 强制删除",
+4
View File
@@ -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 強制刪除",
@@ -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);
});
});
+2
View File
@@ -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");
+8
View File
@@ -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<string> {
return post("/api/query/build-rename-database-sql", options);
}
export async function buildRenameDatabasePreflightSql(options: { databaseType?: DatabaseType; databaseName: string }): Promise<string> {
return post("/api/query/build-rename-database-preflight-sql", options);
}
export async function buildCreateDatabaseSql(options: CreateDatabaseSqlOptions): Promise<string> {
return post("/api/query/build-create-database-sql", { options });
}
+16
View File
@@ -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<string> {
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<string> {
return invoke("build_rename_database_preflight_sql", {
databaseType: options.databaseType,
databaseName: options.databaseName,
});
}
export async function buildCreateDatabaseSql(options: CreateDatabaseSqlOptions): Promise<string> {
return invoke("build_create_database_sql", { options });
}
@@ -32,3 +32,36 @@ export function supportsObjectRename(databaseType: DatabaseType | undefined, obj
export function buildRenameObjectSql(options: BuildRenameObjectSqlOptions): Promise<string> {
return api.buildRenameObjectSql(options);
}
// ── Database rename (PostgreSQL family) ──
const DATABASE_RENAME_TYPES = new Set<DatabaseType>(["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<string> {
return api.buildRenameDatabaseSql({ ...options, terminateConnections: options.terminateConnections ?? false });
}
export interface BuildRenameDatabasePreflightSqlOptions {
databaseType?: DatabaseType;
databaseName: string;
}
export function buildRenameDatabasePreflightSql(options: BuildRenameDatabasePreflightSqlOptions): Promise<string> {
return api.buildRenameDatabasePreflightSql(options);
}