From 9ba2976d1169794bcebe5aa697728ffa40b38151 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 13 May 2026 01:08:16 +0800 Subject: [PATCH] feat: export entire database as SQL file Add a new "Export Database" feature accessible from the sidebar right-click menu on database/schema nodes. Generates a .sql file containing CREATE TABLE DDL, batched INSERT statements, and view/procedure/function source code. Backend (database_export.rs): - Stream-writes SQL to file with configurable batch size - Supports all SQL database types via existing DDL/INSERT generators - Progress events with cancellation support - Graceful error handling (continues on per-table failures) Frontend (DatabaseExportDialog.vue): - Connection/database/schema selector - Checkboxes: structure, data, objects - Real-time progress with current table and row count - Cancel button Wired up through Tauri commands, Web SSE routes, and API layer. --- crates/dbx-core/src/database_export.rs | 434 ++++++++++++++++++ src-tauri/src/commands/database_export.rs | 53 +++ src-web/src/routes/database_export.rs | 86 ++++ .../export/DatabaseExportDialog.vue | 381 +++++++++++++++ src/components/layout/AppDialogs.vue | 7 + src/components/sidebar/TreeItem.vue | 152 +----- src/composables/useDialogSources.ts | 21 + src/i18n/locales/en.ts | 13 + src/i18n/locales/es.ts | 13 + src/i18n/locales/zh-CN.ts | 13 + src/lib/api.ts | 6 + src/lib/http.ts | 40 ++ src/lib/tauri.ts | 48 ++ src/stores/connectionStore.ts | 6 + 14 files changed, 1134 insertions(+), 139 deletions(-) create mode 100644 crates/dbx-core/src/database_export.rs create mode 100644 src-tauri/src/commands/database_export.rs create mode 100644 src-web/src/routes/database_export.rs create mode 100644 src/components/export/DatabaseExportDialog.vue diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs new file mode 100644 index 0000000000..bd1a49dfa4 --- /dev/null +++ b/crates/dbx-core/src/database_export.rs @@ -0,0 +1,434 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::io::Write; +use tokio::sync::RwLock; + +use crate::models::connection::DatabaseType; + +static EXPORT_CANCELLED: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| RwLock::new(HashSet::new())); + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DatabaseExportRequest { + pub export_id: String, + pub connection_id: String, + pub database: String, + pub schema: String, + pub file_path: String, + pub include_structure: bool, + pub include_data: bool, + pub include_objects: bool, + pub batch_size: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExportProgress { + pub export_id: String, + pub current_object: String, + pub object_index: usize, + pub total_objects: usize, + pub rows_exported: u64, + pub total_rows: Option, + pub status: ExportStatus, + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ExportStatus { + Running, + Done, + Error, + Cancelled, +} + +pub async fn is_export_cancelled(export_id: &str) -> bool { + EXPORT_CANCELLED.read().await.contains(export_id) +} + +pub async fn set_export_cancelled(export_id: &str) { + EXPORT_CANCELLED.write().await.insert(export_id.to_string()); +} + +pub async fn clear_export_cancelled(export_id: &str) { + EXPORT_CANCELLED.write().await.remove(export_id); +} + +pub async fn export_database_sql_core( + state: &crate::connection::AppState, + request: &DatabaseExportRequest, + on_progress: impl Fn(ExportProgress), +) -> Result<(), String> { + // 1. Get database type + let db_type = state + .configs + .read() + .await + .get(&request.connection_id) + .map(|c| c.db_type.clone()) + .ok_or_else(|| format!("Connection config not found: {}", request.connection_id))?; + + // 2. Get pool + let pool_key = state.get_or_create_pool(&request.connection_id, Some(&request.database)).await?; + + // 3. List tables + let all_tables = + crate::schema::list_tables_core(state, &request.connection_id, &request.database, &request.schema).await?; + + // 4. Create file + let mut file = std::fs::File::create(&request.file_path).map_err(|e| format!("Failed to write file: {e}"))?; + + // 5. Write header + let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S"); + writeln!(file, "-- Database export: {}", request.database).map_err(|e| format!("Failed to write file: {e}"))?; + writeln!(file, "-- Date: {timestamp}").map_err(|e| format!("Failed to write file: {e}"))?; + writeln!(file, "-- Generated by DBX").map_err(|e| format!("Failed to write file: {e}"))?; + writeln!(file).map_err(|e| format!("Failed to write file: {e}"))?; + + // 6. For MySQL: disable foreign key checks + if matches!(db_type, DatabaseType::Mysql) { + writeln!(file, "SET FOREIGN_KEY_CHECKS = 0;\n").map_err(|e| format!("Failed to write file: {e}"))?; + } + + // 7. Separate tables and views + let tables: Vec<_> = all_tables.iter().filter(|t| t.table_type != "VIEW").collect(); + let views: Vec<_> = all_tables.iter().filter(|t| t.table_type == "VIEW").collect(); + + // 8. Calculate total objects + let mut total_objects = tables.len() + views.len(); + + // We'll add procedures/functions count later if include_objects + let mut procedures: Vec = Vec::new(); + let mut functions: Vec = Vec::new(); + + if request.include_objects { + if let Ok(objects) = + crate::schema::list_objects_core(state, &request.connection_id, &request.database, &request.schema).await + { + for obj in &objects { + let ot = obj.object_type.to_uppercase(); + if ot.contains("PROCEDURE") { + procedures.push(obj.name.clone()); + } else if ot.contains("FUNCTION") { + functions.push(obj.name.clone()); + } + } + } + total_objects += procedures.len() + functions.len(); + } + + let mut object_index: usize = 0; + + // Export tables + let batch_size = if request.batch_size == 0 { 1000 } else { request.batch_size }; + + for table_info in &tables { + // Check cancellation + if is_export_cancelled(&request.export_id).await { + on_progress(ExportProgress { + export_id: request.export_id.clone(), + current_object: table_info.name.clone(), + object_index, + total_objects, + rows_exported: 0, + total_rows: None, + status: ExportStatus::Cancelled, + error: None, + }); + return Err("Export cancelled".to_string()); + } + + let table_name = &table_info.name; + + // Emit Running progress + on_progress(ExportProgress { + export_id: request.export_id.clone(), + current_object: table_name.clone(), + object_index, + total_objects, + rows_exported: 0, + total_rows: None, + status: ExportStatus::Running, + error: None, + }); + + // Export structure + if request.include_structure { + match crate::schema::get_table_ddl_core( + state, + &request.connection_id, + &request.database, + &request.schema, + table_name, + ) + .await + { + Ok(ddl) => { + writeln!(file, "{};\n", ddl).map_err(|e| format!("Failed to write file: {e}"))?; + } + Err(e) => { + writeln!(file, "-- ERROR exporting table {table_name}: {e}") + .map_err(|e| format!("Failed to write file: {e}"))?; + } + } + } + + // Export data + if request.include_data { + // Get columns + let col_names: Vec = match crate::schema::get_columns_core( + state, + &request.connection_id, + &request.database, + &request.schema, + table_name, + ) + .await + { + Ok(cols) => cols.iter().map(|c| c.name.clone()).collect(), + Err(e) => { + writeln!(file, "-- ERROR exporting table {table_name}: {e}") + .map_err(|e| format!("Failed to write file: {e}"))?; + object_index += 1; + continue; + } + }; + + if !col_names.is_empty() { + // Get row count + let count_query = crate::transfer::count_sql(table_name, &request.schema, &db_type); + let total_rows = match crate::transfer::execute_on_pool(state, &pool_key, &count_query).await { + Ok(result) => result.rows.first().and_then(|r| r.first()).and_then(|v| match v { + serde_json::Value::Number(n) => n.as_u64(), + serde_json::Value::String(s) => s.parse::().ok(), + _ => None, + }), + Err(_) => None, + }; + + // Loop batches + let mut offset: u64 = 0; + let mut rows_exported: u64 = 0; + + loop { + // Check cancellation between batches + if is_export_cancelled(&request.export_id).await { + on_progress(ExportProgress { + export_id: request.export_id.clone(), + current_object: table_name.clone(), + object_index, + total_objects, + rows_exported, + total_rows, + status: ExportStatus::Cancelled, + error: None, + }); + return Err("Export cancelled".to_string()); + } + + let sql = crate::transfer::pagination_sql( + &col_names, + table_name, + &request.schema, + &db_type, + offset, + batch_size, + ); + + let result = match crate::transfer::execute_on_pool(state, &pool_key, &sql).await { + Ok(r) => r, + Err(e) => { + writeln!(file, "-- ERROR exporting data for table {table_name}: {e}") + .map_err(|e| format!("Failed to write file: {e}"))?; + break; + } + }; + + let row_count = result.rows.len(); + if row_count == 0 { + break; + } + + let insert_sql = crate::transfer::generate_insert( + &col_names, + &result.rows, + table_name, + &request.schema, + &db_type, + ); + + if !insert_sql.is_empty() { + writeln!(file, "{};\n", insert_sql).map_err(|e| format!("Failed to write file: {e}"))?; + } + + rows_exported += row_count as u64; + offset += row_count as u64; + + on_progress(ExportProgress { + export_id: request.export_id.clone(), + current_object: table_name.clone(), + object_index, + total_objects, + rows_exported, + total_rows, + status: ExportStatus::Running, + error: None, + }); + + if row_count < batch_size { + break; + } + } + } + } + + object_index += 1; + } + + // Export views (if include_objects) + if request.include_objects { + for view_info in &views { + if is_export_cancelled(&request.export_id).await { + return Err("Export cancelled".to_string()); + } + + let view_name = &view_info.name; + + on_progress(ExportProgress { + export_id: request.export_id.clone(), + current_object: view_name.clone(), + object_index, + total_objects, + rows_exported: 0, + total_rows: None, + status: ExportStatus::Running, + error: None, + }); + + match crate::schema::get_object_source_core( + state, + &request.connection_id, + &request.database, + &request.schema, + view_name, + crate::db::ObjectSourceKind::View, + ) + .await + { + Ok(obj_source) => { + if !obj_source.source.is_empty() { + writeln!(file, "{};\n", obj_source.source).map_err(|e| format!("Failed to write file: {e}"))?; + } + } + Err(e) => { + writeln!(file, "-- ERROR exporting view {view_name}: {e}") + .map_err(|e| format!("Failed to write file: {e}"))?; + } + } + + object_index += 1; + } + + // Export procedures + for proc_name in &procedures { + if is_export_cancelled(&request.export_id).await { + return Err("Export cancelled".to_string()); + } + + on_progress(ExportProgress { + export_id: request.export_id.clone(), + current_object: proc_name.clone(), + object_index, + total_objects, + rows_exported: 0, + total_rows: None, + status: ExportStatus::Running, + error: None, + }); + + match crate::schema::get_object_source_core( + state, + &request.connection_id, + &request.database, + &request.schema, + proc_name, + crate::db::ObjectSourceKind::Procedure, + ) + .await + { + Ok(obj_source) => { + if !obj_source.source.is_empty() { + writeln!(file, "{};\n", obj_source.source).map_err(|e| format!("Failed to write file: {e}"))?; + } + } + Err(e) => { + writeln!(file, "-- ERROR exporting procedure {proc_name}: {e}") + .map_err(|e| format!("Failed to write file: {e}"))?; + } + } + + object_index += 1; + } + + // Export functions + for func_name in &functions { + if is_export_cancelled(&request.export_id).await { + return Err("Export cancelled".to_string()); + } + + on_progress(ExportProgress { + export_id: request.export_id.clone(), + current_object: func_name.clone(), + object_index, + total_objects, + rows_exported: 0, + total_rows: None, + status: ExportStatus::Running, + error: None, + }); + + match crate::schema::get_object_source_core( + state, + &request.connection_id, + &request.database, + &request.schema, + func_name, + crate::db::ObjectSourceKind::Function, + ) + .await + { + Ok(obj_source) => { + if !obj_source.source.is_empty() { + writeln!(file, "{};\n", obj_source.source).map_err(|e| format!("Failed to write file: {e}"))?; + } + } + Err(e) => { + writeln!(file, "-- ERROR exporting function {func_name}: {e}") + .map_err(|e| format!("Failed to write file: {e}"))?; + } + } + + object_index += 1; + } + } + + // For MySQL: re-enable foreign key checks + if matches!(db_type, DatabaseType::Mysql) { + writeln!(file, "SET FOREIGN_KEY_CHECKS = 1;").map_err(|e| format!("Failed to write file: {e}"))?; + } + + // Emit Done progress + on_progress(ExportProgress { + export_id: request.export_id.clone(), + current_object: String::new(), + object_index, + total_objects, + rows_exported: 0, + total_rows: None, + status: ExportStatus::Done, + error: None, + }); + + Ok(()) +} diff --git a/src-tauri/src/commands/database_export.rs b/src-tauri/src/commands/database_export.rs new file mode 100644 index 0000000000..d0b7d9f068 --- /dev/null +++ b/src-tauri/src/commands/database_export.rs @@ -0,0 +1,53 @@ +use std::sync::Arc; +use tauri::{AppHandle, Emitter, State}; + +use crate::commands::connection::AppState; + +pub use dbx_core::database_export::{DatabaseExportRequest, ExportProgress, ExportStatus}; + +fn emit_progress(app: &AppHandle, progress: ExportProgress) { + let _ = app.emit("database-export-progress", progress); +} + +#[tauri::command] +pub async fn export_database_sql( + app: AppHandle, + state: State<'_, Arc>, + request: DatabaseExportRequest, +) -> Result<(), String> { + let state = state.inner().clone(); + let export_id = request.export_id.clone(); + + tokio::spawn(async move { + let result = dbx_core::database_export::export_database_sql_core(&state, &request, |progress| { + emit_progress(&app, progress) + }) + .await; + + if let Err(e) = result { + emit_progress( + &app, + ExportProgress { + export_id: export_id.clone(), + current_object: String::new(), + object_index: 0, + total_objects: 0, + rows_exported: 0, + total_rows: None, + status: ExportStatus::Error, + error: Some(e), + }, + ); + } + + dbx_core::database_export::clear_export_cancelled(&export_id).await; + }); + + Ok(()) +} + +#[tauri::command] +pub async fn cancel_database_export(export_id: String) -> Result<(), String> { + dbx_core::database_export::set_export_cancelled(&export_id).await; + Ok(()) +} diff --git a/src-web/src/routes/database_export.rs b/src-web/src/routes/database_export.rs new file mode 100644 index 0000000000..b25d0f61d5 --- /dev/null +++ b/src-web/src/routes/database_export.rs @@ -0,0 +1,86 @@ +use std::sync::Arc; + +use axum::extract::{Path, State}; +use axum::response::sse::{Event, Sse}; +use axum::Json; +use dbx_core::database_export::{self, DatabaseExportRequest, ExportProgress, ExportStatus}; +use futures::stream::Stream; +use serde::Deserialize; + +use crate::error::AppError; +use crate::state::WebState; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StartExportRequest { + pub request: DatabaseExportRequest, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CancelExportRequest { + pub export_id: String, +} + +pub async fn start_database_export( + State(state): State>, + Json(body): Json, +) -> Result, AppError> { + let req = body.request; + let export_id = req.export_id.clone(); + + let (tx, _) = tokio::sync::broadcast::channel::(256); + state.sse_channels.write().await.insert(export_id.clone(), tx.clone()); + + let app = state.app.clone(); + let state_clone = state.clone(); + + tokio::spawn(async move { + let result = database_export::export_database_sql_core(&app, &req, |progress| { + if let Ok(json) = serde_json::to_string(&progress) { + let _ = tx.send(json); + } + }) + .await; + + if let Err(e) = result { + let progress = ExportProgress { + export_id: req.export_id.clone(), + current_object: String::new(), + object_index: 0, + total_objects: 0, + rows_exported: 0, + total_rows: None, + status: ExportStatus::Error, + error: Some(e), + }; + if let Ok(json) = serde_json::to_string(&progress) { + let _ = tx.send(json); + } + } + + database_export::clear_export_cancelled(&req.export_id).await; + state_clone.remove_sse_channel(&req.export_id).await; + }); + + Ok(Json(serde_json::json!({ "exportId": export_id }))) +} + +pub async fn database_export_progress( + State(state): State>, + Path(export_id): Path, +) -> Result>>, AppError> { + let channels = state.sse_channels.read().await; + let tx = channels.get(&export_id).ok_or_else(|| AppError("Export not found".to_string()))?; + let rx = tx.subscribe(); + drop(channels); + Ok(crate::sse::sse_from_channel(rx)) +} + +pub async fn cancel_database_export( + State(_state): State>, + Json(req): Json, +) -> Json { + database_export::set_export_cancelled(&req.export_id).await; + Json(serde_json::json!({ "cancelled": true })) +} diff --git a/src/components/export/DatabaseExportDialog.vue b/src/components/export/DatabaseExportDialog.vue new file mode 100644 index 0000000000..9e13eee13f --- /dev/null +++ b/src/components/export/DatabaseExportDialog.vue @@ -0,0 +1,381 @@ + + + diff --git a/src/components/layout/AppDialogs.vue b/src/components/layout/AppDialogs.vue index 0061e9d12f..7d0e92b85c 100644 --- a/src/components/layout/AppDialogs.vue +++ b/src/components/layout/AppDialogs.vue @@ -18,6 +18,7 @@ const TableStructureEditorDialog = defineAsyncComponent( const FieldLineageDialog = defineAsyncComponent(() => import("@/components/lineage/FieldLineageDialog.vue")); const ConfigPassphraseDialog = defineAsyncComponent(() => import("@/components/config/ConfigPassphraseDialog.vue")); const DatabaseSearchDialog = defineAsyncComponent(() => import("@/components/search/DatabaseSearchDialog.vue")); +const DatabaseExportDialog = defineAsyncComponent(() => import("@/components/export/DatabaseExportDialog.vue")); import { useConnectionStore } from "@/stores/connectionStore"; import { useDialogSources } from "@/composables/useDialogSources"; @@ -173,6 +174,12 @@ watch( :prefill-schema="dialogs.databaseSearchPrefillSchema.value" @open-target="emit('openDatabaseSearchTarget', $event)" /> + (); -const isExportingDatabase = ref(false); - function currentDatabaseType(): DatabaseType | undefined { return props.node.connectionId ? connectionStore.getConfig(props.node.connectionId)?.db_type : undefined; } @@ -143,10 +134,6 @@ function qualifiedTableName(tableName: string, schema?: string): string { }); } -function safeFileName(name: string): string { - return name.replace(/[\\/:*?"<>|]+/g, "_").trim() || "database"; -} - function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null { switch (node.type) { case "connection": @@ -835,78 +822,6 @@ function createTable() { }; } -async function collectDatabaseExportTables(): Promise> { - const node = props.node; - if (!node.connectionId || !node.database) return []; - - const config = connectionStore.getConfig(node.connectionId); - if (node.type === "schema" && node.schema) { - const tables = await api.listTables(node.connectionId, node.database, node.schema); - return tables.map((table) => ({ - schema: node.schema, - name: table.name, - displayName: `${node.schema}.${table.name}`, - })); - } - - if (isSchemaAware(config?.db_type)) { - const schemas = await api.listSchemas(node.connectionId, node.database); - const groups = await Promise.all( - schemas.map(async (schema) => { - const tables = await api.listTables(node.connectionId!, node.database!, schema); - return tables.map((table) => ({ - schema, - name: table.name, - displayName: `${schema}.${table.name}`, - })); - }), - ); - return groups.flat(); - } - - const tables = await api.listTables(node.connectionId, node.database, node.database); - return tables.map((table) => ({ - name: table.name, - displayName: table.name, - })); -} - -async function fetchExportTableRows( - connectionId: string, - database: string, - table: { schema?: string; name: string }, - databaseType?: DatabaseType, -): Promise<{ columns: QueryResult["columns"]; rows: QueryResult["rows"]; truncated: boolean }> { - const rows: QueryResult["rows"] = []; - let columns: QueryResult["columns"] = []; - let offset = 0; - - while (rows.length < DATABASE_EXPORT_ROW_LIMIT) { - const remaining = DATABASE_EXPORT_ROW_LIMIT - rows.length; - const limit = - databaseType === "sqlserver" ? DATABASE_EXPORT_ROW_LIMIT : Math.min(DATABASE_EXPORT_PAGE_SIZE, remaining); - const sql = buildExportPageSql({ - databaseType, - schema: table.schema, - tableName: table.name, - limit, - offset: databaseType === "sqlserver" ? undefined : offset, - }); - const result = await api.executeQuery(connectionId, database, sql); - if (columns.length === 0) columns = result.columns; - rows.push(...result.rows); - - if (result.rows.length < limit || databaseType === "sqlserver") break; - offset += result.rows.length; - } - - return { - columns, - rows, - truncated: rows.length >= DATABASE_EXPORT_ROW_LIMIT, - }; -} - async function saveFileContent(content: string, defaultFileName: string, filterName: string, filterExt: string) { if (isTauriRuntime()) { const { save } = await import("@tauri-apps/plugin-dialog"); @@ -954,56 +869,6 @@ async function saveBinaryFileContent( } } -async function exportDatabase() { - const node = props.node; - if (!(node.type === "database" || node.type === "schema") || !node.connectionId || !node.database) return; - - isExportingDatabase.value = true; - try { - await connectionStore.ensureConnected(node.connectionId); - const config = connectionStore.getConfig(node.connectionId); - const tables = await collectDatabaseExportTables(); - const exportedTables: ExportedTableSql[] = []; - - for (const table of tables) { - const querySchema = table.schema || node.database; - const qualifiedName = qualifiedTableName(table.name, table.schema); - const ddl = await api.getTableDdl(node.connectionId, node.database, querySchema, table.name); - const result = await fetchExportTableRows(node.connectionId, node.database, table, config?.db_type); - exportedTables.push({ - displayName: table.displayName, - qualifiedTableName: qualifiedName, - ddl, - columns: result.columns, - rows: result.rows, - truncated: result.truncated, - }); - } - - const scopeName = node.type === "schema" && node.schema ? `${node.database}.${node.schema}` : node.database; - const content = buildDatabaseSqlExport({ - databaseName: scopeName, - tables: exportedTables, - quoteIdentifier: quoteIdent, - rowLimitPerTable: DATABASE_EXPORT_ROW_LIMIT, - }); - - await saveFileContent(content, `${safeFileName(scopeName)}.sql`, "SQL", "sql"); - toast( - t("contextMenu.exportDatabaseSuccess", { - count: exportedTables.length, - limit: DATABASE_EXPORT_ROW_LIMIT, - }), - 3000, - ); - } catch (e: any) { - console.error("Export database failed:", e); - toast(t("contextMenu.exportDatabaseFailed", { message: e?.message || String(e) }), 5000); - } finally { - isExportingDatabase.value = false; - } -} - async function exportStructure() { const node = props.node; if (!node.connectionId || !node.database) return; @@ -1152,6 +1017,16 @@ function openDatabaseSearch() { }; } +function openDatabaseExport() { + const node = props.node; + if (!node.connectionId || !node.database) return; + connectionStore.databaseExportSource = { + connectionId: node.connectionId, + database: node.database, + schema: node.type === "schema" ? node.schema : undefined, + }; +} + function openTableImport() { const node = props.node; if (node.type !== "table" || !node.connectionId || !node.database) return; @@ -1706,9 +1581,8 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr {{ t("dataCompare.title") }} - - - + + {{ t("contextMenu.exportDatabase") }}