From 8025519039cf82bf6192cc0a3bbaf5f323c6a8ee Mon Sep 17 00:00:00 2001 From: jearhe <0verme7753@gmail.com> Date: Tue, 6 Oct 2026 18:59:18 +0800 Subject: [PATCH] feat(transfer): add structure transfer plan summary Refs: #10929 --- .../transfer/DataTransferDialog.vue | 43 ++- .../__tests__/structurePlanSummary.spec.ts | 64 ++++ .../transferStructurePreview.spec.ts | 14 +- .../transfer/structurePlanSummary.ts | 104 +++++++ .../components/transfer/transferStrategy.ts | 5 + apps/desktop/src/i18n/locales/az.ts | 23 ++ apps/desktop/src/i18n/locales/en.ts | 23 ++ apps/desktop/src/i18n/locales/es.ts | 23 ++ apps/desktop/src/i18n/locales/id.ts | 23 ++ apps/desktop/src/i18n/locales/it.ts | 23 ++ apps/desktop/src/i18n/locales/ja.ts | 23 ++ apps/desktop/src/i18n/locales/ko.ts | 23 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 23 ++ apps/desktop/src/i18n/locales/ru.ts | 23 ++ apps/desktop/src/i18n/locales/tr.ts | 23 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 23 ++ apps/desktop/src/i18n/locales/zh-TW.ts | 23 ++ apps/desktop/src/lib/backend/api.ts | 2 + apps/desktop/src/lib/backend/tauri.ts | 10 + crates/dbx-core/src/data/transfer.rs | 79 ++++- crates/dbx-core/src/data/transfer/ddl_plan.rs | 8 +- .../src/data/transfer/structure_plan.rs | 289 +++++++++++++++++- docs/content/docs/data-transfer.cn.mdx | 4 + docs/content/docs/data-transfer.mdx | 6 +- 24 files changed, 885 insertions(+), 19 deletions(-) create mode 100644 apps/desktop/src/components/transfer/__tests__/structurePlanSummary.spec.ts create mode 100644 apps/desktop/src/components/transfer/structurePlanSummary.ts diff --git a/apps/desktop/src/components/transfer/DataTransferDialog.vue b/apps/desktop/src/components/transfer/DataTransferDialog.vue index 0d59539cf6..684c2db0c4 100644 --- a/apps/desktop/src/components/transfer/DataTransferDialog.vue +++ b/apps/desktop/src/components/transfer/DataTransferDialog.vue @@ -6,6 +6,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Di import { Button } from "@/components/ui/button"; import { buildTransferObjectSelections, countTransferObjects } from "./transferSelections"; import { createTaskLoadTracker } from "./taskLoadTracker"; +import { describeTransferStructureOperation, summarizeTransferStructureOperations } from "./structurePlanSummary"; import { confirmTransferWithProductionSafety, createTransferSubmission, @@ -13,6 +14,7 @@ import { rebuildUnavailableReason, resolveTransferStrategy, supportsTransferUpsert, + transferPlanReviewText, transferPreviewSql, transferStrategyOptions, TRANSFER_STRUCTURE_PREVIEW_UNAVAILABLE, @@ -1084,7 +1086,7 @@ const confirmationTitle = computed(() => { if (!confirmationRequest.value) return ""; // A rebuild keeps its strategy title (it covers rename + create); a plain structure transfer // is titled by what the user is actually reviewing. - return confirmationRequest.value.dropTargetBeforeCreate ? confirmationStrategy.value : t("transfer.structurePreviewTitle"); + return confirmationRequest.value.dropTargetBeforeCreate ? confirmationStrategy.value : t("transfer.structurePlanTitle"); }); /** The read-only SQL both confirmations review, composed so no operation is shown twice. */ const confirmationSql = computed(() => (confirmationPreview.value ? transferPreviewSql(confirmationPreview.value) : "")); @@ -1096,12 +1098,43 @@ const confirmationDetails = computed(() => { const lines = [confirmationSummary.value]; if (preview.rebuild) { const missingTargets = preview.rebuild.tables.some((table) => !table.backupTable); - lines.push(t("transfer.rebuildSummary", { count: preview.rebuild.tables.length })); + if (!preview.structure) lines.push(t("transfer.rebuildSummary", { count: preview.rebuild.tables.length })); if (missingTargets) lines.push(t("transfer.rebuildMissingTargets")); } if (preview.structure) { - lines.push(t("transfer.structurePreviewSummary", { count: preview.structure.tables.length })); - if (preview.structure.tables.some((table) => table.preexisting)) lines.push(t("transfer.structurePreviewPreexistingTargets")); + const operations = preview.structure.operations ?? []; + const operationSummary = summarizeTransferStructureOperations(operations); + lines.push(t("transfer.structurePlanTitle")); + + const createdCounts = [ + [operationSummary.createdSchemas, "structurePlanSchemas"], + [operationSummary.createdTables, "structurePlanTables"], + [operationSummary.indexes, "structurePlanIndexes"], + [operationSummary.foreignKeys, "structurePlanForeignKeys"], + [operationSummary.sequences, "structurePlanSequences"], + [operationSummary.comments, "structurePlanComments"], + ] as const; + const createdItems = createdCounts.filter(([count]) => count > 0).map(([count, label]) => `${count} ${t(`transfer.${label}`, count)}`); + if (createdItems.length > 0) lines.push(`${t("transfer.structurePlanCreated")}: ${createdItems.join(" · ")}`); + if (operationSummary.skippedTables > 0) { + lines.push(`${t("transfer.structurePlanSkipped")}: ${operationSummary.skippedTables} ${t("transfer.structurePlanTables", operationSummary.skippedTables)}`); + } + if (operationSummary.rebuiltTables > 0) { + lines.push(`${t("transfer.structurePlanRebuilt")}: ${operationSummary.rebuiltTables} ${t("transfer.structurePlanTables", operationSummary.rebuiltTables)}`); + } + if (operations.length > 0) { + lines.push(t("transfer.plannedOperations")); + lines.push( + ...operations.map((operation) => { + const description = describeTransferStructureOperation(operation); + const label = t(description.key, description.values); + const suffix = description.suffixKey ? ` — ${t(description.suffixKey)}` : ""; + return `• ${label}${suffix}`; + }), + ); + } + lines.push(t("transfer.structurePlanSqlPreview")); + const unexpandedObjects = request.objects.filter((selection) => selection.objectType !== "TABLE" && selection.names.length > 0); if (unexpandedObjects.length > 0) { lines.push(t("transfer.structurePreviewUnexpandedObjects", { objects: unexpandedObjects.map((selection) => selection.names.join(", ")).join("; ") })); @@ -1121,7 +1154,7 @@ function requestTransferConfirmation(request: api.TransferRequest, preview: api. confirmationRequest.value = request; confirmationPreview.value = preview; const reviewText = hasTransferSqlPreview(preview) - ? [confirmationStrategy.value, confirmationDetails.value, transferPreviewSql(preview)].filter(Boolean).join("\n\n") + ? transferPlanReviewText(confirmationStrategy.value, confirmationDetails.value, preview) : [confirmationSummary.value, `${t("transfer.targetTableHandling")}: ${confirmationStrategy.value}`, ...request.objects.map((selection) => selection.names.join(", "))].join("\n"); return confirmTransferWithProductionSafety({ request, diff --git a/apps/desktop/src/components/transfer/__tests__/structurePlanSummary.spec.ts b/apps/desktop/src/components/transfer/__tests__/structurePlanSummary.spec.ts new file mode 100644 index 0000000000..62e9ead63d --- /dev/null +++ b/apps/desktop/src/components/transfer/__tests__/structurePlanSummary.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import type { TransferStructureOperation } from "@/lib/backend/api"; +import { describeTransferStructureOperation, summarizeTransferStructureOperations } from "../structurePlanSummary"; + +function operation(kind: TransferStructureOperation["kind"], values: Partial> = {}): TransferStructureOperation { + return { kind, ...values }; +} + +describe("transfer structure plan summary", () => { + it("aggregates counts from operations and counts a sequence once across create and bind", () => { + expect( + summarizeTransferStructureOperations([ + operation("createSchema", { objectName: "reporting" }), + operation("createTable", { sourceTable: "orders", targetTable: "orders" }), + operation("createIndex", { objectName: "idx_orders_created_at" }), + operation("addForeignKey", { objectName: "fk_orders_user" }), + operation("createSequence", { objectName: "orders_id_seq" }), + operation("bindSequence", { objectName: "orders_id_seq" }), + operation("addComment"), + operation("skipExistingTable", { targetTable: "users" }), + operation("rebuildTable", { targetTable: "customers" }), + ]), + ).toEqual({ + createdSchemas: 1, + createdTables: 1, + indexes: 1, + foreignKeys: 1, + sequences: 1, + comments: 1, + skippedTables: 1, + rebuiltTables: 1, + }); + }); + + it("retains object names and source-to-target mapping in display descriptions", () => { + expect(describeTransferStructureOperation(operation("createTable", { sourceTable: "orders", targetTable: "reporting.orders" }))).toEqual({ + key: "transfer.structureOperationCreateTable", + values: { name: "orders → reporting.orders" }, + }); + expect(describeTransferStructureOperation(operation("createIndex", { objectName: "idx_orders_created_at", targetTable: "orders" }))).toEqual({ + key: "transfer.structureOperationCreateIndex", + values: { name: "idx_orders_created_at", table: "orders" }, + }); + }); + + it("makes a skipped existing table explicit in the object list", () => { + expect(describeTransferStructureOperation(operation("skipExistingTable", { targetTable: "users" }))).toEqual({ + key: "transfer.structureOperationSkipExistingTable", + values: { name: "users" }, + suffixKey: "transfer.structurePlanTargetAlreadyExists", + }); + }); + + it("describes rebuild and column comment operations without interpreting SQL", () => { + expect(describeTransferStructureOperation(operation("rebuildTable", { targetTable: "customers" }))).toEqual({ + key: "transfer.structureOperationRebuildTable", + values: { name: "customers" }, + }); + expect(describeTransferStructureOperation(operation("addComment", { objectName: "created_at", targetTable: "orders" }))).toEqual({ + key: "transfer.structureOperationAddColumnComment", + values: { table: "orders", column: "created_at" }, + }); + }); +}); diff --git a/apps/desktop/src/components/transfer/__tests__/transferStructurePreview.spec.ts b/apps/desktop/src/components/transfer/__tests__/transferStructurePreview.spec.ts index ebb6d98cc5..cb630e8f10 100644 --- a/apps/desktop/src/components/transfer/__tests__/transferStructurePreview.spec.ts +++ b/apps/desktop/src/components/transfer/__tests__/transferStructurePreview.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { TransferOwnershipPreview, TransferRequest } from "@/lib/backend/api"; -import { createTransferSubmission, hasTransferSqlPreview, transferPreviewSql, TRANSFER_STRUCTURE_PREVIEW_UNAVAILABLE } from "../transferStrategy"; +import { createTransferSubmission, hasTransferSqlPreview, transferPlanReviewText, transferPreviewSql, TRANSFER_STRUCTURE_PREVIEW_UNAVAILABLE } from "../transferStrategy"; function structureRequest(overrides: Partial = {}): TransferRequest { return { @@ -27,7 +27,7 @@ function structureRequest(overrides: Partial = {}): TransferReq } function structurePreview(sql = '-- orders -> reporting.orders\nCREATE TABLE "reporting"."orders" ("id" integer);'): TransferOwnershipPreview["structure"] { - return { sql, tables: [{ sourceTable: "orders", targetTable: "reporting.orders", preexisting: false, sql }] }; + return { sql, tables: [{ sourceTable: "orders", targetTable: "reporting.orders", preexisting: false, sql }], operations: [] }; } function preview(overrides: Partial = {}): TransferOwnershipPreview { @@ -70,6 +70,16 @@ describe("structure-only SQL preview", () => { expect(harness.executions[0]?.content).toBe("structureOnly"); }); + it("includes the operation summary before SQL in production review text", () => { + const plan = preview(); + const reviewText = transferPlanReviewText("Keep existing tables", "Structure transfer plan\nPlanned operations\nCREATE table orders", plan); + + expect(reviewText).toContain("Structure transfer plan"); + expect(reviewText).toContain("CREATE table orders"); + expect(reviewText).toContain('CREATE TABLE "reporting"."orders"'); + expect(reviewText.indexOf("Structure transfer plan")).toBeLessThan(reviewText.indexOf("CREATE TABLE")); + }); + it("does not execute when the SQL confirmation is cancelled", async () => { const harness = submissionHarness(async () => preview(), false); diff --git a/apps/desktop/src/components/transfer/structurePlanSummary.ts b/apps/desktop/src/components/transfer/structurePlanSummary.ts new file mode 100644 index 0000000000..f5be65c8fe --- /dev/null +++ b/apps/desktop/src/components/transfer/structurePlanSummary.ts @@ -0,0 +1,104 @@ +import type { TransferStructureOperation } from "@/lib/backend/api"; + +export interface TransferStructureOperationSummary { + createdSchemas: number; + createdTables: number; + indexes: number; + foreignKeys: number; + sequences: number; + comments: number; + skippedTables: number; + rebuiltTables: number; +} + +export interface TransferStructureOperationDescription { + key: string; + values: Record; + suffixKey?: string; +} + +/** Aggregate only the planner's structured operations; this deliberately does not inspect SQL. */ +export function summarizeTransferStructureOperations(operations: readonly TransferStructureOperation[]): TransferStructureOperationSummary { + const summary: TransferStructureOperationSummary = { + createdSchemas: 0, + createdTables: 0, + indexes: 0, + foreignKeys: 0, + sequences: 0, + comments: 0, + skippedTables: 0, + rebuiltTables: 0, + }; + const sequenceNames = new Set(); + let unnamedSequences = 0; + + for (const operation of operations) { + switch (operation.kind) { + case "createSchema": + summary.createdSchemas += 1; + break; + case "createTable": + summary.createdTables += 1; + break; + case "skipExistingTable": + summary.skippedTables += 1; + break; + case "rebuildTable": + summary.rebuiltTables += 1; + break; + case "createIndex": + summary.indexes += 1; + break; + case "addForeignKey": + summary.foreignKeys += 1; + break; + case "createSequence": + case "bindSequence": + if (operation.objectName) sequenceNames.add(operation.objectName); + else unnamedSequences += 1; + break; + case "addComment": + summary.comments += 1; + break; + } + } + + summary.sequences = sequenceNames.size + unnamedSequences; + return summary; +} + +/** Convert an operation DTO into localized-message inputs without consulting SQL or UI state. */ +export function describeTransferStructureOperation(operation: TransferStructureOperation): TransferStructureOperationDescription { + const table = operation.targetTable ?? operation.sourceTable ?? ""; + const mappedTable = operation.sourceTable && operation.targetTable && operation.sourceTable !== operation.targetTable ? `${operation.sourceTable} → ${operation.targetTable}` : table; + const objectName = operation.objectName ?? ""; + + switch (operation.kind) { + case "createSchema": + return { key: "transfer.structureOperationCreateSchema", values: { name: objectName } }; + case "createTable": + return { key: "transfer.structureOperationCreateTable", values: { name: mappedTable } }; + case "skipExistingTable": + return { + key: "transfer.structureOperationSkipExistingTable", + values: { name: mappedTable }, + suffixKey: "transfer.structurePlanTargetAlreadyExists", + }; + case "rebuildTable": + return { key: "transfer.structureOperationRebuildTable", values: { name: mappedTable } }; + case "createIndex": + return { key: "transfer.structureOperationCreateIndex", values: { name: objectName, table } }; + case "addForeignKey": + return { key: "transfer.structureOperationAddForeignKey", values: { name: objectName, table } }; + case "createSequence": + return { key: "transfer.structureOperationCreateSequence", values: { name: objectName } }; + case "bindSequence": + return { key: "transfer.structureOperationBindSequence", values: { name: objectName, table } }; + case "addComment": + return operation.objectName ? { key: "transfer.structureOperationAddColumnComment", values: { table, column: operation.objectName } } : { key: "transfer.structureOperationAddTableComment", values: { table } }; + default: { + const exhaustiveCheck: never = operation.kind; + return exhaustiveCheck; + } + } +} diff --git a/apps/desktop/src/components/transfer/transferStrategy.ts b/apps/desktop/src/components/transfer/transferStrategy.ts index 8420c8d32a..0ec48ee28a 100644 --- a/apps/desktop/src/components/transfer/transferStrategy.ts +++ b/apps/desktop/src/components/transfer/transferStrategy.ts @@ -60,6 +60,11 @@ export function transferPreviewSql(preview: TransferOwnershipPreview): string { return preview.rebuild?.sql ?? ""; } +/** Production review keeps the human-readable plan ahead of the exact SQL preview. */ +export function transferPlanReviewText(strategy: string, summary: string, preview: TransferOwnershipPreview): string { + return [strategy, summary, transferPreviewSql(preview)].filter(Boolean).join("\n\n"); +} + /** Whether this preview has SQL the user must review in a read-only confirmation. */ export function hasTransferSqlPreview(preview: TransferOwnershipPreview): boolean { return Boolean(preview.rebuild || preview.structure); diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 982a74b0cb..b72e054e66 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -6004,6 +6004,29 @@ export default withEnglishFallback({ rebuildDataOnlyDisabled: "Yalnız məlumat köçürmələri üçün yenidən qurma əlçatan deyil. Struktur və məlumatları və ya yalnız strukturu seçin.", rebuildUnsupportedDisabled: "Seçilmiş hədəf mühərriki hədəf cədvəllərin yenidən qurulmasını dəstəkləmir.", rebuildPreviewUnavailable: "Server tərəfi yenidən qurma planını təqdim etmədi. Köçürmə başlamayıb; yenidən qurmanı dəstəkləyən server tərəfi ilə önbaxışı yeniləyin.", + structurePlanTitle: "Strukturun ötürülmə planı", + plannedOperations: "Planlaşdırılmış əməliyyatlar", + structurePlanCreated: "Yaradılacaq", + structurePlanSkipped: "Ötürüləcək", + structurePlanRebuilt: "Yenidən qurulacaq", + structurePlanSchemas: "sxem | sxem", + structurePlanTables: "cədvəl | cədvəl", + structurePlanIndexes: "indeks | indeks", + structurePlanForeignKeys: "xarici açar | xarici açar", + structurePlanSequences: "ardıcıllıq | ardıcıllıq", + structurePlanComments: "şərh | şərh", + structurePlanTargetAlreadyExists: "hədəf artıq mövcuddur", + structurePlanSqlPreview: "SQL önbaxışı", + structureOperationCreateSchema: "SCHEMA yarat: {name}", + structureOperationCreateTable: "CƏDVƏL yarat: {name}", + structureOperationSkipExistingTable: "CƏDVƏLİ ötür: {name}", + structureOperationRebuildTable: "CƏDVƏLİ yenidən qur: {name}", + structureOperationCreateIndex: "İNDEKS yarat: {name} ({table})", + structureOperationAddForeignKey: "XARİCİ AÇAR əlavə et: {name} ({table})", + structureOperationCreateSequence: "ARDICILLIQ yarat: {name}", + structureOperationBindSequence: "ARDICILLIĞI CƏDVƏLƏ bağla: {name} → {table}", + structureOperationAddTableComment: "CƏDVƏLƏ ŞƏRH əlavə et: {table}", + structureOperationAddColumnComment: "SÜTUNA ŞƏRH əlavə et: {table}.{column}", structurePreviewTitle: "Struktur SQL önbaxışı", structurePreviewDanger: "Bunlar köçürmənin hədəfdə icra edəcəyi struktur ifadələridir və yalnız oxumaq üçün göstərilir. Köçürmə başlayarkən DBX cari hədəf strukturunu yenidən yoxlayır, ona görə bu arada dəyişən hədəf son icra olunan ifadələri dəyişə bilər.", structurePreviewSummary: "{count} cədvəlin strukturu yaradılacaq və ya yenilənəcək.", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 8e4bd3d70a..56e2648eb8 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -6847,6 +6847,29 @@ export default { rebuildUnsupportedDisabled: "The selected target engine does not support rebuilding target tables.", rebuildPreviewUnavailable: "The backend did not provide a rebuild plan. The transfer has not started; refresh the preview with a backend that supports rebuild.", rebuildMissingTargets: "Some target tables do not exist yet and will be created without a backup.", + structurePlanTitle: "Structure transfer plan", + plannedOperations: "Planned operations", + structurePlanCreated: "Created", + structurePlanSkipped: "Skipped", + structurePlanRebuilt: "Rebuilt", + structurePlanSchemas: "schema | schemas", + structurePlanTables: "table | tables", + structurePlanIndexes: "index | indexes", + structurePlanForeignKeys: "foreign key | foreign keys", + structurePlanSequences: "sequence | sequences", + structurePlanComments: "comment | comments", + structurePlanTargetAlreadyExists: "target already exists", + structurePlanSqlPreview: "SQL Preview", + structureOperationCreateSchema: "CREATE schema {name}", + structureOperationCreateTable: "CREATE table {name}", + structureOperationSkipExistingTable: "SKIP table {name}", + structureOperationRebuildTable: "REBUILD table {name}", + structureOperationCreateIndex: "CREATE index {name} ON {table}", + structureOperationAddForeignKey: "ADD FK {name} ON {table}", + structureOperationCreateSequence: "CREATE sequence {name}", + structureOperationBindSequence: "BIND sequence {name} TO {table}", + structureOperationAddTableComment: "ADD COMMENT ON table {table}", + structureOperationAddColumnComment: "ADD COMMENT ON column {table}.{column}", structurePreviewTitle: "Structure SQL preview", structurePreviewDanger: "These are the structure statements this transfer is about to run on the target, shown read-only. DBX re-checks the current target structure when the transfer starts, so a target that changed in the meantime can change what finally runs.", structurePreviewSummary: "Structure will be created or updated for {count} table(s).", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 43597eddad..ddd1ff82eb 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -6215,6 +6215,29 @@ export default withEnglishFallback({ rebuildUnsupportedDisabled: "El motor de destino seleccionado no permite reconstruir las tablas de destino.", rebuildPreviewUnavailable: "El backend no devolvió un plan de reconstrucción. La transferencia no se ha iniciado; vuelva a obtener la vista previa con un backend compatible.", rebuildMissingTargets: "Algunas tablas de destino aún no existen y se crearán sin copia de seguridad.", + structurePlanTitle: "Plan de transferencia de estructura", + plannedOperations: "Operaciones planificadas", + structurePlanCreated: "Creados", + structurePlanSkipped: "Omitidos", + structurePlanRebuilt: "Reconstruidos", + structurePlanSchemas: "esquema | esquemas", + structurePlanTables: "tabla | tablas", + structurePlanIndexes: "índice | índices", + structurePlanForeignKeys: "clave foránea | claves foráneas", + structurePlanSequences: "secuencia | secuencias", + structurePlanComments: "comentario | comentarios", + structurePlanTargetAlreadyExists: "el destino ya existe", + structurePlanSqlPreview: "Vista previa de SQL", + structureOperationCreateSchema: "CREAR esquema {name}", + structureOperationCreateTable: "CREAR tabla {name}", + structureOperationSkipExistingTable: "OMITIR tabla {name}", + structureOperationRebuildTable: "RECONSTRUIR tabla {name}", + structureOperationCreateIndex: "CREAR índice {name} EN {table}", + structureOperationAddForeignKey: "AÑADIR clave foránea {name} EN {table}", + structureOperationCreateSequence: "CREAR secuencia {name}", + structureOperationBindSequence: "ASOCIAR secuencia {name} A {table}", + structureOperationAddTableComment: "AÑADIR comentario a la tabla {table}", + structureOperationAddColumnComment: "AÑADIR comentario a la columna {table}.{column}", structurePreviewTitle: "Vista previa del SQL de estructura", structurePreviewDanger: "Estas son las sentencias de estructura que la transferencia ejecutará en el destino, mostradas solo para lectura. DBX vuelve a comprobar la estructura del destino al iniciar la transferencia, así que un destino que cambie mientras tanto puede cambiar lo que finalmente se ejecuta.", diff --git a/apps/desktop/src/i18n/locales/id.ts b/apps/desktop/src/i18n/locales/id.ts index d5f5ed5c61..88594ae363 100644 --- a/apps/desktop/src/i18n/locales/id.ts +++ b/apps/desktop/src/i18n/locales/id.ts @@ -6657,6 +6657,29 @@ export default withEnglishFallback({ rebuildUnsupportedDisabled: "Engine target yang dipilih tidak mendukung pembangunan ulang tabel target.", rebuildPreviewUnavailable: "Backend tidak menyediakan rencana pembangunan ulang. Transfer belum dimulai; segarkan pratinjau dengan backend yang mendukung pembangunan ulang.", rebuildMissingTargets: "Beberapa tabel target belum ada dan akan dibuat tanpa cadangan.", + structurePlanTitle: "Rencana transfer struktur", + plannedOperations: "Operasi yang direncanakan", + structurePlanCreated: "Dibuat", + structurePlanSkipped: "Dilewati", + structurePlanRebuilt: "Dibangun ulang", + structurePlanSchemas: "skema | skema", + structurePlanTables: "tabel | tabel", + structurePlanIndexes: "indeks | indeks", + structurePlanForeignKeys: "kunci asing | kunci asing", + structurePlanSequences: "urutan | urutan", + structurePlanComments: "komentar | komentar", + structurePlanTargetAlreadyExists: "target sudah ada", + structurePlanSqlPreview: "Pratinjau SQL", + structureOperationCreateSchema: "BUAT skema {name}", + structureOperationCreateTable: "BUAT tabel {name}", + structureOperationSkipExistingTable: "LEWATI tabel {name}", + structureOperationRebuildTable: "BANGUN ULANG tabel {name}", + structureOperationCreateIndex: "BUAT indeks {name} PADA {table}", + structureOperationAddForeignKey: "TAMBAHKAN kunci asing {name} PADA {table}", + structureOperationCreateSequence: "BUAT urutan {name}", + structureOperationBindSequence: "IKAT urutan {name} KE {table}", + structureOperationAddTableComment: "TAMBAHKAN komentar tabel {table}", + structureOperationAddColumnComment: "TAMBAHKAN komentar kolom {table}.{column}", structurePreviewTitle: "Pratinjau SQL struktur", structurePreviewDanger: "Ini adalah pernyataan struktur yang akan dijalankan transfer ini pada target, ditampilkan hanya untuk dibaca. DBX memeriksa ulang struktur target saat transfer dimulai, jadi target yang berubah di sela itu dapat mengubah pernyataan yang akhirnya dijalankan.", structurePreviewSummary: "Struktur akan dibuat atau diperbarui untuk {count} tabel.", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index c5caa9af4b..5032df155c 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -6031,6 +6031,29 @@ export default withEnglishFallback({ rebuildUnsupportedDisabled: "Il motore di destinazione selezionato non supporta la ricostruzione delle tabelle di destinazione.", rebuildPreviewUnavailable: "Il backend non ha restituito un piano di ricostruzione. Il trasferimento non è iniziato; aggiornare l’anteprima con un backend compatibile.", rebuildMissingTargets: "Alcune tabelle di destinazione non esistono ancora e verranno create senza backup.", + structurePlanTitle: "Piano di trasferimento della struttura", + plannedOperations: "Operazioni pianificate", + structurePlanCreated: "Creati", + structurePlanSkipped: "Ignorati", + structurePlanRebuilt: "Ricostruiti", + structurePlanSchemas: "schema | schemi", + structurePlanTables: "tabella | tabelle", + structurePlanIndexes: "indice | indici", + structurePlanForeignKeys: "chiave esterna | chiavi esterne", + structurePlanSequences: "sequenza | sequenze", + structurePlanComments: "commento | commenti", + structurePlanTargetAlreadyExists: "la destinazione esiste già", + structurePlanSqlPreview: "Anteprima SQL", + structureOperationCreateSchema: "CREA schema {name}", + structureOperationCreateTable: "CREA tabella {name}", + structureOperationSkipExistingTable: "IGNORA tabella {name}", + structureOperationRebuildTable: "RICOSTRUISCI tabella {name}", + structureOperationCreateIndex: "CREA indice {name} SU {table}", + structureOperationAddForeignKey: "AGGIUNGI chiave esterna {name} SU {table}", + structureOperationCreateSequence: "CREA sequenza {name}", + structureOperationBindSequence: "COLLEGA sequenza {name} A {table}", + structureOperationAddTableComment: "AGGIUNGI commento alla tabella {table}", + structureOperationAddColumnComment: "AGGIUNGI commento alla colonna {table}.{column}", structurePreviewTitle: "Anteprima SQL della struttura", structurePreviewDanger: "Queste sono le istruzioni di struttura che il trasferimento eseguirà sulla destinazione, mostrate in sola lettura. DBX ricontrolla la struttura di destinazione all'avvio del trasferimento, quindi una destinazione modificata nel frattempo può cambiare ciò che viene effettivamente eseguito.", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 0e40990fcc..deb573ff90 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -6053,6 +6053,29 @@ export default withEnglishFallback({ rebuildUnsupportedDisabled: "選択したターゲットエンジンはテーブルの再構築をサポートしていません。", rebuildPreviewUnavailable: "バックエンドから再構築計画が返されなかったため、転送は開始されていません。再構築プレビューに対応したバックエンドで再試行してください。", rebuildMissingTargets: "存在しないターゲットテーブルがあります。バックアップなしで直接作成されます。", + structurePlanTitle: "構造転送計画", + plannedOperations: "計画された操作", + structurePlanCreated: "作成", + structurePlanSkipped: "スキップ", + structurePlanRebuilt: "再構築", + structurePlanSchemas: "スキーマ | スキーマ", + structurePlanTables: "テーブル | テーブル", + structurePlanIndexes: "インデックス | インデックス", + structurePlanForeignKeys: "外部キー | 外部キー", + structurePlanSequences: "シーケンス | シーケンス", + structurePlanComments: "コメント | コメント", + structurePlanTargetAlreadyExists: "ターゲットは既に存在します", + structurePlanSqlPreview: "SQL プレビュー", + structureOperationCreateSchema: "スキーマを作成: {name}", + structureOperationCreateTable: "テーブルを作成: {name}", + structureOperationSkipExistingTable: "テーブルをスキップ: {name}", + structureOperationRebuildTable: "テーブルを再構築: {name}", + structureOperationCreateIndex: "インデックスを作成: {name}(テーブル: {table})", + structureOperationAddForeignKey: "外部キーを追加: {name}(テーブル: {table})", + structureOperationCreateSequence: "シーケンスを作成: {name}", + structureOperationBindSequence: "シーケンス {name} を {table} に関連付け", + structureOperationAddTableComment: "テーブルコメントを追加: {table}", + structureOperationAddColumnComment: "列コメントを追加: {table}.{column}", structurePreviewTitle: "構造 SQL プレビュー", structurePreviewDanger: "これは今回の転送でターゲットに対して実行される構造ステートメントの読み取り専用プレビューです。転送開始時に DBX が現在のターゲット構造を再確認するため、その間にターゲットが変更された場合は実際に実行されるステートメントが変わる可能性があります。", structurePreviewSummary: "{count} 件のテーブルの構造を作成または更新します。", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 85cf1e87b9..b757d3d96c 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -5824,6 +5824,29 @@ export default withEnglishFallback({ rebuildUnsupportedDisabled: "선택한 대상 엔진은 대상 테이블 재구축을 지원하지 않습니다.", rebuildPreviewUnavailable: "백엔드가 재구축 계획을 반환하지 않아 전송을 시작하지 않았습니다. 재구축 미리 보기를 지원하는 백엔드에서 다시 시도하세요.", rebuildMissingTargets: "존재하지 않는 대상 테이블이 있습니다. 백업 없이 바로 생성됩니다.", + structurePlanTitle: "구조 전송 계획", + plannedOperations: "계획된 작업", + structurePlanCreated: "생성됨", + structurePlanSkipped: "건너뜀", + structurePlanRebuilt: "재구성됨", + structurePlanSchemas: "개 스키마 | 개 스키마", + structurePlanTables: "개 테이블 | 개 테이블", + structurePlanIndexes: "개 인덱스 | 개 인덱스", + structurePlanForeignKeys: "개 외래 키 | 개 외래 키", + structurePlanSequences: "개 시퀀스 | 개 시퀀스", + structurePlanComments: "개 주석 | 개 주석", + structurePlanTargetAlreadyExists: "대상이 이미 존재함", + structurePlanSqlPreview: "SQL 미리 보기", + structureOperationCreateSchema: "스키마 생성: {name}", + structureOperationCreateTable: "테이블 생성: {name}", + structureOperationSkipExistingTable: "테이블 건너뛰기: {name}", + structureOperationRebuildTable: "테이블 재구성: {name}", + structureOperationCreateIndex: "인덱스 생성: {name} (테이블: {table})", + structureOperationAddForeignKey: "외래 키 추가: {name} (테이블: {table})", + structureOperationCreateSequence: "시퀀스 생성: {name}", + structureOperationBindSequence: "시퀀스 {name}을(를) {table}에 연결", + structureOperationAddTableComment: "테이블 주석 추가: {table}", + structureOperationAddColumnComment: "열 주석 추가: {table}.{column}", structurePreviewTitle: "구조 SQL 미리 보기", structurePreviewDanger: "이것은 이번 전송이 대상에 실행할 구조 문의 읽기 전용 미리 보기입니다. 전송을 시작할 때 DBX가 현재 대상 구조를 다시 확인하므로, 그 사이 대상이 변경되면 실제 실행되는 문이 달라질 수 있습니다.", structurePreviewSummary: "{count}개 테이블의 구조를 만들거나 갱신합니다.", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index ddb1dba35d..099b1f37f1 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -6033,6 +6033,29 @@ export default withEnglishFallback({ rebuildUnsupportedDisabled: "O mecanismo de destino selecionado não oferece suporte à reconstrução das tabelas de destino.", rebuildPreviewUnavailable: "O backend não retornou um plano de reconstrução. A transferência não foi iniciada; atualize a prévia com um backend compatível.", rebuildMissingTargets: "Algumas tabelas de destino ainda não existem e serão criadas sem backup.", + structurePlanTitle: "Plano de transferência de estrutura", + plannedOperations: "Operações planejadas", + structurePlanCreated: "Criados", + structurePlanSkipped: "Ignorados", + structurePlanRebuilt: "Reconstruídos", + structurePlanSchemas: "schema | schemas", + structurePlanTables: "tabela | tabelas", + structurePlanIndexes: "índice | índices", + structurePlanForeignKeys: "chave estrangeira | chaves estrangeiras", + structurePlanSequences: "sequência | sequências", + structurePlanComments: "comentário | comentários", + structurePlanTargetAlreadyExists: "o destino já existe", + structurePlanSqlPreview: "Prévia do SQL", + structureOperationCreateSchema: "CRIAR schema {name}", + structureOperationCreateTable: "CRIAR tabela {name}", + structureOperationSkipExistingTable: "IGNORAR tabela {name}", + structureOperationRebuildTable: "RECONSTRUIR tabela {name}", + structureOperationCreateIndex: "CRIAR índice {name} EM {table}", + structureOperationAddForeignKey: "ADICIONAR chave estrangeira {name} EM {table}", + structureOperationCreateSequence: "CRIAR sequência {name}", + structureOperationBindSequence: "VINCULAR sequência {name} A {table}", + structureOperationAddTableComment: "ADICIONAR comentário à tabela {table}", + structureOperationAddColumnComment: "ADICIONAR comentário à coluna {table}.{column}", structurePreviewTitle: "Prévia do SQL de estrutura", structurePreviewDanger: "Estas são as instruções de estrutura que a transferência executará no destino, exibidas somente para leitura. O DBX revalida a estrutura de destino ao iniciar a transferência, então um destino alterado nesse intervalo pode mudar o que será executado.", structurePreviewSummary: "A estrutura será criada ou atualizada em {count} tabela(s).", diff --git a/apps/desktop/src/i18n/locales/ru.ts b/apps/desktop/src/i18n/locales/ru.ts index f81e017b5d..b87b5c08cc 100644 --- a/apps/desktop/src/i18n/locales/ru.ts +++ b/apps/desktop/src/i18n/locales/ru.ts @@ -7213,6 +7213,29 @@ export default withEnglishFallback({ rebuildUnsupportedDisabled: "Выбранный целевой движок не поддерживает перестроение целевых таблиц.", rebuildPreviewUnavailable: "Серверная часть не предоставила план перестроения. Перенос не начат; обновите предпросмотр на серверной части с поддержкой перестроения.", rebuildMissingTargets: "Некоторые целевые таблицы ещё не существуют и будут созданы без резервной копии.", + structurePlanTitle: "План переноса структуры", + plannedOperations: "Запланированные операции", + structurePlanCreated: "Создано", + structurePlanSkipped: "Пропущено", + structurePlanRebuilt: "Перестроено", + structurePlanSchemas: "схема | схемы | схем", + structurePlanTables: "таблица | таблицы | таблиц", + structurePlanIndexes: "индекс | индекса | индексов", + structurePlanForeignKeys: "внешний ключ | внешних ключа | внешних ключей", + structurePlanSequences: "последовательность | последовательности | последовательностей", + structurePlanComments: "комментарий | комментария | комментариев", + structurePlanTargetAlreadyExists: "цель уже существует", + structurePlanSqlPreview: "Предпросмотр SQL", + structureOperationCreateSchema: "СОЗДАТЬ схему {name}", + structureOperationCreateTable: "СОЗДАТЬ таблицу {name}", + structureOperationSkipExistingTable: "ПРОПУСТИТЬ таблицу {name}", + structureOperationRebuildTable: "ПЕРЕСТРОИТЬ таблицу {name}", + structureOperationCreateIndex: "СОЗДАТЬ индекс {name} ДЛЯ {table}", + structureOperationAddForeignKey: "ДОБАВИТЬ внешний ключ {name} ДЛЯ {table}", + structureOperationCreateSequence: "СОЗДАТЬ последовательность {name}", + structureOperationBindSequence: "ПРИВЯЗАТЬ последовательность {name} К {table}", + structureOperationAddTableComment: "ДОБАВИТЬ комментарий к таблице {table}", + structureOperationAddColumnComment: "ДОБАВИТЬ комментарий к столбцу {table}.{column}", structurePreviewTitle: "Предпросмотр SQL структуры", structurePreviewDanger: "Это доступные только для чтения структурные операторы, которые перенос выполнит на цели. При запуске переноса DBX заново проверяет текущую структуру целевой базы, поэтому изменённая за это время цель может привести к другим фактическим операторам.", structurePreviewSummary: "Структура будет создана или обновлена для таблиц: {count}.", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 57fe5824f2..2d3aa1e08c 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -5919,6 +5919,29 @@ export default withEnglishFallback({ ownershipSkipDetails: "Atla seçeneği, nesne sahipliği değiştirme ifadelerini çalıştırmaz. Tablo verisi ve diğer yapı eşitlemesi devam eder.", ownershipTargetOwner: "Onayla seçeneği, sahibi eksik nesneleri hedef bağlantı kullanıcısına atar: {owner}.", ownershipSkip: "Atla", + structurePlanTitle: "Yapı aktarım planı", + plannedOperations: "Planlanan işlemler", + structurePlanCreated: "Oluşturulacak", + structurePlanSkipped: "Atlanacak", + structurePlanRebuilt: "Yeniden oluşturulacak", + structurePlanSchemas: "şema | şema", + structurePlanTables: "tablo | tablo", + structurePlanIndexes: "indeks | indeks", + structurePlanForeignKeys: "yabancı anahtar | yabancı anahtar", + structurePlanSequences: "sıra | sıra", + structurePlanComments: "yorum | yorum", + structurePlanTargetAlreadyExists: "hedef zaten mevcut", + structurePlanSqlPreview: "SQL önizlemesi", + structureOperationCreateSchema: "ŞEMA OLUŞTUR: {name}", + structureOperationCreateTable: "TABLO OLUŞTUR: {name}", + structureOperationSkipExistingTable: "TABLOYU ATLA: {name}", + structureOperationRebuildTable: "TABLOYU YENİDEN OLUŞTUR: {name}", + structureOperationCreateIndex: "İNDEKS OLUŞTUR: {name} ({table})", + structureOperationAddForeignKey: "YABANCI ANAHTAR EKLE: {name} ({table})", + structureOperationCreateSequence: "SIRA OLUŞTUR: {name}", + structureOperationBindSequence: "SIRAYI TABLOYA BAĞLA: {name} → {table}", + structureOperationAddTableComment: "TABLOYA YORUM EKLE: {table}", + structureOperationAddColumnComment: "SÜTUNA YORUM EKLE: {table}.{column}", structurePreviewTitle: "Yapı SQL önizlemesi", structurePreviewDanger: "Bunlar bu aktarımın hedefte çalıştıracağı yapı ifadeleridir ve yalnızca okunmak üzere gösterilir. Aktarım başlarken DBX güncel hedef yapısını yeniden denetler; bu arada değişen bir hedef, sonunda çalışan ifadeleri değiştirebilir.", structurePreviewSummary: "{count} tablonun yapısı oluşturulacak veya güncellenecek.", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 655d982767..f7e33096e3 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -6833,6 +6833,29 @@ export default withEnglishFallback({ rebuildUnsupportedDisabled: "当前目标引擎不支持重建目标表。", rebuildPreviewUnavailable: "后端未返回重建计划,传输尚未开始。请使用支持重建预览的后端后重新预览。", rebuildMissingTargets: "部分目标表尚不存在,将直接创建(无备份)。", + structurePlanTitle: "结构传输计划", + plannedOperations: "计划操作", + structurePlanCreated: "已创建", + structurePlanSkipped: "已跳过", + structurePlanRebuilt: "已重建", + structurePlanSchemas: "个 Schema | 个 Schema", + structurePlanTables: "张表 | 张表", + structurePlanIndexes: "个索引 | 个索引", + structurePlanForeignKeys: "个外键 | 个外键", + structurePlanSequences: "个序列 | 个序列", + structurePlanComments: "条注释 | 条注释", + structurePlanTargetAlreadyExists: "目标已存在", + structurePlanSqlPreview: "SQL 预览", + structureOperationCreateSchema: "创建 Schema:{name}", + structureOperationCreateTable: "创建表:{name}", + structureOperationSkipExistingTable: "跳过表:{name}", + structureOperationRebuildTable: "重建表:{name}", + structureOperationCreateIndex: "创建索引:{name}(表:{table})", + structureOperationAddForeignKey: "添加外键:{name}(表:{table})", + structureOperationCreateSequence: "创建序列:{name}", + structureOperationBindSequence: "绑定序列:{name} → {table}", + structureOperationAddTableComment: "添加表注释:{table}", + structureOperationAddColumnComment: "添加列注释:{table}.{column}", structurePreviewTitle: "结构 SQL 预览", structurePreviewDanger: "以下是本次传输即将在目标端执行的结构语句,仅只读展示。开始传输时 DBX 会重新检查当前目标结构,若目标在此期间发生变化,最终执行的语句可能与预览不同。", structurePreviewSummary: "将为 {count} 张表创建或更新结构。", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index c9884afd62..43cfc7ebd2 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -5364,6 +5364,29 @@ export default withEnglishFallback({ rebuildUnsupportedDisabled: "目前目標引擎不支援重建目標表。", rebuildPreviewUnavailable: "後端未傳回重建計畫,傳輸尚未開始。請使用支援重建預覽的後端後重新預覽。", rebuildMissingTargets: "部分目標資料表尚不存在,將直接建立(無備份)。", + structurePlanTitle: "結構傳輸計畫", + plannedOperations: "計畫操作", + structurePlanCreated: "已建立", + structurePlanSkipped: "已略過", + structurePlanRebuilt: "已重建", + structurePlanSchemas: "個 Schema | 個 Schema", + structurePlanTables: "個資料表 | 個資料表", + structurePlanIndexes: "個索引 | 個索引", + structurePlanForeignKeys: "個外鍵 | 個外鍵", + structurePlanSequences: "個序列 | 個序列", + structurePlanComments: "則註解 | 則註解", + structurePlanTargetAlreadyExists: "目標已存在", + structurePlanSqlPreview: "SQL 預覽", + structureOperationCreateSchema: "建立 Schema:{name}", + structureOperationCreateTable: "建立資料表:{name}", + structureOperationSkipExistingTable: "略過資料表:{name}", + structureOperationRebuildTable: "重建資料表:{name}", + structureOperationCreateIndex: "建立索引:{name}(資料表:{table})", + structureOperationAddForeignKey: "新增外鍵:{name}(資料表:{table})", + structureOperationCreateSequence: "建立序列:{name}", + structureOperationBindSequence: "將序列 {name} 綁定至 {table}", + structureOperationAddTableComment: "新增資料表註解:{table}", + structureOperationAddColumnComment: "新增欄位註解:{table}.{column}", structurePreviewTitle: "結構 SQL 預覽", structurePreviewDanger: "以下是本次傳輸即將在目標端執行的結構語句,僅供唯讀檢視。開始傳輸時 DBX 會重新檢查目前的目標結構;若目標在這段期間發生變化,最終執行的語句可能與預覽不同。", structurePreviewSummary: "將為 {count} 個資料表建立或更新結構。", diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 0d8779e9be..586aae0c88 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -1111,6 +1111,8 @@ export type { TransferObjectSelection, TransferTableNameCase, TransferOwnershipPolicy, + TransferStructureOperation, + TransferStructureOperationKind, TransferOwnershipPreview, TableImportMode, TableImportConflictPolicy, diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index c0e4ecd466..096d6f7869 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -5494,9 +5494,19 @@ export interface TransferStructurePreviewTable { sql: string; } +export type TransferStructureOperationKind = "createSchema" | "createTable" | "skipExistingTable" | "rebuildTable" | "createIndex" | "addForeignKey" | "createSequence" | "bindSequence" | "addComment"; + +export interface TransferStructureOperation { + kind: TransferStructureOperationKind; + objectName?: string; + sourceTable?: string; + targetTable?: string; +} + export interface TransferStructurePreview { sql: string; tables: TransferStructurePreviewTable[]; + operations: TransferStructureOperation[]; } export interface TransferOwnershipPreview { diff --git a/crates/dbx-core/src/data/transfer.rs b/crates/dbx-core/src/data/transfer.rs index 8e2b1d6942..855e619a0d 100644 --- a/crates/dbx-core/src/data/transfer.rs +++ b/crates/dbx-core/src/data/transfer.rs @@ -344,6 +344,68 @@ pub struct TransferRebuildPreviewTable { pub struct TransferStructurePreview { pub sql: String, pub tables: Vec, + pub operations: Vec, +} + +/// One user-facing logical operation produced by the structure transfer planner. +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum TransferStructureOperationKind { + CreateSchema, + CreateTable, + SkipExistingTable, + RebuildTable, + CreateIndex, + AddForeignKey, + CreateSequence, + BindSequence, + AddComment, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TransferStructureOperation { + pub kind: TransferStructureOperationKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub object_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_table: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_table: Option, +} + +impl TransferStructureOperation { + pub(crate) fn table(kind: TransferStructureOperationKind, source_table: &str, target_table: &str) -> Self { + Self { + kind, + object_name: None, + source_table: Some(source_table.to_string()), + target_table: Some(target_table.to_string()), + } + } + + pub(crate) fn object( + kind: TransferStructureOperationKind, + object_name: &str, + source_table: &str, + target_table: &str, + ) -> Self { + Self { + kind, + object_name: Some(object_name.to_string()), + source_table: Some(source_table.to_string()), + target_table: Some(target_table.to_string()), + } + } + + pub(crate) fn schema(schema: &str) -> Self { + Self { + kind: TransferStructureOperationKind::CreateSchema, + object_name: Some(schema.to_string()), + source_table: None, + target_table: None, + } + } } #[derive(Debug, Clone, Serialize)] @@ -7434,6 +7496,7 @@ async fn get_existing_postgres_sequence_names_for_transfer( /// [`execute_planned_postgres_owned_sequences_for_transfer`] is for. struct PlannedPostgresOwnedSequences { create_statements: Vec, + create_sequences: Vec, owned_sequences: Vec, } @@ -7449,14 +7512,22 @@ async fn plan_postgres_owned_sequences_for_transfer( target_table_preexisting: bool, ) -> Result { if !(request.create_table && pg_compat_transfer && preserves_target_table_name && !target_table_preexisting) { - return Ok(PlannedPostgresOwnedSequences { create_statements: Vec::new(), owned_sequences: Vec::new() }); + return Ok(PlannedPostgresOwnedSequences { + create_statements: Vec::new(), + create_sequences: Vec::new(), + owned_sequences: Vec::new(), + }); } let owned_sequences = get_postgres_owned_sequences_for_transfer(state, source_pool_key, &request.source_schema, &[table.to_string()]) .await?; if owned_sequences.is_empty() { - return Ok(PlannedPostgresOwnedSequences { create_statements: Vec::new(), owned_sequences }); + return Ok(PlannedPostgresOwnedSequences { + create_statements: Vec::new(), + create_sequences: Vec::new(), + owned_sequences, + }); } let sequence_names = owned_sequences.iter().map(|sequence| sequence.name.clone()).collect::>(); @@ -7476,6 +7547,7 @@ async fn plan_postgres_owned_sequences_for_transfer( let sequence_if_not_exists = target_supports_if_not_exists_ddl(state, target_pool_key).await; let mut create_statements = Vec::new(); + let mut create_sequences = Vec::new(); for sequence in &owned_sequences { let should_create = validate_existing_postgres_sequence( sequence, @@ -7491,10 +7563,11 @@ async fn plan_postgres_owned_sequences_for_transfer( &request.target_schema, sequence_if_not_exists, )); + create_sequences.push(sequence.clone()); } } - Ok(PlannedPostgresOwnedSequences { create_statements, owned_sequences }) + Ok(PlannedPostgresOwnedSequences { create_statements, create_sequences, owned_sequences }) } /// Executes the `CREATE SEQUENCE` statements a [`PlannedPostgresOwnedSequences`] planned. diff --git a/crates/dbx-core/src/data/transfer/ddl_plan.rs b/crates/dbx-core/src/data/transfer/ddl_plan.rs index 8b6149a1ac..8700cf96d6 100644 --- a/crates/dbx-core/src/data/transfer/ddl_plan.rs +++ b/crates/dbx-core/src/data/transfer/ddl_plan.rs @@ -13,6 +13,7 @@ pub(super) struct PreparedTableDdl { pub ddl: String, pub reused_source_ddl: bool, pub deferred_fk_alters: Vec, + pub deferred_fk_names: Vec, } #[allow(clippy::too_many_arguments)] @@ -230,13 +231,18 @@ pub(super) async fn prepare_table_ddl( // metadata behind `foreign_keys` is trustworthy before anything is stripped. let mut ddl = ddl; let mut deferred_fk_alters = Vec::new(); + let mut deferred_fk_names = Vec::new(); if supports_deferred_mysql_foreign_keys(target_db_type) && !foreign_keys.is_empty() { ddl = strip_inline_foreign_key_constraint_lines(&ddl); + deferred_fk_names = group_foreign_keys_by_constraint_name(&foreign_keys) + .into_iter() + .map(|(name, _)| name.to_string()) + .collect(); deferred_fk_alters = generate_mysql_foreign_key_alter_statements(&foreign_keys, request, target_table, target_db_type); } - Ok(PreparedTableDdl { ddl, reused_source_ddl, deferred_fk_alters }) + Ok(PreparedTableDdl { ddl, reused_source_ddl, deferred_fk_alters, deferred_fk_names }) } #[cfg(test)] diff --git a/crates/dbx-core/src/data/transfer/structure_plan.rs b/crates/dbx-core/src/data/transfer/structure_plan.rs index fab43c2141..84285dfdf5 100644 --- a/crates/dbx-core/src/data/transfer/structure_plan.rs +++ b/crates/dbx-core/src/data/transfer/structure_plan.rs @@ -44,7 +44,11 @@ pub(super) async fn build_structure_preview( target_pool_key: &str, ) -> Result { if !request.create_table { - return Ok(TransferStructurePreview { sql: CREATE_TABLE_DISABLED_NOTE.to_string(), tables: Vec::new() }); + return Ok(TransferStructurePreview { + sql: CREATE_TABLE_DISABLED_NOTE.to_string(), + tables: Vec::new(), + operations: Vec::new(), + }); } let pg_compat_transfer = is_postgres_compat_transfer(source_db_type, target_db_type); @@ -66,7 +70,10 @@ pub(super) async fn build_structure_preview( let (tables, known_foreign_keys) = sorted_source_tables_with_foreign_keys(state, request).await; let mut sections: Vec = Vec::new(); + let mut operations = Vec::new(); + let mut deferred_fk_operations = Vec::new(); if let Some(create_schema_sql) = &create_schema_sql { + operations.push(TransferStructureOperation::schema(&request.target_schema)); sections.push(format!( "-- Ensure the target schema exists\n{}", statement_block(std::slice::from_ref(create_schema_sql)) @@ -91,6 +98,11 @@ pub(super) async fn build_structure_preview( // A rebuild renames every preexisting target aside before the create pass runs, so // those tables are created fresh — the same reset the create pass performs. if preexisting && !request.drop_target_before_create { + operations.push(TransferStructureOperation::table( + TransferStructureOperationKind::SkipExistingTable, + table, + &target_table, + )); let note = format!( "-- {table} -> {target_table}: the target table already exists, so this transfer plans no structure \ DDL for it" @@ -110,6 +122,7 @@ pub(super) async fn build_structure_preview( request, table, &target_table, + preexisting && request.drop_target_before_create, source_db_type, target_db_type, source_pool_key, @@ -120,6 +133,8 @@ pub(super) async fn build_structure_preview( ) .await?; deferred_fk_alters.extend(planned.deferred_fk_alters); + operations.extend(planned.operations); + deferred_fk_operations.extend(planned.deferred_fk_operations); let section = format!("-- {} -> {}\n{}", table, target_table, statement_block(&planned.statements)); planned_tables.push(TransferStructurePreviewTable { @@ -140,14 +155,92 @@ pub(super) async fn build_structure_preview( statement_block(&deferred_fk_alters) )); } + operations.extend(deferred_fk_operations); sections.extend(unexpanded_schema_object_notes(source_db_type, target_db_type, request)); - Ok(TransferStructurePreview { sql: sections.join("\n\n"), tables: planned_tables }) + Ok(TransferStructurePreview { sql: sections.join("\n\n"), tables: planned_tables, operations }) } struct PlannedTableStructure { statements: Vec, + operations: Vec, deferred_fk_alters: Vec, + deferred_fk_operations: Vec, +} + +fn sequence_operation( + kind: TransferStructureOperationKind, + sequence: &PostgresOwnedSequence, + target_table: &str, +) -> TransferStructureOperation { + TransferStructureOperation::object(kind, &sequence.name, &sequence.owner_table, target_table) +} + +fn postgres_index_operations( + indexes: &[db::IndexInfo], + source_table: &str, + target_table: &str, +) -> Vec { + indexes + .iter() + .filter(|index| !index.is_primary && !index.name.trim().is_empty() && !index.columns.is_empty()) + .map(|index| { + TransferStructureOperation::object( + TransferStructureOperationKind::CreateIndex, + &index.name, + source_table, + target_table, + ) + }) + .collect() +} + +fn foreign_key_operations(names: &[String], source_table: &str, target_table: &str) -> Vec { + names + .iter() + .map(|name| { + TransferStructureOperation::object( + TransferStructureOperationKind::AddForeignKey, + name, + source_table, + target_table, + ) + }) + .collect() +} + +fn comment_operations( + columns: &[db::ColumnInfo], + source_table: &str, + target_table: &str, + target_db_type: &DatabaseType, + table_comment: Option<&str>, +) -> Vec { + let table_comments_supported = supports_comment_on_transfer_ddl(target_db_type); + let column_comments_supported = table_comments_supported || matches!(target_db_type, DatabaseType::ClickHouse); + let mut operations = Vec::new(); + + if table_comments_supported && table_comment.is_some_and(|comment| !comment.trim().is_empty()) { + operations.push(TransferStructureOperation::table( + TransferStructureOperationKind::AddComment, + source_table, + target_table, + )); + } + if column_comments_supported { + operations.extend(columns.iter().filter_map(|column| { + column.comment.as_deref().filter(|comment| !comment.trim().is_empty()).map(|_| { + TransferStructureOperation::object( + TransferStructureOperationKind::AddComment, + &column.name, + source_table, + target_table, + ) + }) + })); + } + + operations } /// Render the statements the create pass runs for one table, in execution order. @@ -157,6 +250,7 @@ async fn plan_table_structure( request: &TransferRequest, table: &str, target_table: &str, + rebuild_existing_target: bool, source_db_type: &DatabaseType, target_db_type: &DatabaseType, source_pool_key: &str, @@ -205,26 +299,50 @@ async fn plan_table_structure( prepared.ddl }; - let mut statements = owned_sequences.create_statements.clone(); + let mut statements = Vec::new(); + let mut operations = Vec::new(); + for (sequence, statement) in owned_sequences.create_sequences.iter().zip(&owned_sequences.create_statements) { + statements.push(statement.clone()); + operations.push(sequence_operation(TransferStructureOperationKind::CreateSequence, sequence, target_table)); + } + let table_kind = if rebuild_existing_target { + TransferStructureOperationKind::RebuildTable + } else { + TransferStructureOperationKind::CreateTable + }; + operations.push(TransferStructureOperation::table(table_kind, table, target_table)); + // Normalize the DDL exactly like the executor does before running it: the reused // PostgreSQL script spans several statements, has its inline foreign keys stripped, and // drops the post-table index/foreign-key statements that are re-applied from structured // metadata below. A statement the create pass filters out must never appear here. statements.extend(transfer_ddl_statements(&ddl, target_db_type)); - statements.extend(generate_comment_ddl_with_column_quoting( + + let comment_statements = generate_comment_ddl_with_column_quoting( &columns, target_table, &request.target_schema, target_db_type, table_comment.as_deref(), request.quote_target_column_names, - )); + ); + operations.extend(comment_operations(&columns, table, target_table, target_db_type, table_comment.as_deref())); + statements.extend(comment_statements); + for sequence in &owned_sequences.owned_sequences { statements.push(postgres_owned_sequence_bind_sql(request, sequence)); + operations.push(sequence_operation(TransferStructureOperationKind::BindSequence, sequence, target_table)); } + + let mut deferred_fk_operations = Vec::new(); + if supports_deferred_mysql_foreign_keys(target_db_type) { + deferred_fk_operations = foreign_key_operations(&prepared.deferred_fk_names, table, target_table); + } + if pg_compat_transfer && preserves_target_table_name { // PostgreSQL-compatible targets restore indexes and foreign keys from structured - // source metadata once the table exists. + // source metadata once the table exists. The operation records use the same metadata + // consumed by the DDL generators; SQL is never parsed to infer them. let indexes = get_postgres_indexes_for_transfer( state, source_pool_key, @@ -239,6 +357,8 @@ async fn plan_table_structure( &request.target_schema, index_if_not_exists, )); + operations.extend(postgres_index_operations(&indexes, table, target_table)); + let foreign_keys = get_postgres_foreign_keys_for_transfer( state, source_pool_key, @@ -253,9 +373,19 @@ async fn plan_table_structure( &request.source_schema, &request.target_schema, )); + let foreign_key_names = group_foreign_keys_by_constraint_name(&foreign_keys) + .into_iter() + .map(|(name, _)| name.to_string()) + .collect::>(); + operations.extend(foreign_key_operations(&foreign_key_names, table, target_table)); } - Ok(PlannedTableStructure { statements, deferred_fk_alters: prepared.deferred_fk_alters }) + Ok(PlannedTableStructure { + statements, + operations, + deferred_fk_alters: prepared.deferred_fk_alters, + deferred_fk_operations, + }) } /// Read and de-duplicate the source columns the create pass uses, failing the same way the @@ -408,6 +538,123 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn structured_ddl_metadata_produces_operation_dtos() { + let index = db::IndexInfo { + name: "idx_orders_created_at".into(), + columns: vec!["created_at".into()], + is_unique: false, + is_primary: false, + filter: None, + index_type: None, + included_columns: None, + comment: None, + key_is_expression: Vec::new(), + column_opclasses: Vec::new(), + key_options: Vec::new(), + constraint_backed: false, + }; + let index_sql = generate_postgres_index_ddl(std::slice::from_ref(&index), "orders", "public", true); + let index_operations = postgres_index_operations(std::slice::from_ref(&index), "orders", "orders"); + assert!(index_sql[0].contains("idx_orders_created_at")); + assert_eq!(index_operations[0].kind, TransferStructureOperationKind::CreateIndex); + assert_eq!(index_operations[0].object_name.as_deref(), Some("idx_orders_created_at")); + + let foreign_key = db::ForeignKeyInfo { + name: "fk_orders_user".into(), + column: "user_id".into(), + ref_schema: Some("public".into()), + ref_table: "users".into(), + ref_column: "id".into(), + on_update: None, + on_delete: None, + }; + let foreign_key_sql = + generate_postgres_foreign_key_ddl(std::slice::from_ref(&foreign_key), "orders", "public", "reporting"); + let foreign_key_names = group_foreign_keys_by_constraint_name(std::slice::from_ref(&foreign_key)) + .into_iter() + .map(|(name, _)| name.to_string()) + .collect::>(); + let pg_foreign_key_operations = foreign_key_operations(&foreign_key_names, "orders", "orders"); + assert!(foreign_key_sql[0].contains("fk_orders_user")); + assert_eq!(pg_foreign_key_operations[0].kind, TransferStructureOperationKind::AddForeignKey); + assert_eq!(pg_foreign_key_operations[0].object_name.as_deref(), Some("fk_orders_user")); + + let request = structure_request(json!({ + "sourceDatabase": "app", + "sourceSchema": "public", + "targetDatabase": "warehouse", + "targetSchema": "reporting" + })); + let mysql_foreign_key_sql = generate_mysql_foreign_key_alter_statements( + std::slice::from_ref(&foreign_key), + &request, + "orders", + &DatabaseType::Mysql, + ); + let mysql_foreign_key_operations = foreign_key_operations(&foreign_key_names, "orders", "orders"); + assert!(mysql_foreign_key_sql[0].contains("ADD CONSTRAINT `fk_orders_user`")); + assert_eq!(mysql_foreign_key_operations[0].kind, TransferStructureOperationKind::AddForeignKey); + + let sequence = PostgresOwnedSequence { + name: "orders_id_seq".into(), + owner_table: "orders".into(), + owner_column: "id".into(), + }; + let sequence_definition = PostgresTransferSequence { + name: sequence.name.clone(), + data_type: "bigint".into(), + start_value: "1".into(), + min_value: "1".into(), + max_value: "9223372036854775807".into(), + increment: "1".into(), + cycle: false, + cache_value: "1".into(), + last_value: None, + is_called: None, + }; + let create_sequence_sql = + generate_postgres_transfer_sequence_create_ddl(&sequence_definition, "reporting", false); + let bind_sequence_sql = postgres_owned_sequence_bind_sql(&request, &sequence); + let create_sequence_operation = + sequence_operation(TransferStructureOperationKind::CreateSequence, &sequence, "orders"); + let bind_sequence_operation = + sequence_operation(TransferStructureOperationKind::BindSequence, &sequence, "orders"); + assert!(create_sequence_sql.contains("CREATE SEQUENCE")); + assert_eq!(create_sequence_operation.kind, TransferStructureOperationKind::CreateSequence); + assert!(bind_sequence_sql.contains("OWNED BY")); + assert_eq!(bind_sequence_operation.kind, TransferStructureOperationKind::BindSequence); + assert_eq!(bind_sequence_operation.target_table.as_deref(), Some("orders")); + + let columns = vec![db::ColumnInfo { + name: "id".into(), + comment: Some("primary identifier".into()), + ..Default::default() + }]; + let comment_sql = generate_comment_ddl_with_column_quoting( + &columns, + "orders", + "reporting", + &DatabaseType::Postgres, + Some("order records"), + true, + ); + let comment_operations = + comment_operations(&columns, "orders", "orders", &DatabaseType::Postgres, Some("order records")); + assert_eq!(comment_sql.len(), 2); + assert_eq!(comment_operations.len(), 2); + assert!(comment_operations + .iter() + .all(|operation| operation.kind == TransferStructureOperationKind::AddComment)); + assert_eq!(comment_operations[0].object_name, None); + assert_eq!(comment_operations[1].object_name.as_deref(), Some("id")); + + let schema = serde_json::to_value(TransferStructureOperation::schema("reporting")).unwrap(); + assert_eq!(schema["kind"], "createSchema"); + assert_eq!(schema["objectName"], "reporting"); + assert!(schema.get("sourceTable").is_none()); + } + async fn sqlite_fixture() -> (tempfile::TempDir, Arc, String, String) { let directory = tempfile::tempdir().unwrap(); let storage = crate::persistence::test_storage::open(&directory.path().join("storage.db")).await.unwrap(); @@ -485,6 +732,16 @@ mod tests { let structure = preview.structure.expect("structure-only must expose its structure SQL plan"); assert_eq!(structure.tables.len(), 2, "every selected table must be planned: {:?}", structure.tables); + assert_eq!( + structure + .operations + .iter() + .filter(|operation| operation.kind == TransferStructureOperationKind::CreateTable) + .count(), + 2, + "new targets must have one logical create operation each: {:?}", + structure.operations + ); for table in &structure.tables { assert!(!table.preexisting, "a fresh target has no preexisting tables: {table:?}"); assert!(table.sql.contains("CREATE TABLE"), "{}", table.sql); @@ -516,6 +773,9 @@ mod tests { structure.sql ); assert!(structure.sql.contains("already exists"), "{}", structure.sql); + assert_eq!(structure.operations.len(), 1); + assert_eq!(structure.operations[0].kind, TransferStructureOperationKind::SkipExistingTable); + assert_eq!(structure.operations[0].target_table.as_deref(), Some("orders")); // The preview must not have touched the preexisting table. let rows = execute_read_on_pool(&state, &target_pool, "SELECT legacy FROM orders WHERE id = 7").await.unwrap(); @@ -591,6 +851,21 @@ mod tests { // The structure plan is rendered without repeating the rename/cleanup operations. let structure = preview.structure.expect("structure-only must expose its structure SQL plan"); assert!(structure.sql.contains("CREATE TABLE"), "{}", structure.sql); + assert_eq!( + structure + .operations + .iter() + .filter(|operation| operation.kind == TransferStructureOperationKind::RebuildTable) + .count(), + 2, + "each rebuilt target is represented once: {:?}", + structure.operations + ); + assert!( + structure.operations.iter().all(|operation| operation.kind != TransferStructureOperationKind::CreateTable), + "a rebuild must not also be listed as a normal create: {:?}", + structure.operations + ); assert!(!structure.sql.contains("RENAME"), "{}", structure.sql); assert!(!structure.sql.contains("DROP TABLE"), "{}", structure.sql); for table in &structure.tables { diff --git a/docs/content/docs/data-transfer.cn.mdx b/docs/content/docs/data-transfer.cn.mdx index 4647bbcd26..2ffb65ccfb 100644 --- a/docs/content/docs/data-transfer.cn.mdx +++ b/docs/content/docs/data-transfer.cn.mdx @@ -76,6 +76,10 @@ description: 在不同连接、数据库、Schema 或数据库引擎之间批量 “仅结构”传输不会静默修改目标库:DBX 会先生成即将执行的结构语句,在只读确认对话框中展示(语法高亮、可复制、可换行),此时取消则不会执行任何语句。 +SQL Preview 上方会显示 **结构传输计划**,包含操作计数和对象级列表,例如计划创建的表、明确跳过的已有目标、重建,以及结构化元数据支持的索引、外键、序列或注释。操作来自与 SQL 相同的规划决策;DBX 不会解析 SQL 来猜测操作。 + +该摘要描述的是传输计划,不是完整的源/目标 Schema Diff。它不会比较列类型、默认值或可空性,不会检测仅目标端对象,也不表示已有目标与源结构完全相同。例如,`SKIP table users — target already exists` 表示该表不会规划结构 DDL,并不代表源表与目标表一致。 + 预览覆盖传输即将执行的表级结构操作: - `CREATE TABLE`(必要时还包括需要内联创建的 PostgreSQL 兼容 schema) diff --git a/docs/content/docs/data-transfer.mdx b/docs/content/docs/data-transfer.mdx index 8d81233b3d..3d32a4f31a 100644 --- a/docs/content/docs/data-transfer.mdx +++ b/docs/content/docs/data-transfer.mdx @@ -76,13 +76,17 @@ If table creation is disabled, compatible target tables must already exist. Even Structure-only transfers do not change the target silently: DBX plans the structure statements first and shows them in a read-only confirmation dialog (syntax highlighted, copyable, wrapped) before anything runs. Cancel there and nothing is executed. +Above the SQL Preview, DBX shows a **Structure transfer plan** with operation counts and an object-level list, including planned table creation, explicit skips for existing targets, rebuilds, and structured indexes, foreign keys, sequences, or comments where available. Operations come from the same planning decisions as the SQL; DBX does not parse SQL to guess what will happen. + +This summary describes the transfer plan, not a full source/target schema diff. It does not compare column types, defaults, or nullability, detect target-only objects, or claim that an existing target is schema-identical. For example, `SKIP table users — target already exists` means no table structure DDL is planned for that table; it does not assert that the source and target tables match. + The preview covers the table-level structure operations the transfer is about to run: - `CREATE TABLE` (and the inline PostgreSQL-compatible schema, when one has to be created) - `COMMENT` statements - Deferred MySQL-family `ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY` statements - PostgreSQL-compatible indexes and foreign keys -- PostgreSQL sequence statements implied by the source structure, +- PostgreSQL sequence statements implied by the source structure - plus the rebuild rename/backup and cleanup statements when rebuild is enabled. It is a preview, not a frozen script. Starting the transfer re-reads source and target metadata and re-runs the existing safety checks, so a target changed in the meantime can make execution differ from the preview — DBX re-validates the structure rather than replaying the shown statements blindly.