feat(transfer): add rebuild structure and data mode

This commit is contained in:
HolyeTyrone
2026-09-08 16:46:39 +08:00
committed by GitHub
parent 2b2d1f0d95
commit 538aeed99c
37 changed files with 6720 additions and 404 deletions
+1
View File
@@ -70,6 +70,7 @@ tmp/
.dlp-quarantine/
outputs/
target-ai-write-confirmation/
.serena/
# Code review tool artifacts
review_diff.json
@@ -146,7 +146,7 @@ async function copyFullCode() {
<div class="py-4 min-w-0">
<p class="text-sm text-muted-foreground mb-3">{{ message || t("dangerDialog.message") }}</p>
<p v-if="detailsText" class="text-xs text-muted-foreground mb-3 whitespace-pre-line">{{ detailsText }}</p>
<p v-if="detailsText" class="text-xs text-muted-foreground mb-3 whitespace-pre-line max-h-40 overflow-auto">{{ detailsText }}</p>
<slot name="options" />
<div v-if="code" data-testid="danger-code-container" class="min-w-0">
<div data-testid="danger-code-actions" class="mb-1 flex items-center justify-end gap-0.5">
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { computed, onBeforeUnmount, ref, shallowRef, watch } from "vue";
import { uuid } from "@/lib/common/utils";
import { useI18n } from "vue-i18n";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { buildTransferObjectSelections } from "./transferSelections";
import { createTaskLoadTracker } from "./taskLoadTracker";
import { confirmTransferWithProductionSafety, createTransferSubmission, rebuildUnavailableReason, resolveTransferStrategy, transferStrategyOptions, type TransferStrategy } from "./transferStrategy";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -14,11 +15,13 @@ import SearchableSelect from "@/components/ui/searchable-select/SearchableSelect
import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import * as api from "@/lib/backend/api";
import type { TransferContent, TransferMode, TransferObjectKind, TransferTableNameCase } from "@/lib/backend/api";
import type { TransferContent, TransferObjectKind, TransferTableNameCase } from "@/lib/backend/api";
import { crossFamilyTransferableKinds, isSameTransferFamily, transferObjectKindsForDatabase } from "@/lib/database/transferObjectKinds";
import ObjectSelectionTree from "@/components/transfer/ObjectSelectionTree.vue";
import TransferTaskTree from "@/components/transfer/TransferTaskTree.vue";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import type { DatabaseType } from "@/types/database";
import type { TransferTask, TransferTaskConfig } from "@/types/database";
import { isSchemaAware, supportsTransfer } from "@/lib/database/databaseCapabilities";
@@ -37,6 +40,7 @@ const { t } = useI18n();
const { startDataTransferTask } = useExportTracker();
const { toast } = useToast();
const taskStore = useTransferTaskStore();
const productionSafetyStore = useProductionSafetyStore();
const open = defineModel<boolean>("open", { default: false });
const props = defineProps<{
@@ -155,17 +159,34 @@ const targetSchema = ref("");
const pendingTargetSchemaPrefill = ref("");
// Options
const transferMode = ref<TransferMode>("append");
const selectedStrategy = ref<TransferStrategy>("append");
const targetTableStrategy = computed<TransferStrategy>({
get: () => (transferContent.value === "structureOnly" && selectedStrategy.value !== "rebuild" ? "append" : selectedStrategy.value),
set: (value) => {
selectedStrategy.value = value;
},
});
const targetTableNameCase = ref<TransferTableNameCase>("preserve");
const quoteTargetColumnNames = ref(true);
const batchSize = ref(1000);
const showRebuildConfirm = ref(false);
const isSubmitting = ref(false);
const pendingTransferId = ref<string | null>(null);
const showStartConfirm = ref(false);
const confirmationRequest = shallowRef<api.TransferRequest | null>(null);
const confirmationPreview = shallowRef<api.TransferOwnershipPreview | null>(null);
let resolveTransferConfirmation: ((confirmed: boolean) => void) | undefined;
const ownershipDialogOpen = ref(false);
const ownershipMissingOwners = ref<string[]>([]);
const ownershipTargetOwner = ref("");
const pendingOwnershipRequest = ref<api.TransferRequest | null>(null);
const pendingOwnershipRefresh = ref<{ shouldRefreshTargetTree: boolean } | null>(null);
let resolveOwnershipConfirmation: ((policy: api.TransferOwnershipPolicy | null) => void) | undefined;
const transferSubmission = createTransferSubmission({
ensureWritable: (request) => ensureReadOnlyWriteAccess({ connection: store.getConfig(request.targetConnectionId), source: t("readOnlyUnlock.sourceTransfer"), treatAsMutation: true }),
preview: api.previewTransferOwnership,
confirmOwnership: requestOwnershipConfirmation,
confirm: requestTransferConfirmation,
execute: (request) => runTransfer(request, request.content !== "dataOnly"),
});
// Saved-task state: the form mirrors the active task; a canonical JSON
// snapshot taken at load/save time drives the unsaved-changes check.
@@ -186,6 +207,13 @@ function isMongoConnection(id: string): boolean {
const showTargetColumnQuoteOption = computed(() => ["gaussdb", "opengauss"].includes(connectionType(targetConnectionId.value) ?? ""));
const rebuildDisabledReason = computed(() => rebuildUnavailableReason(transferContent.value, connectionType(targetConnectionId.value)));
const rebuildDisabledHint = computed(() => {
if (rebuildDisabledReason.value === "dataOnly") return t("transfer.rebuildDataOnlyDisabled");
if (rebuildDisabledReason.value === "unsupported") return t("transfer.rebuildUnsupportedDisabled");
return "";
});
function isCatalogCapable(id: string): boolean {
const config = store.getConfig(id);
return isDorisFamilyCatalogCapable(config?.db_type, config?.driver_profile);
@@ -225,6 +253,7 @@ const canStart = computed(() => {
(sourceCatalogs.value.length <= 1 || !!sourceCatalog.value) &&
(targetCatalogs.value.length <= 1 || !!targetCatalog.value) &&
(selectedTables.value.size > 0 || Object.values(selectedObjects.value).some((names) => names.size > 0)) &&
(targetTableStrategy.value !== "rebuild" || !rebuildDisabledReason.value) &&
!sameSourceAndTarget
);
});
@@ -620,6 +649,7 @@ watch(
function resetState(cancelTaskLoad = true) {
if (cancelTaskLoad) taskLoadTracker.cancel();
cancelPendingTransfer();
sourceConnectionId.value = "";
sourceCatalog.value = "";
sourceCatalogs.value = [];
@@ -642,17 +672,10 @@ function resetState(cancelTaskLoad = true) {
targetSchema.value = "";
pendingTargetSchemaPrefill.value = "";
transferContent.value = "structureAndData";
transferMode.value = "append";
selectedStrategy.value = "append";
targetTableNameCase.value = "preserve";
quoteTargetColumnNames.value = true;
batchSize.value = 1000;
isSubmitting.value = false;
showStartConfirm.value = false;
ownershipDialogOpen.value = false;
ownershipMissingOwners.value = [];
ownershipTargetOwner.value = "";
pendingOwnershipRequest.value = null;
pendingOwnershipRefresh.value = null;
pendingSelectedObjectsPrefill.value = null;
activeTaskId.value = null;
savedConfigSnapshot.value = "";
@@ -740,11 +763,8 @@ function swapSourceAndTarget() {
void loadObjects();
}
async function startTransfer() {
async function requestStartTransfer() {
if (!canStart.value || isSubmitting.value) return;
if (!(await ensureReadOnlyWriteAccess({ connection: store.getConfig(targetConnectionId.value), source: t("readOnlyUnlock.sourceTransfer"), treatAsMutation: true }))) {
return;
}
isSubmitting.value = true;
const effectiveSourceSchema = sourceSchema.value || sourceDatabaseName.value;
@@ -752,8 +772,6 @@ async function startTransfer() {
const sourceDatabase = sourceDatabaseName.value;
const targetConnection = targetConnectionId.value;
const targetDatabase = targetDatabaseName.value;
const shouldRefreshTargetTree = transferContent.value !== "dataOnly";
const request: api.TransferRequest = {
transferId: uuid(),
sourceConnectionId: sourceConnectionId.value,
@@ -768,34 +786,25 @@ async function startTransfer() {
createTable: transferContent.value !== "dataOnly",
content: transferContent.value,
objects: buildTransferObjectSelections(selectedObjects.value, treeDisabledGroups.value),
mode: transferMode.value,
...transferStrategyOptions(targetTableStrategy.value),
targetTableNameCase: targetTableNameCase.value,
quoteTargetColumnNames: quoteTargetColumnNames.value,
ownershipPolicy: "preserve",
batchSize: batchSize.value,
dropTargetConfirmed: false,
};
if (transferContent.value !== "dataOnly") {
try {
const preview = await api.previewTransferOwnership(request);
if (preview.missingOwners.length > 0) {
ownershipMissingOwners.value = preview.missingOwners;
ownershipTargetOwner.value = preview.targetOwner;
pendingOwnershipRequest.value = request;
pendingOwnershipRefresh.value = {
shouldRefreshTargetTree,
};
ownershipDialogOpen.value = true;
isSubmitting.value = false;
return;
}
} catch {
pendingTransferId.value = request.transferId;
try {
await transferSubmission.start(request);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
toast(message === "TRANSFER_REBUILD_PREVIEW_UNAVAILABLE" ? t("transfer.rebuildPreviewUnavailable") : t("transfer.previewFailed", { message }), 5000);
} finally {
if (pendingTransferId.value === request.transferId) {
pendingTransferId.value = null;
isSubmitting.value = false;
return;
}
}
runTransfer(request, shouldRefreshTargetTree);
}
function runTransfer(request: api.TransferRequest, shouldRefreshTargetTree: boolean) {
@@ -812,19 +821,22 @@ function runTransfer(request: api.TransferRequest, shouldRefreshTargetTree: bool
resetState();
}
function requestOwnershipConfirmation(preview: api.TransferOwnershipPreview): Promise<api.TransferOwnershipPolicy | null> {
ownershipMissingOwners.value = preview.missingOwners;
ownershipTargetOwner.value = preview.targetOwner;
ownershipDialogOpen.value = true;
return new Promise((resolve) => {
resolveOwnershipConfirmation = resolve;
});
}
function resolveOwnershipDecision(policy: api.TransferOwnershipPolicy | null) {
const request = pendingOwnershipRequest.value;
const refresh = pendingOwnershipRefresh.value;
pendingOwnershipRequest.value = null;
pendingOwnershipRefresh.value = null;
const resolve = resolveOwnershipConfirmation;
resolveOwnershipConfirmation = undefined;
ownershipDialogOpen.value = false;
ownershipMissingOwners.value = [];
ownershipTargetOwner.value = "";
if (!policy || !request || !refresh) {
isSubmitting.value = false;
return;
}
runTransfer({ ...request, ownershipPolicy: policy }, refresh.shouldRefreshTargetTree);
resolve?.(policy);
}
function getConnectionName(id: string) {
@@ -854,10 +866,11 @@ function currentConfig(): TransferTaskConfig {
targetSchema: targetSchema.value || undefined,
objects,
content: transferContent.value,
mode: transferMode.value,
...transferStrategyOptions(targetTableStrategy.value),
targetTableNameCase: targetTableNameCase.value,
quoteTargetColumnNames: quoteTargetColumnNames.value,
batchSize: batchSize.value,
dropTargetConfirmed: false,
};
}
@@ -875,6 +888,27 @@ const isConfigDirty = computed(() => !!activeTaskId.value && configSnapshot(curr
const needsDiscardConfirm = computed(() => isConfigDirty.value || (!activeTaskId.value && formHasContent.value));
const canSaveConfig = computed(() => !!sourceConnectionId.value && isTransferDatabaseSelected(sourceDatabase.value) && !!targetConnectionId.value && isTransferDatabaseSelected(targetDatabase.value));
// Invalidate before asynchronous callbacks can publish a preview for an old form,
// including a task switch whose new values happen to equal the previous task.
watch(() => configSnapshot(currentConfig()), cancelPendingTransfer, { flush: "sync" });
watch(activeTaskId, cancelPendingTransfer, { flush: "sync" });
watch(
open,
(isOpen) => {
if (!isOpen) cancelPendingTransfer();
},
{ flush: "sync" },
);
watch(
() => {
const connection = store.getConfig(targetConnectionId.value);
return JSON.stringify([connection?.read_only, connection?.is_production, connection?.production_databases]);
},
cancelPendingTransfer,
{ flush: "sync" },
);
onBeforeUnmount(cancelPendingTransfer);
/** Applies a saved task to the form, loading catalogs/databases/schemas/objects with explicit awaits. */
async function loadTaskIntoForm(task: TransferTask) {
const taskLoadToken = taskLoadTracker.begin(task.id);
@@ -883,7 +917,7 @@ async function loadTaskIntoForm(task: TransferTask) {
resetState(false);
activeTaskId.value = task.id;
transferContent.value = config.content;
transferMode.value = config.mode;
selectedStrategy.value = resolveTransferStrategy(config);
targetTableNameCase.value = config.targetTableNameCase;
quoteTargetColumnNames.value = config.quoteTargetColumnNames;
batchSize.value = config.batchSize;
@@ -1000,26 +1034,93 @@ function onSelectedTaskIdUpdate(id: string | null) {
activeTaskId.value = id;
}
// ---- start confirmation (guards the only run entry: the footer button) ----
// ---- confirmation for the immutable, previewed transfer request ----
/** Number of objects currently selected across all kinds. */
const selectedObjectCount = computed(() => Object.values(selectedObjects.value).reduce((total, names) => total + (names?.size ?? 0), 0));
const startConfirmSource = computed(() => `${getConnectionName(sourceConnectionId.value)}.${databaseOptionLabel(sourceConnectionId.value, sourceDatabase.value)}`);
const startConfirmTarget = computed(() => `${getConnectionName(targetConnectionId.value)}.${databaseOptionLabel(targetConnectionId.value, targetDatabase.value)}`);
/** Opens the confirmation dialog before starting a transfer. */
function requestStartTransfer() {
if (!canStart.value || isSubmitting.value) return;
showStartConfirm.value = true;
function transferStrategyLabel(request: api.TransferRequest): string {
if (request.content === "structureOnly") return t(request.dropTargetBeforeCreate ? "transfer.modeRebuildStructure" : "transfer.modeKeepExisting");
const strategy = resolveTransferStrategy(request);
const keys = { append: "modeAppend", overwrite: "modeOverwrite", upsert: "modeUpsert", rebuild: "modeRebuild" };
return t(`transfer.${keys[strategy]}`);
}
/** Confirmed: close the prompt and run the normal start flow. */
function confirmStartTransfer() {
const confirmationSummary = computed(() => {
const request = confirmationRequest.value;
if (!request) return "";
const source = `${getConnectionName(request.sourceConnectionId)}.${request.sourceDatabase}.${request.sourceSchema}`;
const target = `${getConnectionName(request.targetConnectionId)}.${request.targetDatabase}.${request.targetSchema}`;
const count = request.objects.reduce((total, selection) => total + selection.names.length, 0);
return t("transfer.startConfirmMessage", { source, target, count });
});
const confirmationStrategy = computed(() => (confirmationRequest.value ? transferStrategyLabel(confirmationRequest.value) : ""));
const rebuildConfirmationDetails = computed(() => {
const rebuild = confirmationPreview.value?.rebuild;
if (!rebuild) return "";
return [confirmationSummary.value, t("transfer.rebuildSummary", { count: rebuild.tables.length }), ...rebuild.warnings].filter(Boolean).join("\n");
});
function requestTransferConfirmation(request: api.TransferRequest, preview: api.TransferOwnershipPreview): Promise<boolean> {
confirmationRequest.value = request;
confirmationPreview.value = preview;
const reviewText = preview.rebuild
? [confirmationStrategy.value, rebuildConfirmationDetails.value, preview.rebuild.sql].filter(Boolean).join("\n\n")
: [confirmationSummary.value, `${t("transfer.targetTableHandling")}: ${confirmationStrategy.value}`, ...request.objects.map((selection) => selection.names.join(", "))].join("\n");
return confirmTransferWithProductionSafety({
request,
connection: store.getConfig(request.targetConnectionId),
reviewText,
source: t("transfer.title"),
confirm: () =>
new Promise((resolve) => {
resolveTransferConfirmation = resolve;
if (request.dropTargetBeforeCreate) showRebuildConfirm.value = true;
else showStartConfirm.value = true;
}),
});
}
function resolveStartDecision(confirmed: boolean) {
const resolve = resolveTransferConfirmation;
resolveTransferConfirmation = undefined;
showStartConfirm.value = false;
void startTransfer();
showRebuildConfirm.value = false;
resolve?.(confirmed);
}
function cancelPendingTransfer() {
transferSubmission.cancel();
const scopeId = pendingTransferId.value;
pendingTransferId.value = null;
if (scopeId) productionSafetyStore.cancelScope(scopeId);
resolveStartDecision(false);
resolveOwnershipDecision(null);
confirmationRequest.value = null;
confirmationPreview.value = null;
isSubmitting.value = false;
}
watch(
showStartConfirm,
(isOpen) => {
if (!isOpen) resolveStartDecision(false);
},
{ flush: "sync" },
);
watch(
showRebuildConfirm,
(isOpen) => {
if (!isOpen) resolveStartDecision(false);
},
{ flush: "sync" },
);
watch(
ownershipDialogOpen,
(isOpen) => {
if (!isOpen) resolveOwnershipDecision(null);
},
{ flush: "sync" },
);
/** Saves the form into the active task, or creates a new one and starts its rename. */
async function saveConfigTask() {
if (!canSaveConfig.value) return;
@@ -1245,19 +1346,21 @@ async function saveConfigTask() {
</label>
</div>
</div>
<div v-if="transferContent !== 'structureOnly'" class="flex items-center gap-3">
<Label class="text-xs shrink-0">{{ t("transfer.dataWriteMode") }}</Label>
<Select v-model="transferMode">
<div class="flex items-center gap-3">
<Label class="text-xs shrink-0">{{ t("transfer.targetTableHandling") }}</Label>
<Select v-model="targetTableStrategy">
<SelectTrigger class="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="append">{{ t("transfer.modeAppend") }}</SelectItem>
<SelectItem value="overwrite">{{ t("transfer.modeOverwrite") }}</SelectItem>
<SelectItem value="upsert">{{ t("transfer.modeUpsert") }}</SelectItem>
<SelectItem value="append">{{ t(transferContent === "structureOnly" ? "transfer.modeKeepExisting" : "transfer.modeAppend") }}</SelectItem>
<SelectItem v-if="transferContent !== 'structureOnly'" value="overwrite">{{ t("transfer.modeOverwrite") }}</SelectItem>
<SelectItem v-if="transferContent !== 'structureOnly'" value="upsert">{{ t("transfer.modeUpsert") }}</SelectItem>
<SelectItem value="rebuild" :disabled="!!rebuildDisabledReason">{{ t(transferContent === "structureOnly" ? "transfer.modeRebuildStructure" : "transfer.modeRebuild") }}</SelectItem>
</SelectContent>
</Select>
</div>
<p v-if="rebuildDisabledHint" class="text-xs text-muted-foreground">{{ rebuildDisabledHint }}</p>
<div class="flex items-center gap-3">
<Label class="text-xs shrink-0">{{ t("transfer.targetTableNameCase") }}</Label>
<Select v-model="targetTableNameCase">
@@ -1324,20 +1427,32 @@ async function saveConfigTask() {
<DialogHeader>
<DialogTitle>{{ t("transfer.startConfirmTitle") }}</DialogTitle>
<DialogDescription>
{{ t("transfer.startConfirmMessage", { source: startConfirmSource, target: startConfirmTarget, count: selectedObjectCount }) }}
{{ confirmationSummary }}
</DialogDescription>
</DialogHeader>
<p class="text-xs text-muted-foreground">{{ t("transfer.targetTableHandling") }}: {{ confirmationStrategy }}</p>
<DialogFooter class="gap-2">
<Button variant="outline" size="sm" @click="showStartConfirm = false">
<Button variant="outline" size="sm" @click="resolveStartDecision(false)">
{{ t("transfer.cancel") }}
</Button>
<Button size="sm" @click="confirmStartTransfer">
<Button size="sm" @click="resolveStartDecision(true)">
{{ t("transfer.start") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DangerConfirmDialog
v-model:open="showRebuildConfirm"
:sql="confirmationPreview?.rebuild?.sql"
:title="confirmationStrategy"
:message="t('transfer.rebuildDanger')"
:details-text="rebuildConfirmationDetails"
:confirm-label="t('transfer.start')"
:close-on-confirm="false"
@confirm="resolveStartDecision(true)"
/>
<Dialog v-model:open="ownershipDialogOpen">
<DialogContent class="sm:max-w-[520px]" @interact-outside.prevent>
<DialogHeader>
@@ -0,0 +1,301 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TransferOwnershipPreview, TransferRequest } from "@/lib/backend/api";
import type { ConnectionConfig } from "@/types/database";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { confirmTransferWithProductionSafety, createTransferSubmission, rebuildUnavailableReason, resolveTransferStrategy, transferStrategyOptions } from "../transferStrategy";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
function request(overrides: Partial<TransferRequest> = {}): TransferRequest {
return {
transferId: "transfer-1",
sourceConnectionId: "source",
sourceDatabase: "app",
sourceSchema: "public",
targetConnectionId: "target",
targetDatabase: "warehouse",
targetSchema: "reporting",
tables: ["Orders"],
objects: [{ objectType: "TABLE", names: ["Orders"] }],
createTable: true,
content: "structureAndData",
mode: "append",
targetTableNameCase: "lower",
quoteTargetColumnNames: true,
ownershipPolicy: "preserve",
batchSize: 1000,
dropTargetBeforeCreate: true,
dropTargetConfirmed: false,
...overrides,
};
}
function preview(overrides: Partial<TransferOwnershipPreview> = {}): TransferOwnershipPreview {
return {
missingOwners: [],
targetOwner: "target_user",
rebuild: {
sql: 'ALTER TABLE "reporting"."orders" RENAME TO "orders__dbx_bak_123";\nCREATE TABLE "reporting"."orders" ("id" INTEGER);',
tables: [{ sourceTable: "Orders", targetTable: '"reporting"."orders"', backupTable: '"reporting"."orders__dbx_bak_123"' }],
warnings: ["Target-only metadata will be replaced"],
},
...overrides,
};
}
describe("transfer strategies", () => {
it.each(["append", "overwrite", "upsert"] as const)("loads legacy %s plus rebuild as rebuild", (mode) => {
expect(resolveTransferStrategy({ mode, dropTargetBeforeCreate: true })).toBe("rebuild");
});
it.each([
["append", { mode: "append", dropTargetBeforeCreate: false }],
["overwrite", { mode: "overwrite", dropTargetBeforeCreate: false }],
["upsert", { mode: "upsert", dropTargetBeforeCreate: false }],
["rebuild", { mode: "append", dropTargetBeforeCreate: true }],
] as const)("maps %s to one backend strategy", (strategy, options) => {
expect(transferStrategyOptions(strategy)).toEqual(options);
expect(resolveTransferStrategy(options)).toBe(strategy);
});
it("defaults legacy tasks without strategy fields to append", () => {
expect(resolveTransferStrategy({})).toBe("append");
});
it.each([
["dataOnly", "postgres", "dataOnly"],
["structureAndData", "mongodb", "unsupported"],
["structureOnly", "postgres", undefined],
["structureAndData", "sqlite", undefined],
["structureAndData", undefined, "unsupported"],
] as const)("explains rebuild availability for %s and %s", (content, type, reason) => {
expect(rebuildUnavailableReason(content, type)).toBe(reason);
});
});
describe("transfer submission", () => {
it("reviews backend SQL and executes the frozen request only after confirmation", async () => {
const decision = deferred<boolean>();
const reviewed: Array<{ request: TransferRequest; preview: TransferOwnershipPreview }> = [];
const executions: TransferRequest[] = [];
const previews: TransferRequest[] = [];
const submission = createTransferSubmission({
preview: async (value) => {
previews.push(value);
return preview();
},
confirmOwnership: async () => "preserve",
confirm: (value, plan) => {
reviewed.push({ request: value, preview: plan });
return decision.promise;
},
execute: (value) => {
executions.push(value);
},
});
const input = request({ mode: "upsert", dropTargetConfirmed: true });
const pending = submission.start(input);
input.targetDatabase = "changed";
input.tables.push("Unreviewed");
input.objects[0]!.names.push("Unreviewed");
await vi.waitFor(() => expect(reviewed).toHaveLength(1));
expect(previews[0]).toMatchObject({ transferId: "transfer-1", targetDatabase: "warehouse", mode: "append", tables: ["Orders"], objects: [{ objectType: "TABLE", names: ["Orders"] }], dropTargetConfirmed: false });
expect(() => previews[0]!.tables.push("Mutated")).toThrow();
expect(reviewed[0]?.preview.rebuild?.sql).toBe('ALTER TABLE "reporting"."orders" RENAME TO "orders__dbx_bak_123";\nCREATE TABLE "reporting"."orders" ("id" INTEGER);');
expect(executions).toEqual([]);
decision.resolve(true);
await expect(pending).resolves.toBe(true);
expect(executions).toEqual([{ ...previews[0], dropTargetConfirmed: true }]);
expect(previews[0]?.dropTargetConfirmed).toBe(false);
});
it("discards a preview after the form or dialog invalidates its submission", async () => {
const backend = deferred<TransferOwnershipPreview>();
const reviewed: TransferRequest[] = [];
const executions: TransferRequest[] = [];
const submission = createTransferSubmission({
preview: () => backend.promise,
confirmOwnership: async () => "preserve",
confirm: async (value) => {
reviewed.push(value);
return true;
},
execute: (value) => {
executions.push(value);
},
});
const pending = submission.start(request());
submission.cancel();
backend.resolve(preview({ missingOwners: ["old_owner"] }));
await expect(pending).resolves.toBe(false);
expect(reviewed).toEqual([]);
expect(executions).toEqual([]);
});
it("does not execute an older confirmation after a newer transfer starts", async () => {
const decision = deferred<boolean>();
const executions: TransferRequest[] = [];
let confirmationCount = 0;
const submission = createTransferSubmission({
preview: async () => preview(),
confirmOwnership: async () => "preserve",
confirm: async (value) => {
confirmationCount++;
return value.transferId === "old" ? decision.promise : true;
},
execute: (value) => {
executions.push(value);
},
});
const old = submission.start(request({ transferId: "old" }));
await vi.waitFor(() => expect(confirmationCount).toBe(1));
await expect(submission.start(request({ transferId: "new" }))).resolves.toBe(true);
decision.resolve(true);
await expect(old).resolves.toBe(false);
expect(executions.map((value) => value.transferId)).toEqual(["new"]);
});
it("re-previews a changed ownership policy with the same transfer ID before final confirmation", async () => {
const previews: TransferRequest[] = [];
const reviewed: TransferOwnershipPreview[] = [];
const executions: TransferRequest[] = [];
const submission = createTransferSubmission({
preview: async (value) => {
previews.push(value);
return preview({ missingOwners: ["old_owner"], rebuild: { ...preview().rebuild!, sql: value.ownershipPolicy === "reassignMissing" ? "REASSIGNED PLAN" : "ORIGINAL PLAN" } });
},
confirmOwnership: async () => "reassignMissing",
confirm: async (_value, plan) => {
reviewed.push(plan);
return true;
},
execute: (value) => {
executions.push(value);
},
});
await expect(submission.start(request())).resolves.toBe(true);
expect(previews.map((value) => [value.transferId, value.ownershipPolicy, value.dropTargetConfirmed])).toEqual([
["transfer-1", "preserve", false],
["transfer-1", "reassignMissing", false],
]);
expect(reviewed.map((value) => value.rebuild?.sql)).toEqual(["REASSIGNED PLAN"]);
expect(executions[0]?.ownershipPolicy).toBe("reassignMissing");
});
it("refuses rebuild execution when the backend does not provide a rebuild plan", async () => {
const executions: TransferRequest[] = [];
const submission = createTransferSubmission({
preview: async () => preview({ rebuild: undefined }),
confirmOwnership: async () => "preserve",
confirm: async () => true,
execute: (value) => {
executions.push(value);
},
});
await expect(submission.start(request())).rejects.toThrow("TRANSFER_REBUILD_PREVIEW_UNAVAILABLE");
expect(executions).toEqual([]);
});
it("does not surface errors from a cancelled preview", async () => {
const backend = deferred<TransferOwnershipPreview>();
const submission = createTransferSubmission({ preview: () => backend.promise, confirmOwnership: async () => null, confirm: async () => false, execute: () => undefined });
const pending = submission.start(request());
submission.cancel();
backend.reject(new Error("old connection failed"));
await expect(pending).resolves.toBe(false);
});
it.each(["append", "overwrite", "upsert"] as const)("keeps ordinary %s data-only requests free of rebuild authorization", async (mode) => {
const executions: TransferRequest[] = [];
const submission = createTransferSubmission({
preview: async () => {
throw new Error("Data-only must not load DDL preview");
},
confirmOwnership: async () => null,
confirm: async () => true,
execute: (value) => {
executions.push(value);
},
});
await submission.start(request({ content: "dataOnly", createTable: false, mode, dropTargetBeforeCreate: false, dropTargetConfirmed: true }));
expect(executions[0]).toMatchObject({ mode, content: "dataOnly", createTable: false, dropTargetBeforeCreate: false, dropTargetConfirmed: false });
});
});
describe("transfer production confirmation", () => {
beforeEach(() => setActivePinia(createPinia()));
function connection(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
return { id: "target", name: "Warehouse", db_type: "postgres", host: "localhost", port: 5432, username: "target_user", password: "", ...overrides };
}
it.each([{ is_production: true }, { production_databases: ["warehouse"] }])("uses one shared confirmation for production scope %j", async (scope) => {
let ordinaryConfirmations = 0;
const pending = confirmTransferWithProductionSafety({
request: request(),
connection: connection(scope),
reviewText: preview().rebuild!.sql,
source: "Data Transfer",
confirm: async () => {
ordinaryConfirmations++;
return true;
},
});
const store = useProductionSafetyStore();
expect(store.pending).toMatchObject({ scopeId: "transfer-1", database: "warehouse", connectionName: "Warehouse", sql: preview().rebuild!.sql });
expect(ordinaryConfirmations).toBe(0);
store.confirm();
await expect(pending).resolves.toBe(true);
expect(store.pending).toBeUndefined();
});
it("cancels a pending production confirmation with the transfer scope", async () => {
const pending = confirmTransferWithProductionSafety({ request: request(), connection: connection({ production_databases: ["warehouse"] }), reviewText: "PLAN", confirm: async () => true });
useProductionSafetyStore().cancelScope("transfer-1");
await expect(pending).resolves.toBe(false);
expect(useProductionSafetyStore().pending).toBeUndefined();
});
it("uses the transfer confirmation for a non-production target", async () => {
let ordinaryConfirmations = 0;
const result = await confirmTransferWithProductionSafety({
request: request(),
connection: connection(),
reviewText: "PLAN",
confirm: async () => {
ordinaryConfirmations++;
return false;
},
});
expect(result).toBe(false);
expect(ordinaryConfirmations).toBe(1);
expect(useProductionSafetyStore().pending).toBeUndefined();
});
});
@@ -0,0 +1,97 @@
import type { TransferContent, TransferMode, TransferOwnershipPolicy, TransferOwnershipPreview, TransferRequest } from "@/lib/backend/api";
import type { ConnectionConfig, DatabaseType } from "@/types/database";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
export type TransferStrategy = TransferMode | "rebuild";
export function resolveTransferStrategy(options: { mode?: TransferMode; dropTargetBeforeCreate?: boolean }): TransferStrategy {
return options.dropTargetBeforeCreate ? "rebuild" : (options.mode ?? "append");
}
export function transferStrategyOptions(strategy: TransferStrategy): Pick<TransferRequest, "mode" | "dropTargetBeforeCreate"> {
return { mode: strategy === "rebuild" ? "append" : strategy, dropTargetBeforeCreate: strategy === "rebuild" };
}
const REBUILD_TARGET_TYPES = new Set<DatabaseType>(["mysql", "postgres", "sqlserver", "kingbase", "gaussdb", "opengauss", "kwdb", "goldendb", "sqlite", "duckdb", "cloudflare-d1"]);
export function rebuildUnavailableReason(content: TransferContent, targetType: DatabaseType | undefined): "dataOnly" | "unsupported" | undefined {
if (content === "dataOnly") return "dataOnly";
return targetType && REBUILD_TARGET_TYPES.has(targetType) ? undefined : "unsupported";
}
/** Snapshot the complete selection before any asynchronous preview or confirmation. */
function freezeTransferRequest(request: TransferRequest): TransferRequest {
const snapshot = {
...request,
...transferStrategyOptions(resolveTransferStrategy(request)),
tables: [...request.tables],
objects: request.objects.map((selection) => ({ ...selection, names: [...selection.names] })),
dropTargetConfirmed: false,
};
Object.freeze(snapshot.tables);
for (const selection of snapshot.objects) {
Object.freeze(selection.names);
Object.freeze(selection);
}
Object.freeze(snapshot.objects);
return Object.freeze(snapshot);
}
interface TransferSubmissionOptions {
ensureWritable?: (request: TransferRequest) => Promise<boolean>;
preview: (request: TransferRequest) => Promise<TransferOwnershipPreview>;
confirmOwnership: (preview: TransferOwnershipPreview) => Promise<TransferOwnershipPolicy | null>;
confirm: (request: TransferRequest, preview: TransferOwnershipPreview) => Promise<boolean>;
execute: (request: TransferRequest) => void;
}
/** Invalidating a submission prevents every later await from reopening prompts or starting it. */
export function createTransferSubmission(options: TransferSubmissionOptions) {
let generation = 0;
return {
cancel() {
generation += 1;
},
async start(input: TransferRequest): Promise<boolean> {
const token = ++generation;
const isCurrent = () => token === generation;
let request = freezeTransferRequest(input);
try {
if (options.ensureWritable && (!(await options.ensureWritable(request)) || !isCurrent())) return false;
let preview: TransferOwnershipPreview = request.content === "dataOnly" ? { missingOwners: [], targetOwner: "" } : await options.preview(request);
if (!isCurrent()) return false;
if (preview.missingOwners.length > 0) {
const policy = await options.confirmOwnership(preview);
if (!policy || !isCurrent()) return false;
if (policy !== request.ownershipPolicy) {
request = freezeTransferRequest({ ...request, ownershipPolicy: policy });
preview = await options.preview(request);
if (!isCurrent()) return false;
}
}
if (request.dropTargetBeforeCreate && !preview.rebuild) throw new Error("TRANSFER_REBUILD_PREVIEW_UNAVAILABLE");
if (!(await options.confirm(request, preview)) || !isCurrent()) return false;
options.execute(Object.freeze({ ...request, dropTargetConfirmed: request.dropTargetBeforeCreate }));
return true;
} catch (error) {
if (!isCurrent()) return false;
throw error;
}
},
};
}
/** The shared production prompt also serves as the destructive rebuild confirmation. */
export function confirmTransferWithProductionSafety(options: { request: TransferRequest; connection?: ConnectionConfig; reviewText: string; source?: string; confirm: () => Promise<boolean> }): Promise<boolean> {
const context = productionContextForDatabase(options.connection, options.request.targetDatabase);
if (!context.active) return options.confirm();
return useProductionSafetyStore().requestConfirmation({
sql: options.reviewText,
connectionName: options.connection?.name,
database: options.request.targetDatabase,
productionDatabases: context.databases,
source: options.source,
scopeId: options.request.transferId,
});
}
+10 -1
View File
@@ -5568,7 +5568,7 @@ export default {
crossFamilyViewHint: "Cross-database view transfer: select the tables the view depends on as well, or make sure they already exist in the target database",
objectUnsupportedDisabled: "This database does not support transferring this object type",
skippedExistingObjects: "Skipped {count} existing object(s)",
dataWriteMode: "Data write mode",
targetTableHandling: "Target table handling",
source: "Source",
target: "Target",
sourceConnection: "Source Connection",
@@ -5596,6 +5596,15 @@ export default {
tableNameCaseLower: "Lowercase",
tableNameCaseUpper: "Uppercase",
batchSize: "Batch size",
modeRebuild: "Rebuild structure and data (RENAME + CREATE + INSERT)",
modeKeepExisting: "Keep existing tables",
modeRebuildStructure: "Rebuild structure (RENAME + CREATE)",
rebuildSummary: "Rebuild {count} table(s): existing target tables are renamed to backups first, then rebuilt from the source structure and populated; backups are removed after all steps succeed.",
rebuildDanger: "Rebuilding replaces the selected target tables, their existing rows, and target-only metadata. Review the actual targets, backup names, and SQL. Once backups are cleaned up, they cannot be used for recovery.",
rebuildDataOnlyDisabled: "Rebuild is unavailable for data-only transfers. Select structure and data or structure only.",
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.",
previewFailed: "Could not prepare the transfer: {message}",
start: "Start Transfer",
startConfirmTitle: "Confirm transfer",
startConfirmMessage: "Transfer {count} selected objects from {source} to {target}. Start now?",
+11 -2
View File
@@ -5190,7 +5190,7 @@ export default withEnglishFallback({
},
transfer: {
title: "Transferencia de datos",
content: "Modo de transferencia",
content: "Contenido de la transferencia",
contentStructureAndData: "Estructura y datos",
contentStructureOnly: "Solo estructura",
contentDataOnly: "Solo datos",
@@ -5212,7 +5212,7 @@ export default withEnglishFallback({
crossFamilyViewHint: "Transferencia de vistas entre bases de datos: seleccione también las tablas de las que depende la vista o asegúrese de que ya existen en la base de datos de destino",
objectUnsupportedDisabled: "La base de datos actual no admite la transferencia de este tipo de objeto",
skippedExistingObjects: "Se omitieron {count} objetos existentes",
dataWriteMode: "Modo de escritura de datos",
targetTableHandling: "Tratamiento de las tablas de destino",
source: "Origen",
target: "Destino",
sourceConnection: "Conexión de origen",
@@ -5233,6 +5233,15 @@ export default withEnglishFallback({
tableNameCaseLower: "Convertir a minúsculas",
tableNameCaseUpper: "Convertir a mayúsculas",
batchSize: "Tamaño de lote",
modeRebuild: "Reconstruir estructura y datos (RENAME + CREATE + INSERT)",
modeKeepExisting: "Conservar tablas existentes",
modeRebuildStructure: "Reconstruir estructura (RENAME + CREATE)",
rebuildSummary: "Se reconstruirán {count} tabla(s): las tablas de destino existentes se renombran como copias de seguridad, se recrean con la estructura de origen y se rellenan; las copias se eliminan al finalizar todos los pasos.",
rebuildDanger: "La reconstrucción sustituye las tablas de destino seleccionadas, sus filas existentes y los metadatos exclusivos del destino. Revise los destinos reales, los nombres de respaldo y el SQL. Los respaldos eliminados no se pueden usar para recuperar las tablas.",
rebuildDataOnlyDisabled: "La reconstrucción no está disponible para transferencias de solo datos. Seleccione estructura y datos o solo estructura.",
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.",
previewFailed: "No se pudo preparar la transferencia: {message}",
start: "Iniciar transferencia",
startConfirmTitle: "Confirmar transferencia",
startConfirmMessage: "Se transferirán {count} objetos seleccionados de {source} a {target}. ¿Iniciar ahora?",
+10 -1
View File
@@ -5210,7 +5210,7 @@ export default withEnglishFallback({
crossFamilyViewHint: "Trasferimento di viste tra database: selezionare anche le tabelle da cui la vista dipende, o assicurarsi che esistano già nel database di destinazione",
objectUnsupportedDisabled: "Il database corrente non supporta il trasferimento di questo tipo di oggetto",
skippedExistingObjects: "Saltati {count} oggetti esistenti",
dataWriteMode: "Modalità di scrittura dati",
targetTableHandling: "Gestione delle tabelle di destinazione",
source: "Sorgente",
target: "Destinazione",
sourceConnection: "Connessione Sorgente",
@@ -5234,6 +5234,15 @@ export default withEnglishFallback({
tableNameCaseLower: "Converti in minuscolo",
tableNameCaseUpper: "Converti in maiuscolo",
batchSize: "Dimensione lotto",
modeRebuild: "Ricostruisci struttura e dati (RENAME + CREATE + INSERT)",
modeKeepExisting: "Mantieni tabelle esistenti",
modeRebuildStructure: "Ricostruisci struttura (RENAME + CREATE)",
rebuildSummary: "Verranno ricostruite {count} tabelle: le tabelle di destinazione esistenti vengono prima rinominate come backup, quindi ricreate dalla struttura di origine e popolate; i backup vengono eliminati al termine di tutti i passaggi.",
rebuildDanger: "La ricostruzione sostituisce le tabelle di destinazione selezionate, le righe esistenti e i metadati presenti solo nella destinazione. Controllare le destinazioni effettive, i nomi dei backup e il SQL. I backup eliminati non possono essere usati per il ripristino.",
rebuildDataOnlyDisabled: "La ricostruzione non è disponibile per i trasferimenti di soli dati. Selezionare struttura e dati oppure solo struttura.",
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.",
previewFailed: "Impossibile preparare il trasferimento: {message}",
start: "Avvia Trasferimento",
startConfirmTitle: "Conferma trasferimento",
startConfirmMessage: "Verranno trasferiti {count} oggetti selezionati da {source} a {target}. Avviare ora?",
+10 -1
View File
@@ -5239,7 +5239,7 @@ export default withEnglishFallback({
crossFamilyViewHint: "データベース間ビュー転送:ビューが依存するテーブルも選択するか、ターゲットDBに既に存在することを確認してください",
objectUnsupportedDisabled: "現在のデータベースは、このオブジェクトタイプの転送をサポートしていません",
skippedExistingObjects: "既存のオブジェクト {count} 件をスキップしました",
dataWriteMode: "データ書き込みモード",
targetTableHandling: "ターゲットテーブルの処理",
source: "ソース",
target: "対象",
sourceConnection: "ソース接続",
@@ -5263,6 +5263,15 @@ export default withEnglishFallback({
tableNameCaseLower: "小文字に変換",
tableNameCaseUpper: "大文字に変換",
batchSize: "バッチサイズ",
modeRebuild: "構造とデータを再構築 (RENAME + CREATE + INSERT)",
modeKeepExisting: "既存テーブルを保持",
modeRebuildStructure: "構造を再構築 (RENAME + CREATE)",
rebuildSummary: "{count} 件のテーブルを再構築します:既存のターゲットテーブルをまずバックアップにリネームし、ソース構造から再作成してデータを投入します。すべての手順が成功した後にバックアップを削除します。",
rebuildDanger: "再構築により、選択したターゲットテーブル、既存の行、ターゲットのみに存在するメタデータが置き換えられます。実際のターゲット、バックアップ名、SQL を確認してください。削除されたバックアップからは復元できません。",
rebuildDataOnlyDisabled: "データのみの転送では再構築できません。「構造とデータ」または「構造のみ」を選択してください。",
rebuildUnsupportedDisabled: "選択したターゲットエンジンはテーブルの再構築をサポートしていません。",
rebuildPreviewUnavailable: "バックエンドから再構築計画が返されなかったため、転送は開始されていません。再構築プレビューに対応したバックエンドで再試行してください。",
previewFailed: "転送を準備できませんでした:{message}",
start: "転送開始",
startConfirmTitle: "転送の開始確認",
startConfirmMessage: "{source} で選択した {count} 個のオブジェクトを {target} に転送します。開始しますか?",
+11 -2
View File
@@ -4882,7 +4882,7 @@ export default withEnglishFallback({
},
transfer: {
title: "데이터 이전",
content: "전송 콘텐츠",
content: "전송 내용",
contentStructureAndData: "구조 및 데이터",
contentStructureOnly: "구조만",
contentDataOnly: "데이터만",
@@ -4904,7 +4904,7 @@ export default withEnglishFallback({
crossFamilyViewHint: "데이터베이스 간 뷰 전송: 뷰가 의존하는 테이블도 선택하거나 대상 DB에 이미 존재하는지 확인하세요",
objectUnsupportedDisabled: "현재 데이터베이스는 이 개체 유형의 전송을 지원하지 않습니다",
skippedExistingObjects: "기존 개체 {count}개 건너뜀",
dataWriteMode: "데이터 쓰기 모드",
targetTableHandling: "대상 테이블 처리 방식",
source: "소스",
target: "대상",
sourceConnection: "소스 연결",
@@ -4928,6 +4928,15 @@ export default withEnglishFallback({
tableNameCaseLower: "소문자",
tableNameCaseUpper: "대문자",
batchSize: "배치 크기",
modeRebuild: "구조 및 데이터 재구축 (RENAME + CREATE + INSERT)",
modeKeepExisting: "기존 테이블 유지",
modeRebuildStructure: "구조 재구축 (RENAME + CREATE)",
rebuildSummary: "{count}개 테이블을 재구축합니다: 기존 대상 테이블을 먼저 백업으로 이름을 바꾸고 소스 구조로 다시 만든 뒤 데이터를 넣습니다. 모든 단계가 성공한 후 백업을 삭제합니다.",
rebuildDanger: "재구축하면 선택한 대상 테이블, 기존 행, 대상에만 있는 메타데이터가 교체됩니다. 실제 대상, 백업 이름, SQL을 확인하세요. 삭제된 백업은 복구에 사용할 수 없습니다.",
rebuildDataOnlyDisabled: "데이터만 전송할 때는 재구축할 수 없습니다. 구조 및 데이터 또는 구조만을 선택하세요.",
rebuildUnsupportedDisabled: "선택한 대상 엔진은 대상 테이블 재구축을 지원하지 않습니다.",
rebuildPreviewUnavailable: "백엔드가 재구축 계획을 반환하지 않아 전송을 시작하지 않았습니다. 재구축 미리 보기를 지원하는 백엔드에서 다시 시도하세요.",
previewFailed: "전송을 준비할 수 없습니다: {message}",
start: "이전 시작",
startConfirmTitle: "전송 시작 확인",
startConfirmMessage: "{source}에서 선택한 개체 {count}개를 {target}(으)로 전송합니다. 시작하시겠습니까?",
+11 -2
View File
@@ -5190,7 +5190,7 @@ export default withEnglishFallback({
},
transfer: {
title: "Transferência de Dados",
content: "Conteúdo de transferência",
content: "Conteúdo da transferência",
contentStructureAndData: "Estrutura e dados",
contentStructureOnly: "Apenas estrutura",
contentDataOnly: "Apenas dados",
@@ -5212,7 +5212,7 @@ export default withEnglishFallback({
crossFamilyViewHint: "Transferência de views entre bancos: selecione também as tabelas das quais a view depende ou garanta que elas já existam no banco de destino",
objectUnsupportedDisabled: "O banco de dados atual não suporta a transferência deste tipo de objeto",
skippedExistingObjects: "Ignorado {count} objeto(s) existente(s)",
dataWriteMode: "Modo de gravação de dados",
targetTableHandling: "Tratamento das tabelas de destino",
source: "Origem",
target: "Destino",
sourceConnection: "Conexão de origem",
@@ -5236,6 +5236,15 @@ export default withEnglishFallback({
tableNameCaseLower: "Converter para minúsculas",
tableNameCaseUpper: "Converter para maiúsculas",
batchSize: "Tamanho do lote",
modeRebuild: "Reconstruir estrutura e dados (RENAME + CREATE + INSERT)",
modeKeepExisting: "Manter tabelas existentes",
modeRebuildStructure: "Reconstruir estrutura (RENAME + CREATE)",
rebuildSummary: "{count} tabela(s) serão reconstruídas: as tabelas de destino existentes são renomeadas como backups, recriadas com a estrutura de origem e preenchidas; os backups são removidos após todas as etapas.",
rebuildDanger: "A reconstrução substitui as tabelas de destino selecionadas, suas linhas existentes e os metadados exclusivos do destino. Revise os destinos reais, os nomes dos backups e o SQL. Backups removidos não podem ser usados para recuperação.",
rebuildDataOnlyDisabled: "A reconstrução não está disponível para transferências apenas de dados. Selecione estrutura e dados ou apenas estrutura.",
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.",
previewFailed: "Não foi possível preparar a transferência: {message}",
start: "Iniciar transferência",
startConfirmTitle: "Confirmar transferência",
startConfirmMessage: "{count} objetos selecionados de {source} serão transferidos para {target}. Iniciar agora?",
+11 -2
View File
@@ -5530,7 +5530,7 @@ export default withEnglishFallback({
},
transfer: {
title: "数据传输",
content: "传输方式",
content: "传输内容",
contentStructureAndData: "结构和数据",
contentStructureOnly: "仅结构",
contentDataOnly: "仅数据",
@@ -5552,7 +5552,7 @@ export default withEnglishFallback({
crossFamilyViewHint: "跨库视图传输:请同时选择视图依赖的表,或确保目标库已存在这些表",
objectUnsupportedDisabled: "当前数据库不支持该对象类型传输",
skippedExistingObjects: "跳过 {count} 个已存在对象",
dataWriteMode: "数据写入方式",
targetTableHandling: "目标表处理方式",
source: "源",
target: "目标",
sourceConnection: "源连接",
@@ -5580,6 +5580,15 @@ export default withEnglishFallback({
tableNameCaseLower: "转为小写",
tableNameCaseUpper: "转为大写",
batchSize: "每批行数",
modeRebuild: "重建结构和数据 (RENAME + CREATE + INSERT)",
modeKeepExisting: "保留已有表",
modeRebuildStructure: "重建结构 (RENAME + CREATE)",
rebuildSummary: "将重建 {count} 张表:先将现有目标表改名为备份表,按源结构重建并传输数据,全部成功后清理备份。",
rebuildDanger: "重建会替换所选目标表、已有数据和仅目标端存在的元数据。请核对实际目标、备份名和 SQL;备份清理后将无法再用于恢复。",
rebuildDataOnlyDisabled: "仅数据模式不支持重建,请选择“结构和数据”或“仅结构”。",
rebuildUnsupportedDisabled: "当前目标引擎不支持重建目标表。",
rebuildPreviewUnavailable: "后端未返回重建计划,传输尚未开始。请使用支持重建预览的后端后重新预览。",
previewFailed: "无法准备传输:{message}",
start: "开始传输",
startConfirmTitle: "确认开始传输",
startConfirmMessage: "将把 {source} 中选中的 {count} 个对象传输到 {target},确定开始吗?",
+11 -2
View File
@@ -4510,7 +4510,7 @@ export default withEnglishFallback({
},
transfer: {
title: "資料傳輸",
content: "傳輸方式",
content: "傳輸內容",
contentStructureAndData: "結構和資料",
contentStructureOnly: "僅結構",
contentDataOnly: "僅資料",
@@ -4532,7 +4532,7 @@ export default withEnglishFallback({
crossFamilyViewHint: "跨庫檢視傳輸:請同時選擇檢視依賴的表,或確保目標庫已存在這些表",
objectUnsupportedDisabled: "目前資料庫不支援該物件類型傳輸",
skippedExistingObjects: "跳過 {count} 個已存在物件",
dataWriteMode: "資料寫入方式",
targetTableHandling: "目標表處理方式",
source: "來源",
target: "目標",
sourceConnection: "來源連線",
@@ -4551,6 +4551,15 @@ export default withEnglishFallback({
modeOverwrite: "覆蓋 (TRUNCATE + INSERT)",
modeUpsert: "更新插入 (按主鍵 INSERT 或 UPDATE)",
batchSize: "每批筆數",
modeRebuild: "重建結構和資料 (RENAME + CREATE + INSERT)",
modeKeepExisting: "保留既有表",
modeRebuildStructure: "重建結構 (RENAME + CREATE)",
rebuildSummary: "將重建 {count} 張資料表:先將既有目標表改名為備份表,依來源結構重建並傳輸資料,全部成功後清理備份。",
rebuildDanger: "重建會取代所選目標表、既有資料和僅目標端存在的中繼資料。請核對實際目標、備份名稱和 SQL;備份清理後將無法再用於還原。",
rebuildDataOnlyDisabled: "僅資料模式不支援重建,請選擇「結構和資料」或「僅結構」。",
rebuildUnsupportedDisabled: "目前目標引擎不支援重建目標表。",
rebuildPreviewUnavailable: "後端未傳回重建計畫,傳輸尚未開始。請使用支援重建預覽的後端後重新預覽。",
previewFailed: "無法準備傳輸:{message}",
start: "開始傳輸",
startConfirmTitle: "確認開始傳輸",
startConfirmMessage: "將把 {source} 中選中的 {count} 個物件傳輸到 {target},確定開始嗎?",
+7
View File
@@ -4626,11 +4626,18 @@ export interface TransferRequest {
quoteTargetColumnNames: boolean;
ownershipPolicy?: TransferOwnershipPolicy;
batchSize: number;
dropTargetBeforeCreate: boolean;
dropTargetConfirmed: boolean;
}
export interface TransferOwnershipPreview {
missingOwners: string[];
targetOwner: string;
rebuild?: {
sql: string;
tables: Array<{ sourceTable: string; targetTable: string; backupTable?: string }>;
warnings: string[];
};
}
export interface TransferProgress {
@@ -82,6 +82,37 @@ describe("transferTaskStore", () => {
expect(store.tasks[0]?.config.quoteTargetColumnNames).toBe(true);
});
it("loads a legacy rebuild task as append without restoring its previous confirmation", async () => {
vi.mocked(api.loadTransferTaskLibrary).mockResolvedValue({
version: 1,
folders: [],
tasks: [{ id: "legacy-rebuild", name: "legacy rebuild", config: makeConfig({ mode: "upsert", dropTargetBeforeCreate: true, dropTargetConfirmed: true }) }],
});
const store = useTransferTaskStore();
await store.initFromStorage();
expect(store.getTask("legacy-rebuild")?.config).toMatchObject({ mode: "append", dropTargetBeforeCreate: true, dropTargetConfirmed: false });
});
it("saves mutually exclusive rebuild options without persisting request confirmation", async () => {
const store = useTransferTaskStore();
const task = await store.saveTask({ name: "rebuild", config: makeConfig({ mode: "overwrite", dropTargetBeforeCreate: true, dropTargetConfirmed: true }) });
expect(task.config).toMatchObject({ mode: "append", dropTargetBeforeCreate: true, dropTargetConfirmed: false });
const persisted = vi.mocked(api.saveTransferTaskLibrary).mock.calls.at(-1)?.[0] as TransferTaskLibrary;
expect(persisted.tasks[0]?.config).toMatchObject({ mode: "append", dropTargetBeforeCreate: true, dropTargetConfirmed: false });
});
it.each(["append", "overwrite", "upsert"] as const)("keeps ordinary %s tasks unchanged while dropping stale confirmation", async (mode) => {
const store = useTransferTaskStore();
const task = await store.saveTask({ name: mode, config: makeConfig({ mode, dropTargetBeforeCreate: false, dropTargetConfirmed: true }) });
expect(task.config).toMatchObject({ mode, dropTargetBeforeCreate: false, dropTargetConfirmed: false });
});
it("refuses to overwrite a persisted library with invalid entries", async () => {
vi.mocked(api.loadTransferTaskLibrary).mockResolvedValue({
version: 1,
+6 -3
View File
@@ -3,6 +3,7 @@ import { defineStore } from "pinia";
import { uuid } from "@/lib/common/utils";
import * as api from "@/lib/backend/api";
import type { TransferTask, TransferTaskConfig, TransferTaskFolder, TransferTaskLibrary } from "@/types/database";
import { resolveTransferStrategy, transferStrategyOptions } from "@/components/transfer/transferStrategy";
export class TransferTaskNameConflictError extends Error {
readonly code = "TRANSFER_TASK_NAME_CONFLICT";
@@ -87,10 +88,11 @@ function normalizeTask(raw: unknown): TransferTask | null {
targetSchema: config.targetSchema || undefined,
objects,
content: config.content ?? "structureAndData",
mode: config.mode ?? "append",
...transferStrategyOptions(resolveTransferStrategy(config)),
targetTableNameCase: config.targetTableNameCase ?? "preserve",
quoteTargetColumnNames: config.quoteTargetColumnNames ?? true,
batchSize: typeof config.batchSize === "number" && config.batchSize > 0 ? config.batchSize : 1000,
dropTargetConfirmed: false,
},
createdAt: candidate.createdAt || nowIso(),
updatedAt: candidate.updatedAt || nowIso(),
@@ -281,14 +283,15 @@ export const useTransferTaskStore = defineStore("transferTasks", () => {
const folderId = Object.prototype.hasOwnProperty.call(input, "folderId") ? input.folderId || undefined : existing?.folderId;
ensureTaskNameAvailable(trimmed, folderId, input.id);
const timestamp = nowIso();
const config: TransferTaskConfig = { ...input.config, ...transferStrategyOptions(resolveTransferStrategy(input.config)), dropTargetConfirmed: false };
const task: TransferTask = existing
? { ...existing, folderId, name: trimmed, config: input.config, updatedAt: timestamp }
? { ...existing, folderId, name: trimmed, config, updatedAt: timestamp }
: {
id: uuid(),
folderId,
name: trimmed,
orderIndex: maxOrderIndex(tasks.value.filter((item) => (item.folderId || "") === (folderId || ""))) + 1,
config: input.config,
config,
createdAt: timestamp,
updatedAt: timestamp,
};
+4
View File
@@ -1469,6 +1469,10 @@ export interface TransferTaskConfig {
targetTableNameCase: TransferTableNameCase;
quoteTargetColumnNames: boolean;
batchSize: number;
/** Legacy-compatible rebuild flag; true takes precedence over the saved DML mode. */
dropTargetBeforeCreate?: boolean;
/** Legacy field only. Saved confirmation is always ignored and reset to false. */
dropTargetConfirmed?: boolean;
}
export interface TransferTask {
+82 -20
View File
@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
use crate::models::connection::DatabaseType;
use crate::sql_dialect::{
is_schema_aware, profile_for, qualified_table_name, quote_table_data_identifier, quote_table_identifier,
uses_connection_identifier_quote,
uses_connection_identifier_quote, DdlDialectProfile,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -415,35 +415,28 @@ pub fn build_drop_table_sql(options: TableAdminSqlOptions) -> String {
&options.table_name,
options.identifier_quote.as_deref(),
);
// IoTDB is the one engine whose drop target is a derived path pattern rather than the
// qualified name, so it cannot be expressed as a profile template.
if matches!(options.database_type, Some(DatabaseType::Iotdb)) {
return format!("DELETE TIMESERIES {};", iotdb_timeseries_pattern(&table));
} else if matches!(options.database_type, Some(DatabaseType::InfluxDb)) {
return format!("DROP MEASUREMENT {};", table);
}
// CASCADE is valid for PostgreSQL-family dialects; keep default RESTRICT behavior elsewhere.
let cascade = if options.cascade.unwrap_or(false) && supports_drop_table_cascade(options.database_type) {
" CASCADE"
} else {
""
};
format!("DROP TABLE {table}{cascade};")
// Unknown database type: fall back to the ANSI shape rather than guessing a profile.
let Some(database_type) = options.database_type else {
return format!("DROP TABLE {table}{cascade};");
};
DdlDialectProfile::render_template(
profile_for(database_type).drop_table_template,
&[("table", &table), ("cascade", cascade)],
)
}
fn supports_drop_table_cascade(database_type: Option<DatabaseType>) -> bool {
matches!(
database_type,
Some(
DatabaseType::Postgres
| DatabaseType::Redshift
| DatabaseType::Gaussdb
| DatabaseType::Kwdb
| DatabaseType::Kingbase
| DatabaseType::Highgo
| DatabaseType::Uxdb
| DatabaseType::Vastbase
| DatabaseType::OpenGauss
)
)
database_type.is_some_and(|database_type| profile_for(database_type).drop_table_supports_cascade)
}
pub fn build_drop_table_child_object_sql(options: DropTableChildObjectSqlOptions) -> Result<String, String> {
@@ -1012,7 +1005,11 @@ fn is_postgres_like_rename(database_type: DatabaseType) -> bool {
fn is_oracle_like_rename(database_type: DatabaseType) -> bool {
// 神通 Oscar 实测支持 `ALTER TABLE old RENAME TO new`(PG 风格,与 Dameng/Oracle 一致)。
matches!(database_type, DatabaseType::Oracle | DatabaseType::Dameng | DatabaseType::Oscar)
// OceanBase Oracle 模式同样接受该语法。
matches!(
database_type,
DatabaseType::Oracle | DatabaseType::Dameng | DatabaseType::OceanbaseOracle | DatabaseType::Oscar
)
}
fn is_postgres_like_structure_copy(database_type: DatabaseType) -> bool {
@@ -1803,6 +1800,57 @@ mod tests {
}
}
/// `build_drop_table_sql` renders `DdlDialectProfile::drop_table_template`, so the six
/// profile families plus DuckDB (which resolves to `conservative_ansi`, not the SQLite
/// family) must all produce a valid statement.
#[test]
fn builds_drop_table_sql_per_profile_family() {
let drop_sql = |database_type: DatabaseType, schema: Option<&str>, cascade: Option<bool>| {
build_drop_table_sql(TableAdminSqlOptions {
database_type: Some(database_type),
schema: schema.map(str::to_string),
table_name: "events".to_string(),
cascade,
identifier_quote: None,
})
};
assert_eq!(drop_sql(DatabaseType::Mysql, None, None), "DROP TABLE `events`;");
assert_eq!(drop_sql(DatabaseType::Postgres, Some("public"), None), "DROP TABLE \"public\".\"events\";");
assert_eq!(drop_sql(DatabaseType::Oracle, Some("APP"), None), "DROP TABLE \"APP\".\"events\";");
assert_eq!(drop_sql(DatabaseType::SqlServer, Some("dbo"), None), "DROP TABLE [dbo].[events];");
assert_eq!(drop_sql(DatabaseType::Sqlite, None, None), "DROP TABLE \"events\";");
assert_eq!(drop_sql(DatabaseType::DuckDb, None, None), "DROP TABLE \"events\";");
// No database type: the ANSI shape, with the default double-quote from
// `qualified_name_with_quote`, and no CASCADE.
assert_eq!(
build_drop_table_sql(TableAdminSqlOptions {
database_type: None,
schema: None,
table_name: "events".to_string(),
cascade: Some(true),
identifier_quote: None,
}),
"DROP TABLE \"events\";"
);
// CASCADE stays limited to the PostgreSQL-family profiles. Oracle spells it
// `CASCADE CONSTRAINTS` and is deliberately excluded; Firebird, Vertica and Exasol
// share the PostgreSQL profile but opt out.
assert_eq!(
drop_sql(DatabaseType::Postgres, Some("public"), Some(true)),
"DROP TABLE \"public\".\"events\" CASCADE;"
);
assert_eq!(drop_sql(DatabaseType::OpenGauss, None, Some(true)), "DROP TABLE \"events\" CASCADE;");
assert_eq!(drop_sql(DatabaseType::Firebird, None, Some(true)), "DROP TABLE \"events\";");
assert_eq!(drop_sql(DatabaseType::Vertica, None, Some(true)), "DROP TABLE \"events\";");
assert_eq!(drop_sql(DatabaseType::Oracle, None, Some(true)), "DROP TABLE \"events\";");
assert_eq!(drop_sql(DatabaseType::DuckDb, None, Some(true)), "DROP TABLE \"events\";");
// InfluxDB drops a measurement; the profile template carries the whole shape.
assert_eq!(drop_sql(DatabaseType::InfluxDb, None, None), "DROP MEASUREMENT \"events\";");
}
#[test]
fn builds_mysql_auto_increment_sql_and_rejects_invalid_values() {
for value in ["1", "9007199254740993", "18446744073709551615"] {
@@ -2614,6 +2662,20 @@ mod tests {
.unwrap(),
"ALTER VIEW \"SYSDBA\".\"ACTIVE_USERS\" RENAME TO \"ENABLED_USERS\";"
);
// OceanBase in Oracle mode accepts the same statement; without it the transfer
// rename-then-drop path has no way to back up a target table.
assert!(supports_object_rename(Some(DatabaseType::OceanbaseOracle), DatabaseObjectType::Table));
assert_eq!(
build_rename_object_sql(RenameObjectSqlOptions {
database_type: Some(DatabaseType::OceanbaseOracle),
object_type: DatabaseObjectType::Table,
schema: Some("APP".to_string()),
old_name: "ORDERS".to_string(),
new_name: "ORDERS__DBX_BAK".to_string(),
})
.unwrap(),
"ALTER TABLE \"APP\".\"ORDERS\" RENAME TO \"ORDERS__DBX_BAK\";"
);
}
#[test]
+1
View File
@@ -106,6 +106,7 @@ pub mod temporal_format;
pub mod text_export;
pub mod token_usage;
pub mod transfer;
pub mod transfer_rebuild;
pub mod two_phase_commit;
pub mod types;
pub mod update;
+21
View File
@@ -436,6 +436,9 @@ fn wrap_if_not_exists(sql: &str, db_type: DatabaseType) -> String {
let (prefix, suffix) = sql.split_at(idx);
format!("{prefix} IF NOT EXISTS{suffix}")
} else if upper.starts_with("DROP TABLE") {
if !profile.drop_table_supports_if_exists {
return sql.to_string();
}
if upper.contains("IF EXISTS") {
return sql.to_string();
}
@@ -1604,6 +1607,24 @@ mod tests {
assert!(result.contains("DROP TABLE IF EXISTS"), "Got: {result}");
}
/// Oracle before 23c rejects `DROP TABLE IF EXISTS`, so the wrapper must leave the
/// statement alone instead of generating invalid SQL.
#[test]
fn idempotent_drop_table_skips_if_exists_where_unsupported() {
for db_type in [DatabaseType::Oracle, DatabaseType::Db2, DatabaseType::Access] {
let result = apply_idempotent_strategy("DROP TABLE old_users;", db_type, IdempotentStrategy::IfNotExists);
assert_eq!(result, "DROP TABLE old_users;", "{db_type:?} must not gain IF EXISTS");
}
// The ConditionalCheck strategy falls back to the guarded comment form instead.
let conditional = apply_idempotent_strategy(
"DROP TABLE old_users;",
DatabaseType::Oracle,
IdempotentStrategy::ConditionalCheck,
);
assert!(!conditional.contains("IF EXISTS"), "Got: {conditional}");
assert!(conditional.contains("-- Conditional"), "Got: {conditional}");
}
#[test]
fn idempotent_drop_index_if_exists() {
let sql = "DROP INDEX idx_email;";
+148 -6
View File
@@ -118,6 +118,12 @@ pub struct DdlDialectProfile {
pub drop_index_uses_on_table: bool,
/// `DROP FOREIGN KEY` (MySQL) vs `DROP CONSTRAINT`.
pub drop_fk_as_foreign_key: bool,
/// `DROP TABLE t CASCADE` is accepted. Oracle's `CASCADE CONSTRAINTS` is a
/// different clause and does not count.
pub drop_table_supports_cascade: bool,
/// `DROP TABLE IF EXISTS t` is valid grammar. False for Oracle (pre-23c),
/// Access, Firebird, Db2, Informix, SAP HANA and Teradata.
pub drop_table_supports_if_exists: bool,
/// Index method placement.
pub index_type_placement: IndexTypePlacement,
/// `INCLUDE (cols)` on indexes.
@@ -165,6 +171,9 @@ pub struct DdlDialectProfile {
pub rule_drop_template: Option<&'static str>,
/// `ALTER {object_type} {name} OWNER TO {owner};`
pub owner_alter_template: Option<&'static str>,
/// `DROP TABLE {table}{cascade};` — the caller quotes `{table}` itself. Engines
/// that drop a differently-named object override the whole shape.
pub drop_table_template: &'static str,
/// Optional session lock-timeout preamble for generated scripts.
pub lock_timeout_sql: Option<&'static str>,
/// Data-driven base-type rewrites for this target (empty → rely on matrix / normalize only).
@@ -222,6 +231,9 @@ const SQLSERVER_SEQ_ALTER: &str =
"ALTER SEQUENCE {name} RESTART WITH {start_value} INCREMENT BY {increment} MINVALUE {min_value} MAXVALUE {max_value} {cycle};";
const RULE_DROP: &str = "DROP RULE IF EXISTS {rule_name} ON {table_name}{cascade};";
const OWNER_ALTER: &str = "ALTER {object_type} {name} OWNER TO {owner};";
/// Shared ANSI `DROP TABLE` shape. `{if_exists}` is deliberately absent: whether a
/// dialect accepts `IF EXISTS` is a capability flag, not part of the rendered shape.
const DROP_TABLE: &str = "DROP TABLE {table}{cascade};";
// ---------------------------------------------------------------------------
// Type maps (data only — no per-database logic at call sites)
@@ -325,6 +337,8 @@ fn mysql_family(db: DatabaseType) -> DdlDialectProfile {
foreign_keys_inline_in_create: false,
drop_index_uses_on_table: true,
drop_fk_as_foreign_key: true,
drop_table_supports_cascade: false,
drop_table_supports_if_exists: true,
index_type_placement: IndexTypePlacement::UsingBeforeOn,
index_supports_include: false,
index_supports_filter: false,
@@ -354,6 +368,7 @@ fn mysql_family(db: DatabaseType) -> DdlDialectProfile {
sequence_drop_template: None,
rule_drop_template: None,
owner_alter_template: None,
drop_table_template: DROP_TABLE,
lock_timeout_sql: Some("SET SESSION lock_wait_timeout = 3;"),
type_map: &[],
}
@@ -371,6 +386,8 @@ fn postgres_family(db: DatabaseType) -> DdlDialectProfile {
foreign_keys_inline_in_create: false,
drop_index_uses_on_table: false,
drop_fk_as_foreign_key: false,
drop_table_supports_cascade: true,
drop_table_supports_if_exists: true,
index_type_placement: IndexTypePlacement::UsingSuffix,
index_supports_include: true,
index_supports_filter: true,
@@ -400,6 +417,7 @@ fn postgres_family(db: DatabaseType) -> DdlDialectProfile {
sequence_drop_template: Some(SEQ_DROP),
rule_drop_template: Some(RULE_DROP),
owner_alter_template: Some(OWNER_ALTER),
drop_table_template: DROP_TABLE,
lock_timeout_sql: Some("SET lock_timeout = '3s';"),
type_map: &[],
}
@@ -420,6 +438,9 @@ fn oracle_family(db: DatabaseType) -> DdlDialectProfile {
foreign_keys_inline_in_create: false,
drop_index_uses_on_table: false,
drop_fk_as_foreign_key: false,
// Oracle spells it `CASCADE CONSTRAINTS`, and `IF EXISTS` only landed in 23c.
drop_table_supports_cascade: false,
drop_table_supports_if_exists: false,
index_type_placement: IndexTypePlacement::None,
index_supports_include: false,
index_supports_filter: false,
@@ -450,6 +471,7 @@ fn oracle_family(db: DatabaseType) -> DdlDialectProfile {
sequence_drop_template: Some(SEQ_DROP),
rule_drop_template: None,
owner_alter_template: None,
drop_table_template: DROP_TABLE,
lock_timeout_sql: Some("ALTER SESSION SET DDL_LOCK_TIMEOUT = 3;"),
type_map: &[],
}
@@ -467,6 +489,9 @@ fn sqlserver_family(db: DatabaseType) -> DdlDialectProfile {
foreign_keys_inline_in_create: false,
drop_index_uses_on_table: false,
drop_fk_as_foreign_key: false,
drop_table_supports_cascade: false,
// `DROP TABLE IF EXISTS` is valid on SQL Server 2016+.
drop_table_supports_if_exists: true,
index_type_placement: IndexTypePlacement::TypePrefix,
index_supports_include: true,
index_supports_filter: true,
@@ -496,6 +521,7 @@ fn sqlserver_family(db: DatabaseType) -> DdlDialectProfile {
sequence_drop_template: Some(SEQ_DROP),
rule_drop_template: None,
owner_alter_template: None,
drop_table_template: DROP_TABLE,
lock_timeout_sql: Some("SET LOCK_TIMEOUT 3000;"),
type_map: &[],
}
@@ -513,6 +539,8 @@ fn sqlite_family(db: DatabaseType) -> DdlDialectProfile {
foreign_keys_inline_in_create: true,
drop_index_uses_on_table: false,
drop_fk_as_foreign_key: false,
drop_table_supports_cascade: false,
drop_table_supports_if_exists: true,
index_type_placement: IndexTypePlacement::None,
index_supports_include: false,
index_supports_filter: true,
@@ -542,6 +570,7 @@ fn sqlite_family(db: DatabaseType) -> DdlDialectProfile {
sequence_drop_template: None,
rule_drop_template: None,
owner_alter_template: None,
drop_table_template: DROP_TABLE,
lock_timeout_sql: None,
type_map: SQLITE_TYPE_MAP,
}
@@ -559,6 +588,9 @@ fn access_profile() -> DdlDialectProfile {
foreign_keys_inline_in_create: false,
drop_index_uses_on_table: false,
drop_fk_as_foreign_key: false,
// Jet SQL has neither CASCADE nor IF EXISTS on DROP TABLE.
drop_table_supports_cascade: false,
drop_table_supports_if_exists: false,
index_type_placement: IndexTypePlacement::None,
index_supports_include: false,
index_supports_filter: false,
@@ -588,6 +620,7 @@ fn access_profile() -> DdlDialectProfile {
sequence_drop_template: None,
rule_drop_template: None,
owner_alter_template: None,
drop_table_template: DROP_TABLE,
lock_timeout_sql: None,
type_map: ACCESS_TYPE_MAP,
}
@@ -605,6 +638,10 @@ fn h2_profile() -> DdlDialectProfile {
foreign_keys_inline_in_create: false,
drop_index_uses_on_table: false,
drop_fk_as_foreign_key: false,
// H2 accepts CASCADE, but it stays off to keep the cascade set aligned with
// the frontend preview list in `dbAdminSql.ts`.
drop_table_supports_cascade: false,
drop_table_supports_if_exists: true,
index_type_placement: IndexTypePlacement::None,
index_supports_include: false,
index_supports_filter: false,
@@ -634,6 +671,7 @@ fn h2_profile() -> DdlDialectProfile {
sequence_drop_template: None,
rule_drop_template: None,
owner_alter_template: None,
drop_table_template: DROP_TABLE,
lock_timeout_sql: None,
type_map: &[],
}
@@ -656,6 +694,10 @@ fn conservative_ansi(db: DatabaseType) -> DdlDialectProfile {
foreign_keys_inline_in_create: false,
drop_index_uses_on_table: false,
drop_fk_as_foreign_key: false,
drop_table_supports_cascade: false,
// Most engines in this bucket accept `IF EXISTS`; `profile_for` opts out the
// ones that do not.
drop_table_supports_if_exists: true,
index_type_placement: IndexTypePlacement::None,
index_supports_include: false,
index_supports_filter: false,
@@ -685,6 +727,7 @@ fn conservative_ansi(db: DatabaseType) -> DdlDialectProfile {
sequence_drop_template: None,
rule_drop_template: None,
owner_alter_template: None,
drop_table_template: DROP_TABLE,
lock_timeout_sql: None,
type_map: &[],
}
@@ -698,6 +741,14 @@ fn clickhouse_profile() -> DdlDialectProfile {
p
}
fn influxdb_profile() -> DdlDialectProfile {
let mut p = conservative_ansi(DatabaseType::InfluxDb);
// InfluxDB drops a measurement, not a table, and has no IF EXISTS form.
p.drop_table_template = "DROP MEASUREMENT {table};";
p.drop_table_supports_if_exists = false;
p
}
/// Resolve DDL profile for a concrete target database type.
///
/// This is the **only** place that maps [`DatabaseType`] → profile data.
@@ -709,8 +760,19 @@ pub fn profile_for(db_type: DatabaseType) -> DdlDialectProfile {
Mysql | Doris | StarRocks | Goldendb | Sundb | Databend | Gbase | ManticoreSearch => mysql_family(db_type),
// PostgreSQL family
Postgres | Redshift | Gaussdb | Kingbase | Highgo | Vastbase | OpenGauss | Kwdb | Firebird | Vertica
| Exasol | Uxdb => postgres_family(db_type),
Postgres | Redshift | Gaussdb | Kingbase | Highgo | Vastbase | OpenGauss | Kwdb | Uxdb => {
postgres_family(db_type)
}
// PostgreSQL-shaped DDL, but not the PostgreSQL `DROP TABLE` grammar:
// Firebird has neither CASCADE nor IF EXISTS; Vertica and Exasol are kept off
// CASCADE so the set stays aligned with the frontend preview list.
Firebird | Vertica | Exasol => {
let mut profile = postgres_family(db_type);
profile.drop_table_supports_cascade = false;
profile.drop_table_supports_if_exists = db_type != Firebird;
profile
}
// Oracle family
Oracle | Dameng | OceanbaseOracle | Yashandb | Xugu | Iris => oracle_family(db_type),
@@ -728,13 +790,23 @@ pub fn profile_for(db_type: DatabaseType) -> DdlDialectProfile {
ClickHouse => clickhouse_profile(),
DuckDb | Questdb | SapHana | Teradata | Snowflake | Trino | PrestoSql | Hive | Kyuubi | Impala | Argo
| Spark | Db2 | Informix | Bigquery | Spanner | Kylin | Ignite | Ignite3 | Oscar | Tdengine | Iotdb
| Databricks | Jdbc => conservative_ansi(db_type),
// Engines whose DROP TABLE grammar has no `IF EXISTS`.
Db2 | Informix | SapHana | Teradata => {
let mut profile = conservative_ansi(db_type);
profile.drop_table_supports_if_exists = false;
profile
}
DuckDb | Questdb | Snowflake | Trino | PrestoSql | Hive | Kyuubi | Impala | Argo | Spark | Bigquery
| Spanner | Kylin | Ignite | Ignite3 | Oscar | Tdengine | Iotdb | Databricks | Jdbc => {
conservative_ansi(db_type)
}
InfluxDb => influxdb_profile(),
// Non-tabular / not applicable for relational CREATE TABLE
Redis | MongoDb | DynamoDb | Elasticsearch | Easysearch | Meilisearch | Qdrant | Milvus | Weaviate
| ChromaDb | Neo4j | Cassandra | Etcd | ZooKeeper | Nacos | Consul | InfluxDb | InfluxDb3 | VictoriaMetrics
| ChromaDb | Neo4j | Cassandra | Etcd | ZooKeeper | Nacos | Consul | InfluxDb3 | VictoriaMetrics
| MessageQueue | Mqtt | Hbase => conservative_ansi(db_type),
}
}
@@ -810,4 +882,74 @@ mod tests {
);
assert_eq!(rendered, "DROP FUNCTION IF EXISTS \"public\".\"f\" CASCADE;");
}
#[test]
fn drop_table_if_exists_support_matches_dialect_grammar() {
for db in [
DatabaseType::Mysql,
DatabaseType::Postgres,
DatabaseType::SqlServer,
DatabaseType::Sqlite,
DatabaseType::DuckDb,
DatabaseType::ClickHouse,
DatabaseType::H2,
DatabaseType::Snowflake,
] {
assert!(profile_for(db).drop_table_supports_if_exists, "{db:?} does support DROP TABLE IF EXISTS");
}
// Oracle only gained `IF EXISTS` in 23c; the rest have no such grammar at all.
for db in [
DatabaseType::Oracle,
DatabaseType::Dameng,
DatabaseType::OceanbaseOracle,
DatabaseType::Access,
DatabaseType::Firebird,
DatabaseType::Db2,
DatabaseType::Informix,
DatabaseType::SapHana,
DatabaseType::Teradata,
DatabaseType::InfluxDb,
] {
assert!(!profile_for(db).drop_table_supports_if_exists, "{db:?} does not support DROP TABLE IF EXISTS");
}
}
#[test]
fn drop_table_cascade_support_is_limited_to_the_postgres_grammar() {
for db in [
DatabaseType::Postgres,
DatabaseType::Redshift,
DatabaseType::Gaussdb,
DatabaseType::Kwdb,
DatabaseType::Kingbase,
DatabaseType::Highgo,
DatabaseType::Uxdb,
DatabaseType::Vastbase,
DatabaseType::OpenGauss,
] {
assert!(profile_for(db).drop_table_supports_cascade, "{db:?} should keep DROP TABLE … CASCADE");
}
// Oracle's `CASCADE CONSTRAINTS` is a different clause; Firebird, Vertica and Exasol
// share the PostgreSQL profile but stay out of the cascade set so the backend matches
// the frontend preview list in `dbAdminSql.ts`.
for db in [
DatabaseType::Oracle,
DatabaseType::Firebird,
DatabaseType::Vertica,
DatabaseType::Exasol,
DatabaseType::Mysql,
DatabaseType::Sqlite,
DatabaseType::DuckDb,
DatabaseType::SqlServer,
DatabaseType::H2,
] {
assert!(!profile_for(db).drop_table_supports_cascade, "{db:?} must not emit DROP TABLE … CASCADE");
}
}
#[test]
fn drop_table_template_carries_the_whole_statement_shape() {
assert_eq!(profile_for(DatabaseType::Postgres).drop_table_template, "DROP TABLE {table}{cascade};");
assert_eq!(profile_for(DatabaseType::InfluxDb).drop_table_template, "DROP MEASUREMENT {table};");
}
}
File diff suppressed because it is too large Load Diff
+420
View File
@@ -0,0 +1,420 @@
//! Prepare target CREATE TABLE and deferred foreign keys without executing DDL.
//!
//! The transfer pass and the ownership preview share this planning helper so both always
//! agree on the DDL that will run and the names it will use. Nothing here executes DDL:
//! execution stays with `execute_transfer_create_table_ddl_on_pool` and the caller.
use super::*;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct PreparedTableDdl {
pub ddl: String,
pub reused_source_ddl: bool,
pub deferred_fk_alters: Vec<String>,
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn prepare_table_ddl(
state: &AppState,
request: &TransferRequest,
table: &str,
target_table: &str,
source_db_type: &DatabaseType,
target_db_type: &DatabaseType,
source_pool_key: &str,
columns: &[db::ColumnInfo],
table_comment: Option<&str>,
known_foreign_keys: &HashMap<String, Vec<db::ForeignKeyInfo>>,
) -> Result<PreparedTableDdl, String> {
let rebuild = request.drop_target_before_create;
let foreign_keys = if let Some(foreign_keys) = known_foreign_keys.get(table) {
foreign_keys.clone()
} else if rebuild {
// Strict for rebuilds: silently dropping foreign keys the inspection failed on
// would produce a rebuilt table that enforces fewer constraints than the source.
crate::schema::list_foreign_keys_core(
state,
&request.source_connection_id,
&request.source_database,
&request.source_schema,
table,
)
.await
.map_err(|e| format!("Failed to inspect source foreign keys for table '{table}' before rebuilding: {e}"))?
} else if supports_deferred_mysql_foreign_keys(target_db_type) {
// Ordinary MySQL-family targets keep the legacy best-effort behavior: fall back
// to the generated DDL without foreign keys when the inspection fails. Other
// targets never use the foreign keys, so no query is issued at all.
match crate::schema::list_foreign_keys_core(
state,
&request.source_connection_id,
&request.source_database,
&request.source_schema,
table,
)
.await
{
Ok(foreign_keys) => foreign_keys,
Err(e) => {
log::warn!("[transfer] failed to inspect source foreign keys for {table}: {e}");
Vec::new()
}
}
} else {
Vec::new()
};
let (source_driver_profile, target_driver_profile) = {
let configs = state.configs.read().await;
(
configs.get(&request.source_connection_id).and_then(|config| config.driver_profile.clone()),
configs.get(&request.target_connection_id).and_then(|config| config.driver_profile.clone()),
)
};
let can_reuse = can_reuse_source_table_ddl(
source_db_type,
target_db_type,
source_driver_profile.as_deref(),
target_driver_profile.as_deref(),
target_table == table,
) && (request.quote_target_column_names
|| !matches!(target_db_type, DatabaseType::Gaussdb | DatabaseType::OpenGauss));
// A rebuild recreates the source structure exactly. When the structure comes from
// the source DDL (the `can_reuse` path) the column list is only used for data
// mapping, so an empty list is fine — the DDL is read directly from the source. Only
// when the DDL must be *generated* from the column list does incomplete metadata
// become fatal, because there would be nothing to build the CREATE TABLE from.
if rebuild && !can_reuse {
let incomplete = columns.is_empty()
|| columns.iter().any(|column| column.name.trim().is_empty() || column.data_type.trim().is_empty());
if incomplete {
return Err(format!(
"Cannot rebuild table '{target_table}': the source returned incomplete column metadata, and the \
target cannot reuse the source DDL."
));
}
}
let mut reused_source_ddl = false;
let ddl = if can_reuse {
let (source_ddl, source_ddl_was_read) = if let Some(catalog) =
resolve_external_transfer_catalog(request.source_catalog.as_deref(), source_db_type)
{
// Doris/StarRocks external catalog: read DDL directly via
// SHOW CREATE TABLE catalog.database.table using the existing source
// pool (bare MySQL — addresses any catalog).
let pool = {
let pool =
state.pool_handle(source_pool_key).await.ok_or_else(|| "Source pool not found".to_string())?;
let PoolKind::Mysql(p, _) = &pool else {
return Err("Source pool must be MySQL-family for catalog DDL".to_string());
};
p.clone()
};
match db::doris::get_catalog_table_ddl(&pool, catalog, &request.source_database, table).await {
Ok(ddl) => (ddl, true),
Err(err) => {
log::warn!("[transfer] catalog DDL read failed for {table} in catalog '{catalog}': {err}; falling back to generated DDL");
(
generate_create_table_ddl_with_column_quoting(
columns,
target_table,
&request.source_schema,
&request.target_schema,
target_db_type,
source_db_type,
table_comment,
request.target_catalog.as_deref(),
request.quote_target_column_names,
),
false,
)
}
}
} else {
match crate::schema::get_table_ddl_core(
state,
&request.source_connection_id,
&request.source_database,
&request.source_schema,
table,
None,
)
.await
{
Ok(ddl) => (ddl, true),
Err(e) if rebuild => {
// A rebuild cannot fall back to generated DDL when the source DDL
// it promised to reproduce cannot be read.
return Err(format!("Failed to read source DDL for table '{table}' before rebuilding: {e}"));
}
Err(_) => (
generate_create_table_ddl_with_column_quoting(
columns,
target_table,
&request.source_schema,
&request.target_schema,
target_db_type,
source_db_type,
table_comment,
request.target_catalog.as_deref(),
request.quote_target_column_names,
),
false,
),
}
};
if contains_oceanbase_mysql_table_options(&source_ddl)
&& !db::oceanbase_mysql::is_profile(target_db_type, target_driver_profile.as_deref())
{
generate_create_table_ddl_with_column_quoting(
columns,
target_table,
&request.source_schema,
&request.target_schema,
target_db_type,
source_db_type,
table_comment,
request.target_catalog.as_deref(),
request.quote_target_column_names,
)
} else {
reused_source_ddl = source_ddl_was_read;
rewrite_transfer_source_table_ddl(
&source_ddl,
&request.source_schema,
&request.target_schema,
source_db_type,
target_db_type,
)
}
} else {
generate_create_table_ddl_with_column_quoting(
columns,
target_table,
&request.source_schema,
&request.target_schema,
target_db_type,
source_db_type,
table_comment,
request.target_catalog.as_deref(),
request.quote_target_column_names,
)
};
// MySQL-family targets: defer the foreign keys to ALTER statements so the table
// creation order never has to satisfy foreign key dependencies (a foreign key cycle
// has no valid CREATE TABLE order at all). The rebuild path above guarantees the
// metadata behind `foreign_keys` is trustworthy before anything is stripped.
let mut ddl = ddl;
let mut deferred_fk_alters = 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_alters =
generate_mysql_foreign_key_alter_statements(&foreign_keys, request, target_table, target_db_type);
}
Ok(PreparedTableDdl { ddl, reused_source_ddl, deferred_fk_alters })
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
async fn state_fixture() -> (Arc<AppState>, tempfile::TempDir) {
let directory = tempfile::tempdir().unwrap();
let storage = crate::storage::Storage::open(&directory.path().join("storage.db")).await.unwrap();
let state = Arc::new(AppState::new_with_plugin_dir(storage, directory.path().join("plugins")));
(state, directory)
}
fn request(rebuild: bool) -> TransferRequest {
serde_json::from_value(json!({
"transferId": "ddl-plan-test",
"sourceConnectionId": "source", "sourceDatabase": "main", "sourceSchema": "main",
"targetConnectionId": "target", "targetDatabase": "main", "targetSchema": "main",
"tables": ["child", "Parent"], "createTable": true, "content": "structureAndData",
"mode": "append", "batchSize": 10, "dropTargetBeforeCreate": rebuild,
"dropTargetConfirmed": true
}))
.unwrap()
}
fn columns() -> Vec<db::ColumnInfo> {
vec![
db::ColumnInfo {
name: "id".into(),
data_type: "INTEGER".into(),
is_primary_key: true,
..Default::default()
},
db::ColumnInfo { name: "parent_id".into(), data_type: "INTEGER".into(), ..Default::default() },
]
}
#[tokio::test]
async fn prepared_sqlite_ddl_preserves_source_foreign_keys_without_mutating_source() {
let (state, directory) = state_fixture().await;
let path = directory.path().join("source.db");
let pool = crate::db::sqlite::connect_path_create_if_missing(path.to_str().unwrap()).await.unwrap();
pool.with_connection(|connection| {
connection
.execute_batch(
"PRAGMA foreign_keys = ON;
CREATE TABLE Parent (id INTEGER PRIMARY KEY);
CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES Parent(id) ON DELETE CASCADE);
INSERT INTO Parent VALUES (7);
INSERT INTO child VALUES (11, 7);",
)
.map_err(|error| error.to_string())
})
.unwrap();
let config: ConnectionConfig = serde_json::from_value(json!({
"id": "source", "name": "source", "db_type": "sqlite", "host": path.to_str().unwrap(),
"port": 0, "username": "", "password": "", "database": null,
"one_time": false, "save_password": false, "read_only": false
}))
.unwrap();
state.configs.write().await.insert("source".into(), config);
let source_pool_key = ensure_transfer_pool(&state, "source", "main", None).await.unwrap();
let columns = crate::db::sqlite::get_columns(&pool, "main", "child").await.unwrap();
let prepared = prepare_table_ddl(
&state,
&request(true),
"child",
"child",
&DatabaseType::Sqlite,
&DatabaseType::Sqlite,
&source_pool_key,
&columns,
None,
&HashMap::new(),
)
.await
.unwrap();
let source_rows = crate::db::sqlite::execute_query(&pool, "SELECT id, parent_id FROM child").await.unwrap();
assert_eq!(source_rows.rows, vec![vec![json!(11), json!(7)]]);
let target =
crate::db::sqlite::connect_path_create_if_missing(directory.path().join("target.db").to_str().unwrap())
.await
.unwrap();
target
.with_connection(|connection| {
connection
.execute_batch("PRAGMA foreign_keys = ON; CREATE TABLE Parent (id INTEGER PRIMARY KEY);")
.map_err(|error| error.to_string())?;
connection.execute_batch(&prepared.ddl).map_err(|error| error.to_string())
})
.unwrap();
let foreign_keys = crate::db::sqlite::list_foreign_keys(&target, "main", "child").await.unwrap();
assert_eq!(foreign_keys.len(), 1, "source FK must survive the prepared CREATE TABLE: {}", prepared.ddl);
assert_eq!(foreign_keys[0].ref_table, "Parent");
assert!(crate::db::sqlite::execute_query(&target, "INSERT INTO child VALUES (1, 999)").await.is_err());
assert!(prepared.reused_source_ddl);
}
#[tokio::test]
async fn rebuild_rejects_failed_foreign_key_inspection() {
let (state, _directory) = state_fixture().await;
let error = prepare_table_ddl(
&state,
&request(true),
"child",
"child",
&DatabaseType::Sqlite,
&DatabaseType::Mysql,
"missing-source-pool",
&columns(),
None,
&HashMap::new(),
)
.await
.expect_err("a rebuild cannot discard foreign keys when source metadata is unavailable");
assert!(error.contains("child"), "{error}");
assert!(error.to_ascii_lowercase().contains("foreign key"), "{error}");
}
#[tokio::test]
async fn ordinary_foreign_key_metadata_failure_preserves_generated_fallback() {
let (state, _directory) = state_fixture().await;
let prepared = prepare_table_ddl(
&state,
&request(false),
"child",
"child",
&DatabaseType::Sqlite,
&DatabaseType::Mysql,
"missing-source-pool",
&columns(),
None,
&HashMap::new(),
)
.await
.unwrap();
assert!(!prepared.reused_source_ddl);
assert!(prepared.ddl.contains("CREATE TABLE"));
}
#[tokio::test]
async fn deferred_foreign_key_uses_actual_target_and_case_converted_parent() {
let (state, _directory) = state_fixture().await;
let mut request = request(true);
request.target_table_name_case = TransferTableNameCase::Lower;
let known = HashMap::from([(
"child".into(),
vec![db::ForeignKeyInfo {
name: "fk_child_parent".into(),
column: "parent_id".into(),
ref_schema: Some("main".into()),
ref_table: "Parent".into(),
ref_column: "id".into(),
on_delete: Some("CASCADE".into()),
on_update: Some("RESTRICT".into()),
}],
)]);
let prepared = prepare_table_ddl(
&state,
&request,
"child",
"resolved_child",
&DatabaseType::Sqlite,
&DatabaseType::Mysql,
"missing-source-pool",
&columns(),
None,
&known,
)
.await
.unwrap();
assert_eq!(prepared.deferred_fk_alters, vec![
"ALTER TABLE `resolved_child` ADD CONSTRAINT `fk_child_parent` FOREIGN KEY (`parent_id`) REFERENCES `parent` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT"
]);
assert!(prepared.ddl.contains("`resolved_child`"), "{}", prepared.ddl);
}
#[tokio::test]
async fn rebuild_requires_complete_column_metadata() {
let (state, _directory) = state_fixture().await;
let incomplete = vec![db::ColumnInfo { name: "id".into(), ..Default::default() }];
for columns in [&[][..], incomplete.as_slice()] {
let result = prepare_table_ddl(
&state,
&request(true),
"child",
"child",
&DatabaseType::Sqlite,
&DatabaseType::Mysql,
"missing-source-pool",
columns,
None,
&HashMap::from([("child".into(), Vec::new())]),
)
.await;
assert!(result.is_err(), "incomplete source metadata must fail before any target DDL: {result:?}");
}
}
}
@@ -0,0 +1,116 @@
use super::*;
use serde_json::json;
async fn sqlite_fixture() -> (tempfile::TempDir, Arc<AppState>, TransferRequest, String, String) {
let directory = tempfile::tempdir().unwrap();
let storage = crate::storage::Storage::open(&directory.path().join("storage.db")).await.unwrap();
let state = Arc::new(AppState::new_with_plugin_dir(storage, directory.path().join("plugins")));
for id in ["source", "target"] {
let path = directory.path().join(format!("{id}.db"));
crate::db::sqlite::connect_path_create_if_missing(path.to_str().unwrap()).await.unwrap();
let config: ConnectionConfig = serde_json::from_value(json!({
"id": id, "name": id, "db_type": "sqlite", "host": path.to_str().unwrap(),
"port": 0, "username": "", "password": "", "database": null,
"one_time": false, "save_password": false, "read_only": false
}))
.unwrap();
state.configs.write().await.insert(id.to_string(), config);
}
let source_pool = ensure_transfer_pool(&state, "source", "main", None).await.unwrap();
let target_pool = ensure_transfer_pool(&state, "target", "main", None).await.unwrap();
for table in ["orders", "users"] {
execute_on_pool(&state, &source_pool, &format!("CREATE TABLE {table}(id INTEGER PRIMARY KEY, name TEXT)"))
.await
.unwrap();
execute_on_pool(&state, &source_pool, &format!("INSERT INTO {table} VALUES(1, 'source')")).await.unwrap();
execute_on_pool(&state, &target_pool, &format!("CREATE TABLE {table}(id INTEGER PRIMARY KEY, old_value TEXT)"))
.await
.unwrap();
execute_on_pool(&state, &target_pool, &format!("INSERT INTO {table} VALUES(99, 'original')")).await.unwrap();
}
let request: TransferRequest = serde_json::from_value(json!({
"transferId": uuid::Uuid::new_v4().to_string(),
"sourceConnectionId": "source", "sourceDatabase": "main", "sourceSchema": "main",
"targetConnectionId": "target", "targetDatabase": "main", "targetSchema": "main",
"tables": ["orders", "users"], "createTable": true, "content": "structureAndData",
"mode": "append", "batchSize": 1, "dropTargetBeforeCreate": true,
"dropTargetConfirmed": true
}))
.unwrap();
(directory, state, request, source_pool, target_pool)
}
#[tokio::test]
async fn transfer_rebuild_preflights_every_backup_collision_before_renaming_any_table() {
let (_directory, state, request, _, target_pool) = sqlite_fixture().await;
let backup =
crate::transfer_rebuild::backup_table_name(DatabaseType::Sqlite, &request.transfer_id, "main.users", "users")
.unwrap();
execute_on_pool(&state, &target_pool, &format!("CREATE TABLE \"{backup}\"(id INTEGER)")).await.unwrap();
let result =
rename_tables_to_backup(&state, &request, &request.tables, DatabaseType::Sqlite, &target_pool, |_| {}).await;
assert!(result.is_err(), "a preexisting backup must block the operation");
let original = execute_read_on_pool(&state, &target_pool, "SELECT old_value FROM orders WHERE id=99").await;
assert!(original.is_ok(), "a collision on users must not rename orders: {original:?}");
assert_eq!(original.unwrap().rows[0][0], json!("original"));
}
#[tokio::test]
async fn transfer_rebuild_cancellation_remains_a_cancellation_after_backup() {
let (_directory, state, mut request, source_pool, target_pool) = sqlite_fixture().await;
request.tables = vec!["orders".into()];
let backups =
rename_tables_to_backup(&state, &request, &request.tables, DatabaseType::Sqlite, &target_pool, |_| {})
.await
.unwrap();
set_cancelled(&request.transfer_id).await;
let result = transfer_table(
&state,
&request,
"orders",
0,
&DatabaseType::Sqlite,
&DatabaseType::Sqlite,
&source_pool,
&target_pool,
&HashMap::new(),
&mut Vec::new(),
Some(&backups),
|_| {},
)
.await;
clear_cancelled(&request.transfer_id).await;
assert_eq!(result.unwrap_err(), "Cancelled", "recovery details must not change the cancellation discriminator");
}
#[tokio::test]
async fn transfer_rebuild_preview_plans_without_executing_ddl() {
let (_directory, state, request, source_pool, target_pool) = sqlite_fixture().await;
let preview = preview_transfer_ownership(
&state,
&request,
&DatabaseType::Sqlite,
&DatabaseType::Sqlite,
&source_pool,
&target_pool,
)
.await
.unwrap();
let rebuild = preview.rebuild.expect("a rebuild transfer must expose its SQL plan");
assert_eq!(rebuild.tables.len(), 2, "every source table must be mapped: {:?}", rebuild.tables);
for table in &rebuild.tables {
assert!(table.backup_table.is_some(), "preexisting targets must carry a backup name: {table:?}");
}
assert!(rebuild.sql.contains("-- 1. Backup existing target tables"), "{}", rebuild.sql);
assert!(rebuild.sql.contains("-- 2. Recreate the"), "{}", rebuild.sql);
assert!(rebuild.sql.contains("-- 3. Drop backups after success"), "{}", rebuild.sql);
// The preview must be pure planning: neither table may have been renamed aside.
for table in ["orders", "users"] {
let rows =
execute_read_on_pool(&state, &target_pool, &format!("SELECT old_value FROM {table} WHERE id=99")).await;
assert!(rows.is_ok(), "preview must not rename {table}: {rows:?}");
assert_eq!(rows.unwrap().rows[0][0], json!("original"));
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+580 -2
View File
@@ -3,8 +3,9 @@ use dbx_core::db::postgres;
use dbx_core::models::connection::{ConnectionConfig, DatabaseType};
use dbx_core::storage::Storage;
use dbx_core::transfer::{
get_db_type, transfer_postgres_schema_dependencies, transfer_postgres_schema_objects, transfer_table, TransferMode,
TransferObjectKind, TransferObjectSelection, TransferOwnershipPolicy, TransferRequest, TransferTableNameCase,
drop_backup_tables, get_db_type, rename_tables_to_backup, transfer_postgres_schema_dependencies,
transfer_postgres_schema_objects, transfer_table, TransferContent, TransferMode, TransferObjectKind,
TransferObjectSelection, TransferOwnershipPolicy, TransferRequest, TransferTableNameCase,
};
use serde_json::json;
use std::sync::Arc;
@@ -75,6 +76,23 @@ async fn query_scalar(pool: &deadpool_postgres::Pool, sql: &str) -> serde_json::
postgres::execute_query(pool, sql).await.unwrap().rows[0][0].clone()
}
/// Query scalar as text for structural assertions (like MySQL `schema_count`).
async fn query_text(pool: &deadpool_postgres::Pool, sql: &str) -> String {
let val = query_scalar(pool, sql).await;
if let Some(s) = val.as_str() {
return s.to_string();
}
if let Some(n) = val.as_i64() {
return n.to_string();
}
String::new()
}
/// `COUNT(*)` over `information_schema`, the shape every drop_target structural assertion needs.
async fn schema_count(pool: &deadpool_postgres::Pool, from_and_where: &str) -> String {
query_text(pool, &format!("SELECT COUNT(*)::text FROM information_schema.{from_and_where}")).await
}
async fn query_index_rows(pool: &deadpool_postgres::Pool, schema: &str) -> Vec<(String, String)> {
postgres::execute_query(
pool,
@@ -228,6 +246,8 @@ async fn live_postgres_transfer_upserts_generated_always_identity_values() {
target_catalog: None,
tables: vec!["items".to_string()],
create_table: false,
drop_target_before_create: false,
drop_target_confirmed: false,
content: dbx_core::transfer::TransferContent::default(),
objects: Vec::new(),
mode: TransferMode::Upsert,
@@ -249,6 +269,7 @@ async fn live_postgres_transfer_upserts_generated_always_identity_values() {
&target_pool_key,
&std::collections::HashMap::new(),
&mut Vec::new(),
None,
|_| {},
)
.await
@@ -282,6 +303,7 @@ async fn live_postgres_transfer_upserts_generated_always_identity_values() {
&target_pool_key,
&std::collections::HashMap::new(),
&mut Vec::new(),
None,
|_| {},
)
.await
@@ -396,6 +418,8 @@ async fn live_postgres_structure_only_preserves_table_indexes() {
target_catalog: None,
tables: vec!["index_transfer".to_string()],
create_table: true,
drop_target_before_create: false,
drop_target_confirmed: false,
content: dbx_core::transfer::TransferContent::default(),
objects: Vec::new(),
mode: TransferMode::Append,
@@ -419,6 +443,7 @@ async fn live_postgres_structure_only_preserves_table_indexes() {
&target_pool_key,
&std::collections::HashMap::new(),
&mut Vec::new(),
None,
|_| {},
)
.await;
@@ -442,6 +467,7 @@ async fn live_postgres_structure_only_preserves_table_indexes() {
&target_pool_key,
&std::collections::HashMap::new(),
&mut Vec::new(),
None,
|_| {},
)
.await;
@@ -645,6 +671,8 @@ async fn live_postgres_transfer_preserves_data_and_schema_objects() {
target_catalog: None,
tables: vec!["users".to_string(), "audit_logs".to_string(), "files".to_string()],
create_table: true,
drop_target_before_create: false,
drop_target_confirmed: false,
content: dbx_core::transfer::TransferContent::default(),
objects: Vec::new(),
mode: TransferMode::Append,
@@ -670,6 +698,7 @@ async fn live_postgres_transfer_preserves_data_and_schema_objects() {
&target_pool_key,
&std::collections::HashMap::new(),
&mut Vec::new(),
None,
|_| {},
)
.await
@@ -846,6 +875,13 @@ async fn live_postgres_transfer_preserves_data_and_schema_objects() {
let _ = std::fs::remove_dir_all(dir);
}
/// Scope: `drop_target_before_create: false`, the default. `create_table` is a request,
/// not a command — an existing target table is reused as-is and the CREATE DDL is
/// skipped, so the source rows are appended into the table the user already had.
///
/// `drop_target_before_create: true` inverts this on purpose: the rename pre-pass frees
/// the name, so the CREATE DDL always runs and the target is rebuilt from the source
/// structure. See `live_postgres_transfer_drop_target_rebuilds_structure_and_indexes`.
#[tokio::test]
#[ignore = "requires PostgreSQL URLs via DBX_LIVE_PG_TRANSFER_SOURCE_URL and DBX_LIVE_PG_TRANSFER_TARGET_URL"]
async fn live_postgres_transfer_skips_create_ddl_for_existing_target_table() {
@@ -937,6 +973,8 @@ async fn live_postgres_transfer_skips_create_ddl_for_existing_target_table() {
target_catalog: None,
tables: vec!["items".to_string()],
create_table: true,
drop_target_before_create: false,
drop_target_confirmed: false,
content: dbx_core::transfer::TransferContent::default(),
objects: Vec::new(),
mode: TransferMode::Append,
@@ -959,6 +997,7 @@ async fn live_postgres_transfer_skips_create_ddl_for_existing_target_table() {
&target_pool_key,
&std::collections::HashMap::new(),
&mut Vec::new(),
None,
|_| {},
)
.await
@@ -1058,6 +1097,8 @@ async fn live_postgres_transfer_creates_selected_sequence_before_referencing_tab
target_catalog: None,
tables: vec!["biz_banner".to_string()],
create_table: true,
drop_target_before_create: false,
drop_target_confirmed: false,
content: dbx_core::transfer::TransferContent::default(),
objects: vec![
TransferObjectSelection { object_type: TransferObjectKind::Table, names: vec!["biz_banner".to_string()] },
@@ -1087,6 +1128,7 @@ async fn live_postgres_transfer_creates_selected_sequence_before_referencing_tab
&target_pool_key,
&std::collections::HashMap::new(),
&mut Vec::new(),
None,
|_| {},
)
.await
@@ -1168,3 +1210,539 @@ async fn live_postgres_transfer_creates_selected_sequence_before_referencing_tab
let _ = postgres::execute_batch(&target_pool, &[cleanup_sql[1].clone()]).await;
let _ = std::fs::remove_dir_all(dir);
}
/// Counterpart to `live_postgres_transfer_skips_create_ddl_for_existing_target_table`: when
/// `drop_target_before_create` is true, the rename pre-pass frees the name so the CREATE DDL
/// always runs and the target is rebuilt from the source structure, including indexes.
///
/// This test also verifies Fix B: PG backup table indexes are renamed during the pre-pass so
/// schema-scoped index names are freed for the rebuilt table. Without Fix B, `CREATE INDEX IF
/// NOT EXISTS {original_name}` would silently skip because the backup still holds that name.
#[tokio::test]
#[ignore = "requires live PostgreSQL connection"]
async fn live_postgres_transfer_drop_target_rebuilds_structure_and_indexes() {
let suffix = uuid::Uuid::new_v4().simple().to_string();
let connection_id = format!("pg-drop-rebuild-{suffix}");
let source_schema = format!("drop_src_{}", &suffix[..12]);
let target_schema = format!("drop_tgt_{}", &suffix[..12]);
let url = std::env::var("DBX_LIVE_PG_TRANSFER_SOURCE_URL").expect("DBX_LIVE_PG_TRANSFER_SOURCE_URL");
let pool = postgres::connect(&url, std::time::Duration::from_secs(5)).await.expect("live PG connection");
let database = query_text(&pool, "SELECT current_database()").await;
let config = postgres_test_config(&connection_id, &database);
// Source: orders(id, name, extra_col) + secondary index on name
postgres::execute_batch(
&pool,
&[
format!("CREATE SCHEMA {source_schema}"),
format!(
"CREATE TABLE {source_schema}.orders (\
id INT PRIMARY KEY, name TEXT NOT NULL, extra_col TEXT)"
),
format!("CREATE INDEX idx_orders_name ON {source_schema}.orders(name)"),
format!("COMMENT ON INDEX {source_schema}.idx_orders_name IS 'source index comment'"),
format!("INSERT INTO {source_schema}.orders VALUES (1, 'alpha', 'x')"),
],
)
.await
.unwrap();
// Target: incompatible structure orders(id, name) with stale row, plus its own index
postgres::execute_batch(
&pool,
&[
format!("CREATE SCHEMA {target_schema}"),
format!("CREATE TABLE {target_schema}.orders (id INT PRIMARY KEY, name TEXT NOT NULL)"),
format!("CREATE INDEX idx_orders_name ON {target_schema}.orders(name)"),
format!("COMMENT ON INDEX {target_schema}.idx_orders_name IS 'stale target index comment'"),
format!("INSERT INTO {target_schema}.orders VALUES (99, 'stale')"),
],
)
.await
.unwrap();
let dir = std::env::temp_dir().join(format!("dbx-live-drop-rebuild-{suffix}"));
std::fs::create_dir_all(&dir).unwrap();
let storage = Storage::open(&dir.join("storage.db")).await.unwrap();
let state = Arc::new(AppState::new(storage));
state.configs.write().await.insert(connection_id.clone(), config);
let pool_key = format!("{}:{}", connection_id, database);
state
.update_connection_pools(|connections| {
connections.insert(pool_key.clone(), PoolKind::Postgres(pool.clone()));
})
.await;
let transfer_id = format!("transfer-{suffix}");
let request = TransferRequest {
transfer_id: transfer_id.clone(),
source_connection_id: connection_id.clone(),
source_database: database.to_string(),
source_schema: source_schema.clone(),
source_catalog: None,
target_connection_id: connection_id.clone(),
target_database: database.to_string(),
target_schema: target_schema.clone(),
target_catalog: None,
tables: vec!["orders".to_string()],
create_table: true,
drop_target_before_create: true,
drop_target_confirmed: true,
content: TransferContent::default(),
objects: Vec::new(),
mode: TransferMode::Append,
target_table_name_case: TransferTableNameCase::Preserve,
quote_target_column_names: true,
ownership_policy: TransferOwnershipPolicy::Preserve,
batch_size: 1000,
};
// Execute rename pre-pass + transfer + backup cleanup
let backup_names =
rename_tables_to_backup(&state, &request, &request.tables, DatabaseType::Postgres, &pool_key, |_| {})
.await
.unwrap();
let backup_name = backup_names.get("orders").cloned().expect("preexisting target must be backed up");
assert!(backup_name.starts_with("orders__dbx_bak_"), "unexpected backup name: {backup_name}");
// After pre-pass: backup table exists, original indexes were renamed (Fix B)
assert_eq!(
schema_count(&pool, &format!("tables WHERE table_schema = '{target_schema}' AND table_name = '{backup_name}'"))
.await,
"1",
"backup table must exist after rename pre-pass"
);
// Verify backup's index was renamed (Fix B): the original idx_orders_name should not exist on backup
let backup_indexes = postgres::execute_query(
&pool,
&format!(
"SELECT indexname FROM pg_indexes WHERE schemaname = '{target_schema}' \
AND tablename = '{backup_name}' ORDER BY indexname"
),
)
.await
.unwrap();
assert!(
backup_indexes.rows.iter().all(|row| {
let idx_name = row[0].as_str().unwrap();
idx_name.contains("__dbx_bak_") || idx_name.ends_with("_pkey")
}),
"backup indexes should be renamed with __dbx_bak_ suffix, got: {:?}",
backup_indexes.rows
);
let transferred = transfer_table(
&state,
&request,
"orders",
0,
&DatabaseType::Postgres,
&DatabaseType::Postgres,
&pool_key,
&pool_key,
&std::collections::HashMap::new(),
&mut Vec::new(),
Some(&backup_names),
|_| {},
)
.await
.unwrap();
assert_eq!(transferred, 1, "should transfer 1 row");
drop_backup_tables(&state, &request, DatabaseType::Postgres, &pool_key, &backup_names, &request.tables)
.await
.unwrap();
// Verify structure rebuild: extra_col present (source had it, target didn't)
assert_eq!(
schema_count(
&pool,
&format!(
"columns WHERE table_schema = '{target_schema}' AND table_name = 'orders' \
AND column_name = 'extra_col'"
)
)
.await,
"1",
"extra_col from source must be present after rebuild"
);
// Verify index rebuilt: idx_orders_name exists on the new table
assert_eq!(
query_text(
&pool,
&format!(
"SELECT COUNT(*)::text FROM pg_indexes WHERE schemaname = '{target_schema}' \
AND tablename = 'orders' AND indexname = 'idx_orders_name'"
)
)
.await,
"1",
"idx_orders_name must be rebuilt on the new table"
);
// Verify index comment transferred to the rebuilt table's index
let rebuilt_comment = postgres::execute_query(
&pool,
&format!(
"SELECT obj_description(c.oid, 'pg_class') FROM pg_catalog.pg_class c \
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
WHERE n.nspname = '{target_schema}' AND c.relname = 'idx_orders_name'"
),
)
.await
.unwrap();
assert_eq!(rebuilt_comment.rows[0][0], json!("source index comment"), "index comment must come from source");
// Verify data: stale row gone, source row present
assert_eq!(
query_text(&pool, &format!("SELECT COUNT(*)::text FROM {target_schema}.orders WHERE id = 99")).await,
"0",
"stale target row must be gone"
);
assert_eq!(
query_scalar(&pool, &format!("SELECT name FROM {target_schema}.orders WHERE id = 1")).await,
json!("alpha")
);
assert_eq!(
query_scalar(&pool, &format!("SELECT extra_col FROM {target_schema}.orders WHERE id = 1")).await,
json!("x")
);
// Verify backup dropped
assert_eq!(
schema_count(&pool, &format!("tables WHERE table_schema = '{target_schema}' AND table_name = '{backup_name}'"))
.await,
"0",
"backup must be dropped after successful transfer"
);
let _ = postgres::execute_batch(
&pool,
&[format!("DROP SCHEMA {source_schema} CASCADE"), format!("DROP SCHEMA {target_schema} CASCADE")],
)
.await;
let _ = std::fs::remove_dir_all(dir);
}
struct PostgresRebuildFixture {
state: Arc<AppState>,
source_pool: deadpool_postgres::Pool,
target_pool: deadpool_postgres::Pool,
source_pool_key: String,
target_pool_key: String,
request: TransferRequest,
_storage_dir: tempfile::TempDir,
}
impl PostgresRebuildFixture {
async fn new(label: &str, tables: &[&str]) -> Self {
let source_url = std::env::var("DBX_LIVE_PG_TRANSFER_SOURCE_URL").expect("DBX_LIVE_PG_TRANSFER_SOURCE_URL");
let target_url = std::env::var("DBX_LIVE_PG_TRANSFER_TARGET_URL").unwrap_or_else(|_| source_url.clone());
let source_pool = postgres::connect(&source_url, std::time::Duration::from_secs(5)).await.unwrap();
let target_pool = postgres::connect(&target_url, std::time::Duration::from_secs(5)).await.unwrap();
let source_database = query_text(&source_pool, "SELECT current_database()").await;
let target_database = query_text(&target_pool, "SELECT current_database()").await;
let suffix = uuid::Uuid::new_v4().simple().to_string();
let source_schema = format!("dbx_{label}_src_{}", &suffix[..8]);
let target_schema = format!("dbx_{label}_dst_{}", &suffix[..8]);
let source_connection_id = format!("{label}-source-{suffix}");
let target_connection_id = format!("{label}-target-{suffix}");
let source_pool_key = format!("{source_connection_id}:{source_database}");
let target_pool_key = format!("{target_connection_id}:{target_database}");
let storage_dir = tempfile::tempdir().unwrap();
let state = Arc::new(AppState::new(Storage::open(&storage_dir.path().join("storage.db")).await.unwrap()));
state
.update_connection_pools(|connections| {
connections.insert(source_pool_key.clone(), PoolKind::Postgres(source_pool.clone()));
connections.insert(target_pool_key.clone(), PoolKind::Postgres(target_pool.clone()));
})
.await;
{
let mut configs = state.configs.write().await;
configs.insert(source_connection_id.clone(), postgres_test_config(&source_connection_id, &source_database));
configs.insert(target_connection_id.clone(), postgres_test_config(&target_connection_id, &target_database));
}
postgres::execute_query(&source_pool, &format!("CREATE SCHEMA {source_schema}")).await.unwrap();
postgres::execute_query(&target_pool, &format!("CREATE SCHEMA {target_schema}")).await.unwrap();
Self {
state,
source_pool,
target_pool,
source_pool_key,
target_pool_key,
request: TransferRequest {
transfer_id: format!("{label}-{suffix}"),
source_connection_id,
source_database,
source_schema,
source_catalog: None,
target_connection_id,
target_database,
target_schema,
target_catalog: None,
tables: tables.iter().map(|table| (*table).to_string()).collect(),
create_table: true,
drop_target_before_create: true,
drop_target_confirmed: true,
content: TransferContent::default(),
objects: Vec::new(),
mode: TransferMode::Append,
target_table_name_case: TransferTableNameCase::Preserve,
quote_target_column_names: true,
ownership_policy: TransferOwnershipPolicy::Preserve,
batch_size: 100,
},
_storage_dir: storage_dir,
}
}
async fn rename(&self) -> Result<std::collections::HashMap<String, String>, String> {
rename_tables_to_backup(
&self.state,
&self.request,
&self.request.tables,
DatabaseType::Postgres,
&self.target_pool_key,
|_| {},
)
.await
}
async fn transfer(&self, backups: &std::collections::HashMap<String, String>) -> Result<u64, String> {
transfer_table(
&self.state,
&self.request,
&self.request.tables[0],
0,
&DatabaseType::Postgres,
&DatabaseType::Postgres,
&self.source_pool_key,
&self.target_pool_key,
&std::collections::HashMap::new(),
&mut Vec::new(),
Some(backups),
|_| {},
)
.await
}
async fn drop_backups(&self, backups: &std::collections::HashMap<String, String>) -> Result<(), String> {
drop_backup_tables(
&self.state,
&self.request,
DatabaseType::Postgres,
&self.target_pool_key,
backups,
&self.request.tables,
)
.await
}
async fn cleanup(&self) {
postgres::execute_query(&self.source_pool, &format!("DROP SCHEMA {} CASCADE", self.request.source_schema))
.await
.unwrap();
postgres::execute_query(&self.target_pool, &format!("DROP SCHEMA {} CASCADE", self.request.target_schema))
.await
.unwrap();
}
}
#[tokio::test]
#[ignore = "requires disposable PostgreSQL URLs via DBX_LIVE_PG_TRANSFER_SOURCE_URL and DBX_LIVE_PG_TRANSFER_TARGET_URL"]
async fn live_postgres_transfer_rebuilds_same_named_serial_sequence() {
let fixture = PostgresRebuildFixture::new("serial_rebuild", &["orders"]).await;
let source_schema = &fixture.request.source_schema;
let target_schema = &fixture.request.target_schema;
postgres::execute_batch(
&fixture.source_pool,
&[
format!("CREATE TABLE {source_schema}.orders (id SERIAL PRIMARY KEY, name TEXT NOT NULL)"),
format!("INSERT INTO {source_schema}.orders (id, name) VALUES (42, 'source')"),
],
)
.await
.unwrap();
postgres::execute_batch(
&fixture.target_pool,
&[
format!("CREATE TABLE {target_schema}.orders (id SERIAL PRIMARY KEY, name TEXT NOT NULL)"),
format!("INSERT INTO {target_schema}.orders (id, name) VALUES (99, 'old target')"),
],
)
.await
.unwrap();
let result = async {
let backups = fixture.rename().await?;
let backup = backups.get("orders").expect("original target must have a backup");
assert_eq!(fixture.transfer(&backups).await?, 1);
assert_eq!(
query_text(&fixture.target_pool, &format!("SELECT name FROM {target_schema}.{backup} WHERE id = 99")).await,
"old target",
"original data remains recoverable until successful cleanup"
);
let new_sequence =
query_text(&fixture.target_pool, &format!("SELECT pg_get_serial_sequence('{target_schema}.orders', 'id')"))
.await;
let old_sequence = query_text(
&fixture.target_pool,
&format!("SELECT pg_get_serial_sequence('{target_schema}.{backup}', 'id')"),
)
.await;
assert!(!new_sequence.is_empty() && !old_sequence.is_empty());
assert_ne!(new_sequence, old_sequence, "new table and backup must own independent sequences");
fixture.drop_backups(&backups).await?;
assert_eq!(
query_scalar(
&fixture.target_pool,
&format!("INSERT INTO {target_schema}.orders (name) VALUES ('after rebuild') RETURNING id")
)
.await,
json!(43),
"dropping the backup must preserve the rebuilt sequence, synchronized to transferred data"
);
assert_eq!(
query_text(&fixture.target_pool, &format!("SELECT name FROM {target_schema}.orders WHERE id=42")).await,
"source"
);
assert_eq!(
query_scalar(&fixture.target_pool, &format!("SELECT to_regclass('{old_sequence}')::text")).await,
serde_json::Value::Null,
"backup cleanup must also remove the old owned sequence"
);
Ok::<_, String>(())
}
.await;
fixture.cleanup().await;
result.unwrap();
}
#[tokio::test]
#[ignore = "requires disposable PostgreSQL URLs via DBX_LIVE_PG_TRANSFER_SOURCE_URL and DBX_LIVE_PG_TRANSFER_TARGET_URL"]
async fn live_postgres_transfer_rebuilds_indexes_with_shared_long_prefix() {
let fixture = PostgresRebuildFixture::new("long_indexes", &["orders"]).await;
let source_schema = &fixture.request.source_schema;
let target_schema = &fixture.request.target_schema;
// Both identifiers fit PostgreSQL's 63-byte limit but share their first 46 bytes.
let index_a = format!("{}first", "i".repeat(46));
let index_b = format!("{}second", "i".repeat(46));
for (pool, schema, row) in [
(&fixture.source_pool, source_schema, "(1, 'source', 7)"),
(&fixture.target_pool, target_schema, "(99, 'old target', 9)"),
] {
postgres::execute_batch(
pool,
&[
format!("CREATE TABLE {schema}.orders (id INT PRIMARY KEY, name TEXT NOT NULL, amount INT)"),
format!("CREATE INDEX {index_a} ON {schema}.orders (name)"),
format!("CREATE INDEX {index_b} ON {schema}.orders (amount)"),
format!("INSERT INTO {schema}.orders VALUES {row}"),
],
)
.await
.unwrap();
}
let result = async {
let backups = fixture.rename().await?;
let backup = backups.get("orders").expect("original target must have a backup");
assert_eq!(fixture.transfer(&backups).await?, 1);
fixture.drop_backups(&backups).await?;
let indexes = postgres::execute_query(
&fixture.target_pool,
&format!(
"SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = '{target_schema}' \
AND tablename = 'orders' AND indexname IN ('{index_a}', '{index_b}') ORDER BY indexname"
),
)
.await?;
assert_eq!(indexes.rows.len(), 2, "both secondary indexes must survive rebuild; got {:?}", indexes.rows);
assert_eq!(indexes.rows[0][0], json!(index_a));
assert!(indexes.rows[0][1].as_str().unwrap().contains("(name)"));
assert_eq!(indexes.rows[1][0], json!(index_b));
assert!(indexes.rows[1][1].as_str().unwrap().contains("(amount)"));
assert_eq!(
query_text(&fixture.target_pool, &format!("SELECT name FROM {target_schema}.orders WHERE id=1")).await,
"source"
);
assert_eq!(
query_scalar(&fixture.target_pool, &format!("SELECT to_regclass('{target_schema}.{backup}')::text")).await,
serde_json::Value::Null,
"successful rebuild must remove the backup"
);
Ok::<_, String>(())
}
.await;
fixture.cleanup().await;
result.unwrap();
}
#[tokio::test]
#[ignore = "requires disposable PostgreSQL URLs via DBX_LIVE_PG_TRANSFER_SOURCE_URL and DBX_LIVE_PG_TRANSFER_TARGET_URL"]
async fn live_postgres_transfer_rebuild_rejects_target_only_view_before_any_rename() {
let fixture = PostgresRebuildFixture::new("external_view", &["first_table", "orders"]).await;
let source_schema = &fixture.request.source_schema;
let target_schema = &fixture.request.target_schema;
for (pool, schema, row) in [
(&fixture.source_pool, source_schema, "(1, 'source')"),
(&fixture.target_pool, target_schema, "(99, 'old target')"),
] {
postgres::execute_batch(
pool,
&[
format!("CREATE TABLE {schema}.first_table (id INT PRIMARY KEY, name TEXT NOT NULL)"),
format!("CREATE TABLE {schema}.orders (id INT PRIMARY KEY, name TEXT NOT NULL)"),
format!("INSERT INTO {schema}.first_table VALUES {row}"),
format!("INSERT INTO {schema}.orders VALUES {row}"),
],
)
.await
.unwrap();
}
postgres::execute_query(
&fixture.target_pool,
&format!("CREATE VIEW {target_schema}.target_only_orders AS SELECT id, name FROM {target_schema}.orders"),
)
.await
.unwrap();
let catalog_snapshot_sql = format!(
"SELECT c.relname, c.oid::text, c.relkind::text FROM pg_class c \
JOIN pg_namespace n ON n.oid = c.relnamespace \
WHERE n.nspname = '{target_schema}' ORDER BY c.relname"
);
let before = postgres::execute_query(&fixture.target_pool, &catalog_snapshot_sql).await.unwrap().rows;
let result = fixture.rename().await;
let after = postgres::execute_query(&fixture.target_pool, &catalog_snapshot_sql).await.unwrap().rows;
// Collect observations before cleanup so even a missing preflight leaves no schemas behind.
let rows = postgres::execute_query(
&fixture.target_pool,
&format!(
"SELECT 'first_table', id, name FROM {target_schema}.first_table \
UNION ALL SELECT 'orders', id, name FROM {target_schema}.orders \
UNION ALL SELECT 'target_only_orders', id, name FROM {target_schema}.target_only_orders ORDER BY 1"
),
)
.await;
fixture.cleanup().await;
let error = result.expect_err("target-only dependent view must reject the whole plan before any table rename");
assert!(error.contains("target_only_orders"), "preflight must identify the dependent view: {error}");
assert_eq!(after, before, "all original table, index and view identities must remain unchanged");
assert_eq!(
rows.unwrap().rows,
vec![
vec![json!("first_table"), json!(99), json!("old target")],
vec![json!("orders"), json!(99), json!("old target")],
vec![json!("target_only_orders"), json!(99), json!("old target")],
],
"preflight rejection must preserve both selected tables and the target-only view's result"
);
}
@@ -1379,6 +1379,8 @@ async fn live_sqlserver_transfer_table_skips_rowversion_insert_column() {
target_catalog: None,
tables: vec![source_table.clone()],
create_table: true,
drop_target_before_create: false,
drop_target_confirmed: false,
content: dbx_core::transfer::TransferContent::default(),
objects: Vec::new(),
mode: dbx_core::transfer::TransferMode::Append,
@@ -1398,6 +1400,7 @@ async fn live_sqlserver_transfer_table_skips_rowversion_insert_column() {
&target_pool_key,
&std::collections::HashMap::new(),
&mut Vec::new(),
None,
|_| {},
)
.await;
@@ -0,0 +1,284 @@
use dbx_core::connection::AppState;
use dbx_core::models::connection::{ConnectionConfig, DatabaseType};
use dbx_core::storage::Storage;
use dbx_core::transfer::{
drop_backup_tables, rename_tables_to_backup, transfer_table, TransferContent, TransferMode,
TransferOwnershipPolicy, TransferRequest, TransferTableNameCase,
};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
fn live_sqlserver_config(id: &str, database: &str) -> ConnectionConfig {
ConnectionConfig {
docs_notes_path: None,
id: id.to_string(),
name: id.to_string(),
note: String::new(),
db_type: DatabaseType::SqlServer,
driver_profile: None,
driver_label: None,
url_params: None,
agent_java_options: Vec::new(),
host: std::env::var("DBX_LIVE_SQLSERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()),
port: std::env::var("DBX_LIVE_SQLSERVER_PORT").ok().and_then(|value| value.parse().ok()).unwrap_or(1433),
username: std::env::var("DBX_LIVE_SQLSERVER_USER").unwrap_or_else(|_| "sa".to_string()),
password: std::env::var("DBX_LIVE_SQLSERVER_PASSWORD").expect("DBX_LIVE_SQLSERVER_PASSWORD"),
database: Some(database.to_string()),
default_schema: None,
visible_databases: None,
visible_database_patterns: None,
visible_schemas: None,
attached_databases: Vec::new(),
init_script: None,
color: None,
transport_layers: Vec::new(),
connect_timeout_secs: 15,
query_timeout_secs: 30,
idle_timeout_secs: 60,
keepalive_interval_secs: 0,
ssl: false,
ca_cert_path: String::new(),
client_cert_path: String::new(),
client_key_path: String::new(),
sysdba: false,
oracle_connection_type: None,
connection_string: None,
redis_connection_mode: None,
redis_sentinel_master: String::new(),
redis_sentinel_nodes: String::new(),
redis_sentinel_username: String::new(),
redis_sentinel_password: String::new(),
redis_sentinel_tls: false,
redis_cluster_nodes: String::new(),
redis_key_separator: dbx_core::models::connection::default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
redis_key_templates: Vec::new(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
save_password: true,
read_only: false,
is_production: false,
production_databases: vec![],
show_system_schemas: false,
database_info: None,
}
}
async fn sqlserver_env() -> (String, u16, String, String) {
let host = std::env::var("DBX_LIVE_SQLSERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port = std::env::var("DBX_LIVE_SQLSERVER_PORT").ok().and_then(|value| value.parse().ok()).unwrap_or(1433);
let user = std::env::var("DBX_LIVE_SQLSERVER_USER").unwrap_or_else(|_| "sa".to_string());
let password = std::env::var("DBX_LIVE_SQLSERVER_PASSWORD").expect("DBX_LIVE_SQLSERVER_PASSWORD");
(host, port, user, password)
}
async fn sqlserver_connect(database: &str) -> dbx_core::db::sqlserver::SqlServerClient {
let (host, port, user, password) = sqlserver_env().await;
dbx_core::db::sqlserver::connect(&host, port, &user, &password, Some(database), None, Duration::from_secs(20))
.await
.expect("connect SQL Server")
}
/// Whether a schema-scoped object with `name` still exists on `table`.
async fn sqlserver_object_on_table_exists(
client: &mut dbx_core::db::sqlserver::SqlServerClient,
table: &str,
name: &str,
) -> bool {
let sql = format!(
"SELECT CASE WHEN EXISTS ( \
SELECT 1 FROM sys.objects o WHERE o.name = N'{name}' AND o.parent_object_id = OBJECT_ID(N'dbo.{table}') \
UNION ALL \
SELECT 1 FROM sys.indexes i WHERE i.name = N'{name}' AND i.object_id = OBJECT_ID(N'dbo.{table}') \
) THEN 1 ELSE 0 END",
name = name.replace('\'', "''"),
table = table.replace('\'', "''"),
);
let result = dbx_core::db::sqlserver::execute_query(client, &sql).await.expect("query object existence");
result.rows.first().and_then(|row| row.first()).and_then(|v| v.as_i64()).map(|n| n == 1).unwrap_or(false)
}
#[tokio::test]
#[ignore = "requires DBX_LIVE_SQLSERVER_HOST/PORT/USER/PASSWORD pointing at SQL Server"]
async fn live_sqlserver_transfer_rebuild_releases_constraint_and_index_names() {
let suffix = uuid::Uuid::new_v4().simple().to_string();
let source_db = format!("dbx_rebuild_src_{}", &suffix[..12]);
let target_db = format!("dbx_rebuild_dst_{}", &suffix[..12]);
let connection_id = format!("live-sqlserver-rebuild-{suffix}");
// Create both databases and populate source + target with same-named constraints.
let mut master = sqlserver_connect("master").await;
dbx_core::db::sqlserver::execute_batch(
&mut master,
&format!("CREATE DATABASE [{source_db}]; CREATE DATABASE [{target_db}];"),
)
.await
.expect("create rebuild databases");
let mut source_client = sqlserver_connect(&source_db).await;
dbx_core::db::sqlserver::execute_batch(
&mut source_client,
"CREATE TABLE dbo.departments (id INT NOT NULL CONSTRAINT PK_departments PRIMARY KEY, name NVARCHAR(32)); \
CREATE TABLE dbo.employees (id INT NOT NULL CONSTRAINT PK_employees PRIMARY KEY, dept_id INT NULL, \
CONSTRAINT FK_emp_dept FOREIGN KEY (dept_id) REFERENCES dbo.departments(id)); \
CREATE INDEX IX_emp_dept ON dbo.employees(dept_id); \
INSERT INTO dbo.departments VALUES (10, N'Engineering'), (20, N'Sales'); \
INSERT INTO dbo.employees VALUES (1, 10), (2, 20);",
)
.await
.expect("create source tables");
let mut target_client = sqlserver_connect(&target_db).await;
dbx_core::db::sqlserver::execute_batch(
&mut target_client,
"CREATE TABLE dbo.departments (id INT NOT NULL CONSTRAINT PK_departments PRIMARY KEY, name NVARCHAR(32)); \
CREATE TABLE dbo.employees (id INT NOT NULL CONSTRAINT PK_employees PRIMARY KEY, dept_id INT NULL, \
CONSTRAINT FK_emp_dept FOREIGN KEY (dept_id) REFERENCES dbo.departments(id)); \
CREATE INDEX IX_emp_dept ON dbo.employees(dept_id); \
INSERT INTO dbo.departments VALUES (99, N'Stale'); \
INSERT INTO dbo.employees VALUES (98, 99);",
)
.await
.expect("create target tables");
let dir = std::env::temp_dir().join(format!("dbx-live-sqlserver-rebuild-{suffix}"));
std::fs::create_dir_all(&dir).expect("create rebuild directory");
let storage = Storage::open(&dir.join("storage.db")).await.expect("open rebuild storage");
let state = Arc::new(AppState::new(storage));
let config = live_sqlserver_config(&connection_id, &source_db);
state.configs.write().await.insert(connection_id.clone(), config);
let source_pool_key = state.get_or_create_pool(&connection_id, Some(&source_db)).await.expect("source pool");
let target_pool_key = state.get_or_create_pool(&connection_id, Some(&target_db)).await.expect("target pool");
let request = TransferRequest {
transfer_id: format!("live-sqlserver-rebuild-{suffix}"),
source_connection_id: connection_id.clone(),
source_database: source_db.clone(),
source_schema: "dbo".to_string(),
source_catalog: None,
target_connection_id: connection_id.clone(),
target_database: target_db.clone(),
target_schema: "dbo".to_string(),
target_catalog: None,
tables: vec!["departments".to_string(), "employees".to_string()],
create_table: true,
drop_target_before_create: true,
drop_target_confirmed: true,
content: TransferContent::default(),
objects: Vec::new(),
mode: TransferMode::Append,
target_table_name_case: TransferTableNameCase::Preserve,
quote_target_column_names: true,
ownership_policy: TransferOwnershipPolicy::Preserve,
batch_size: 10,
};
let test_result = async {
let backup_names = rename_tables_to_backup(
&state,
&request,
&request.tables,
DatabaseType::SqlServer,
&target_pool_key,
|_| {},
)
.await?;
assert_eq!(backup_names.len(), 2, "both preexisting targets must be backed up");
// The pre-pass must have released every schema-unique constraint and index name
// so the rebuilt tables can reuse the source DDL's names without colliding.
for (table, name) in [
("departments", "PK_departments"),
("employees", "PK_employees"),
("employees", "FK_emp_dept"),
("employees", "IX_emp_dept"),
] {
assert!(
!sqlserver_object_on_table_exists(&mut target_client, table, name).await,
"{name} on {table} must have been released before rebuild"
);
}
let mut pending_fk_alters: Vec<(String, String)> = Vec::new();
for (i, table) in request.tables.iter().enumerate() {
transfer_table(
&state,
&request,
table,
i,
&DatabaseType::SqlServer,
&DatabaseType::SqlServer,
&source_pool_key,
&target_pool_key,
&HashMap::new(),
&mut pending_fk_alters,
Some(&backup_names),
|_| {},
)
.await?;
}
// The rebuilt tables must have reclaimed the FK and index names — the names that
// would otherwise collide with the backups. Primary keys are regenerated inline by
// the source DDL (SQL Server auto-names them), so only their presence matters.
for (table, name) in [("employees", "FK_emp_dept"), ("employees", "IX_emp_dept")] {
assert!(
sqlserver_object_on_table_exists(&mut target_client, table, name).await,
"{name} on {table} must be rebuilt"
);
}
for table in ["departments", "employees"] {
let pk = dbx_core::db::sqlserver::execute_query(
&mut target_client,
&format!(
"SELECT COUNT(*) FROM sys.key_constraints WHERE parent_object_id = OBJECT_ID('dbo.{table}') AND type = 'PK'"
),
)
.await
.expect("count primary key");
assert_eq!(
pk.rows.first().and_then(|r| r.first()).and_then(|v| v.as_i64()),
Some(1),
"{table} must have a primary key"
);
}
// Stale target rows replaced by source data.
let count = dbx_core::db::sqlserver::execute_query(
&mut target_client,
"SELECT CAST(COUNT(*) AS INT) FROM dbo.departments WHERE id IN (10,20)",
)
.await
.expect("count rebuilt departments");
assert_eq!(count.rows.first().and_then(|r| r.first()).and_then(|v| v.as_i64()), Some(2));
// The rebuilt foreign key must still reject an orphan reference.
let orphan = dbx_core::db::sqlserver::execute_query(&mut target_client, "INSERT INTO dbo.employees VALUES (7, 999)").await;
assert!(orphan.is_err(), "rebuilt FK must reject a missing referenced department");
drop_backup_tables(&state, &request, DatabaseType::SqlServer, &target_pool_key, &backup_names, &request.tables).await?;
Ok::<_, String>(())
}
.await;
// SQL Server refuses to drop a database with active connections (code 3702); the
// state pools and setup clients still hold some. Force them all off first.
let cleanup = dbx_core::db::sqlserver::execute_batch(
&mut master,
&format!(
"ALTER DATABASE [{source_db}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{source_db}]; \
ALTER DATABASE [{target_db}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{target_db}];"
),
)
.await;
let _ = std::fs::remove_dir_all(dir);
cleanup.expect("drop rebuild databases");
test_result.unwrap();
}
+126 -1
View File
@@ -88,6 +88,23 @@ pub async fn start_transfer(
)));
}
// `drop_target_before_create` rebuilds target tables. Gate it before responding so the
// caller sees the error code rather than a progress stream that fails later.
if req.drop_target_before_create {
let target_db_type =
transfer::get_db_type(&state.app, &req.target_connection_id).await.map_err(AppError::from)?;
dbx_core::transfer_rebuild::ensure_drop_target_allowed(
&state.app,
&req.target_connection_id,
&req.target_database,
target_db_type,
req.drop_target_before_create,
req.drop_target_confirmed,
)
.await
.map_err(AppError::from)?;
}
let transfer_id = req.transfer_id.clone();
// Keep bounded replay state so a web EventSource opened after this POST
@@ -202,6 +219,82 @@ pub async fn start_transfer(
let mut failed_tables: Vec<String> = Vec::new();
let mut pending_fk_alters: Vec<(String, String)> = Vec::new();
// When drop_target_before_create is true, perform a rename pre-pass in
// parents_first=false order (children first) so that foreign keys on the
// backup tables remain intact. The main create/insert pass then runs in
// parents_first=true order (parents first).
// Children-first order used by the rename pre-pass, kept for the post-loop backup
// cleanup: a backup can still be referenced by another backup, so the drops must
// follow the same order.
let mut backup_drop_order: Vec<String> = Vec::new();
let backup_names = if req.drop_target_before_create {
// Re-sort tables in children-first order for the rename pre-pass
let (tables_for_rename, _) = transfer::sort_tables_by_fk_dependency_with_foreign_keys(
&app,
&req.target_connection_id,
&req.target_database,
&req.target_schema,
&tables,
false, // parents_first=false: children first
)
.await
.unwrap_or_else(|e| {
log::warn!("[transfer] failed to sort tables for rename pre-pass, using original order: {e}");
(tables.clone(), std::collections::HashMap::new())
});
backup_drop_order = tables_for_rename.clone();
let progress_channel_clone = progress_channel.clone();
match transfer::rename_tables_to_backup(
&app,
&req,
&tables_for_rename,
target_db_type,
&target_pool_key,
|progress| {
send_transfer_progress(&progress_channel_clone, &progress);
},
)
.await
{
Ok(names) => Some(names),
Err(e) if e == "Cancelled" => {
let progress = transfer::TransferProgress {
transfer_id: req.transfer_id.clone(),
table: "rename pre-pass".to_string(),
table_index: 0,
total_tables: tables.len(),
rows_transferred: 0,
total_rows: None,
status: TransferStatus::Cancelled,
error: None,
terminal: true,
};
send_transfer_progress(&progress_channel, &progress);
finish_transfer_channel(&state_clone, &req.transfer_id, &progress_channel).await;
return;
}
Err(e) => {
let progress = transfer::TransferProgress {
transfer_id: req.transfer_id.clone(),
table: "rename pre-pass".to_string(),
table_index: 0,
total_tables: tables.len(),
rows_transferred: 0,
total_rows: None,
status: TransferStatus::Error,
error: Some(e),
terminal: true,
};
send_transfer_progress(&progress_channel, &progress);
finish_transfer_channel(&state_clone, &req.transfer_id, &progress_channel).await;
return;
}
}
} else {
None
};
if matches!(source_db_type, dbx_core::models::connection::DatabaseType::Postgres)
&& matches!(target_db_type, dbx_core::models::connection::DatabaseType::Postgres)
{
@@ -285,6 +378,7 @@ pub async fn start_transfer(
&target_pool_key,
&known_foreign_keys,
&mut pending_fk_alters,
backup_names.as_ref(),
|progress| {
last_rows_transferred = progress.rows_transferred;
last_total_rows = progress.total_rows;
@@ -345,7 +439,9 @@ pub async fn start_transfer(
// Add any foreign keys deferred during MySQL-family table creation now that
// every selected table exists — see transfer_table's use of
// strip_inline_foreign_key_constraint_lines for why these can't be created
// inline (a foreign key cycle has no valid CREATE TABLE order at all).
// inline (a foreign key cycle has no valid CREATE TABLE order at all). The
// rename pre-pass already freed the backup constraint names, so these re-create
// the original names without colliding.
let mut failed_fk_tables: Vec<String> = Vec::new();
let mut failed_fk_count = 0usize;
for (table, alter_sql) in &pending_fk_alters {
@@ -413,6 +509,33 @@ pub async fn start_transfer(
if !object_outcome.failed.is_empty() {
failed_tables.push(format!("schema objects ({})", object_outcome.failed.len()));
}
// The rename pre-pass left one backup per rebuilt table. Drop them only now that
// every table, every deferred foreign key and every selected schema object has
// succeeded — a failure anywhere above keeps the originals recoverable under
// their backup names.
if let Some(backup_names) = backup_names.as_ref() {
if failed_tables.is_empty() {
if let Err(e) = transfer::drop_backup_tables(
&app,
&req,
target_db_type,
&target_pool_key,
backup_names,
&backup_drop_order,
)
.await
{
failed_tables.push(e);
}
} else {
log::warn!(
"[transfer] keeping {} backup table(s) because {} step(s) failed",
backup_names.len(),
failed_tables.len()
);
}
}
let skip_suffix = if !object_outcome.skipped.is_empty() && failed_tables.is_empty() {
format!(",跳过 {} 个已存在对象", object_outcome.skipped.len())
} else if !object_outcome.skipped.is_empty() {
@@ -634,6 +757,8 @@ mod tests {
target_catalog: None,
tables: Vec::new(),
create_table: false,
drop_target_before_create: false,
drop_target_confirmed: false,
content: TransferContent::DataOnly,
objects: Vec::new(),
mode: TransferMode::Append,
+3
View File
@@ -61,6 +61,7 @@ description: 在不同连接、数据库、Schema 或数据库引擎之间批量
| 选项 | 说明 |
| --- | --- |
| 创建目标表 | 目标表不存在时,根据源元数据和目标数据库能力生成表结构 |
| 重建结构和数据 | 将现有目标表重命名为备份表(`__dbx_bak_` 前缀),从源结构重建,插入数据后删除备份。任一步骤失败则保留备份表以供手工恢复。生产数据库需二次确认。支持 MySQL、PostgreSQL、SQL Server、KingBase、GaussDB、OpenGauss、KWDB、GoldenDB、SQLite、DuckDB 和 Cloudflare D1。 |
| 追加 | 保留目标表已有数据并插入源数据 |
| 覆盖 | 在传输该表前清空目标表,再写入源数据 |
| Upsert | 目标路径支持且能确定键时,按键更新或插入 |
@@ -69,6 +70,8 @@ description: 在不同连接、数据库、Schema 或数据库引擎之间批量
如果关闭“创建目标表”,目标表必须已经存在且字段可兼容。即使开启建表,跨引擎 DDL 也可能因目标数据库不支持某些类型、默认值、约束或对象而需要人工调整。
<Callout type="warn">**重建结构和数据**会替换现有目标表及其索引、触发器、约束、注释和对象权限为源结构。原表会先被重命名为备份表,传输成功后删除。任一步骤失败则保留备份表,可手工恢复。</Callout>
## PostgreSQL 结构和所有者
PostgreSQL 到 PostgreSQL 且启用创建表时,DBX 会在能力允许时处理序列、索引、外键以及部分 Schema 级对象和依赖。若源对象所有者在目标实例中不存在,开始前会提示选择:
+3
View File
@@ -61,6 +61,7 @@ After copying one or more tables in the object tree, paste them at a target loca
| Option | Details |
| --- | --- |
| Create target tables | Generates target structure from source metadata when the table does not exist and the path supports it |
| Rebuild structure and data | Renames existing target tables to backup (`__dbx_bak_` prefix), rebuilds from source structure, inserts data, then drops backup on success. Any failure preserves the backup table for manual recovery. Requires confirmation on production databases. Available for MySQL, PostgreSQL, SQL Server, KingBase, GaussDB, OpenGauss, KWDB, GoldenDB, SQLite, DuckDB, and Cloudflare D1. |
| Append | Keeps existing target rows and inserts source rows |
| Overwrite | Clears each target table before writing its source rows |
| Upsert | Updates or inserts by key when the target path supports it and a usable key is available |
@@ -69,6 +70,8 @@ After copying one or more tables in the object tree, paste them at a target loca
If table creation is disabled, compatible target tables must already exist. Even with table creation enabled, cross-engine DDL may require manual changes when the target does not support a source type, default, constraint, or object.
<Callout type="warn">**Rebuild structure and data** replaces existing target tables and their indexes, triggers, constraints, comments, and object permissions with source structure. The original table is renamed to a backup first, then dropped after a successful transfer. If any step fails, the backup table is retained and can be manually restored.</Callout>
## PostgreSQL Structure and Ownership
For PostgreSQL-to-PostgreSQL transfers with table creation enabled, DBX handles supported sequences, indexes, foreign keys, and selected schema-level objects and dependencies. If source owners do not exist on the target instance, DBX prompts you to:
@@ -84,6 +84,7 @@ MCP 和 CLI 没有确认界面,仍只读取已保存的只读标记,因此
| 数据表格 | 在确认同一解锁窗口前禁用或拒绝保存、删除等修改 | 保存行、删除行等操作进入统一生产确认 |
| 表结构与对象操作 | 在确认解锁窗口前隐藏或禁用可识别的修改入口 | DDL、对象源码保存、数据库管理等进入统一确认 |
| Schema 对比、SQL 文件、数据生成 | 目标只读时不允许写入 | 执行生成 SQL 或脚本前进入统一确认 |
| 数据传输 | 不能使用只读连接作为目标;生产数据库会阻止**先删除目标表再创建**操作 | 危险的表替换操作需要明确确认并显示 DROP SQL 预览 |
| Redis、MongoDB、etcd、消息队列等 | 按系统命令和操作重新检查只读 | 写入入口继续受生产保护和专项危险操作规则约束 |
| AI Ask | 不执行 SQL | 不执行 SQL |
| AI Agent | 不能绕过只读 | 生产 SQL 返回编辑器,由用户检查并手动执行 |
+1
View File
@@ -84,6 +84,7 @@ Every production write requires a new explicit decision. Confirmation is not per
| Data grid | Disables or rejects saves, deletes, and other mutations until the same unlock window is confirmed | Row saves and deletes enter the shared production confirmation flow |
| Table structure and object actions | Hides or disables recognizable mutation entry points until the unlock window is confirmed | DDL, source saves, and database administration enter the shared confirmation flow |
| Schema diff, SQL files, and data generation | Cannot write to a read-only target | Generated SQL or scripts require production confirmation before execution |
| Data Transfer | Cannot target a read-only connection; **Drop Target Before Create** is blocked on production databases | Dangerous table-replacement operations require explicit confirmation and show DROP SQL preview |
| Redis, MongoDB, etcd, message queues, and similar systems | Rechecks read-only state for system-specific commands and actions | Mutation entry points remain subject to production and specialized danger rules |
| AI Ask | Does not execute SQL | Does not execute SQL |
| AI Agent | Cannot bypass read-only state | Returns production SQL to the editor for human review and manual execution |
@@ -0,0 +1,400 @@
# PIP: 数据传输「创建前删除目标表」
## 状态
Proposed(设计已确认,待实现)
## 摘要
为数据传输新增「创建前删除目标表」选项:勾选后,传输前先把目标表改名为备份表,按源结构重建,写入数据,全部成功后删除备份;任一步失败则保留备份并在错误中回报备份表全名。对齐 Navicat 的「创建前删除目标对象」。
采用 rename-then-drop 而非直接 DROP,因为传输路径不开事务且多数方言 DDL 不可回滚,备份表是唯一可行的恢复手段。
## 背景
当前目标表已存在且结构与源不一致时,传输会 fail-fast,错误原文即:
> DBX does not alter an existing target table's columns during transfer — drop the target table or adjust its structure to match the source first.
见 `crates/dbx-core/src/transfer.rs:1633-1662`(`validate_preexisting_target_columns`)。用户必须离开传输对话框、手工删表、再回来重跑。本功能把这句提示自动化。
`TransferMode`(`transfer.rs:71-78`)目前是 `Append | Overwrite | Upsert`,`Overwrite` 生成单条 `TRUNCATE TABLE`(`:7679-7690`,SQLite/CloudflareD1/DuckDb 用 `DELETE FROM`)。`create_table: bool`(`:189`)在 UI 上没有开关,由传输内容推导(`DataTransferDialog.vue:768`)。
## 已确认的设计决策
| # | 决策 | 选择 |
|---|---|---|
| 1 | 数据模型 | **独立布尔字段** `drop_target_before_create`,不新增 `TransferMode` 变体 |
| 2 | 方言范围 | **首版覆盖 14 个标准库**,其余在 UI 禁用并提示暂不支持 |
| 3 | 失败恢复 | **rename-then-drop** 备份策略 |
| 4 | 架构落点 | **方案 4′**:`DdlDialectProfile` 只加数据字段,`build_drop_table_sql` 留在 `db_admin_sql.rs` 作为唯一渲染入口 |
| 5 | 强确认 | **不要求手打库名**,仅 `DangerConfirmDialog` + 生产库闸门 |
### 决策 1 的决定性理由
写入方式 Select 在 `transferContent === 'structureOnly'` 时被 `v-if` 隐藏(`DataTransferDialog.vue:1248`),而「仅结构 + 先删除重建」恰是本功能最正当的用法。做成 Select 第四项会让主场景直接不可达。独立布尔另有三点收益:不污染 `TransferMode`(避免一个字段承担 DDL 与 DML 两个维度);危险开关有稳定版面位置可挂常驻警告;`transfer.rs:3836`/`:3838`/`:4233` 三处 match 与 `:12475`/`:12575` 两处 `for mode in [...]` 测试遍历全部无需改动。
### 决策 2 的覆盖清单
**启用(14 个)**:mysql、postgres、oracle、sqlserver、dameng、oceanbase-oracle、kingbase、gaussdb、opengauss、kwdb、goldendb、sqlite、duckdb、cloudflare-d1
**禁用并提示暂不支持(10 个,均为 `dataTransfer: true` 但 DROP 语义高危或不适用)**:
| 库 | 禁用原因 |
|---|---|
| mongodb | 无表概念。现有 Overwrite 走 `deleteMany({})`(`transfer.rs:4802-4811`)而非 drop collection;真 drop 会丢索引 / validator / shard key |
| clickhouse | `can_reuse_source_table_ddl` 对其返回 false(断言 `transfer.rs:10663`),重建必丢 ENGINE / ORDER BY / 分区 / TTL |
| hive、spark、kyuubi、impala、argo | managed(非 EXTERNAL)表 DROP 会连带删除仓库目录中的数据文件,删除范围溢出用户勾选的表清单 |
| questdb | 丢 designated timestamp / partition by |
| turso、rqlite | 见「顺带修复」——先修既有 TRUNCATE 分支遗漏再评估 |
## 可行性依据(已有资产)
drop-then-create 在 `transfer.rs` 内已是既定模式,仅未用于表对象:
| 资产 | 位置 | 说明 |
|---|---|---|
| 物化视图 drop-then-create | `transfer.rs:2463-2469` | `generate_postgres_materialized_view_ddls` 返回 `vec![DROP, CREATE]` |
| 触发器 drop-then-create | `transfer.rs:8323-8350` | 进度按两步计数(`:8192` `trigger_step_count = len * 2`) |
| RLS policy drop-then-create | `transfer.rs:6421-6432` | `sort_order` 保证顺序 |
| MySQL routine drop-then-create | `object_source_sql.rs:565-588` | `mysql_drop_routine_if_exists` |
| **子表优先拓扑排序** | `transfer.rs:6790-6859` | Kahn 算法带 `parents_first: bool`;`false` 分支注释明确写明用于 batch drop,已作为 Tauri command 暴露(`src-tauri/src/commands/transfer.rs:418-428`) |
| **表改名生成器** | `db_admin_sql.rs:872-928` | `build_rename_object_sql` + `supports_object_rename:840-869`,**14 库中 13 个已支持** |
| 跨方言 DROP 生成器 | `db_admin_sql.rs:411-430` | `build_drop_table_sql`(不生成 `IF EXISTS`;CASCADE 白名单仅 9 个 PG 系,`:432-447`) |
| SQL Server 条件删除解法 | `schema_diff.rs:3691-3702` | `IF OBJECT_ID(N'..', N'U') IS NOT NULL DROP TABLE` |
| 带 IF EXISTS 的 DROP | `database_export.rs:3379-3381` | 复用 `crate::transfer::qualified_table` |
| 同类选项全栈先例 | `database_export.rs:65` → `tauri.ts:4804` → `DatabaseExportDialog.vue:871-874` | `drop_table_if_exists`,命名与产品语义可对齐 |
| 标识符长度上限常量 | `descriptor.rs:161` | MySQL 64 / PG 63 / SQL Server 128 / **Oracle 30**;目前**零校验消费者** |
| 备份名派生 + 冲突预检范式 | `sqlite_rebuild.rs:493-511` | schema 内容 SHA256 派生(幂等);冲突即报错,不自动清理不重试换名 |
| 清理失败处理范式 | `table_import.rs:5675-5700` | staging 清理失败视为连接状态污染,直接作废池 |
| 生产库谓词 | `production_safety.rs:111` / `:197` | `DDL_OBJECT_TARGET_RE` 已覆盖 `DROP TABLE [IF EXISTS]`,解析不出目标时 fail-closed |
| 前端生产闸门 | `productionExecutionGuard.ts:15-63` | 已有 20+ 调用点,transfer 链路零接入 |
| 危险操作 UI 骨架 | `useSidebarTableMutationRuntime.ts:100-111` / `:459-497` | 先生成 SQL 预览再弹窗 |
| 强确认组件 | `DangerConfirmDialog.vue:29-60` | 含 `confirmDisabled` / `showSuppressToggle` / `details` |
## 已排除的方案(含前期判断纠正)
### 排除:PG 家族包显式事务以获得原子性
`execute_on_pool`(`transfer.rs:5097-5098`)每次调用都重新 checkout 连接 —— MySQL 走 sqlx 池、PG 系走 deadpool。`BEGIN` 与后续语句会落在不同连接上,`BEGIN` 随连接归还立即回滚,**给出虚假的原子性错觉,比没有事务更危险**。
旁证:MySQL 的 `lock_timeout_sql`(`ddl_profile.rs:351` `SET SESSION lock_wait_timeout = 3;`)在这条路径上本来就是失效的。
Oracle / Dameng / Kingbase(`PoolKind::Agent`,单 client + Mutex + 固定 `agent_session_id`)与 SQL Server(`Arc<Mutex<SqlServerClient>>`)侥幸同会话,但这是实现巧合非契约 —— `PoolErrorAction::ReconnectAndRetry`(`transfer.rs:5060-5081`)会在错误时重建池并换 `pool_key`。
### 纠正:rename-then-drop 不需要同会话
前期误判「多步操作无法保证同会话」会阻塞决策 3。前提事实成立,但推论错误:rename-then-drop 的价值是**可恢复性**而非原子性,每步独立提交恰恰是它能工作的原因。逐失败点见「关键设计 · 失败点状态矩阵」。**无需新增「持连接执行多语句」入口。**
### 排除:激活 `plugins/dialects/*.yaml` 的 drop_table 模板(原决策 4 方案 B)
yaml 里确实已有 `ddl_capabilities.templates.drop_table`(`mysql.yaml:116`、`oracle.yaml:109`)、`online_safety.drop_table`、`destruction_level.drop_table: "FATAL"`,且全部零消费。但缺的不是接线,是整条通路,有三个结构性障碍加一个既有 bug:
1. **`to_descriptor` 根本不读模板**(`dialect_yaml.rs:637-694` 只映射 33 个 bool + `max_length` + `structural_capabilities`)。`templates` / `rollback_templates` / `online_safety` / `destruction_level` 只走「解析 → 存进 `LoadedDialect.yaml` → 回写」的往返。
2. **类型不兼容**:profile 字段是 `Option<&'static str>` 且 struct 是 `Copy`(`ddl_profile.rs:99`),yaml 给 `String`。放弃 `Copy` 会波及全部 `profile_for` 调用点(`script_generator.rs` 四处、`schema_diff.rs` 十处、`type_rewrite.rs:315`、`db_admin_sql.rs:1101`)。
3. **键空间不匹配**:profile 按 `DatabaseType`(80+ 变体),registry 按 `DialectKind`(11 个)。`dameng.yaml` / `kingbase.yaml` / `goldendb.yaml` / `oceanbase.yaml` 的模板永远取不到。
4. **既有 bug**:注册键 = `lowercase(yaml.dialect.name)`(`build.rs:63` + `dialect_loader.rs:117`),查找键 = `kind.label()`(`sql_dialect.rs:73`)。`"PostgreSQL"→"postgresql"` vs `"postgres"`、`"SQL Server"→"sql server"` vs `"sqlserver"` 两处 miss,**postgres 与 sqlserver 一直走硬编码回落**。目前不可见(yaml 与硬编码对 `transactional_ddl` 恰好同值),但意味着任何新增 yaml 字段对这两库都不生效。
改为方案 4′(见阶段 1)。原方案值得单独一个 PIP,约 4-5 人日,回归风险集中在已上线的侧边栏删表与批量删除路径。
## 实现路径
### 阶段 1:方言底座(方案 4′)
**任务 1.1 — `DdlDialectProfile` 新增四个数据字段**
在 `crates/dbx-core/src/sql_dialect/ddl_profile.rs` 的 `DdlDialectProfile`(`:99-167`)新增:
- `drop_table_template: Option<&'static str>`(占位符 `{table}` / `{cascade}`,与既有 `FN_DROP:209` / `SEQ_DROP:214` 同风格)
- `drop_table_supports_cascade: bool`
- `drop_table_supports_if_exists: bool`
- `rename_table_syntax: RenameTableSyntax`(新枚举,参照既有 `RenameColumnSyntax:70-79`)
全部为 `Copy` 兼容类型,不破坏 struct 的 `Copy` 派生。渲染沿用 `render_template`(`:199-205`,纯 `String::replace`,**调用方必须自己先 `quote_ident`**)。
覆盖 6 个 family 构造函数 + `conservative_ansi`:`mysql_family:311`、`postgres_family:356`、`oracle_family:401`、`sqlserver_family:447`、`sqlite_family:492`、`conservative_ansi:627`(⚠️ **DuckDB 落在 `conservative_ansi`(`profile_for:710`),不在 sqlite family**,`create_table_if_not_exists=false`、`type_map` 为空)。
**任务 1.2 — `build_drop_table_sql` 改为读 profile**
`db_admin_sql.rs:411-430` 保持为唯一渲染入口,内部改为读 profile 字段替代硬编码。`supports_drop_table_cascade`(`:432-447`)改为读 `profile.drop_table_supports_cascade`,同时同步前端复制的白名单 `dbAdminSql.ts:322`(两处逐项一致,漏改会导致前端预览 SQL 与后端生成 SQL 分叉)。
**为什么不把函数整体搬进 profile**:`qualified_name_with_quote`(`:1078-1093`,私有)存在的唯一理由是 Cloud Spanner 双方言 —— 连接上报的 `identifier_quote` 必须覆盖静态映射,而 `DdlDialectProfile::quote_ident`(`:174-196`)只认静态 `QuoteStyle`。整体搬迁会撕裂 `identifier_quote` 语义、迫使私有 helper 跨模块提升可见性、让 `supports_drop_table_cascade` 与姊妹函数 `supports_truncate_table_cascade` 分居两处、并让 IoTDB(`DELETE TIMESERIES`)/ InfluxDB(`DROP MEASUREMENT`)特判(`:418-422`)失去归属。
**必须先定真源**:`oracle.yaml:109` 的模板是 `DROP TABLE {table} CASCADE CONSTRAINTS`,而 `supports_drop_table_cascade` 白名单**不含 Oracle**。三个 Oracle 系 yaml 之间也不一致(`dameng.yaml:101` 是 `IF EXISTS`),只因都被 `oracle.yaml` 覆盖才未暴露。本 PIP 以 Rust 侧现状为真源(Oracle 无 CASCADE),yaml 差异记为技术债。
- 验收:单测挂 `db_admin_sql.rs:1573-1594` 现有 `builds_drop_and_clear_table_sql` 旁;覆盖 6 个 family + DuckDB;**Rust 生产调用方仅 2 个**(`src-tauri/src/commands/query.rs:632-635`、`crates/dbx-web/src/routes/query.rs:904-906`),签名不变故零改动
- 工作量:1 人日
**任务 1.3 — `TransferRequest` 新增字段与校验**
`transfer.rs:176-203` 新增 `#[serde(default)] pub drop_target_before_create: bool`(紧邻 `create_table:189`)。`validate_transfer_request`(`:727-743`)拒绝 `drop_target_before_create && content == DataOnly`(措辞参照 `table_import.rs:5925-5935` 拒绝 `create_table` + `Truncate` 的先例)。
Tauri 与 Web 共用 `dbx_core::transfer::TransferRequest`,一次改动双向生效。`crates/dbx-web/src/routes/transfer.rs:623-660` 的 fixture 需补字段。
- 验收:`#[serde(default)]` 保证旧任务反序列化不破;校验单测挂 `transfer.rs:8745` `transfer_validation_tests`
- 工作量:0.5 人日
**任务 1.4 — 补 oceanbase-oracle 的 rename 支持**
`is_oracle_like_rename`(`db_admin_sql.rs:1009-1012`)只列 `Oracle | Dameng | Oscar`,不含 `OceanbaseOracle`,导致 `supports_object_rename` 返回 false、`build_rename_object_sql` 直接返回 `Err`。补一个变体 + 单测(`:2433-2620` 已覆盖 mysql/sqlite/duckdb/postgres/sqlserver/oracle/dameng/oscar)。
- 工作量:0.25 人日
**任务 1.5 — 备份表名生成器(新增)**
读 `descriptor.rs:161 max_identifier_length`(目前零校验消费者),实现「截断 + 短哈希」。`{table}__dbx_bak` 比原名长 13 字符,Oracle 上限 30 意味着原名超过 17 字符即溢出。派生思路参照 `sqlite_rebuild.rs:493-496`(内容哈希,幂等),冲突预检参照 `:498-511`(命中即报错,不自动清理不重试换名)。
- 验收:Oracle(30)/ PG(63)/ MySQL(64)/ SQL Server(128)四个边界各一条单测;长表名不溢出且不同源表不碰撞
- 工作量:0.75 人日
**任务 1.6 — 接入生产库闸门(传输链路首次)**
在 `src-tauri/src/commands/transfer.rs:26`(`ensure_connection_writable` 旁)与 `crates/dbx-web/src/routes/transfer.rs:81-89`(只读拒绝旁)新增:`drop_target_before_create && is_production_database(...)` 时要求请求携带显式确认标记,否则返回可辨识错误码供前端弹确认框。
- 工作量:0.5 人日
### 阶段 2:传输编排与 rename-then-drop
**任务 2.1 — 单表 rename → create 路径,并重置 `target_table_preexisting`**
在建表主流程 `transfer.rs:7364-7592` 的 `:7376` `else` 分支之前插入 rename 备份。本模式下跳过 `validate_preexisting_target_columns`(`:7669-7677`,表即将改名,结构不匹配不再是问题)与 TRUNCATE 判定(`:7679-7690`,新表本就为空)。
⚠️ **最容易漏的一处**:`:7318`(PG 索引抓取)、`:7331`(PG 外键抓取)、`:7595`(`should_restore_postgres_table_schema`)三处均门控在 `!target_table_preexisting` 上。rename 后**必须**把该标志置回 `false`,否则 `source_indexes` / `source_foreign_keys` 会是空 `Vec`,索引与外键**静默不重建**。
- 验收:必须有 live 测试锁定「DROP 模式下 PG 索引与外键被重建」
- 工作量:1 人日
**任务 2.2 — 双趟编排**
rename 顺序(子表先)与 create/insert 顺序(父表先)相反,但现有编排是单趟 per-table 循环、一张表内部完成建表加写数据。改为:`drop_target_before_create` 为真时先按 `parents_first=false`(`transfer.rs:6770`)全量 rename 一趟,再走现有 `parents_first=true` 循环。进度事件需覆盖 rename 阶段(参照 `:8192` 的两步计数)。
⚠️ **Tauri(`src-tauri/src/commands/transfer.rs:160-253`)与 Web(`crates/dbx-web/src/routes/transfer.rs:277-340`)两处必须同步改**,否则 Web API 行为与桌面端分歧。取消(`is_cancelled`,`transfer.rs:6673`)在 rename 预趟中同样生效;成环的表由 `:6846-6856` 追加到末尾而非报错,此行为保留。
- 工作量:1.5 人日(**架构风险集中点**,建议先跑通「无外键多表」再进 2.3)
**任务 2.3 — 外键处理**
传输路径当前对 DROP 被 FK 阻塞**零处理**:无 `SET FOREIGN_KEY_CHECKS=0`(仅 `database_export.rs:2533`/`:3344` 写进导出文件且限 MySQL);`session_replication_role` / `SET CONSTRAINTS` / `NOCHECK CONSTRAINT` 全仓零命中;`CASCADE` 在 `transfer.rs` 中不存在。
**rename 相比直接 DROP 的关键差异(见「关键设计」)**:MySQL 的 `RENAME TABLE` 与 PG 的 `ALTER TABLE RENAME` 都会让**入向外键跟着走**,改名后其他表的 FK 指向备份表。这与直接 DROP(打断后重建)行为不同,必须显式处理。
可复用的既有 FK 结构:`strip_inline_foreign_key_constraint_lines`(`:4970-4989`)、`supports_deferred_mysql_foreign_keys`(`:1831-1834`)、`generate_mysql_foreign_key_alter_statements`(`:2167-2232`)、`pending_fk_alters` 末尾统一执行(`src-tauri/src/commands/transfer.rs:259-272`)。会话开关的恢复参照 `transfer.rs:1703-1717` 的 `SET IDENTITY_INSERT ON/OFF` 双 result match 补偿模式(本文件唯一的补偿式清理先例)。
⚠️ 注意会话状态不跨 `execute_on_pool` 保留(见「已排除的方案」),`SET FOREIGN_KEY_CHECKS=0` 在 MySQL 池上**不会生效**。MySQL 侧必须靠 rename 顺序(子表先)而非关闭 FK 检查。
- 验收:live 测试覆盖「父子表」与「循环引用」两种拓扑在 MySQL 与 PG 上均能完成
- 工作量:2 人日
**任务 2.4 — 备份表生命周期**
序列:rename 备份 → create 新表 → 写数据 → 全部成功后 `DROP TABLE {backup}`。任一步失败保留备份,错误信息回报备份表全名(文案风格参照 `sqlite_rebuild.rs:508-510`)。删备份失败的处理参照 `table_import.rs:5675-5700`:视为连接状态污染,合并错误串并作废池。
- 工作量:1 人日
### 阶段 3:前端选项与警示
**任务 3.1 — 契约与类型**
`apps/desktop/src/lib/backend/tauri.ts:4566-4585` 的 `TransferRequest` 新增 `dropTargetBeforeCreate: boolean`(紧邻 `createTable:4577`);`types/database.ts:1438-1455` 的 `TransferTaskConfig` 同步。其余为 `import type` 自动跟随。
⚠️ Rust serde camelCase 与 TS 类型**无任何编译期或 CI 校验**,需人工核对。
- 工作量:0.25 人日
**任务 3.2 — 复选框与联动数据流**
**插入点**:`DataTransferDialog.vue` 的 `:1247`(传输内容 radio 组收尾 `</div>`)之后、`:1248`(写入方式 `<div v-if=...>`)之前。视觉顺序即依赖顺序。
**形态**:原生 `<input type="checkbox">` + `accent-destructive`,包在 `<label>` 内(隐式关联),`pl-5` 缩进表示从属于「传输内容」。
> 已核实:`DataTransferDialog.vue` 内**没有任何复选框**;手搓 `CheckSquare`/`Square` 是 `DatabaseExportDialog.vue` 的写法,该写法有 7 项 a11y 缺陷(完全无法键盘操作、无 role/state、无 name 关联、Space/Enter 无响应、disabled 仅视觉、未勾选态对比度低于 3:1、无焦点指示器)。本文件自身惯用原生 `<input type="radio">`(`:1233`/`:1237`/`:1242`),项目**不存在** shadcn Checkbox 组件。危险复选框范式见 `ObjectBrowser.vue:3785`、`ConnectionTree.vue:2777`。
**数据流(关键,强于 watch 兜底)**:
- `dropTargetBeforeCreateChecked`(ref)只喂 `v-model`,仅服务 UI
- `canDropTargetBeforeCreate`(computed)= `transferContent !== 'dataOnly' && targetSupportsDropRecreate`
- `dropTargetBeforeCreate`(computed)= `canDropTargetBeforeCreate && dropTargetBeforeCreateChecked` —— **这是 `TransferRequest` 与 `currentConfig()` 唯一的读取源**,不可达状态在结构上无法泄进请求体
- 另加一个仅为视觉卫生的 watch(`[transferContent, targetConnectionId]` → 若已不可达则写 `false`)。只写 false,故**不需要** `skip*Watch` 标记
> 反面教训:朴素的 reset watch 会破坏 `loadTaskIntoForm()` —— 它在 `:886` 先设 `transferContent` 再设 `transferMode`,这正是 `:442-451` 那批 `skip*Watch` 一次性标记存在的原因。
**方言门控**:必须用 `effectiveDatabaseTypeForConnection`,**不能**用 `transferDatabaseTypeForConnection`(后者在 `jdbcDialect.ts:97` 会把 doris/starrocks 掩回 `mysql`)。禁用样式照 `DatabaseExportDialog.vue:871` 的 `cursor-not-allowed text-muted-foreground/50`——渲染但禁用,避免版面跳动并让用户看到原因。
**顺带修两处现存缺陷**(各一行):`:771` 改为 `structureOnly` 时不发送 `transferMode`;`:1222` 的琥珀横幅补 `dark:` 变体(现为 `bg-amber-50 text-amber-700`,暗色主题下浅底浅字;正确写法见 `TableImportDialog.vue:1521`)。
⚠️ `__tests__/DataTransferDialog.spec.ts` 是**源码文本断言**测试:`:59-60` 逐字符 pin 住 `createTable: transferContent.value !== "dataOnly"`、`:12` pin 住 `DialogContent` 完整 class 串、`:23`/`:33-38` pin 住布局 class。触碰这些行必须同步改 spec。
- 工作量:1 人日
**任务 3.3 — 三层警示 + 确认链**
**第一层**(常驻、刻意中性,`text-[11px] text-muted-foreground/70`,与 `contentStructureOnlyHint:1239` 同层级):说明三步机制。对未勾选项挂红字只会训练用户忽略红字。
**第二层**(勾选后出现,`role="alert"`,`border-destructive/30 bg-destructive/10 text-destructive` + `AlertTriangle`,来自 `@lucide/vue`)。**因采用 rename-then-drop,文案不再是「永久消失」**:
> 目标表及其索引、触发器、约束、注释和对象权限会被替换为源结构。原表会先改名为备份表,传输成功后删除;任一步失败则保留备份,可手工恢复。
行内最高级别且只有这一条。不加第三条横幅、不在勾选时弹 modal、不要求手打——勾选动作本身可逆,不配不可逆级别的摩擦。配色不加过渡动画(危险警示应瞬时呈现,与 `:1222`、`TableImportDialog.vue:1521` 一致)。
**第三层**:`requestStartTransfer()`(`:1012-1015`)按 `dropTargetBeforeCreate` 分流——未勾选走原 `showStartConfirm`(`:1322`),已勾选走 `DangerConfirmDialog`。普通 append 传输不该看到红色弹窗,否则红色贬值。
- **DROP SQL 预览**:照 `useSidebarTableMutationRuntime.ts:108-111` 的「先生成预览再弹窗」。价值最高的元素——它顺带回答「目标表名大小写」(`:1261`)改写后到底操作的是哪个名字。约束:`buildDropTableSql`(`dbAdminSql.ts:298`)是**每张表一次 IPC**,故只生成前 10 条 + 一行 `-- 其余 N 张表省略`(`DangerConfirmDialog` 自身另有 8192 字符 / 200 行截断,`:13-14`);必须传 `detailsText`(`:149`)声明这是**示例**,实际 SQL 由 Rust 按目标方言生成
- **生产闸门**:把 `:743-745` 的 `ensureReadOnlyWriteAccess` 升级为 `executeWithProductionContextGuard`(`productionExecutionGuard.ts:47-63`,其 `:48` 内部已含 `ensureReadOnlyWriteAccess`,unlock 在 `readOnlyUnlockStore` 中记忆化(`readOnlyWriteAccess.ts:60`),不会二次弹解锁框),`reviewText` 设为 DROP 预览
- **不传 `showSuppressToggle`**(`DangerConfirmDialog.vue:38`):已保存任务支持一键 Start,此弹窗是该路径唯一关卡
⚠️ `DataTransferDialog.spec.ts:9-11` 断言「只有一个可 resize 的 `DialogContent`」,新弹窗**不能**带 `resize`。
- 工作量:1.5 人日
**任务 3.4 — 任务持久化闭环**
`currentConfig()`(`:846-861`)、`loadTaskIntoForm()`(`:886-889`)、`resetState()`(`:644-648`)三处都要带上新字段;`configSnapshot` / `isConfigDirty` 因整对象 `JSON.stringify`(`:865-875`)自动生效。
- 验收:旧任务(无该字段)加载后为 `false` 而非 `undefined`。照 `transferTaskStore.spec.ts:70`(「keeps target column quoting enabled for saved tasks created before the option existed」)风格补一条向后兼容测试
- 工作量:0.5 人日
### 阶段 4:测试与文档
**任务 4.1 — 单元测试(最先写,无需真实库)**
- DROP / rename SQL 生成的方言单测:挂 `db_admin_sql.rs:1573-1594`(DROP)与 `:2433-2620`(rename)
- 备份名生成器的四个长度边界单测
- `validate_transfer_request` 拒绝非法组合:挂 `transfer.rs:8745`
- 建表流程单测:挂 `transfer.rs:9765` `transfer_content_mode_tests` 或新建 `transfer_drop_recreate_tests`
- 工作量:1 人日
**任务 4.2 — live 集成测试**
`crates/dbx-core/tests/live_mysql_transfer.rs` 的 `fn transfer_request()`(`:69-96`)是新字段第一个落点。**最佳断言范式在 `:707-741`**(插脏数据 → 传输 → 断言脏数据消失)。生命周期沿用 `:651-657` 建 AppState → `:660` `let test_result = async {...}.await` → `:779` 先 cleanup 再 `cleanup.unwrap(); test_result.unwrap();` 的 cleanup-before-assert 结构。
⚠️ **两处既有断言与新模式冲突,必须处理**:
- `live_mysql_transfer.rs:882-895` 断言 `"target row was destroyed by TRUNCATE despite the transfer failing"` —— 锁定「校验必须在 TRUNCATE 之前」。新模式下该校验因 rename 而不必要,需显式决策并在测试中体现等价语义
- `live_postgres_transfer.rs:849-851` `live_postgres_transfer_skips_create_ddl_for_existing_target_table` —— 语义**直接冲突**。另注意该文件**没有任何 Overwrite 测试**
新增覆盖:结构不兼容目标 + 新模式 → 成功;父子外键两表 → rename 顺序正确;PG 索引与外键在重建后存在(对应任务 2.1 验收);失败时备份表保留且错误含备份名。
跑法:`make db DB=mysql@8.4` → 导出 `DBX_LIVE_MYSQL_TRANSFER_{HOST,PORT,USER,PASSWORD}` → `cargo test -p dbx-core --test live_mysql_transfer -- --ignored`。**`RUST_MIN_STACK=8388608` 必须设置**(transfer future 栈很深,`live_mysql_transfer.rs:104-118` 有 2MB 栈手工线程测试)。
- 工作量:1.5 人日
**任务 4.3 — 文档与 i18n**
文档必改 4 个文件:`docs/content/docs/data-transfer.{mdx,cn.mdx}` 的传输选项表格(各 `:59-70`,另 `:14`/`:48` 也提到写入模式);**容易漏的** `docs/content/docs/production-safety.{mdx,cn.mdx}:133`(现文案「跨引擎传输和批量导入需要单独检查类型映射、目标表和清空选项」)。
**i18n key 清单**(扁平挂 `transfer.*`,en 插在 `en.ts:5372` 之后、zh-CN 在 `:5356` 之后,与其描述的 content radio 相邻):
| key | zh-CN | en |
|---|---|---|
| `dropTargetBeforeCreate` | 创建前删除目标表 | Drop the target table before creating |
| `dropTargetBeforeCreateHint` | 先将目标表改名备份,按源结构重建后写入数据,成功后删除备份 | Rename the target table to a backup, recreate it from the source structure, write data, then drop the backup |
| `dropTargetWarning` | 目标表及其索引、触发器、约束、注释和对象权限会被替换为源结构。原表会先改名为备份表,传输成功后删除;任一步失败则保留备份,可手工恢复。 | The target table and its indexes, triggers, constraints, comments, and object grants are replaced by the source structure. The original is renamed to a backup and dropped only after the transfer succeeds; if any step fails the backup is kept for manual recovery. |
| `dropTargetDataOnlyDisabled` | 仅数据模式不建表,删除目标表后不会重建 | Data-only mode does not create tables, so a dropped target would not be rebuilt |
| `dropTargetUnsupportedDatabase` | 该数据库暂不支持此选项 | This option is not yet supported for this database |
| `dropTargetNonTableNote` | 该选项只作用于表;已选中的视图、存储过程等对象不会被删除。 | This option affects tables only; selected views, procedures, and other objects are not dropped. |
| `dropTargetConfirmTitle` | 确认重建目标表 | Confirm rebuilding target tables |
| `dropTargetConfirmMessage` | 将在 {target} 重建 {count} 张目标表并写入数据。原表会先改名备份,成功后删除。 | {count} target table(s) in {target} will be rebuilt and repopulated. Originals are renamed to backups and dropped only on success. |
| `dropTargetConfirmSqlNote` | 以下为示例语句,实际由传输任务按目标方言生成。 | The statements below are illustrative; the transfer task generates the actual SQL for the target dialect. |
| `dropTargetConfirmButton` | 重建后传输 | Rebuild and transfer |
| `dropTargetPreviewOmitted` | -- 其余 {count} 张表省略 | -- {count} more table(s) omitted |
| `dropTargetBackupKept` | 传输失败,已保留备份表 {backup},可手工恢复 | Transfer failed; backup table {backup} was kept for manual recovery |
⚠️ **漏加 i18n key 不会被任何现有检查捕获** —— 7 个非英语文件都是 `export default withEnglishFallback({...})`(`locales/fallback.ts:29`),缺键静默回退英文;`transfer` 命名空间**没有 parity 测试**;`i18n/index.ts:74-81` 的 `createI18n` 未传 message schema 泛型,`t()` 的 key 不做类型检查。必须人工逐文件核对 8 个语言文件(`en` / `zh-CN` / `zh-TW` / `ja` / `ko` / `es` / `it` / `pt-BR`)。
不需要改:`docs/content/docs/meta.json`(已含 `data-transfer`)、CHANGELOG(`changelog.rs:90-107` 是远端 CDN 读取器,仓库无本地 `CHANGELOG.md`)、知识库(`scripts/build-knowledge-base.mjs` 产物在 `outputs/`,已 gitignore 且不在 CI)。
- 工作量:0.75 人日
## 关键设计
### 备份表命名与冲突
- 命名:`{truncated_table}__dbx_bak_{short_hash}`,总长受 `descriptor.rs:161 max_identifier_length` 约束(Oracle 30 / PG 63 / MySQL 64 / SQL Server 128)
- 哈希从源表限定名 + 传输 id 派生,保证同一传输内幂等、不同源表不碰撞
- 冲突预检:rename 前查询目标库是否已存在该名;命中即报错,**不自动清理、不重试换名**(沿用 `sqlite_rebuild.rs:498-511` 的判断——自动清理一个我们没创建的对象是不可接受的)
### 外键与 rename 的交互
MySQL 的 `RENAME TABLE` 与 PG 的 `ALTER TABLE ... RENAME TO` 都会让**入向外键跟着走**:改名后其他表的 FK 定义指向备份表,新建的同名表不会自动继承这些入向引用。这与直接 DROP(打断后由 `pending_fk_alters` 重建)行为不同。
处理:rename 预趟按 `parents_first=false`(子表先)执行,使被引用表在其引用者之后才被改名;配合现有的 FK 延迟重建机制(`generate_mysql_foreign_key_alter_statements:2167-2232` + `pending_fk_alters` 末尾统一执行)。**不能依赖 `SET FOREIGN_KEY_CHECKS=0`** —— 会话状态不跨 `execute_on_pool` 保留。
### 失败点状态矩阵
| 失败点 | 目标端状态 | 用户可恢复 |
|---|---|---|
| rename 之前(预检 / 权限 / 冲突) | 完全未改动 | 无需恢复 |
| rename 成功、create 失败 | 备份表存在,原名无表 | 是:改回名 |
| create 成功、写数据失败 | 备份表存在,新表空或部分 | 是:删新表、改回名 |
| 写数据成功、删备份失败 | 新表完整,备份残留 | 是:手工删备份(错误串含表名,池已作废) |
| 用户中途取消 | 同上按取消时点 | 是,同上 |
每一步独立提交,不依赖回滚。这是 rename-then-drop 相比直接 DROP 的全部价值。
## 顺带修复与技术债
**建议纳入本次改动**(各一行,与新功能同源):
- `script_generator.rs:437-443` 对 `DROP TABLE` **无条件**插入 `IF EXISTS`(`CREATE TABLE`/`CREATE INDEX` 分支会查 profile,DROP 分支因 profile 无字段而不查),Oracle 23c 之前生成非法 SQL。任务 1.1 的 `drop_table_supports_if_exists` 字段正好修掉
- `DataTransferDialog.vue:771` 在 `structureOnly` 下仍无条件发送 `transferMode`
- `DataTransferDialog.vue:1222` 琥珀横幅缺 `dark:` 变体
**建议独立提交**(与本功能无依赖):
- `transfer.rs:7683-7688` 与 `db_admin_sql.rs:552-576` 的 SQLite 系名单漏了 Turso / Rqlite(两者无 `TRUNCATE`,会落到 `TRUNCATE TABLE` 分支)。修完可评估把这两库纳入决策 2 的启用清单
**记为技术债,不在本次范围**:
- dialect registry 注册键与查找键错位(`build.rs:63` + `dialect_loader.rs:117` vs `sql_dialect.rs:73`),导致 postgres / sqlserver 的 yaml descriptor 从未生效。目前不可见但会让任何新增 yaml 字段对这两库失效
- `plugins/dialects/` 的 `templates` / `rollback_templates` / `online_safety` / `destruction_level` 四组数据零消费(`to_descriptor:637-694` 不读)。激活方案见「已排除的方案」,值得独立 PIP
- 三个 Oracle 系 yaml 的 `drop_table` 模板互相不一致(`oracle.yaml:109` 带 `CASCADE CONSTRAINTS`、`dameng.yaml:101` 带 `IF EXISTS`、`oceanbase.yaml:83` 带 `CASCADE CONSTRAINTS`),且与 Rust 侧 `supports_drop_table_cascade`(不含 Oracle)冲突
- `descriptor.rs:451` 的 Oracle `max_identifier_length = 30` 对 Oracle 12.2+ 偏保守(实际支持 128)。保守值对备份名生成器是安全侧,暂不改
- `query.rs:3815` 的事务性 DDL 白名单不含 DuckDB,尽管 DuckDB 实际支持
## 范围边界
**不在本次范围**:
- **备份表遗留清理入口**。仓库中没有任何机制让用户发现或清理残留备份(grep `_old_` / `__dbx` / `staging` / `orphan` 确认无此类 UI 或命令)。本次仅在失败错误串中回报备份表全名(`dropTargetBackupKept` key)。完整方案(命名前缀约定 + 列举查询 + UI 入口)约 +1 人日,建议观察实际使用后再定
- 10 个高危 / 不适用方言的支持(见决策 2)
- 非表对象(视图 / 存储过程 / 触发器 / 序列)的先删后建。这些对象已有各自的 `CREATE OR REPLACE` 或 drop-then-create 路径,本选项只作用于表(`dropTargetNonTableNote` key 明确告知用户)
## 工作量与关键路径
```
1.1 ─┬─> 1.2 ─┐
└─> 1.4 ─┤
1.3 ──────────┼─> 2.1 ─> 2.2 ─> 2.3 ─> 2.4 ─> 4.2
1.5 ──────────┤ │
1.6 ──────────┘ └─> 3.1 ─> 3.2 ─> 3.3 ─> 3.4
4.1 可与阶段 1/2 并行;4.3 最后
```
关键路径:**1.3 → 2.1 → 2.2 → 2.3 → 4.2**。任务 2.2 是架构风险集中点。
| 阶段 | 人日 |
|---|---|
| 1(方言底座) | 3.0 |
| 2(编排与 rename) | 5.5 |
| 3(前端) | 3.25 |
| 4(测试文档) | 3.25 |
| **合计** | **约 15 人日** |
## 风险
| 风险 | 影响 | 缓解 |
|---|---|---|
| `target_table_preexisting` 未重置 → PG 索引与外键静默不重建 | 高(数据可用但结构残缺,难察觉) | 任务 2.1 验收强制 live 测试;review 专查 `transfer.rs:7318`/`:7331`/`:7595` |
| 入向外键跟随 rename 指向备份表 | 高(约束静默丢失) | 见「关键设计 · 外键与 rename 的交互」;任务 2.3 的循环引用 live 测试 |
| Tauri 与 Web 两处编排改动不同步 | 中(Web API 与桌面端分歧) | 任务 2.2 验收显式要求;两处均有 fixture 可挂断言 |
| 备份名在 Oracle 30 字符上限溢出 | 中(长表名直接失败) | 任务 1.5 的四个边界单测 |
| 备份表残留无清理入口 | 中(磁盘占用累积) | 本次仅回报表名;见「范围边界」 |
| 前后端 cascade 白名单分叉 | 低(预览 SQL 与实际不符) | 任务 1.2 明确要求同步 `dbAdminSql.ts:322` |
| i18n 漏键静默回退英文 | 低 | 无自动化手段,人工核对 8 文件;可考虑为 `transfer` 补 parity 测试(注意 `docsNamespaceParity.spec.ts:11-17` 记录的陷阱:必须 import 原始对象而非 `withEnglishFallback` 合并后的默认导出) |
| `DataTransferDialog.spec.ts` 源码文本断言被打断 | 低(CI 红但原因明确) | 任务 3.2/3.3 已列出被 pin 的具体行号 |
@@ -0,0 +1,102 @@
# Transfer rebuild hardening Implementation Plan
> **For agentic workers:** Use the approved design from the conversation and execute the independent frontend, dependency-check, and regression-test tasks in parallel. Keep core orchestration and integration sequential. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将重建整合为第四种目标表处理方式,并修复本次 review 发现的依赖越界、序列和索引冲突、提前清理和取消恢复问题。
**Architecture:** UI 提供四种互斥策略,API 继续使用 `TransferMode` 与 `dropTargetBeforeCreate`。后端先准备完整备份计划并保存恢复记录,再执行备份、建表、写入和结构恢复,全部必要步骤成功后才清理;Tauri 与 Web 共用编排。确认预览复用后端计划,包含实际限定目标名和 SQL。
**Tech Stack:** Rust / Tokio / existing database drivers, Vue 3 / TypeScript / Pinia, Vitest, Cargo tests.
**Spec:** 本会话用户已确认的 review 与改造方案;原设计背景见 `docs/pips/plans/2026-09-03-transfer-drop-before-create.md`。
## Global Constraints
- 使用现有依赖,离线构建;数据库验证仅在临时实例或临时 SQLite 文件中进行。
- 兼容已有任务的重建布尔字段,不增加第四个底层 DML `TransferMode`。
- 不把生产标记当作永久禁止:复用连接和数据库级生产确认,确认字段仅作用于一次请求。
- 不对未选中的依赖对象执行隐式 CASCADE;预检无法确认安全时在第一条 DDL 前报错。
- 普通追加、清空、Upsert 的行为保持兼容;重建模式以 INSERT 写入新表。
- 所有对象名称经方言 quoting,备份名称按对象完整身份派生并预检冲突。
- 回归测试验证数据库对象与数据结果,不添加源码文本断言。
- 本轮交付可审查的本地修改,不 push 或发布。
## Task 1: 四种 UI 策略及确认流程
**Files:** `apps/desktop/src/components/transfer/DataTransferDialog.vue`, new `transferStrategy.ts` and behavior tests, `stores/transferTaskStore.ts`, `types/database.ts`, `lib/backend/tauri.ts`, transfer locale keys and user documentation.
**Interfaces:** Existing `TransferRequest` stays compatible. `TransferOwnershipPreview` gains optional `rebuild: { sql: string; tables: Array<{ sourceTable: string; targetTable: string; backupTable?: string }>; warnings: string[] }`. Preview and actual execution use the same frozen request and transfer ID.
- [ ] Write behavior tests for legacy task migration and mutually exclusive request mapping.
```ts
expect(resolveTransferStrategy({ mode: 'upsert', dropTargetBeforeCreate: true })).toBe('rebuild');
expect(transferStrategyOptions('rebuild')).toEqual({ mode: 'append', dropTargetBeforeCreate: true });
```
- [ ] Run the new tests and observe failures before implementation.
- [ ] Add four-way strategy selection. Structure-only offers keep/rebuild; data-only and unsupported targets disable rebuild with a reason.
- [ ] Freeze the request before preview/confirmation. Display backend rebuild SQL; combine destructive and production confirmation without instructing users to disable protection.
- [ ] Normalize saved legacy rebuild tasks, keep confirmation transient, update all eight locales and docs including malformed JSX quotes.
- [ ] Run covering Vitest tests and frontend typecheck.
## Task 2: 依赖预检
**Files:** `crates/dbx-core/src/transfer_rebuild.rs` and its tests.
**Interfaces:** Preserve `ensure_no_external_incoming_foreign_keys`. Add `ensure_no_external_table_dependencies(state: &AppState, target_pool_key: &str, target_database: &str, target_schema: &str, target_tables: &[String], target_database_type: DatabaseType) -> Result<(), String>` for rebuild planning and cleanup verification.
- [ ] Add a real SQLite regression that creates parent/external child with `ON DELETE CASCADE`, runs preflight, and verifies rejection before mutation.
- [ ] Add PostgreSQL dependency SQL coverage and live regression for a target-only dependent view.
- [ ] Implement SQLite incoming-FK metadata checks and dependent-view checks with quoted values/identifiers; refuse unsafe or uninspectable dependencies.
- [ ] Keep MySQL, PostgreSQL, Oracle and SQL Server incoming-FK checks; include schema identity and normalized table matching where required.
- [ ] Run the targeted tests; the temporary external table and its rows must remain intact.
## Task 3: 备份计划及生命周期
**Files:** `crates/dbx-core/src/transfer.rs`, `transfer_rebuild.rs` integration, focused new core module if appropriate, `storage.rs`, `src-tauri/src/commands/transfer.rs`, `crates/dbx-web/src/routes/transfer.rs`.
**Interfaces:** Keep `rename_tables_to_backup` and `transfer_table` public signatures compatible. Add serializable recovery records with qualified source/target/backup identities, SQL, operation state and error/cancellation outcome. Ownership preview gains the optional rebuild preview from Task 1.
- [ ] Reproduce failed source-sequence ownership and colliding long PostgreSQL index names with live regression tests.
- [ ] Resolve all actual target names, source structure, backup names and collisions before the first DDL; propagate metadata lookup errors for rebuilds.
- [ ] Rename owned PostgreSQL sequences and indexes with hashes containing each object's identity; abort on rename errors and retain complete recovery information.
- [ ] Free MySQL backup FK names using atomically replaced constraints on backup tables, preserving referenced backups and ON UPDATE/DELETE rules.
- [ ] Persist the recovery plan before mutations and update execution state; terminal errors/cancellation expose retained backups and manual recovery SQL.
- [ ] Share the orchestration between desktop and Web. Use typed internal cancellation and preserve the public cancellation event.
- [ ] Restore all deferred constraints and selected schema objects before cleanup. Never issue broad `DROP ... CASCADE`.
- [ ] Break only backup-to-backup FK cycles during final cleanup; a cleanup failure reports retained objects without claiming the whole rollback set remains intact.
- [ ] Use the same prepared DDL and actual target identifiers for preview and execution.
## Task 4: 集成回归及审查
**Files:** `crates/dbx-core/tests/live_mysql_transfer.rs`, `live_postgres_transfer.rs`, new SQLite rebuild regression tests, existing frontend behavior tests.
- [ ] Add regression cases for PostgreSQL SERIAL/identity ownership, shared long index prefixes, external views, MySQL cyclic FKs, SQLite external children, partial rename cancellation, failure before final cleanup, and successful cleanup.
- [ ] Verify red before the relevant fixes, then run green against temporary PostgreSQL and MySQL servers and temporary SQLite databases.
- [ ] Run `RUST_MIN_STACK=8388608 cargo test --offline --locked -p dbx-core --no-default-features --features sqlite-bundled --lib transfer`.
- [ ] Run relevant frontend Vitest cases, `pnpm exec vue-tsc --noEmit --project apps/desktop/tsconfig.json`, and Rust checks for core/Web/Tauri with available local features.
- [ ] Request an independent code review, fix actionable findings, inspect the final diff and working-tree state.
## Progress
- Baseline: commit `c3e417a9a`, clean working tree; prior review verified 45 frontend and 265 core transfer tests passing.
- Branch: `codex/transfer-rebuild`; use the current clean workspace to keep the user's local changes directly reviewable and reuse offline build caches.
- Task 1 (UI strategies): `transferStrategy.ts` + 26 behavior tests, legacy-task migration and store coverage (23 tests) all passing; `vue-tsc --noEmit` clean; locales and dialog updated.
- Task 2 (dependency preflight): `ensure_no_external_table_dependencies` (incoming FKs + dependent views) implemented with SQLite/PG-family/MySQL-family/Oracle-family/SQL Server/DuckDB coverage; wired into `rename_tables_to_backup` before the first rename. 24+ module tests pass.
- Task 3 (backup lifecycle):
- Backup-name collision preflight is now all-or-nothing before any rename (red test fixed).
- Cancellation keeps its exact `"Cancelled"` discriminator; retained-backup annotation is appended only to real failures (red test fixed).
- PostgreSQL-family pre-pass renames owned sequences and indexes with per-object identity hashes; listing/rename failures abort instead of being swallowed.
- Recovery journal (`transfer-rebuild:<id>` in the existing state store) is persisted before the first rename and updated per step; removed after successful cleanup.
- New `free_backup_foreign_key_names` frees MySQL backup constraint names atomically before the deferred FK ALTERs.
- Cleanup order: table loop → free constraint names → deferred FK ALTERs → schema objects → `drop_backup_tables` (which now breaks the backup-to-backup FK graph first, fixing "Cannot drop table ... referenced by a foreign key constraint" seen with Flowable schemas).
- DDL planning shared: `transfer/ddl_plan.rs::prepare_table_ddl` replaces the inline block in `transfer_table_inner`; rebuild mode fails closed on incomplete columns, unreadable source DDL, and failed FK inspection.
- Verification: 283 core transfer lib tests pass (2 prior red tests fixed); full dbx-core lib suite 5653 passed with one pre-existing environment failure (`external_driver_preview_retry_preserves_marker_truncation`, JDBC plugin timeout, reproduced on the clean baseline); `dbx-web` and Tauri `dbx` `cargo check` clean; frontend Vitest (49) and `vue-tsc` clean.
- Live regression (temporary Docker `postgres:16-alpine` + `mysql:8.0` on loopback ports 55432/53306, cleaned up afterwards):
- PostgreSQL rebuild tests: 4/4 passed (`rebuilds_same_named_serial_sequence`, `rebuilds_indexes_with_shared_long_prefix`, `rebuild_rejects_target_only_view_before_any_rename`, `drop_target_rebuilds_structure_and_indexes`).
- MySQL `drop_target` tests: 5/5 passed, including the Flowable-style `parent_child` FK, the `circular_foreign_keys` cleanup (the `ERROR 3730` scenario), and `retains_backup_on_failure`.
- Recovery semantics (user decision "B"): `create_transfer_target_table` was extracted, and the rebuild path now creates the target table from the source DDL *before* reading the source column list. A source column-read failure therefore still leaves the empty new table + retained backup behind (drop the empty table, rename the backup back).
- Ownership preview gains the optional rebuild plan: `TransferOwnershipPreview.rebuild` (`TransferRebuildPreview` with `sql`/`tables`/`warnings`) is built by `build_rebuild_preview` — pure planning that reuses the same resolution, backup-name derivation, dependency preflight, and DDL planning as execution, so the confirmation dialog shows the real rename/create/cleanup statements. A SQLite regression (`transfer_rebuild_preview_plans_without_executing_ddl`) verifies it plans without renaming anything.
- Final verification: 284 core transfer lib tests, PostgreSQL rebuild 4/4, MySQL `drop_target` 5/5, `dbx-web` + Tauri `dbx` `cargo check` clean, `vue-tsc` clean.
- Pending: an independent review pass.
+124 -1
View File
@@ -30,6 +30,18 @@ pub async fn start_transfer(
let target_db_type = get_db_type(&state, &request.target_connection_id).await?;
dbx_core::transfer::validate_transfer_request(&request)?;
// `drop_target_before_create` rebuilds target tables; gate the dialect and require an
// explicit confirmation for production databases.
dbx_core::transfer_rebuild::ensure_drop_target_allowed(
&state,
&request.target_connection_id,
&request.target_database,
target_db_type,
request.drop_target_before_create,
request.drop_target_confirmed,
)
.await?;
// Cross-family object transfers are validated inside transfer_schema_objects:
// only mechanically rewriteable kinds (views, sequences) are allowed.
// Structure-only data transfer is unsupported for MongoDB.
@@ -102,6 +114,87 @@ pub async fn start_transfer(
let mut last_rows_transferred = 0_u64;
let mut last_total_rows = None;
// When drop_target_before_create is true, perform a rename pre-pass in
// parents_first=false order (children first) so that foreign keys on the
// backup tables remain intact. The main create/insert pass then runs in
// parents_first=true order (parents first).
// Children-first order used by the rename pre-pass, kept for the post-loop backup
// cleanup: a backup can still be referenced by another backup, so the drops must
// follow the same order.
let mut backup_drop_order: Vec<String> = Vec::new();
let backup_names = if request.drop_target_before_create {
// Re-sort tables in children-first order for the rename pre-pass
let (tables_for_rename, _) = dbx_core::transfer::sort_tables_by_fk_dependency_with_foreign_keys(
&state,
&request.target_connection_id,
&request.target_database,
&request.target_schema,
&sorted_tables,
false, // parents_first=false: children first
)
.await
.unwrap_or_else(|e| {
log::warn!("[transfer] failed to sort tables for rename pre-pass, using original order: {e}");
(sorted_tables.clone(), std::collections::HashMap::new())
});
backup_drop_order = tables_for_rename.clone();
match dbx_core::transfer::rename_tables_to_backup(
&state,
&request,
&tables_for_rename,
target_db_type,
&target_pool_key,
|progress| {
last_rows_transferred = progress.rows_transferred;
last_total_rows = progress.total_rows;
emit_progress(&app, progress);
},
)
.await
{
Ok(names) => Some(names),
Err(e) if e == "Cancelled" => {
emit_progress(
&app,
TransferProgress {
transfer_id: transfer_id.clone(),
table: "rename pre-pass".to_string(),
table_index: 0,
total_tables,
rows_transferred: last_rows_transferred,
total_rows: last_total_rows,
status: TransferStatus::Cancelled,
error: None,
terminal: true,
},
);
dbx_core::transfer::clear_cancelled(&transfer_id).await;
return;
}
Err(e) => {
emit_progress(
&app,
TransferProgress {
transfer_id: transfer_id.clone(),
table: "rename pre-pass".to_string(),
table_index: 0,
total_tables,
rows_transferred: last_rows_transferred,
total_rows: last_total_rows,
status: TransferStatus::Error,
error: Some(e),
terminal: true,
},
);
dbx_core::transfer::clear_cancelled(&transfer_id).await;
return;
}
}
} else {
None
};
if matches!(source_db_type, dbx_core::models::connection::DatabaseType::Postgres)
&& matches!(target_db_type, dbx_core::models::connection::DatabaseType::Postgres)
{
@@ -190,6 +283,7 @@ pub async fn start_transfer(
&target_pool_key,
&known_foreign_keys,
&mut pending_fk_alters,
backup_names.as_ref(),
|progress| {
last_rows_transferred = progress.rows_transferred;
last_total_rows = progress.total_rows;
@@ -255,7 +349,9 @@ pub async fn start_transfer(
// Add any foreign keys deferred during MySQL-family table creation now that
// every selected table exists — see transfer_table's use of
// strip_inline_foreign_key_constraint_lines for why these can't be created
// inline (a foreign key cycle has no valid CREATE TABLE order at all).
// inline (a foreign key cycle has no valid CREATE TABLE order at all). The
// rename pre-pass already freed the backup constraint names, so these re-create
// the original names without colliding.
let mut failed_fk_tables: Vec<String> = Vec::new();
let mut failed_fk_count = 0usize;
for (table, alter_sql) in &pending_fk_alters {
@@ -328,6 +424,33 @@ pub async fn start_transfer(
if !object_outcome.failed.is_empty() {
failed_tables.push(format!("schema objects ({})", object_outcome.failed.len()));
}
// The rename pre-pass left one backup per rebuilt table. Drop them only now that
// every table, every deferred foreign key and every selected schema object has
// succeeded — a failure anywhere above keeps the originals recoverable under
// their backup names.
if let Some(backup_names) = backup_names.as_ref() {
if failed_tables.is_empty() {
if let Err(e) = dbx_core::transfer::drop_backup_tables(
&state,
&request,
target_db_type,
&target_pool_key,
backup_names,
&backup_drop_order,
)
.await
{
failed_tables.push(e);
}
} else {
log::warn!(
"[transfer] keeping {} backup table(s) because {} step(s) failed",
backup_names.len(),
failed_tables.len()
);
}
}
let skip_suffix = if !object_outcome.skipped.is_empty() && failed_tables.is_empty() {
format!(",跳过 {} 个已存在对象", object_outcome.skipped.len())
} else if !object_outcome.skipped.is_empty() {