From 9cfee7f49775a17747378e3a2bd8dfd41d108094 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Fri, 24 Jul 2026 23:52:10 +0800 Subject: [PATCH] feat(export): support database setup and batch SQL scripts --- .../export/DatabaseExportDialog.vue | 49 ++++- .../sql-file/SqlFileExecutionDialog.vue | 175 ++++++++------- apps/desktop/src/i18n/locales/en.ts | 4 +- apps/desktop/src/i18n/locales/es.ts | 4 +- apps/desktop/src/i18n/locales/it.ts | 4 +- apps/desktop/src/i18n/locales/ja.ts | 4 +- apps/desktop/src/i18n/locales/pt-BR.ts | 4 +- apps/desktop/src/i18n/locales/zh-CN.ts | 4 +- apps/desktop/src/i18n/locales/zh-TW.ts | 4 +- apps/desktop/src/lib/backend/api.ts | 1 + apps/desktop/src/lib/backend/http.ts | 4 + apps/desktop/src/lib/backend/tauri.ts | 6 + .../src/lib/sql/httpSqlFileProgress.ts | 40 ++-- crates/dbx-core/src/database_export.rs | 122 +++++++++-- crates/dbx-core/src/sql.rs | 2 + crates/dbx-core/src/sql_file_import.rs | 205 ++++++++++++++---- .../tests/database_export_prefetch.rs | 1 + crates/dbx-web/src/main.rs | 6 +- crates/dbx-web/src/routes/sql_file.rs | 175 +++++++++++---- src-tauri/src/commands/sql_file.rs | 42 ++-- src-tauri/src/lib.rs | 1 + 21 files changed, 622 insertions(+), 235 deletions(-) diff --git a/apps/desktop/src/components/export/DatabaseExportDialog.vue b/apps/desktop/src/components/export/DatabaseExportDialog.vue index 7c9b95c9f0..f0a7ac2398 100644 --- a/apps/desktop/src/components/export/DatabaseExportDialog.vue +++ b/apps/desktop/src/components/export/DatabaseExportDialog.vue @@ -59,6 +59,7 @@ const tableError = ref(null); const includeStructure = ref(true); const includeData = ref(true); const includeObjects = ref(true); +const includeCreateDatabase = ref(false); const dropTableIfExists = ref(false); const omitAutoIncrement = ref(false); // `AUTO_INCREMENT` stripping is a MySQL-only DDL transform (backend gates on @@ -77,6 +78,7 @@ const pendingPrefillTables = ref([]); const exportAllDatabases = ref(false); const batchDatabaseIndex = ref(0); const batchDatabaseTotal = ref(0); +const batchRowsExported = ref(0); const activeDatabaseExportId = ref(""); const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"].includes(c.db_type))); @@ -110,11 +112,6 @@ function joinExportPath(directory: string, fileName: string): string { return `${directory.replace(/[\\/]+$/, "")}${separator}${fileName}`; } -function currentRowsExported(): number { - const progress: ExportProgress | null = exportProgress.value; - return progress?.rowsExported ?? 0; -} - async function loadDatabases(connId: string) { if (!connId) return; loadingMeta.value = true; @@ -276,6 +273,7 @@ async function startExport() { includeStructure: includeStructure.value, includeData: includeData.value, includeObjects: includeObjects.value, + includeCreateDatabase: includeCreateDatabase.value, dropTableIfExists: dropTableIfExists.value, omitAutoIncrement: omitAutoIncrement.value, batchSize: 1000, @@ -343,6 +341,7 @@ async function startAllDatabasesExport() { exportCancelled.value = false; exportProgress.value = null; batchDatabaseIndex.value = 0; + batchRowsExported.value = 0; const dbs = [...selectedDatabases.value]; const batchId = generateDatabaseExportId(); @@ -370,19 +369,30 @@ async function startAllDatabasesExport() { includeStructure: includeStructure.value, includeData: includeData.value, includeObjects: includeObjects.value, + includeCreateDatabase: includeCreateDatabase.value, dropTableIfExists: dropTableIfExists.value, omitAutoIncrement: omitAutoIncrement.value, batchSize: 1000, }; + let currentDatabaseRowsExported = 0; await runDatabaseExportUntilTerminal(request, (progress) => { - exportProgress.value = { ...progress, exportId: batchId, currentObject: `${item.displayName}: ${progress.currentObject || item.displayName}` }; + const nextRowsExported = Math.max(0, progress.rowsExported); + batchRowsExported.value += Math.max(0, nextRowsExported - currentDatabaseRowsExported); + currentDatabaseRowsExported = nextRowsExported; + exportProgress.value = { + ...progress, + exportId: batchId, + currentObject: `${item.displayName}: ${progress.currentObject || item.displayName}`, + rowsExported: batchRowsExported.value, + }; updateDatabaseExportTask(batchId, { ...progress, exportId: batchId, currentObject: item.displayName, objectIndex: index, totalObjects: exportPlan.length, + rowsExported: batchRowsExported.value, }); if (progress.status === "Error") { exportError.value = progress.error; @@ -400,16 +410,18 @@ async function startAllDatabasesExport() { if (!exportError.value && !exportCancelled.value) { exportDone.value = true; isExporting.value = false; - updateDatabaseExportTask(batchId, { + const finalProgress: api.ExportProgress = { exportId: batchId, currentObject: t("databaseExport.allDatabasesTask", { count: dbs.length }), objectIndex: exportPlan.length, totalObjects: exportPlan.length, - rowsExported: currentRowsExported(), + rowsExported: batchRowsExported.value, totalRows: null, status: "Done", error: null, - }); + }; + exportProgress.value = finalProgress; + updateDatabaseExportTask(batchId, finalProgress); toast(t("databaseExport.exportAllSuccess", { count: dbs.length }), 3000); } } catch (e: any) { @@ -419,7 +431,7 @@ async function startAllDatabasesExport() { currentObject: t("databaseExport.allDatabasesTask", { count: dbs.length }), objectIndex: Math.max(0, batchDatabaseIndex.value - 1), totalObjects: batchDatabaseTotal.value || dbs.length, - rowsExported: currentRowsExported(), + rowsExported: batchRowsExported.value, totalRows: null, status: "Error", error: exportError.value, @@ -459,6 +471,7 @@ function resetState() { includeStructure.value = true; includeData.value = true; includeObjects.value = true; + includeCreateDatabase.value = false; dropTableIfExists.value = false; omitAutoIncrement.value = false; isExporting.value = false; @@ -469,10 +482,17 @@ function resetState() { exportId.value = ""; batchDatabaseIndex.value = 0; batchDatabaseTotal.value = 0; + batchRowsExported.value = 0; activeDatabaseExportId.value = ""; } const progressPercent = computed(() => { + if (exportAllDatabases.value && batchDatabaseTotal.value > 0) { + if (exportDone.value) return 100; + const current = exportProgress.value; + const currentDatabaseProgress = current && current.totalObjects > 0 ? current.objectIndex / current.totalObjects : 0; + return Math.round(Math.min(1, (Math.max(0, batchDatabaseIndex.value - 1) + currentDatabaseProgress) / batchDatabaseTotal.value) * 100); + } const p = exportProgress.value; if (!p || p.totalObjects === 0) return 0; return Math.round((p.objectIndex / p.totalObjects) * 100); @@ -679,6 +699,11 @@ watch( {{ t("databaseExport.includeStructure") }} +
+ + + {{ t("databaseExport.includeCreateDatabase") }} +
@@ -708,7 +733,7 @@ watch( {{ t("databaseExport.currentDatabase", { current: batchDatabaseIndex, total: batchDatabaseTotal }) }}
-
+
{{ t("databaseExport.currentTable", { table: exportProgress.currentObject, @@ -723,7 +748,7 @@ watch(
- {{ t("databaseExport.rowsExported", { count: exportProgress.rowsExported.toLocaleString() }) }} + {{ exportAllDatabases ? t("databaseExport.allRowsExported", { count: exportProgress.rowsExported.toLocaleString() }) : t("databaseExport.rowsExported", { current: exportProgress.objectIndex, total: exportProgress.totalObjects, count: exportProgress.rowsExported.toLocaleString() }) }}
diff --git a/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue b/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue index bfc646ef38..9dcbc190c8 100644 --- a/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue +++ b/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue @@ -17,7 +17,7 @@ import { useProductionSafetyStore } from "@/stores/productionSafetyStore"; import { productionContextForDatabase } from "@/lib/database/productionSafety"; import { fetchSqlFileTargetOptions } from "@/composables/useDatabaseOptions"; import { requiresSqlFileTargetDatabaseSelection } from "@/lib/connection/connectionLevelDatabaseBootstrap"; -import { cancelSqlFileExecution, executeSqlFile, listenSqlFileProgress, previewSqlFile, type SqlFilePreview, type SqlFileProgress, type SqlFileStatus } from "@/lib/backend/api"; +import { cancelSqlFileExecution, executeSqlFiles, listenSqlFileProgress, previewSqlFile, type SqlFilePreview, type SqlFileProgress, type SqlFileStatus } from "@/lib/backend/api"; import { useExportTracker } from "@/composables/useExportTracker"; import { Check, CheckSquare, FileCode, FolderOpen, Loader2, Play, Square, X } from "@lucide/vue"; @@ -38,7 +38,7 @@ const productionSafetyStore = useProductionSafetyStore(); const fileInput = ref(null); const filePath = ref(""); -const preview = ref(null); +const previews = ref([]); const selectingFile = ref(false); const loadingPreview = ref(false); const connectionId = ref(""); @@ -56,6 +56,7 @@ const progress = ref(null); const terminalStatus = ref("idle"); const terminalError = ref(""); const refreshedTarget = ref(false); +const MAX_WEB_SQL_FILE_BYTES = 200 * 1024 * 1024; const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"].includes(c.db_type))); @@ -63,8 +64,14 @@ const selectedConnection = computed(() => sqlConnections.value.find((c) => c.id const canStart = computed(() => { const connection = selectedConnection.value; - if (!preview.value || !connection || running.value || loadingPreview.value || loadingDatabases.value) return false; - return !!database.value.trim() || !requiresSqlFileTargetDatabaseSelection(connection, preview.value.canExecuteWithoutSelectedDatabase); + if (previews.value.length === 0 || !connection || running.value || loadingPreview.value || loadingDatabases.value) return false; + let hasDatabaseContext = false; + const canExecuteWithoutSelectedDatabase = previews.value.every((item) => { + if (!hasDatabaseContext && !item.canExecuteWithoutSelectedDatabase) return false; + hasDatabaseContext ||= item.establishesDatabaseContext === true; + return true; + }); + return !!database.value.trim() || !requiresSqlFileTargetDatabaseSelection(connection, canExecuteWithoutSelectedDatabase); }); const statusTone = computed(() => { @@ -90,6 +97,7 @@ const progressPercent = computed(() => { if (current <= 0) return running.value ? 8 : 0; return Math.min(95, Math.max(8, Math.round((attempted / current) * 100))); }); +const preview = computed(() => previews.value[0] ?? null); const previewLineCount = computed(() => preview.value?.preview.split(/\r\n|\r|\n/).length ?? 0); const previewLineNumbers = computed(() => Array.from({ length: previewLineCount.value }, (_, index) => index + 1)); const previewIsTruncated = computed(() => { @@ -127,10 +135,6 @@ function statusLabel(status: SqlFileStatus | "idle") { return t(`sqlFile.status.${status}`); } -function isTerminalStatus(status: SqlFileStatus | "idle") { - return status === "done" || status === "error" || status === "cancelled"; -} - function resolveInitialConnectionId() { if (props.prefillConnectionId && sqlConnections.value.some((c) => c.id === props.prefillConnectionId)) { return props.prefillConnectionId; @@ -162,7 +166,7 @@ function resetExecution() { function resetState() { filePath.value = ""; - preview.value = null; + previews.value = []; selectingFile.value = false; loadingPreview.value = false; connectionId.value = resolveInitialConnectionId(); @@ -209,17 +213,26 @@ async function previewSelectedSqlFile(fileOrPath: string | File) { if (isTauriRuntime()) { return previewSqlFile(fileOrPath as string); } + const file = fileOrPath as File; + if (file.size > MAX_WEB_SQL_FILE_BYTES) { + throw new Error(`File too large: ${file.size} bytes (max ${MAX_WEB_SQL_FILE_BYTES} bytes)`); + } const { previewSqlFile: previewWebSqlFile } = await import("@/lib/backend/http"); - return previewWebSqlFile(fileOrPath as File); + return previewWebSqlFile(file); } -async function loadPreview(fileOrPath: string | File) { +async function loadPreviews(filesOrPaths: Array) { loadingPreview.value = true; - preview.value = null; + previews.value = []; + filePath.value = ""; + resetExecution(); try { - preview.value = await previewSelectedSqlFile(fileOrPath); - filePath.value = preview.value.filePath; - resetExecution(); + const nextPreviews: SqlFilePreview[] = []; + for (const fileOrPath of filesOrPaths) { + nextPreviews.push(await previewSelectedSqlFile(fileOrPath)); + } + previews.value = nextPreviews; + filePath.value = nextPreviews.map((item) => item.filePath).join("; "); } catch (e: any) { toast(e?.message || String(e), 5000); } finally { @@ -237,11 +250,12 @@ async function selectFile() { try { const { open } = await import("@tauri-apps/plugin-dialog"); const selected = await open({ - multiple: false, + multiple: true, filters: [{ name: "SQL", extensions: ["sql"] }], }); - if (typeof selected === "string") { - await loadPreview(selected); + const paths = Array.isArray(selected) ? selected : selected ? [selected] : []; + if (paths.length > 0) { + await loadPreviews(paths); } } catch (e: any) { toast(e?.message || String(e), 5000); @@ -252,23 +266,27 @@ async function selectFile() { async function handleFileInputChange(event: Event) { const input = event.target as HTMLInputElement; - const file = input.files?.[0]; + const files = Array.from(input.files ?? []); input.value = ""; - if (!file || running.value) return; + if (files.length === 0 || running.value) return; selectingFile.value = true; try { - await loadPreview(file); + await loadPreviews(files); } finally { selectingFile.value = false; } } -async function listenProgress(id: string, handler: (next: SqlFileProgress) => void): Promise<() => void> { +async function listenProgress(id: string, handler: (next: SqlFileProgress) => void, onError?: (error: Error) => void): Promise<() => void> { if (isTauriRuntime()) { return listenSqlFileProgress(handler); } const { listenSqlFileProgressById } = await import("@/lib/sql/httpSqlFileProgress"); - return listenSqlFileProgressById(id, handler); + return listenSqlFileProgressById(id, handler, onError); +} + +function isTerminalProgress(status: SqlFileStatus): boolean { + return status === "done" || status === "error" || status === "cancelled"; } async function refreshTargetAfterImport() { @@ -282,12 +300,15 @@ async function refreshTargetAfterImport() { } async function startExecution() { - if (!canStart.value || !preview.value) return; + if (!canStart.value || previews.value.length === 0) return; const productionContext = productionContextForDatabase(selectedConnection.value, database.value); if (productionContext.active) { // File previews are truncated, so production file execution is always reviewed instead of inferring safety from a partial preview. const confirmed = await productionSafetyStore.requestConfirmation({ - sql: preview.value.preview, + sql: previews.value + .map((item) => item.preview) + .join("\n\n") + .slice(0, 20_000), connectionName: selectedConnection.value?.name, database: database.value, productionDatabases: productionContext.databases, @@ -296,8 +317,8 @@ async function startExecution() { if (!confirmed) return; } - const id = uuid(); - executionId.value = id; + const batchId = uuid(); + executionId.value = batchId; running.value = true; cancelling.value = false; cancelRequested.value = false; @@ -305,9 +326,9 @@ async function startExecution() { terminalStatus.value = "running"; terminalError.value = ""; progress.value = null; - addSqlFileTask(id, preview.value.fileName, preview.value.filePath); + const taskLabel = previews.value.length === 1 ? previews.value[0]!.fileName : `${previews.value[0]!.fileName} (+${previews.value.length - 1})`; + addSqlFileTask(batchId, taskLabel, filePath.value); - let unlisten: (() => void) | undefined; try { await store.ensureConnected(connectionId.value); if (cancelRequested.value) { @@ -315,58 +336,60 @@ async function startExecution() { return; } - unlisten = await listenProgress(id, (next) => { - if (next.executionId !== id) return; - progress.value = next; - terminalStatus.value = next.status; - terminalError.value = next.error ?? terminalError.value; - updateSqlFileTask(id, next); - if (isTerminalStatus(next.status)) { - running.value = false; - cancelling.value = false; - } - if (next.status === "done") { - void refreshTargetAfterImport(); - } + let resolveTerminalProgress: (progress: SqlFileProgress) => void = () => {}; + let rejectTerminalProgress: (error: Error) => void = () => {}; + const terminalProgress = new Promise((resolve, reject) => { + resolveTerminalProgress = resolve; + rejectTerminalProgress = reject; }); + let completedSuccessfully = false; + const unlisten = await listenProgress( + batchId, + (next) => { + if (next.executionId !== batchId) return; + progress.value = next; + terminalStatus.value = next.status; + terminalError.value = next.error ?? terminalError.value; + updateSqlFileTask(batchId, next); + if (isTerminalProgress(next.status)) { + resolveTerminalProgress(next); + } + }, + rejectTerminalProgress, + ); - if (cancelRequested.value) { - terminalStatus.value = "cancelled"; - return; + try { + executionStarted.value = true; + await executeSqlFiles( + { + executionId: batchId, + connectionId: connectionId.value, + database: database.value.trim(), + filePath: previews.value[0]!.filePath, + continueOnError: continueOnError.value, + }, + previews.value.map((item) => item.filePath), + ); + const terminal = await terminalProgress; + if (terminal.status === "error") { + throw new Error(terminal.error || "SQL file execution failed"); + } + if (terminal.status === "cancelled") { + cancelRequested.value = true; + } + completedSuccessfully = terminal.status === "done"; + } finally { + executionStarted.value = false; + unlisten(); } - executionStarted.value = true; - await executeSqlFile({ - executionId: id, - connectionId: connectionId.value, - database: database.value.trim(), - filePath: preview.value.filePath, - continueOnError: continueOnError.value, - }); - if (!isTerminalStatus(terminalStatus.value)) { - terminalStatus.value = cancelRequested.value ? "cancelled" : "done"; - const lastProgress = progress.value as SqlFileProgress | null; - updateSqlFileTask(id, { - executionId: id, - status: terminalStatus.value, - statementIndex: lastProgress?.statementIndex ?? 0, - successCount: lastProgress?.successCount ?? 0, - failureCount: lastProgress?.failureCount ?? 0, - affectedRows: lastProgress?.affectedRows ?? 0, - elapsedMs: lastProgress?.elapsedMs ?? 0, - statementSummary: lastProgress?.statementSummary ?? "", - error: lastProgress?.error ?? null, - }); - if (terminalStatus.value === "done") { - await refreshTargetAfterImport(); - } - } + if (completedSuccessfully) await refreshTargetAfterImport(); } catch (e: any) { terminalStatus.value = cancelRequested.value ? "cancelled" : "error"; terminalError.value = e?.message || String(e); const lastProgress = progress.value as SqlFileProgress | null; - updateSqlFileTask(id, { - executionId: id, + updateSqlFileTask(batchId, { + executionId: batchId, status: terminalStatus.value, statementIndex: lastProgress?.statementIndex ?? 0, successCount: lastProgress?.successCount ?? 0, @@ -380,7 +403,6 @@ async function startExecution() { toast(terminalError.value, 5000); } } finally { - unlisten?.(); running.value = false; cancelling.value = false; executionStarted.value = false; @@ -429,7 +451,7 @@ watch( // When opened from the SQL Files panel with a pre-selected file, load its // preview automatically so the user can review statements before running. if (props.prefillFilePath) { - void loadPreview(props.prefillFilePath); + void loadPreviews([props.prefillFilePath]); } }, { immediate: true }, @@ -454,7 +476,7 @@ watch(
- +