feat(backup): support restoring selected MySQL tables

This commit is contained in:
CSXFanMeng authored and GitHub committed 2026-09-11 23:20:05 +08:00
1 parent e8b3c1457a
commit 2d9521898f
30 files changed
+1014 -9

No files matched your search

@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
listenSqlFileProgress: vi.fn(),
openFileDialog: vi.fn(),
previewSqlFile: vi.fn(),
inspectSqlFileTables: vi.fn(),
progressHandler: undefined as undefined | ((progress: Record<string, unknown>) => void),
refreshDatabaseTreeNode: vi.fn(),
requestConfirmation: vi.fn(),
@@ -41,7 +42,7 @@ vi.mock("@/composables/useExportTracker", () => ({
useExportTracker: () => ({ addSqlFileTask: mocks.addSqlFileTask, updateSqlFileTask: mocks.updateSqlFileTask }),
}));
vi.mock("@/composables/useDatabaseOptions", () => ({ fetchSqlFileTargetOptions: mocks.fetchSqlFileTargetOptions }));
vi.mock("@/lib/connection/connectionLevelDatabaseBootstrap", () => ({ requiresSqlFileTargetDatabaseSelection: () => false }));
vi.mock("@/lib/connection/connectionLevelDatabaseBootstrap", () => ({ requiresSqlFileTargetDatabaseSelection: () => false, supportsConnectionLevelDatabaseBootstrap: (connection: any) => connection?.db_type === "mysql" }));
vi.mock("@/lib/database/productionSafety", () => ({ productionContextForDatabase: () => ({ active: false, databases: [] }) }));
vi.mock("@/stores/productionSafetyStore", () => ({
useProductionSafetyStore: () => ({ requestConfirmation: mocks.requestConfirmation }),
@@ -59,6 +60,7 @@ vi.mock("@/lib/backend/api", () => ({
executeSqlFiles: mocks.executeSqlFiles,
listenSqlFileProgress: mocks.listenSqlFileProgress,
previewSqlFile: mocks.previewSqlFile,
inspectSqlFileTables: mocks.inspectSqlFileTables,
}));
vi.mock("@lucide/vue", () => {
const Icon = passthrough("span");
@@ -77,7 +79,15 @@ vi.mock("@/components/ui/tooltip", () => ({
TooltipTrigger: passthrough("div"),
}));
vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") }));
vi.mock("@/components/ui/input", () => ({ Input: passthrough("input") }));
vi.mock("@/components/ui/input", () => ({
Input: defineComponent({
props: ["modelValue"],
emits: ["update:modelValue"],
setup(props, { emit }) {
return () => h("input", { value: props.modelValue, onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value) });
},
}),
}));
vi.mock("@/components/ui/label", () => ({ Label: passthrough("label") }));
vi.mock("@/components/ui/select", () => ({
Select: passthrough("div"),
@@ -181,6 +191,11 @@ beforeEach(() => {
}
});
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.inspectSqlFileTables.mockResolvedValue([
{ database: "app", name: "users" },
{ database: "app", name: "orders" },
{ database: "archive", name: "users" },
]);
mocks.fetchSqlFileTargetOptions.mockResolvedValue([]);
mocks.openFileDialog.mockResolvedValue(["/tmp/first.sql", "/tmp/second.sql"]);
mocks.previewSqlFile.mockImplementation(async (filePath: string) => ({
@@ -296,3 +311,93 @@ describe("SqlFileExecutionDialog retries", () => {
expect(root!.textContent).not.toContain("stale statement failure");
});
});
describe("SqlFileExecutionDialog selected-table restore", () => {
async function mountBackup() {
root = document.createElement("div");
document.body.append(root);
app = createApp(SqlFileExecutionDialog, { open: true, prefillConnectionId: "mysql-1", prefillDatabase: "app", prefillFilePath: "/tmp/backup.sql.gz" });
app.mount(root);
await vi.waitFor(() => expect(findButton("sqlFile.execute").disabled).toBe(false));
root.querySelectorAll<HTMLInputElement>('input[type="radio"]')[1]!.click();
await nextTick();
}
it("scans the backup and sends only checked table identities", async () => {
await mountBackup();
await vi.waitFor(() => expect(root!.textContent).toContain("archive.users"));
expect(mocks.inspectSqlFileTables).toHaveBeenCalledWith("/tmp/backup.sql.gz");
expect(findButton("sqlFile.execute").disabled).toBe(true);
const label = Array.from(root!.querySelectorAll("label")).find((label) => label.textContent === "app.users")!;
label.querySelector<HTMLInputElement>("input")!.click();
await nextTick();
mocks.executeSqlFiles.mockImplementationOnce(async (request) => {
mocks.progressHandler?.(progress(request.executionId, "done", { successCount: 2 }));
});
findButton("sqlFile.execute").click();
await vi.waitFor(() => expect(mocks.executeSqlFiles).toHaveBeenCalledWith(expect.objectContaining({ selectedTables: [{ database: "app", name: "users" }] }), ["/tmp/backup.sql.gz"]));
});
it("selects only search matches and preserves selections outside the search", async () => {
await mountBackup();
await vi.waitFor(() => expect(root!.textContent).toContain("archive.users"));
const search = root!.querySelector<HTMLInputElement>('[aria-label="sqlFile.searchBackupTables"]')!;
search.value = "app.";
search.dispatchEvent(new Event("input"));
await nextTick();
root!.querySelector<HTMLInputElement>('[data-table-restore] input[type="checkbox"]')!.click();
await nextTick();
search.value = "archive";
search.dispatchEvent(new Event("input"));
await nextTick();
expect(root!.querySelector<HTMLInputElement>('[data-table-restore] input[type="checkbox"]')!.checked).toBe(false);
mocks.executeSqlFiles.mockImplementationOnce(async (request) => {
mocks.progressHandler?.(progress(request.executionId, "done"));
});
findButton("sqlFile.execute").click();
await vi.waitFor(() =>
expect(mocks.executeSqlFiles).toHaveBeenCalledWith(
expect.objectContaining({
selectedTables: [
{ database: "app", name: "users" },
{ database: "app", name: "orders" },
],
}),
expect.any(Array),
),
);
});
it("blocks execution on scan failure and allows switching back to full restore", async () => {
mocks.inspectSqlFileTables.mockRejectedValueOnce(new Error("unsupported dump statement"));
await mountBackup();
await vi.waitFor(() => expect(root!.querySelector('[role="alert"]')?.textContent).toContain("unsupported dump statement"));
expect(findButton("sqlFile.execute").disabled).toBe(true);
root!.querySelector<HTMLInputElement>('input[type="radio"]')!.click();
await nextTick();
expect(findButton("sqlFile.execute").disabled).toBe(false);
});
it("ignores a scan result after switching back to all contents", async () => {
let finish!: (tables: any[]) => void;
mocks.inspectSqlFileTables.mockImplementationOnce(
() =>
new Promise((resolve) => {
finish = resolve;
}),
);
await mountBackup();
expect(findButton("sqlFile.execute").disabled).toBe(true);
root!.querySelector<HTMLInputElement>('input[type="radio"]')!.click();
await nextTick();
finish([{ database: null, name: "stale" }]);
await nextTick();
expect(root!.textContent).not.toContain("stale");
mocks.executeSqlFiles.mockImplementationOnce(async (request) => {
mocks.progressHandler?.(progress(request.executionId, "done"));
});
findButton("sqlFile.execute").click();
await vi.waitFor(() => expect(mocks.executeSqlFiles).toHaveBeenCalled());
expect(mocks.executeSqlFiles.mock.calls[0]![0]).not.toHaveProperty("selectedTables");
});
});
@@ -18,8 +18,8 @@ import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import { connectionIsEffectivelyReadOnly, ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess";
import { fetchSqlFileTargetOptions } from "@/composables/useDatabaseOptions";
import { requiresSqlFileTargetDatabaseSelection } from "@/lib/connection/connectionLevelDatabaseBootstrap";
import { cancelSqlFileExecution, executeSqlFiles, listenSqlFileProgress, previewSqlFile, type SqlFilePreview, type SqlFileProgress, type SqlFileStatus } from "@/lib/backend/api";
import { requiresSqlFileTargetDatabaseSelection, supportsConnectionLevelDatabaseBootstrap } from "@/lib/connection/connectionLevelDatabaseBootstrap";
import { cancelSqlFileExecution, executeSqlFiles, inspectSqlFileTables, listenSqlFileProgress, previewSqlFile, type SqlFilePreview, type SqlFileProgress, type SqlFileStatus, type SqlFileTable } from "@/lib/backend/api";
import { buildDisplayFileNames, tooltipText as computeTooltipText } from "./sqlFilePreviewLabel";
import { useExportTracker, type ExportTask } from "@/composables/useExportTracker";
import { translateBackendError } from "@/i18n/backend-errors";
@@ -135,9 +135,59 @@ const sqlConnections = computed(() => store.connections.filter((c) => !["redis",
const selectedConnection = computed(() => sqlConnections.value.find((c) => c.id === connectionId.value));
const restoreSelectedTables = ref(false);
const backupTables = ref<SqlFileTable[]>([]);
const selectedTableKeys = ref(new Set<string>());
const tableSearch = ref("");
const loadingTables = ref(false);
const tableScanError = ref("");
let tableScanGeneration = 0;
const canSelectTables = computed(() => previews.value.length === 1 && supportsConnectionLevelDatabaseBootstrap(selectedConnection.value));
const tableKey = (table: SqlFileTable) => JSON.stringify([table.database, table.name]);
const tableLabel = (table: SqlFileTable) => (table.database ? `${table.database}.${table.name}` : table.name);
const filteredBackupTables = computed(() => backupTables.value.filter((table) => tableLabel(table).toLocaleLowerCase().includes(tableSearch.value.trim().toLocaleLowerCase())));
const selectedTables = computed(() => backupTables.value.filter((table) => selectedTableKeys.value.has(tableKey(table))));
const allFilteredTablesSelected = computed(() => filteredBackupTables.value.length > 0 && filteredBackupTables.value.every((table) => selectedTableKeys.value.has(tableKey(table))));
function toggleTable(table: SqlFileTable) {
const key = tableKey(table);
if (selectedTableKeys.value.has(key)) selectedTableKeys.value.delete(key);
else selectedTableKeys.value.add(key);
}
function toggleFilteredTables() {
const deselect = allFilteredTablesSelected.value;
for (const table of filteredBackupTables.value) {
if (deselect) selectedTableKeys.value.delete(tableKey(table));
else selectedTableKeys.value.add(tableKey(table));
}
}
watch([restoreSelectedTables, canSelectTables, () => previews.value[0]?.filePath, connectionId, open], async () => {
const generation = ++tableScanGeneration;
backupTables.value = [];
selectedTableKeys.value = new Set();
tableSearch.value = "";
tableScanError.value = "";
loadingTables.value = false;
if (!canSelectTables.value) restoreSelectedTables.value = false;
if (!open.value || !restoreSelectedTables.value || !canSelectTables.value) return;
loadingTables.value = true;
try {
const tables = await inspectSqlFileTables(previews.value[0]!.filePath);
if (generation !== tableScanGeneration) return;
backupTables.value = tables;
} catch (error: any) {
if (generation === tableScanGeneration) tableScanError.value = error?.message || String(error);
} finally {
if (generation === tableScanGeneration) loadingTables.value = false;
}
});
const canStart = computed(() => {
const connection = selectedConnection.value;
if (previews.value.length === 0 || !connection || running.value || loadingPreview.value || loadingDatabases.value) return false;
if (restoreSelectedTables.value && (loadingTables.value || tableScanError.value || selectedTables.value.length === 0)) return false;
let hasDatabaseContext = false;
const canExecuteWithoutSelectedDatabase = previews.value.every((item) => {
if (!hasDatabaseContext && !item.canExecuteWithoutSelectedDatabase) return false;
@@ -259,6 +309,7 @@ function resetState() {
databaseOptions.value = [];
loadingDatabases.value = false;
continueOnError.value = false;
restoreSelectedTables.value = false;
resetExecution();
}
@@ -495,6 +546,7 @@ async function startExecution() {
database: database.value.trim(),
filePath: previews.value[0]!.filePath,
continueOnError: continueOnError.value,
...(restoreSelectedTables.value ? { selectedTables: selectedTables.value.map((table) => ({ ...table })) } : {}),
},
previews.value.map((item) => item.filePath),
);
@@ -702,6 +754,33 @@ watch(
</div>
</div>
<div v-if="canSelectTables" class="min-w-0 space-y-2.5" data-table-restore>
<Label class="text-xs">{{ t("sqlFile.restoreScope") }}</Label>
<div class="flex items-center gap-4 text-xs">
<label class="flex items-center gap-2"><input v-model="restoreSelectedTables" type="radio" :value="false" :disabled="running" name="sql-file-restore-scope" />{{ t("sqlFile.restoreAll") }}</label>
<label class="flex items-center gap-2"><input v-model="restoreSelectedTables" type="radio" :value="true" :disabled="running" name="sql-file-restore-scope" />{{ t("sqlFile.restoreSelectedTables") }}</label>
</div>
<template v-if="restoreSelectedTables">
<p class="text-xs text-muted-foreground">{{ t("sqlFile.restoreTablesOnly") }}</p>
<div v-if="loadingTables" class="flex items-center gap-2 text-xs" role="status"><Loader2 class="h-3.5 w-3.5 animate-spin" />{{ t("sqlFile.scanningTables") }}</div>
<p v-else-if="tableScanError" class="break-words text-xs text-destructive" role="alert">{{ tableScanError }}</p>
<template v-else>
<Input v-model="tableSearch" :placeholder="t('sqlFile.searchBackupTables')" :aria-label="t('sqlFile.searchBackupTables')" :disabled="running" class="h-8 text-xs" />
<div class="flex items-center justify-between gap-3 text-xs">
<label class="flex items-center gap-2"><input type="checkbox" :checked="allFilteredTablesSelected" :disabled="running || filteredBackupTables.length === 0" @change="toggleFilteredTables" />{{ t("sqlFile.selectVisibleTables") }}</label>
<span>{{ t("sqlFile.selectedTableCount", { selected: selectedTables.length, total: backupTables.length }) }}</span>
</div>
<div class="max-h-44 overflow-y-auto border rounded-md p-2 text-xs">
<label v-for="table in filteredBackupTables" :key="tableKey(table)" class="flex min-w-0 items-center gap-2 py-1">
<input type="checkbox" :checked="selectedTableKeys.has(tableKey(table))" :disabled="running" @change="toggleTable(table)" />
<span class="min-w-0 break-all">{{ tableLabel(table) }}</span>
</label>
<p v-if="filteredBackupTables.length === 0" class="text-muted-foreground">{{ t("sqlFile.noBackupTables") }}</p>
</div>
</template>
</template>
</div>
<div class="min-w-0 space-y-2.5">
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{{ t("sqlFile.options") }}