feat(schema): add object rename actions

This commit is contained in:
t8y2 2026-05-17 10:54:50 +08:00
parent 307f773387
commit f80b1e8bc9
6 changed files with 395 additions and 0 deletions

View File

@ -7,6 +7,7 @@ import {
Copy,
Eye,
Loader2,
Pencil,
PencilLine,
RefreshCw,
Search,
@ -34,11 +35,13 @@ import { isSchemaAware } from "@/lib/databaseCapabilities";
import { buildTableSelectSql, qualifiedTableName } from "@/lib/tableSelectSql";
import { useToast } from "@/composables/useToast";
import { buildExecutableObjectSourceSql, objectSourceSaveExecutionMode } from "@/lib/objectSourceEditor";
import { buildRenameObjectSql, supportsObjectRename } from "@/lib/objectRenameSql";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import QueryEditor from "@/components/editor/QueryEditor.vue";
import type { SqlFormatDialect } from "@/lib/sqlFormatter";
import { isCancelSearchShortcut } from "@/lib/keyboardShortcuts";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
type ObjectRow = {
id: string;
@ -85,6 +88,10 @@ const sourceSaveError = ref("");
const error = ref("");
const showDropConfirm = ref(false);
const dropTarget = ref<ObjectRow | null>(null);
const showRenameDialog = ref(false);
const renameTarget = ref<ObjectRow | null>(null);
const renameInput = ref("");
const renameError = ref("");
let loadId = 0;
const needsSchema = computed(() => isSchemaAware(props.connection.db_type));
@ -176,6 +183,10 @@ function canOpenSource(row: ObjectRow) {
return row.type === "VIEW" || row.type === "PROCEDURE" || row.type === "FUNCTION";
}
function canRename(row: ObjectRow) {
return supportsObjectRename(props.connection.db_type, row.type);
}
function sourceTitle(row: ObjectRow | null) {
if (!row) return t("objects.source");
return `${row.name} ${t("objects.source")}`;
@ -241,6 +252,59 @@ function requestDrop(row: ObjectRow) {
showDropConfirm.value = true;
}
function requestRename(row: ObjectRow) {
renameTarget.value = row;
renameInput.value = row.name;
renameError.value = "";
showRenameDialog.value = true;
}
function renamePreviewSql() {
const row = renameTarget.value;
const newName = renameInput.value.trim();
if (!row || !newName || newName === row.name) return "";
try {
return buildRenameObjectSql({
databaseType: props.connection.db_type,
objectType: row.type,
schema: row.schema || selectedSchema.value,
oldName: row.name,
newName,
});
} catch {
return "";
}
}
async function confirmRename() {
const row = renameTarget.value;
const newName = renameInput.value.trim();
if (!row || !newName || newName === row.name) return;
renameError.value = "";
try {
const schema = row.schema || selectedSchema.value || props.database;
const sql = buildRenameObjectSql({
databaseType: props.connection.db_type,
objectType: row.type,
schema,
oldName: row.name,
newName,
});
await api.executeQuery(props.connection.id, props.database, sql, schema);
toast(t("contextMenu.renameObjectSuccess", { oldName: row.name, newName }));
showRenameDialog.value = false;
if (sourceRow.value?.id === row.id) closeSource();
await reload();
await connectionStore.refreshObjectListTreeNode(
props.connection.id,
props.database,
row.schema || selectedSchema.value,
);
} catch (e: any) {
renameError.value = e?.message || String(e);
}
}
async function confirmDrop() {
if (!dropTarget.value) return;
const row = dropTarget.value;
@ -570,6 +634,9 @@ watch(
<ContextMenuItem @click="openNewQuery(item)">
<TerminalSquare class="w-4 h-4 mr-2" /> {{ t("contextMenu.newQuery") }}
</ContextMenuItem>
<ContextMenuItem v-if="canRename(item)" @click="requestRename(item)">
<Pencil class="w-4 h-4 mr-2" /> {{ t("contextMenu.renameObject") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem class="text-destructive" @click="requestDrop(item)">
<Trash2 class="w-4 h-4 mr-2" /> {{ t("contextMenu.dropTable") }}
@ -580,6 +647,9 @@ watch(
<ContextMenuItem @click="openSource(item)">
<Code2 class="w-4 h-4 mr-2" /> {{ t("contextMenu.viewSource") }}
</ContextMenuItem>
<ContextMenuItem v-if="canRename(item)" @click="requestRename(item)">
<Pencil class="w-4 h-4 mr-2" /> {{ t("contextMenu.renameObject") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem class="text-destructive" @click="requestDrop(item)">
<Trash2 class="w-4 h-4 mr-2" /> {{ t("contextMenu.dropView") }}
@ -590,6 +660,9 @@ watch(
<ContextMenuItem @click="openSource(item)">
<Code2 class="w-4 h-4 mr-2" /> {{ t("contextMenu.viewSource") }}
</ContextMenuItem>
<ContextMenuItem v-if="canRename(item)" @click="requestRename(item)">
<Pencil class="w-4 h-4 mr-2" /> {{ t("contextMenu.renameObject") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem class="text-destructive" @click="requestDrop(item)">
<Trash2 class="w-4 h-4 mr-2" />
@ -694,6 +767,33 @@ watch(
:confirm-label="t('dangerDialog.deleteConfirm')"
@confirm="confirmDrop"
/>
<Dialog v-model:open="showRenameDialog">
<DialogContent class="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>{{ t("contextMenu.renameObjectTitle") }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3">
<Input
v-model="renameInput"
:placeholder="t('contextMenu.renameObjectNamePlaceholder')"
@keydown.enter.prevent="confirmRename"
/>
<pre
v-if="renamePreviewSql()"
class="max-h-32 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap"
>{{ renamePreviewSql() }}</pre
>
<p v-if="renameError" class="text-sm text-destructive">{{ renameError }}</p>
</div>
<DialogFooter>
<Button variant="outline" @click="showRenameDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="!renameInput.trim() || renameInput.trim() === renameTarget?.name" @click="confirmRename">
{{ t("contextMenu.renameObject") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<style scoped>

View File

@ -89,6 +89,7 @@ import {
import { sidebarSelectionCopyAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/treeNodeClick";
import { formatCsv, formatJson, formatSqlInsert } from "@/lib/exportFormats";
import { buildCreateDatabaseSql, supportsCreateDatabaseCharset } from "@/lib/createDatabaseSql";
import { buildRenameObjectSql, supportsObjectRename, type RenameableObjectType } from "@/lib/objectRenameSql";
import { hexToRgba } from "@/lib/color";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import { isTauriRuntime } from "@/lib/tauriRuntime";
@ -602,6 +603,9 @@ async function duplicateConnection() {
const showDropTableConfirm = ref(false);
const showEmptyTableConfirm = ref(false);
const showTruncateTableConfirm = ref(false);
const showRenameObjectDialog = ref(false);
const renameObjectName = ref("");
const renameObjectError = ref("");
const showDuplicateDialog = ref(false);
const duplicateTableName = ref("");
@ -653,6 +657,66 @@ function requestDropObject() {
showDropObjectConfirm.value = true;
}
function nodeRenameObjectType(): RenameableObjectType | null {
if (props.node.type === "table") return "TABLE";
if (props.node.type === "view") return "VIEW";
if (props.node.type === "procedure") return "PROCEDURE";
if (props.node.type === "function") return "FUNCTION";
return null;
}
const canRenameObject = computed(() => {
const objectType = nodeRenameObjectType();
return !!objectType && supportsObjectRename(currentDatabaseType(), objectType);
});
function openRenameObjectDialog() {
renameObjectName.value = props.node.label;
renameObjectError.value = "";
showRenameObjectDialog.value = true;
}
function buildRenameObjectPreviewSql(): string {
const objectType = nodeRenameObjectType();
const newName = renameObjectName.value.trim();
if (!objectType || !newName || newName === props.node.label) return "";
try {
return buildRenameObjectSql({
databaseType: currentDatabaseType(),
objectType,
schema: props.node.schema,
oldName: props.node.label,
newName,
});
} catch {
return "";
}
}
async function confirmRenameObject() {
const node = props.node;
const objectType = nodeRenameObjectType();
const newName = renameObjectName.value.trim();
if (!objectType || !newName || newName === node.label || !node.connectionId || !node.database) return;
renameObjectError.value = "";
try {
const sql = buildRenameObjectSql({
databaseType: currentDatabaseType(),
objectType,
schema: node.schema,
oldName: node.label,
newName,
});
await connectionStore.ensureConnected(node.connectionId);
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
toast(t("contextMenu.renameObjectSuccess", { oldName: node.label, newName }), 3000);
showRenameObjectDialog.value = false;
await refreshTableList(node);
} catch (e: any) {
renameObjectError.value = e?.message || String(e);
}
}
async function confirmDropObject() {
const node = props.node;
if (!node.connectionId || !node.database) return;
@ -1758,6 +1822,9 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr
<ContextMenuItem v-if="canOpenStructureEditor" @click="openStructureEditor">
<PencilRuler class="w-4 h-4" /> {{ t("contextMenu.editStructure") }}
</ContextMenuItem>
<ContextMenuItem v-if="canRenameObject" @click="openRenameObjectDialog">
<Pencil class="w-4 h-4" /> {{ t("contextMenu.renameObject") }}
</ContextMenuItem>
<ContextMenuItem @click="newQuery">
<TerminalSquare class="w-4 h-4" /> {{ t("contextMenu.newQuery") }}
</ContextMenuItem>
@ -1822,6 +1889,10 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr
<Code2 class="w-4 h-4 mr-2" />
{{ t("contextMenu.viewSource") }}
</ContextMenuItem>
<ContextMenuItem v-if="canRenameObject" @click="openRenameObjectDialog">
<Pencil class="w-4 h-4 mr-2" />
{{ t("contextMenu.renameObject") }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem class="text-destructive" @click="requestDropObject">
<Trash2 class="w-4 h-4 mr-2" />
@ -1953,6 +2024,36 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr
</DialogContent>
</Dialog>
<Dialog v-model:open="showRenameObjectDialog">
<DialogContent class="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>{{ t("contextMenu.renameObjectTitle") }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3">
<Input
v-model="renameObjectName"
:placeholder="t('contextMenu.renameObjectNamePlaceholder')"
@keydown.enter.prevent="confirmRenameObject"
/>
<pre
v-if="buildRenameObjectPreviewSql()"
class="max-h-32 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap"
>{{ buildRenameObjectPreviewSql() }}</pre
>
<p v-if="renameObjectError" class="text-sm text-destructive">{{ renameObjectError }}</p>
</div>
<DialogFooter>
<Button variant="outline" @click="showRenameObjectDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button
:disabled="!renameObjectName.trim() || renameObjectName.trim() === node.label"
@click="confirmRenameObject"
>
{{ t("contextMenu.renameObject") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showDeleteSavedSqlFileConfirm">
<DialogContent class="sm:max-w-[400px]">
<DialogHeader>

View File

@ -645,6 +645,10 @@ export default {
emptyTable: "Empty Table",
truncateTable: "Truncate Table",
duplicateStructure: "Duplicate Structure",
renameObject: "Rename",
renameObjectTitle: "Rename Object",
renameObjectNamePlaceholder: "New name",
renameObjectSuccess: '"{oldName}" renamed to "{newName}"',
confirmDropTableTitle: "Drop Table",
confirmDropTableMessage:
'Are you sure you want to drop "{name}"? This will permanently delete the table and all its data.',

View File

@ -630,6 +630,10 @@ export default {
emptyTable: "清空数据",
truncateTable: "截断表",
duplicateStructure: "复制表结构",
renameObject: "重命名",
renameObjectTitle: "重命名对象",
renameObjectNamePlaceholder: "新名称",
renameObjectSuccess: "已将「{oldName}」重命名为「{newName}」",
confirmDropTableTitle: "删除表",
confirmDropTableMessage: "确定要删除「{name}」吗?这将永久删除表及其所有数据。",
confirmEmptyTableTitle: "清空数据",

View File

@ -0,0 +1,87 @@
import type { DatabaseObjectType, DatabaseType } from "@/types/database";
import { isSchemaAware } from "@/lib/databaseCapabilities";
import { quoteTableIdentifier } from "@/lib/tableSelectSql";
export type RenameableObjectType = DatabaseObjectType;
export interface BuildRenameObjectSqlOptions {
databaseType?: DatabaseType;
objectType: RenameableObjectType;
schema?: string | null;
oldName: string;
newName: string;
}
const postgresLikeRenameTypes = new Set<DatabaseType>([
"postgres",
"redshift",
"gaussdb",
"kingbase",
"highgo",
"vastbase",
]);
const oracleLikeRenameTypes = new Set<DatabaseType>(["oracle", "dameng"]);
function sqlServerString(value: string): string {
return `N'${value.replaceAll("'", "''")}'`;
}
function quoteRenameIdentifier(databaseType: DatabaseType | undefined, name: string): string {
if (databaseType === "mysql" || databaseType === "goldendb") return `\`${name.replaceAll("`", "``")}\``;
return quoteTableIdentifier(databaseType, name);
}
function qualifiedName(databaseType: DatabaseType | undefined, schema: string | null | undefined, name: string) {
if (isSchemaAware(databaseType) && schema) {
return `${quoteRenameIdentifier(databaseType, schema)}.${quoteRenameIdentifier(databaseType, name)}`;
}
return quoteRenameIdentifier(databaseType, name);
}
function sqlServerObjectName(schema: string | null | undefined, name: string) {
return schema ? `${schema}.${name}` : name;
}
export function supportsObjectRename(
databaseType: DatabaseType | undefined,
objectType: RenameableObjectType,
): boolean {
if (!databaseType) return false;
if (databaseType === "sqlserver") return true;
if (objectType === "PROCEDURE" || objectType === "FUNCTION") return false;
if (databaseType === "sqlite") return objectType === "TABLE";
if (databaseType === "mysql" || databaseType === "goldendb") return objectType === "TABLE" || objectType === "VIEW";
if (postgresLikeRenameTypes.has(databaseType)) return objectType === "TABLE" || objectType === "VIEW";
if (oracleLikeRenameTypes.has(databaseType)) return objectType === "TABLE" || objectType === "VIEW";
return false;
}
export function buildRenameObjectSql(options: BuildRenameObjectSqlOptions): string {
const { databaseType, objectType, schema, oldName, newName } = options;
if (!supportsObjectRename(databaseType, objectType)) {
throw new Error(`Renaming ${objectType} is not supported for ${databaseType ?? "this database"}.`);
}
if (databaseType === "sqlserver") {
return `EXEC sp_rename ${sqlServerString(sqlServerObjectName(schema, oldName))}, ${sqlServerString(newName)}, N'OBJECT';`;
}
if (databaseType === "mysql" || databaseType === "goldendb") {
return `RENAME TABLE ${qualifiedName(databaseType, schema, oldName)} TO ${qualifiedName(databaseType, schema, newName)};`;
}
if (databaseType === "sqlite") {
return `ALTER TABLE ${qualifiedName(databaseType, schema, oldName)} RENAME TO ${quoteRenameIdentifier(databaseType, newName)};`;
}
if (
postgresLikeRenameTypes.has(databaseType as DatabaseType) ||
oracleLikeRenameTypes.has(databaseType as DatabaseType)
) {
const keyword = objectType === "VIEW" ? "VIEW" : "TABLE";
return `ALTER ${keyword} ${qualifiedName(databaseType, schema, oldName)} RENAME TO ${quoteRenameIdentifier(databaseType, newName)};`;
}
throw new Error(`Renaming ${objectType} is not supported for ${databaseType ?? "this database"}.`);
}

View File

@ -0,0 +1,99 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import { buildRenameObjectSql, supportsObjectRename } from "../src/lib/objectRenameSql.ts";
test("builds MySQL table and view rename statements", () => {
assert.equal(
buildRenameObjectSql({
databaseType: "mysql",
objectType: "TABLE",
oldName: "users",
newName: "app users",
}),
"RENAME TABLE `users` TO `app users`;",
);
assert.equal(
buildRenameObjectSql({
databaseType: "goldendb",
objectType: "VIEW",
oldName: "active_users",
newName: "enabled_users",
}),
"RENAME TABLE `active_users` TO `enabled_users`;",
);
});
test("builds PostgreSQL table and view rename statements", () => {
assert.equal(
buildRenameObjectSql({
databaseType: "postgres",
objectType: "TABLE",
schema: "public",
oldName: "orders",
newName: "archived orders",
}),
'ALTER TABLE "public"."orders" RENAME TO "archived orders";',
);
assert.equal(
buildRenameObjectSql({
databaseType: "postgres",
objectType: "VIEW",
schema: "public",
oldName: "active_users",
newName: "enabled_users",
}),
'ALTER VIEW "public"."active_users" RENAME TO "enabled_users";',
);
});
test("builds SQL Server rename statements for all object kinds", () => {
assert.equal(
buildRenameObjectSql({
databaseType: "sqlserver",
objectType: "FUNCTION",
schema: "dbo",
oldName: "fn_total",
newName: "fn_order_total",
}),
"EXEC sp_rename N'dbo.fn_total', N'fn_order_total', N'OBJECT';",
);
assert.equal(supportsObjectRename("sqlserver", "PROCEDURE"), true);
});
test("builds Oracle-family table and view rename statements", () => {
assert.equal(
buildRenameObjectSql({
databaseType: "oracle",
objectType: "TABLE",
schema: "HR",
oldName: "EMPLOYEES",
newName: "STAFF",
}),
'ALTER TABLE "HR"."EMPLOYEES" RENAME TO "STAFF";',
);
assert.equal(
buildRenameObjectSql({
databaseType: "dameng",
objectType: "VIEW",
schema: "SYSDBA",
oldName: "ACTIVE_USERS",
newName: "ENABLED_USERS",
}),
'ALTER VIEW "SYSDBA"."ACTIVE_USERS" RENAME TO "ENABLED_USERS";',
);
});
test("reports unsupported routine rename cases", () => {
assert.equal(supportsObjectRename("mysql", "PROCEDURE"), false);
assert.equal(supportsObjectRename("postgres", "FUNCTION"), false);
assert.throws(
() =>
buildRenameObjectSql({
databaseType: "mysql",
objectType: "PROCEDURE",
oldName: "refresh_cache",
newName: "refresh_cache_v2",
}),
/Renaming PROCEDURE is not supported/,
);
});