fix(postgres): preserve copied table comments safely

This commit is contained in:
zipg authored and GitHub committed 2026-07-31 19:33:43 +08:00
1 parent 00927ac435
commit ecac4800c4
10 files changed
+166 -19

No files matched your search

@@ -71,6 +71,7 @@ import {
buildEmptyTableSql,
buildTruncateTableSql,
collectDuplicateTableColumnComments,
duplicateTableStructureRequiresScript,
supportsDropTableCascade,
supportsTruncateTableCascade,
type TableAdminSqlOptions,
@@ -241,7 +242,7 @@ const batchEmptyPlan = ref<BatchTableEmptyPlanItem<ObjectBrowserRow>[]>([]);
// Paste table dialog state
const showPasteDialog = ref(false);
const pasteTableMode = ref<PasteTableMode>("structure-and-data");
const pasteTableEntries = ref<{ sourceName: string; targetName: string; schema?: string }[]>([]);
const pasteTableEntries = ref<{ sourceName: string; targetName: string; schema?: string; tableComment?: string | null }[]>([]);
const pasteTableDataCopySupported = computed(() => supportsWholeRowTableDataCopy(effectiveDatabaseType.value));
const objectColumnWidths = ref<Record<ObjectBrowserColumnKey, number>>({
select: 34,
@@ -1910,7 +1911,7 @@ function requestDuplicateStructure(row: ObjectBrowserRow) {
showDuplicateDialog.value = true;
}
async function buildDuplicateStructurePlan(sourceName: string, targetName: string, schema: string | undefined, sourceColumns?: ColumnInfo[]) {
async function buildDuplicateStructurePlan(sourceName: string, targetName: string, schema: string | undefined, tableComment?: string | null, sourceColumns?: ColumnInfo[]) {
let columns = sourceColumns;
if (effectiveDatabaseType.value === "dameng" && !columns) {
try {
@@ -1925,9 +1926,10 @@ async function buildDuplicateStructurePlan(sourceName: string, targetName: strin
schema,
sourceName,
targetName,
tableComment,
columnComments,
});
return { sql, sourceColumns: columns, executeAsScript: columnComments.length > 0 };
return { sql, sourceColumns: columns, executeAsScript: duplicateTableStructureRequiresScript(sql) };
}
function executeDuplicateStructurePlan(plan: { sql: string; executeAsScript: boolean }, schema: string | undefined) {
@@ -1941,7 +1943,7 @@ async function confirmDuplicateStructure() {
showDuplicateDialog.value = false;
try {
const schema = row.schema || selectedSchema.value;
const plan = await buildDuplicateStructurePlan(row.name, newName, schema);
const plan = await buildDuplicateStructurePlan(row.name, newName, schema, row.comment);
const executed = await executeObjectBrowserSqlWithProductionGuard(plan.sql, () => executeDuplicateStructurePlan(plan, schema));
if (!executed) return;
toast(t("contextMenu.duplicateStructureSuccess", { name: newName }));
@@ -1962,6 +1964,7 @@ function copySelectedTablesToClipboard() {
database: props.database,
schema: normalizeObjectBrowserTableClipboardSchema(row.schema || selectedSchema.value),
tableName: row.name,
tableComment: row.comment,
})),
};
toast(t("contextMenu.pasteTableClipboardUpdated"), 2000);
@@ -2031,6 +2034,7 @@ function copySingleTableToClipboard(row: ObjectBrowserRow) {
database: props.database,
schema: normalizeObjectBrowserTableClipboardSchema(row.schema || selectedSchema.value),
tableName: row.name,
tableComment: row.comment,
},
],
};
@@ -2052,6 +2056,7 @@ function openPasteTableDialog() {
sourceName: entry.tableName,
targetName: `${entry.tableName}_copy`,
schema: normalizeObjectBrowserTableClipboardSchema(entry.schema, entry.database, entry.connectionId),
tableComment: entry.tableComment,
}));
showPasteDialog.value = true;
}
@@ -2090,7 +2095,7 @@ async function confirmPasteTable() {
try {
let sourceColumns: ColumnInfo[] | undefined;
if (mode === "structure-and-data" || mode === "structure-only") {
const plan = await buildDuplicateStructurePlan(entry.sourceName, targetName, schema, sourceColumns);
const plan = await buildDuplicateStructurePlan(entry.sourceName, targetName, schema, entry.tableComment, sourceColumns);
sourceColumns = plan.sourceColumns;
const executed = await executeObjectBrowserSqlWithProductionGuard(plan.sql, () => executeDuplicateStructurePlan(plan, schema));
if (!executed) {
@@ -30,4 +30,10 @@ describe("ObjectBrowser table clipboard context menu", () => {
expect(objectBrowserSource).toMatch(/if \(!executed\) \{[\s\S]*?pasteCancelled = true;[\s\S]*?break;/);
expect(objectBrowserSource).toMatch(/if \(pasteCancelled\) \{[\s\S]*?if \(hasMutatedTable\)[\s\S]*?await reload\(\)[\s\S]*?refreshObjectListTreeNode[\s\S]*?pasteTableCancelledAfterPartial[\s\S]*?return;/);
});
it("carries table comments through local copy and paste", () => {
expect(objectBrowserSource).toMatch(/tableName: row\.name,\s*tableComment: row\.comment/);
expect(objectBrowserSource).toMatch(/targetName: `\$\{entry\.tableName\}_copy`,[\s\S]*?tableComment: entry\.tableComment/);
expect(objectBrowserSource).toMatch(/buildDuplicateStructurePlan\(entry\.sourceName, targetName, schema, entry\.tableComment/);
});
});
@@ -1597,6 +1597,7 @@ function copySelectedSidebarNames(): boolean {
database: node.database!,
schema: connectionObjectTreeNodeSchema(store.getConfig(node.connectionId!), node.database!, node.schema),
tableName: node.label,
tableComment: node.comment,
})),
}
: null;
@@ -113,6 +113,7 @@ import {
buildCopyTableDataSql,
buildEmptyTableSql,
buildTruncateTableSql,
duplicateTableStructureRequiresScript,
supportsDropTableCascade,
supportsTruncateTableCascade,
supportsSchemaComment,
@@ -921,6 +922,7 @@ function requestPasteTreeClipboard(): boolean {
connectionId: entry.connectionId,
database: entry.database,
schema: normalizeTreeClipboardSchema(entry.connectionId, entry.database, entry.schema),
tableComment: entry.tableComment,
}));
showPasteDialog.value = true;
return true;
@@ -1421,6 +1423,7 @@ function updateTreeClipboardForNodes(nodes: TreeNode[]) {
database: node.database,
schema: normalizeTreeClipboardSchema(node.connectionId, node.database, node.schema),
tableName: node.label,
tableComment: node.comment,
})),
};
}
@@ -1923,7 +1926,7 @@ function openRenameObjectDialog() {
showRenameObjectDialog.value = true;
}
async function executeTreeNodeSqlWithProductionGuard(node: Pick<TreeNode, "connectionId" | "database" | "schema">, sql: string, options: { database?: string; schema?: string } = {}) {
async function executeTreeNodeSqlWithProductionGuard(node: Pick<TreeNode, "connectionId" | "database" | "schema">, sql: string, options: { database?: string; schema?: string; executeAsScript?: boolean } = {}) {
if (!node.connectionId) return undefined;
const database = options.database ?? node.database ?? "";
return executeWithProductionSqlGuard({
@@ -1931,7 +1934,7 @@ async function executeTreeNodeSqlWithProductionGuard(node: Pick<TreeNode, "conne
database,
sql,
source: t("production.sourceSidebar"),
execute: () => api.executeQuery(node.connectionId!, database, sql, options.schema ?? node.schema),
execute: () => (options.executeAsScript ? api.executeScript(node.connectionId!, database, sql, options.schema ?? node.schema) : api.executeQuery(node.connectionId!, database, sql, options.schema ?? node.schema)),
});
}
@@ -2989,8 +2992,13 @@ async function confirmDuplicateStructure() {
schema: node.schema,
sourceName: node.label,
targetName: newName,
tableComment: node.comment,
});
await executeTreeNodeSqlWithProductionGuard(node, sql, {
database: node.database,
schema: node.schema,
executeAsScript: duplicateTableStructureRequiresScript(sql),
});
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
toast(t("contextMenu.duplicateStructureSuccess", { name: newName }), 3000);
await refreshTableList(node);
} catch (e: any) {
@@ -3031,8 +3039,13 @@ async function confirmPasteTable() {
schema: entry.schema,
sourceName: entry.sourceName,
targetName,
tableComment: entry.tableComment,
});
const structureExecuted = await executeTreeNodeSqlWithProductionGuard(entry, structureSql, {
database: entry.database,
schema: entry.schema,
executeAsScript: duplicateTableStructureRequiresScript(structureSql),
});
const structureExecuted = await executeTreeNodeSqlWithProductionGuard(entry, structureSql, { database: entry.database, schema: entry.schema });
if (!structureExecuted) {
pasteCancelled = true;
break;
@@ -3121,6 +3134,7 @@ function openPasteTableDialog() {
connectionId: entry.connectionId,
database: entry.database,
schema: normalizeTreeClipboardSchema(entry.connectionId, entry.database, entry.schema),
tableComment: entry.tableComment,
}));
showPasteDialog.value = true;
}
@@ -17,4 +17,11 @@ describe("cross-database table paste", () => {
expect(runtimeSource).toContain("if (canTransferTreeClipboardToCurrentNode()) return openTransferFromTreeClipboard();");
expect(runtimeSource).toContain("pasteTableMode.value = defaultPasteTableMode(currentDatabaseType());");
});
it("carries table comments through the local sidebar paste path", () => {
expect(runtimeSource).toMatch(/tableName: node\.label,\s*tableComment: node\.comment/);
expect(runtimeSource).toMatch(/targetName: `\$\{entry\.tableName\}_copy`,[\s\S]*?tableComment: entry\.tableComment/);
expect(runtimeSource).toMatch(/targetName,\s*tableComment: entry\.tableComment/);
expect(runtimeSource).toContain("executeAsScript: duplicateTableStructureRequiresScript(structureSql)");
});
});
@@ -58,7 +58,7 @@ export const duplicateTableName = ref("");
export const duplicateStructureSource = ref<DuplicateStructureSource | null>(null);
export const showPasteDialog = ref(false);
export const pasteTableMode = ref<PasteTableMode>("structure-and-data");
export const pasteTableEntries = ref<Array<{ sourceName: string; targetName: string; connectionId: string; database: string; schema?: string }>>([]);
export const pasteTableEntries = ref<Array<{ sourceName: string; targetName: string; connectionId: string; database: string; schema?: string; tableComment?: string | null }>>([]);
export const showCreateDatabaseDialog = ref(false);
export const createDatabaseName = ref("");
export const createDatabaseCharset = ref("utf8mb4");
+12 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { collectDuplicateTableColumnComments } from "@/lib/database/dbAdminSql";
import { collectDuplicateTableColumnComments, duplicateTableStructureRequiresScript } from "@/lib/database/dbAdminSql";
describe("collectDuplicateTableColumnComments", () => {
it("preserves meaningful whitespace and excludes whitespace-only comments", () => {
@@ -19,3 +19,14 @@ describe("collectDuplicateTableColumnComments", () => {
]);
});
});
describe("duplicateTableStructureRequiresScript", () => {
it("detects generated table and column comment statements", () => {
expect(duplicateTableStructureRequiresScript('CREATE TABLE "copy" (LIKE "source" INCLUDING ALL);\nCOMMENT ON TABLE "copy" IS \'orders\';')).toBe(true);
expect(duplicateTableStructureRequiresScript('CREATE TABLE "copy" AS SELECT * FROM "source" WHERE 1=0;\nCOMMENT ON COLUMN "copy"."id" IS \'identifier\';')).toBe(true);
});
it("keeps single-statement structure copies on the query path", () => {
expect(duplicateTableStructureRequiresScript('CREATE TABLE "copy" (LIKE "source" INCLUDING ALL);')).toBe(false);
});
});
@@ -55,6 +55,7 @@ export interface DuplicateTableStructureSqlOptions {
schema?: string | null;
sourceName: string;
targetName: string;
tableComment?: string | null;
columnComments?: Array<{ name: string; comment: string }>;
}
@@ -153,6 +154,10 @@ export function buildDuplicateTableStructureSql(options: DuplicateTableStructure
return api.buildDuplicateTableStructureSql(options);
}
export function duplicateTableStructureRequiresScript(sql: string): boolean {
return /;\s*\n\s*COMMENT ON (?:TABLE|COLUMN)\b/i.test(sql);
}
export function buildCopyTableDataSql(options: CopyTableDataSqlOptions): Promise<string> {
return api.buildCopyTableDataSql(options);
}
@@ -249,6 +249,7 @@ interface TreeClipboardTableEntry {
database: string;
schema?: string;
tableName: string;
tableComment?: string | null;
}
interface TreeClipboardConnectionEntry {