From 569a915b9e109cce06ebd72012e2a15dcc099720 Mon Sep 17 00:00:00 2001 From: blankll Date: Sat, 23 May 2026 16:28:30 +0800 Subject: [PATCH 1/3] fix(transfer): harden backup, target-db probe, system-db filter, mssql pagination Four correctness fixes to the scope-first transfer module: 1. backup_server partial-failure summary parity Mirror migrate_server semantics via new summarize_backup_outcome helper. Per-table failures aggregated with db.schema.table context. - All succeed -> Completed, error=None - Partial -> Completed, error=Some(summary) - All fail -> Failed, error=Some(summary) 2. PG + MSSQL ensure_target_database always probes accessibility Previously only verified connectivity when CREATEing the DB. Now opens a temp adapter to target and runs SELECT 1 whether DB was just created or already existed, surfacing inaccessible targets upfront instead of mid-transfer. MySQL unchanged (same-connection semantics). 3. System database exclusion in whole-server scope New list_databases_for_connection(exclude_system_databases) + should_exclude_system_database + filter_system_databases_for_whole_server. Applied only via expand_selection implicit expansion; explicit user selections respected; browse.rs::list_databases unchanged (UI sidebar unaffected). - MySQL : mysql, information_schema, performance_schema, sys - PG : template0, template1 (kept 'postgres' as legitimate user DB) - MSSQL : master, msdb, tempdb, model 4. SQL Server pagination New paginate_clause(db_type, offset, limit, base_has_order_by) helper. - MySQL/PG/SQLite -> LIMIT n OFFSET m - MSSQL -> [ORDER BY (SELECT NULL)] OFFSET n ROWS FETCH NEXT m ROWS ONLY The synthetic ORDER BY is suppressed when the base query already has one (e.g. ExportSource.order_by), preventing invalid T-SQL with two ORDER BY clauses. Replaces inline LIMIT/OFFSET in export.rs (batch and preview paths) and migration.rs. Gates: cargo fmt clean, cargo test --lib 82/82 (+6 new tests), vue-tsc clean, eslint 0 errors, jest 276/276. Stacked on feat/transfer-redesign-scope-first (PR #54). --- src-tauri/src/commands/transfer.rs | 332 ++++++++++++++++++++-------- src-tauri/src/transfer/export.rs | 53 ++++- src-tauri/src/transfer/migration.rs | 7 +- src-tauri/src/transfer/mod.rs | 82 +++++++ 4 files changed, 380 insertions(+), 94 deletions(-) diff --git a/src-tauri/src/commands/transfer.rs b/src-tauri/src/commands/transfer.rs index 7f2c7262..e8c57011 100644 --- a/src-tauri/src/commands/transfer.rs +++ b/src-tauri/src/commands/transfer.rs @@ -20,6 +20,13 @@ use tauri::{AppHandle, Emitter, State}; use tokio::sync::Mutex; use uuid::Uuid; +const MYSQL_SYSTEM_DATABASES: &[&str] = + &["mysql", "information_schema", "performance_schema", "sys"]; +// PostgreSQL: template databases are not openable and should always be skipped. +// Keep `postgres` available because users may intentionally store data there. +const POSTGRES_SYSTEM_DATABASES: &[&str] = &["template0", "template1"]; +const SQLSERVER_SYSTEM_DATABASES: &[&str] = &["master", "msdb", "tempdb", "model"]; + #[tauri::command] pub async fn preview_export_data( request: ExportRequest, @@ -610,46 +617,88 @@ async fn get_connection( async fn list_databases_for_connection( connection_id: &str, connections: &Arc>>, + exclude_system_databases: bool, ) -> Result, String> { let connection = get_connection(connection_id, connections).await?; - let databases = match connection { + let (db_type, databases) = match connection { ActiveConnection::Postgres(adapter) => { let adapter = adapter.lock().await; - adapter - .list_databases() - .await - .map_err(|e| e.to_string())? - .into_iter() - .map(|db| db.name) - .collect::>() + ( + Some(DatabaseType::PostgreSQL), + adapter + .list_databases() + .await + .map_err(|e| e.to_string())? + .into_iter() + .map(|db| db.name) + .collect::>(), + ) } ActiveConnection::MySQL(adapter) => { let adapter = adapter.lock().await; - adapter - .list_databases() - .await - .map_err(|e| e.to_string())? - .into_iter() - .map(|db| db.name) - .collect::>() + ( + Some(DatabaseType::MySQL), + adapter + .list_databases() + .await + .map_err(|e| e.to_string())? + .into_iter() + .map(|db| db.name) + .collect::>(), + ) } ActiveConnection::SQLServer(adapter) => { let adapter = adapter.lock().await; - adapter - .list_databases() - .await - .map_err(|e| e.to_string())? - .into_iter() - .map(|db| db.name) - .collect::>() + ( + Some(DatabaseType::SqlServer), + adapter + .list_databases() + .await + .map_err(|e| e.to_string())? + .into_iter() + .map(|db| db.name) + .collect::>(), + ) } - ActiveConnection::SQLite(_) => vec!["main".to_string()], + ActiveConnection::SQLite(_) => (None, vec!["main".to_string()]), + }; + + let databases = if exclude_system_databases { + filter_system_databases_for_whole_server(db_type, databases) + } else { + databases }; Ok(databases) } +fn should_exclude_system_database(db_type: DatabaseType, database: &str) -> bool { + let deny_list = match db_type { + DatabaseType::MySQL => MYSQL_SYSTEM_DATABASES, + DatabaseType::PostgreSQL => POSTGRES_SYSTEM_DATABASES, + DatabaseType::SqlServer => SQLSERVER_SYSTEM_DATABASES, + _ => &[], + }; + + deny_list + .iter() + .any(|system_db| database.eq_ignore_ascii_case(system_db)) +} + +fn filter_system_databases_for_whole_server( + db_type: Option, + databases: Vec, +) -> Vec { + match db_type { + Some(database_type) => databases + .into_iter() + .filter(|database| !should_exclude_system_database(database_type, database)) + .collect::>(), + None => databases, + } +} + async fn list_schemas_for_database( connection_id: &str, database: &str, @@ -750,7 +799,8 @@ async fn expand_selection( databases_to_expand = selection.schemas.keys().cloned().collect(); } if databases_to_expand.is_empty() { - databases_to_expand = list_databases_for_connection(connection_id, connections).await?; + databases_to_expand = + list_databases_for_connection(connection_id, connections, true).await?; } for database in databases_to_expand { @@ -985,6 +1035,8 @@ async fn ensure_target_database( match connection { ActiveConnection::MySQL(adapter) => { let adapter = adapter.lock().await; + // MySQL uses CREATE DATABASE IF NOT EXISTS and continues with the same + // server connection, so an additional per-database probe is unnecessary here. adapter .execute_query(&format!( "CREATE DATABASE IF NOT EXISTS {}", @@ -997,8 +1049,10 @@ async fn ensure_target_database( ActiveConnection::Postgres(adapter) => { let adapter = adapter.lock().await; let mut system_config = adapter.config.clone(); + let mut target_config = adapter.config.clone(); drop(adapter); system_config.database = Some("postgres".to_string()); + target_config.database = Some(database_name.to_string()); let mut system_adapter = PostgresAdapter::new(system_config); system_adapter @@ -1020,19 +1074,24 @@ async fn ensure_target_database( )) .await .map_err(|e| e.to_string())?; - - let mut target_config = system_adapter.get_config().clone(); - target_config.database = Some(database_name.to_string()); - let mut target_adapter = PostgresAdapter::new(target_config); - target_adapter - .connect() - .await - .map_err(|e| format!("Failed to connect to '{}': {}", database_name, e))?; } + + let mut target_adapter = PostgresAdapter::new(target_config); + target_adapter + .connect() + .await + .map_err(|e| target_database_inaccessible_error(database_name, &e.to_string()))?; + target_adapter + .execute_query("SELECT 1") + .await + .map_err(|e| target_database_inaccessible_error(database_name, &e.to_string()))?; + Ok(()) } ActiveConnection::SQLServer(adapter) => { let adapter = adapter.lock().await; + let mut target_config = adapter.config.clone(); + target_config.database = Some(database_name.to_string()); let exists = adapter .list_databases() .await @@ -1048,12 +1107,32 @@ async fn ensure_target_database( .await .map_err(|e| e.to_string())?; } + + drop(adapter); + + let mut target_adapter = SqlServerAdapter::new(target_config); + target_adapter + .connect() + .await + .map_err(|e| target_database_inaccessible_error(database_name, &e.to_string()))?; + target_adapter + .execute_query("SELECT 1") + .await + .map_err(|e| target_database_inaccessible_error(database_name, &e.to_string()))?; + Ok(()) } _ => Ok(()), } } +fn target_database_inaccessible_error(database_name: &str, error: &str) -> String { + format!( + "Target database '{}' is inaccessible: {}", + database_name, error + ) +} + async fn execute_migration_request( request: MigrationRequest, source_database: Option<&str>, @@ -1551,6 +1630,32 @@ fn summarize_migration_outcome( } } +fn summarize_backup_outcome( + succeeded_tables: u64, + failed_tables: u64, + failures: &[String], +) -> (TransferJobStatus, Option) { + let summary = if failures.is_empty() { + None + } else { + Some(format!( + "Backup summary: {} succeeded, {} failed [{}]", + succeeded_tables, + failed_tables, + failures.join("; ") + )) + }; + + if succeeded_tables > 0 { + (TransferJobStatus::Completed, summary) + } else { + ( + TransferJobStatus::Failed, + summary.or_else(|| Some("Backup failed".to_string())), + ) + } +} + #[tauri::command] pub async fn backup_server( connection_id: String, @@ -1612,23 +1717,24 @@ pub async fn backup_server( None, ); - let mut outcomes: Vec<(String, Result<(), String>)> = Vec::new(); + let mut failed_tables = 0u64; + let mut failures: Vec = Vec::new(); let mut succeeded_tables = 0u64; for (index, (database, schema, table)) in selected_tables.iter().enumerate() { let db_output_dir = PathBuf::from(&destination_clone).join(database); + let table_label = format!( + "{}.{}.{}", + database, + schema.clone().unwrap_or_else(|| "default".to_string()), + table + ); + if let Err(error) = fs::create_dir_all(&db_output_dir) .map_err(|e| format!("Failed to create backup directory: {}", e)) { - outcomes.push(( - format!( - "{}.{}.{}", - database, - schema.clone().unwrap_or_else(|| "default".to_string()), - table - ), - Err(error), - )); + failed_tables += 1; + failures.push(format!("{}: {}", table_label, error)); let _ = emit_job_event( &app_clone, &job_id_clone, @@ -1655,15 +1761,8 @@ pub async fn backup_server( { Ok(columns) => columns, Err(error) => { - outcomes.push(( - format!( - "{}.{}.{}", - database, - schema.clone().unwrap_or_else(|| "default".to_string()), - table - ), - Err(error), - )); + failed_tables += 1; + failures.push(format!("{}: {}", table_label, error)); let _ = emit_job_event( &app_clone, &job_id_clone, @@ -1704,28 +1803,23 @@ pub async fn backup_server( &connections_clone, ) .await; - let table_label = format!( - "{}.{}.{}", - database, - schema.clone().unwrap_or_else(|| "default".to_string()), - table - ); match export_result { Ok(result) if result.success => { - outcomes.push((table_label, Ok(()))); succeeded_tables += 1; } Ok(result) => { + failed_tables += 1; let first_error = result .errors .first() .map(|error| error.message.clone()) .unwrap_or_else(|| "Export failed".to_string()); - outcomes.push((table_label, Err(first_error))); + failures.push(format!("{}: {}", table_label, first_error)); } Err(error) => { - outcomes.push((table_label, Err(error))); + failed_tables += 1; + failures.push(format!("{}: {}", table_label, error)); } } @@ -1741,34 +1835,10 @@ pub async fn backup_server( ); } - let failed_tables = outcomes - .iter() - .filter_map(|(name, result)| result.as_ref().err().map(|_| name.clone())) - .collect::>(); - - let status_text = if succeeded_tables > 0 { - "completed" - } else { - "failed" - }; - - let summary = format!( - "Backup {}: {} succeeded, {} failed{}", - status_text, - succeeded_tables, - failed_tables.len(), - if failed_tables.is_empty() { - String::new() - } else { - format!(" [{}]", failed_tables.join(", ")) - } - ); + let (status, summary) = + summarize_backup_outcome(succeeded_tables, failed_tables, &failures); - if succeeded_tables > 0 { - Ok((TransferJobStatus::Completed, None, total_tables)) - } else { - Ok((TransferJobStatus::Failed, Some(summary), total_tables)) - } + Ok((status, summary, total_tables)) } .await; @@ -2220,7 +2290,11 @@ pub async fn run_transfer_profile( #[cfg(test)] mod tests { - use super::{quote_ident_mysql, quote_ident_pg, summarize_migration_outcome}; + use super::{ + filter_system_databases_for_whole_server, quote_ident_mysql, quote_ident_pg, + summarize_backup_outcome, summarize_migration_outcome, + }; + use crate::database::DatabaseType; use crate::transfer::TransferJobStatus; #[test] @@ -2274,4 +2348,86 @@ mod tests { assert_eq!(status, TransferJobStatus::Failed); assert!(summary.unwrap_or_default().contains("2 failed")); } + + #[test] + fn summarize_backup_outcome_all_succeeded_has_no_summary() { + let (status, summary) = summarize_backup_outcome(3, 0, &[]); + + assert_eq!(status, TransferJobStatus::Completed); + assert!(summary.is_none()); + } + + #[test] + fn summarize_backup_outcome_partial_failures_is_completed_with_summary() { + let (status, summary) = + summarize_backup_outcome(2, 1, &["db.public.users: disk full".to_string()]); + + assert_eq!(status, TransferJobStatus::Completed); + assert!(summary.unwrap_or_default().contains("1 failed")); + } + + #[test] + fn summarize_backup_outcome_all_failed_is_failed_with_summary() { + let (status, summary) = + summarize_backup_outcome(0, 2, &["db.public.orders: timeout".to_string()]); + + assert_eq!(status, TransferJobStatus::Failed); + assert!(summary.unwrap_or_default().contains("2 failed")); + } + + #[test] + fn filter_system_databases_mysql_excludes_known_system_databases() { + let filtered = filter_system_databases_for_whole_server( + Some(DatabaseType::MySQL), + vec![ + "app_db".to_string(), + "mysql".to_string(), + "information_schema".to_string(), + "sys".to_string(), + ], + ); + + assert_eq!(filtered, vec!["app_db".to_string()]); + } + + #[test] + fn filter_system_databases_postgres_excludes_templates_only() { + let filtered = filter_system_databases_for_whole_server( + Some(DatabaseType::PostgreSQL), + vec![ + "postgres".to_string(), + "template0".to_string(), + "template1".to_string(), + "app_db".to_string(), + ], + ); + + assert_eq!(filtered, vec!["postgres".to_string(), "app_db".to_string()]); + } + + #[test] + fn filter_system_databases_sqlserver_excludes_system_databases() { + let filtered = filter_system_databases_for_whole_server( + Some(DatabaseType::SqlServer), + vec![ + "master".to_string(), + "msdb".to_string(), + "tempdb".to_string(), + "model".to_string(), + "tenant_db".to_string(), + ], + ); + + assert_eq!(filtered, vec!["tenant_db".to_string()]); + } + + #[test] + fn filter_system_databases_sqlite_is_unchanged() { + let filtered = filter_system_databases_for_whole_server( + Some(DatabaseType::SQLite), + vec!["main".to_string()], + ); + + assert_eq!(filtered, vec!["main".to_string()]); + } } diff --git a/src-tauri/src/transfer/export.rs b/src-tauri/src/transfer/export.rs index f77589a0..759dc3b9 100644 --- a/src-tauri/src/transfer/export.rs +++ b/src-tauri/src/transfer/export.rs @@ -9,6 +9,7 @@ use rust_xlsxwriter::{Workbook, Worksheet}; use serde_json::Value as JsonValue; use super::defaults::*; +use super::paginate_clause; use super::progress::*; use super::types::*; use crate::database::{DatabaseAdapter, DatabaseType, QueryValue}; @@ -53,6 +54,7 @@ pub async fn execute_export( &columns, &request.source, ); + let base_has_order_by = request.source.order_by.is_some(); let count_query = build_count_query( db_type, @@ -97,7 +99,16 @@ pub async fn execute_export( let mut offset = 0u64; while offset < total_rows { - let query = format!("{} LIMIT {} OFFSET {}", base_query, batch_size, offset); + let query = format!( + "{} {}", + base_query, + paginate_clause( + db_type, + offset as usize, + batch_size as usize, + base_has_order_by + ) + ); let result = adapter .execute_query(&query) .await @@ -133,7 +144,16 @@ pub async fn execute_export( ExportFormat::Jsonl => { let mut offset = 0u64; while offset < total_rows { - let query = format!("{} LIMIT {} OFFSET {}", base_query, batch_size, offset); + let query = format!( + "{} {}", + base_query, + paginate_clause( + db_type, + offset as usize, + batch_size as usize, + base_has_order_by + ) + ); let result = adapter .execute_query(&query) .await @@ -181,7 +201,16 @@ pub async fn execute_export( let mut offset = 0u64; while offset < total_rows { - let query = format!("{} LIMIT {} OFFSET {}", base_query, batch_size, offset); + let query = format!( + "{} {}", + base_query, + paginate_clause( + db_type, + offset as usize, + batch_size as usize, + base_has_order_by + ) + ); let result = adapter .execute_query(&query) .await @@ -254,7 +283,16 @@ pub async fn execute_export( let mut row_idx = header_row_offset; while offset < total_rows { - let query = format!("{} LIMIT {} OFFSET {}", base_query, batch_size, offset); + let query = format!( + "{} {}", + base_query, + paginate_clause( + db_type, + offset as usize, + batch_size as usize, + base_has_order_by + ) + ); let result = adapter .execute_query(&query) .await @@ -561,7 +599,12 @@ pub async fn preview_export( &columns, &request.source, ); - let query = format!("{} LIMIT {}", base_query, preview_rows); + let base_has_order_by = request.source.order_by.is_some(); + let query = format!( + "{} {}", + base_query, + paginate_clause(db_type, 0, preview_rows as usize, base_has_order_by) + ); let result = adapter .execute_query(&query) diff --git a/src-tauri/src/transfer/migration.rs b/src-tauri/src/transfer/migration.rs index 403d7d07..11997176 100644 --- a/src-tauri/src/transfer/migration.rs +++ b/src-tauri/src/transfer/migration.rs @@ -6,6 +6,7 @@ use crate::database::types::ColumnInfo; use crate::database::DatabaseType; use crate::database::{DatabaseAdapter, QueryValue}; +use super::paginate_clause; use super::progress::*; use super::types::*; @@ -217,7 +218,11 @@ async fn migrate_table( let mut offset = 0u64; while offset < total_rows { - let query = format!("{} LIMIT {} OFFSET {}", base_query, batch_size, offset); + let query = format!( + "{} {}", + base_query, + paginate_clause(source_db_type, offset as usize, batch_size as usize, false) + ); let result = source_adapter .execute_query(&query) .await diff --git a/src-tauri/src/transfer/mod.rs b/src-tauri/src/transfer/mod.rs index 67f940f8..a9e74e95 100644 --- a/src-tauri/src/transfer/mod.rs +++ b/src-tauri/src/transfer/mod.rs @@ -1,5 +1,7 @@ //! Transfer module for data export, import, and migration. +use crate::database::DatabaseType; + pub mod ddl; pub mod defaults; pub mod export; @@ -17,3 +19,83 @@ pub use migration::*; pub use profile_store::*; pub use progress::*; pub use types::*; + +/// Returns the pagination clause for a given DB type. +/// +/// `base_has_order_by` must be `true` if the base SELECT already has an `ORDER BY` +/// clause appended. For SQL Server this avoids emitting a second `ORDER BY (SELECT NULL)` +/// (which would produce invalid T-SQL). For other engines it is informational only. +pub fn paginate_clause( + db_type: DatabaseType, + offset: usize, + limit: usize, + base_has_order_by: bool, +) -> String { + match db_type { + DatabaseType::SqlServer => { + if base_has_order_by { + format!("OFFSET {} ROWS FETCH NEXT {} ROWS ONLY", offset, limit) + } else { + format!( + "ORDER BY (SELECT NULL) OFFSET {} ROWS FETCH NEXT {} ROWS ONLY", + offset, limit + ) + } + } + _ => format!("LIMIT {} OFFSET {}", limit, offset), + } +} + +#[cfg(test)] +mod tests { + use super::paginate_clause; + use crate::database::DatabaseType; + + #[test] + fn paginate_clause_uses_limit_offset_for_postgres() { + assert_eq!( + paginate_clause(DatabaseType::PostgreSQL, 10, 25, false), + "LIMIT 25 OFFSET 10" + ); + } + + #[test] + fn paginate_clause_uses_limit_offset_for_mysql() { + assert_eq!( + paginate_clause(DatabaseType::MySQL, 10, 25, false), + "LIMIT 25 OFFSET 10" + ); + } + + #[test] + fn paginate_clause_uses_limit_offset_for_sqlite() { + assert_eq!( + paginate_clause(DatabaseType::SQLite, 10, 25, false), + "LIMIT 25 OFFSET 10" + ); + } + + #[test] + fn paginate_clause_uses_offset_fetch_for_sqlserver_without_order_by() { + assert_eq!( + paginate_clause(DatabaseType::SqlServer, 10, 25, false), + "ORDER BY (SELECT NULL) OFFSET 10 ROWS FETCH NEXT 25 ROWS ONLY" + ); + } + + #[test] + fn paginate_clause_skips_synthetic_order_by_when_base_query_has_one() { + assert_eq!( + paginate_clause(DatabaseType::SqlServer, 10, 25, true), + "OFFSET 10 ROWS FETCH NEXT 25 ROWS ONLY" + ); + } + + #[test] + fn paginate_clause_ignores_base_order_by_flag_for_non_sqlserver() { + assert_eq!( + paginate_clause(DatabaseType::PostgreSQL, 10, 25, true), + "LIMIT 25 OFFSET 10" + ); + } +} From e3de0ca3a2713be87242f8f4e00eae6fa747610b Mon Sep 17 00:00:00 2001 From: blankll Date: Sun, 24 May 2026 01:20:42 +0800 Subject: [PATCH 2/3] feat(transfer): action-first launcher with restore + Oracle hardening Action-first Transfer page (/transfer) with Action -> Source -> Target -> Options -> Launch flow. Restore-from-file as first-class action supporting .sql, .csv, .xlsx. Page-scoped JobsDrawer at bottom; cascading dropdowns for source/target picking. Backend (src-tauri/): - restore_backup: accept job_id, support 'excel' alias, split schema.table targets - DatabaseAdapter: new execute_batch_with_params trait method - pg/mysql/mssql/sqlite: parameterized batch INSERT implementations - restore.rs: new SQL splitter handles dollar quotes ($$, $tag$), same-line semicolons, string literals, comments; streaming CSV/XLSX batches; schema-qualified quoting - import.rs: fix Excel header double-consume bug Frontend (src/components/transfer/launcher/): - TransferLauncher, ActionPicker, SourcePicker, TargetPicker, OptionsPanel, JobsDrawer, PresetsBar (new) - File format selector + auto-detect; target table input for csv/xlsx - Async race guards in cascading dropdowns - Schema-keyed table selection - NaN% guard in JobsDrawer progress - transferApi/transferStore wire jobId through restoreBackup Removed destructive dropTargetFirst from tabular restore paths. i18n: 3 new launcher keys in en/zh. Gates: cargo fmt/clippy/test 116/116, vue-tsc 0 errors, eslint 0E/1W (pre-existing), jest 276/276. --- .gitignore | 3 + src-tauri/Cargo.lock | 70 +- src-tauri/Cargo.toml | 6 +- src-tauri/src/commands/converter.rs | 4 +- src-tauri/src/commands/transfer.rs | 359 +++++++++- src-tauri/src/database/adapter.rs | 9 + src-tauri/src/database/mysql.rs | 33 + src-tauri/src/database/postgres.rs | 45 +- src-tauri/src/database/sqlite.rs | 37 + src-tauri/src/database/sqlserver.rs | 34 + src-tauri/src/database/tests.rs | 1 + src-tauri/src/lib.rs | 1 + src-tauri/src/transfer/import.rs | 34 +- src-tauri/src/transfer/mod.rs | 2 + src-tauri/src/transfer/restore.rs | 671 ++++++++++++++++++ src-tauri/tests/sqlserver_integration.rs | 2 +- .../transfer/launcher/ActionPicker.vue | 51 ++ .../transfer/launcher/JobsDrawer.vue | 167 +++++ .../transfer/launcher/OptionsPanel.vue | 212 ++++++ .../transfer/launcher/PresetsBar.vue | 144 ++++ .../transfer/launcher/SourcePicker.vue | 262 +++++++ .../transfer/launcher/TargetPicker.vue | 97 +++ .../transfer/launcher/TransferLauncher.vue | 178 +++++ src/components/transfer/launcher/index.ts | 8 + src/components/transfer/launcher/types.ts | 37 + src/datasources/transferApi.ts | 29 +- src/lang/enUS.ts | 68 ++ src/lang/zhCN.ts | 68 ++ src/pages/TransferPage.vue | 285 +------- src/store/transferStore.ts | 77 +- 30 files changed, 2656 insertions(+), 338 deletions(-) create mode 100644 src-tauri/src/transfer/restore.rs create mode 100644 src/components/transfer/launcher/ActionPicker.vue create mode 100644 src/components/transfer/launcher/JobsDrawer.vue create mode 100644 src/components/transfer/launcher/OptionsPanel.vue create mode 100644 src/components/transfer/launcher/PresetsBar.vue create mode 100644 src/components/transfer/launcher/SourcePicker.vue create mode 100644 src/components/transfer/launcher/TargetPicker.vue create mode 100644 src/components/transfer/launcher/TransferLauncher.vue create mode 100644 src/components/transfer/launcher/index.ts create mode 100644 src/components/transfer/launcher/types.ts diff --git a/.gitignore b/.gitignore index e61e7a7e..b6f311e6 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ dist-ssr # Test coverage coverage/ + +# TypeScript build info +*.tsbuildinfo diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0e8e1344..2944413f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -525,17 +525,17 @@ dependencies = [ [[package]] name = "calamine" -version = "0.22.1" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe0ba51a659bb6c8bffd6f7c1c5ffafcafa0c97e4769411d841c3cc5c154ab47" +checksum = "138646b9af2c5d7f1804ea4bf93afc597737d2bd4f7341d67c48b03316976eb1" dependencies = [ "byteorder", "codepage", "encoding_rs", "log", - "quick-xml 0.30.0", + "quick-xml 0.31.0", "serde", - "zip 0.6.6", + "zip 2.4.2", ] [[package]] @@ -965,6 +965,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctor" version = "0.2.9" @@ -4013,9 +4034,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.30.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" +checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" dependencies = [ "encoding_rs", "memchr", @@ -4558,6 +4579,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -5066,6 +5093,7 @@ dependencies = [ "base64 0.22.1", "calamine", "chrono", + "csv", "deadpool-postgres", "hex", "mysql_async", @@ -5084,6 +5112,7 @@ dependencies = [ "tauri-plugin-os", "tauri-plugin-store", "tauri-plugin-updater", + "tempfile", "thiserror 1.0.69", "tiberius", "tokio", @@ -7539,6 +7568,23 @@ dependencies = [ "flate2", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.13.0", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + [[package]] name = "zip" version = "4.6.1" @@ -7557,6 +7603,18 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zstd" version = "0.13.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3881abfb..2923cfee 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -63,4 +63,8 @@ chrono = "0.4" rust_decimal = { version = "1", features = [ "db-postgres" ] } hex = "0.4" rust_xlsxwriter = "0.64" -calamine = "0.22" +calamine = "0.26" +csv = "1.3" + +[dev-dependencies] +tempfile = "3" diff --git a/src-tauri/src/commands/converter.rs b/src-tauri/src/commands/converter.rs index a930ba40..b90b368f 100644 --- a/src-tauri/src/commands/converter.rs +++ b/src-tauri/src/commands/converter.rs @@ -170,9 +170,9 @@ mod tests { #[test] fn test_convert_query_value_float() { - let value = QueryValue::Float(3.14); + let value = QueryValue::Float(std::f64::consts::PI); let json = convert_query_value_to_json(&value); - assert_eq!(json, json!(3.14)); + assert_eq!(json, json!(std::f64::consts::PI)); } #[test] diff --git a/src-tauri/src/commands/transfer.rs b/src-tauri/src/commands/transfer.rs index e8c57011..65103ca4 100644 --- a/src-tauri/src/commands/transfer.rs +++ b/src-tauri/src/commands/transfer.rs @@ -3,11 +3,12 @@ use crate::state::{ActiveConnection, AppState}; use crate::transfer::{ auto_map_columns, detect_file, execute_export, execute_import, execute_migration, generate_ddl_for_engine, load_profiles, preview_export, preview_import, preview_migration, - save_profiles, DdlRequest, ExportFormat, ExportPreview, ExportRequest, ExportSource, - FileDetectionResult, ImportFormat, ImportRequest, JobEventPayload, JobProgress, - MigrationPreview, MigrationRequest, MigrationTablePlan, ObjectSelection, TransferError, - TransferJob, TransferJobStatus, TransferProfile, TransferProfileKind, TransferResult, - TransferScope, + restore_csv_file_with_progress, restore_sql_file_with_progress, + restore_xlsx_file_with_progress, save_profiles, DdlRequest, ExportFormat, ExportPreview, + ExportRequest, ExportSource, FileDetectionResult, ImportFormat, ImportRequest, JobEventPayload, + JobProgress, MigrationPreview, MigrationRequest, MigrationTablePlan, ObjectSelection, + RestoreOptions, RestoreStats, TransferError, TransferJob, TransferJobStatus, TransferProfile, + TransferProfileKind, TransferResult, TransferScope, }; use chrono::Utc; use serde_json::Value as JsonValue; @@ -1656,7 +1657,355 @@ fn summarize_backup_outcome( } } +fn summarize_restore_outcome(stats: &RestoreStats) -> (TransferJobStatus, Option) { + let failed_units = stats + .statements_total + .saturating_sub(stats.statements_succeeded); + + let summary = if stats.errors.is_empty() { + None + } else { + Some(format!( + "Restore summary: {} succeeded, {} failed [{}]", + stats.statements_succeeded, + failed_units, + stats.errors.join("; ") + )) + }; + + if stats.statements_succeeded > 0 { + (TransferJobStatus::Completed, summary) + } else { + ( + TransferJobStatus::Failed, + summary.or_else(|| Some("Restore failed".to_string())), + ) + } +} + +async fn restore_with_connection( + connection: ActiveConnection, + target_database: Option, + file_path: String, + file_format: String, + target_table: Option, + drop_target_first: bool, + on_progress: impl FnMut(u64, u64), +) -> Result { + let format = file_format.to_lowercase(); + let options = RestoreOptions::default(); + + fn split_target_table(target_table: &str) -> (Option, String) { + target_table + .split_once('.') + .map(|(schema, table)| (Some(schema.to_string()), table.to_string())) + .unwrap_or_else(|| (None, target_table.to_string())) + } + + #[allow(clippy::too_many_arguments)] + async fn run_restore( + adapter: &A, + target_database: Option<&str>, + file_path: &str, + format: &str, + target_table: Option<&str>, + _drop_target_first: bool, + options: &RestoreOptions, + mut on_progress: impl FnMut(u64, u64), + ) -> Result { + if let Some(database) = target_database { + match adapter.get_config().db_type { + DatabaseType::MySQL => { + adapter + .execute_query(&format!("USE `{}`", database.replace('`', "``"))) + .await + .map_err(|e| e.to_string())?; + } + DatabaseType::SqlServer => { + adapter + .execute_query(&format!("USE [{}]", database.replace(']', "]]"))) + .await + .map_err(|e| e.to_string())?; + } + _ => {} + } + } + + match format { + "sql" => { + restore_sql_file_with_progress(adapter, file_path, options, |current, total| { + on_progress(current, total) + }) + .await + .map_err(|e| e.to_string()) + } + "csv" => { + let table = target_table + .ok_or_else(|| "targetTable is required for csv restore format".to_string())?; + + let (target_schema, target_table_name) = split_target_table(table); + + restore_csv_file_with_progress( + adapter, + file_path, + target_schema.as_deref(), + &target_table_name, + options, + &mut on_progress, + ) + .await + .map_err(|e| e.to_string()) + } + "xlsx" | "excel" => { + let table = target_table + .ok_or_else(|| "targetTable is required for xlsx restore format".to_string())?; + + let (target_schema, target_table_name) = split_target_table(table); + + restore_xlsx_file_with_progress( + adapter, + file_path, + target_schema.as_deref(), + &target_table_name, + options, + &mut on_progress, + ) + .await + .map_err(|e| e.to_string()) + } + _ => Err(format!( + "Unsupported restore file format '{}'. Use sql, csv, xlsx, or excel.", + format + )), + } + } + + match connection { + ActiveConnection::Postgres(adapter) => { + let adapter = adapter.lock().await; + if let Some(database) = target_database.as_deref() { + if Some(database) != adapter.config.database.as_deref() { + let mut temp_config = adapter.config.clone(); + drop(adapter); + temp_config.database = Some(database.to_string()); + let mut temp = PostgresAdapter::new(temp_config); + temp.connect() + .await + .map_err(|e| format!("Failed to connect to '{}': {}", database, e))?; + return run_restore( + &temp, + None, + &file_path, + &format, + target_table.as_deref(), + drop_target_first, + &options, + on_progress, + ) + .await; + } + } + + run_restore( + &*adapter, + None, + &file_path, + &format, + target_table.as_deref(), + drop_target_first, + &options, + on_progress, + ) + .await + } + ActiveConnection::MySQL(adapter) => { + let adapter = adapter.lock().await; + run_restore( + &*adapter, + target_database.as_deref(), + &file_path, + &format, + target_table.as_deref(), + drop_target_first, + &options, + on_progress, + ) + .await + } + ActiveConnection::SQLServer(adapter) => { + let adapter = adapter.lock().await; + if let Some(database) = target_database.as_deref() { + if Some(database) != adapter.config.database.as_deref() { + let mut temp_config = adapter.config.clone(); + drop(adapter); + temp_config.database = Some(database.to_string()); + let mut temp = SqlServerAdapter::new(temp_config); + temp.connect() + .await + .map_err(|e| format!("Failed to connect to '{}': {}", database, e))?; + return run_restore( + &temp, + target_database.as_deref(), + &file_path, + &format, + target_table.as_deref(), + drop_target_first, + &options, + on_progress, + ) + .await; + } + } + + run_restore( + &*adapter, + target_database.as_deref(), + &file_path, + &format, + target_table.as_deref(), + drop_target_first, + &options, + on_progress, + ) + .await + } + ActiveConnection::SQLite(adapter) => { + let adapter = adapter.lock().await; + run_restore( + &*adapter, + None, + &file_path, + &format, + target_table.as_deref(), + drop_target_first, + &options, + on_progress, + ) + .await + } + } +} + +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub async fn restore_backup( + connection_id: String, + target_database: Option, + file_path: String, + file_format: String, + target_table: Option, + drop_target_first: bool, + job_id: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let job_id = job_id.unwrap_or_else(|| Uuid::new_v4().to_string()); + let job_id_clone = job_id.clone(); + let app_clone = app.clone(); + let connections = state.connections.clone(); + + tokio::spawn(async move { + let _ = emit_job_event( + &app_clone, + &job_id_clone, + TransferJobStatus::Queued, + "queued", + 0, + 0, + None, + None, + ); + + let connection = match get_connection(&connection_id, &connections).await { + Ok(connection) => connection, + Err(error) => { + let _ = emit_job_event( + &app_clone, + &job_id_clone, + TransferJobStatus::Failed, + "failed", + 0, + 0, + None, + Some(error), + ); + return; + } + }; + + let _ = emit_job_event( + &app_clone, + &job_id_clone, + TransferJobStatus::Running, + "running", + 0, + 0, + None, + None, + ); + + let restore_result = restore_with_connection( + connection, + target_database, + file_path, + file_format, + target_table, + drop_target_first, + |current, total| { + let _ = emit_job_event( + &app_clone, + &job_id_clone, + TransferJobStatus::Running, + "processing", + current, + total, + None, + None, + ); + }, + ) + .await; + + match restore_result { + Ok(stats) => { + let (status, summary) = summarize_restore_outcome(&stats); + let stage = if status == TransferJobStatus::Completed { + "completed" + } else { + "failed" + }; + + let _ = emit_job_event( + &app_clone, + &job_id_clone, + status, + stage, + stats.statements_total, + stats.statements_total, + None, + summary, + ); + } + Err(error) => { + let _ = emit_job_event( + &app_clone, + &job_id_clone, + TransferJobStatus::Failed, + "failed", + 0, + 0, + None, + Some(error), + ); + } + } + }); + + Ok(job_id) +} + #[tauri::command] +#[allow(clippy::too_many_arguments)] pub async fn backup_server( connection_id: String, selection: ObjectSelection, diff --git a/src-tauri/src/database/adapter.rs b/src-tauri/src/database/adapter.rs index 8a444188..de98281e 100644 --- a/src-tauri/src/database/adapter.rs +++ b/src-tauri/src/database/adapter.rs @@ -109,6 +109,15 @@ pub trait DatabaseAdapter: Send + Sync { /// - The connection is not active async fn execute_query(&self, query: &str) -> DbResult; + /// Execute a parameterized statement with N rows of M values each. + /// Implementations MUST bind via the driver's native parameter API. + async fn execute_batch_with_params( + &self, + statement: &str, + column_count: usize, + values: Vec>, + ) -> DbResult; + /// List all databases on the server. /// /// This method retrieves a list of all databases accessible to the current user. diff --git a/src-tauri/src/database/mysql.rs b/src-tauri/src/database/mysql.rs index 157c7294..f4f84719 100644 --- a/src-tauri/src/database/mysql.rs +++ b/src-tauri/src/database/mysql.rs @@ -416,6 +416,39 @@ impl DatabaseAdapter for MySQLAdapter { } } + async fn execute_batch_with_params( + &self, + statement: &str, + column_count: usize, + values: Vec>, + ) -> DbResult { + let mut conn = self.get_conn().await?; + let mut total_affected = 0u64; + + for row in values { + if row.len() != column_count { + return Err(DbError::InvalidQuery(format!( + "Expected {} values per row, got {}", + column_count, + row.len() + ))); + } + + let params = mysql_async::Params::Positional( + row.into_iter() + .map(|value| Value::Bytes(value.into_bytes())) + .collect::>(), + ); + + conn.exec_drop(statement, params) + .await + .map_err(mysql_error_to_db_error)?; + total_affected += conn.affected_rows(); + } + + Ok(total_affected) + } + async fn list_databases(&self) -> DbResult> { let mut conn = self.get_conn().await?; diff --git a/src-tauri/src/database/postgres.rs b/src-tauri/src/database/postgres.rs index 0b654f2e..441044e3 100644 --- a/src-tauri/src/database/postgres.rs +++ b/src-tauri/src/database/postgres.rs @@ -24,7 +24,7 @@ use std::fs; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio_postgres::{ - types::{FromSql, Kind, Type}, + types::{FromSql, Kind, ToSql, Type}, Client, NoTls, Row, }; @@ -734,6 +734,49 @@ impl DatabaseAdapter for PostgresAdapter { } } + async fn execute_batch_with_params( + &self, + statement: &str, + column_count: usize, + values: Vec>, + ) -> DbResult { + let pool = self + .pool + .as_ref() + .ok_or_else(|| DbError::Connection("Not connected".to_string()))?; + + let client = pool + .pool + .get() + .await + .map_err(|e| DbError::Connection(format!("Failed to get connection: {}", e)))?; + + let mut total_affected = 0u64; + for row in values { + if row.len() != column_count { + return Err(DbError::InvalidQuery(format!( + "Expected {} values per row, got {}", + column_count, + row.len() + ))); + } + + let params = row.iter().map(|value| value.as_str()).collect::>(); + let params_refs = params + .iter() + .map(|value| value as &(dyn ToSql + Sync)) + .collect::>(); + + let affected = client + .execute(statement, ¶ms_refs) + .await + .map_err(postgres_error_to_db_error)?; + total_affected += affected; + } + + Ok(total_affected) + } + async fn list_databases(&self) -> DbResult> { let query = r#" SELECT diff --git a/src-tauri/src/database/sqlite.rs b/src-tauri/src/database/sqlite.rs index 7aefb501..d4e7fe3a 100644 --- a/src-tauri/src/database/sqlite.rs +++ b/src-tauri/src/database/sqlite.rs @@ -442,6 +442,43 @@ impl DatabaseAdapter for SQLiteAdapter { self.execute_query_internal(query).await } + async fn execute_batch_with_params( + &self, + statement: &str, + column_count: usize, + values: Vec>, + ) -> DbResult { + let pool = self + .pool + .as_ref() + .ok_or_else(|| DbError::Connection("Not connected".to_string()))?; + + let conn = pool.get_conn().await?; + let conn_guard = conn + .lock() + .map_err(|e| DbError::QueryExecution(format!("Failed to lock connection: {}", e)))?; + + let mut total_affected = 0u64; + for row in values { + if row.len() != column_count { + return Err(DbError::InvalidQuery(format!( + "Expected {} values per row, got {}", + column_count, + row.len() + ))); + } + + let affected = conn_guard + .execute(statement, rusqlite::params_from_iter(row.iter())) + .map_err(|e| DbError::QueryExecution(format!("Failed to execute query: {}", e)))?; + total_affected += affected as u64; + } + + drop(conn_guard); + pool.return_conn(conn)?; + Ok(total_affected) + } + async fn list_databases(&self) -> DbResult> { Ok(vec![DatabaseSchema { name: self diff --git a/src-tauri/src/database/sqlserver.rs b/src-tauri/src/database/sqlserver.rs index 48657a1b..6919f136 100644 --- a/src-tauri/src/database/sqlserver.rs +++ b/src-tauri/src/database/sqlserver.rs @@ -471,6 +471,40 @@ impl DatabaseAdapter for SqlServerAdapter { } } + async fn execute_batch_with_params( + &self, + statement: &str, + column_count: usize, + values: Vec>, + ) -> DbResult { + let client = self.get_client().await?; + let mut client = client.lock().await; + let mut total_affected = 0u64; + + for row in values { + if row.len() != column_count { + return Err(DbError::InvalidQuery(format!( + "Expected {} values per row, got {}", + column_count, + row.len() + ))); + } + + let params = row + .iter() + .map(|value| value as &dyn tiberius::ToSql) + .collect::>(); + + client + .execute(statement, ¶ms) + .await + .map_err(|e| DbError::QueryExecution(e.to_string()))?; + total_affected += 1; + } + + Ok(total_affected) + } + async fn list_databases(&self) -> DbResult> { let query = r#" SELECT diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index f9c88aab..91f78fcb 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -1,6 +1,7 @@ //! Tests for the database adapter module. #[cfg(test)] +#[allow(clippy::module_inception)] mod tests { use crate::database::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7d84bb90..db7ab082 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -137,6 +137,7 @@ pub fn run() { commands::generate_ddl_for_objects, commands::execute_sql_content, commands::backup_server, + commands::restore_backup, commands::migrate_server, commands::save_transfer_profile, commands::list_transfer_profiles, diff --git a/src-tauri/src/transfer/import.rs b/src-tauri/src/transfer/import.rs index b8055f55..1cc7529a 100644 --- a/src-tauri/src/transfer/import.rs +++ b/src-tauri/src/transfer/import.rs @@ -357,7 +357,6 @@ pub async fn execute_import( let range = workbook .worksheet_range(&sheet_name) - .ok_or_else(|| format!("Sheet '{}' not found", sheet_name))? .map_err(|e| format!("Failed to read sheet '{}': {:?}", sheet_name, e))?; let has_header = request @@ -370,11 +369,7 @@ pub async fn execute_import( let header_row: Vec = if has_header { rows_iter .next() - .map(|row| { - row.iter() - .map(|c: &calamine::DataType| c.to_string()) - .collect() - }) + .map(|row| row.iter().map(|c| c.to_string()).collect()) .unwrap_or_default() } else { request @@ -386,7 +381,7 @@ pub async fn execute_import( let mut batch_values: Vec> = Vec::new(); - for row in range.rows() { + for row in rows_iter { let values: Vec = header_row .iter() .enumerate() @@ -401,11 +396,7 @@ pub async fn execute_import( { None } else { - Some( - row.get(col_idx) - .map(|c: &calamine::DataType| c.to_string()) - .unwrap_or_default(), - ) + Some(row.get(col_idx).map(|c| c.to_string()).unwrap_or_default()) } }) .collect(); @@ -640,16 +631,11 @@ pub fn detect_file(file_path: &str) -> Result { .map_err(|e| format!("Failed to open Excel file for detection: {}", e))?; let range = workbook .worksheet_range("Sheet1") - .ok_or("Sheet 'Sheet1' not found")? .map_err(|e| format!("Failed to read sheet: {:?}", e))?; let cols = range .rows() .next() - .map(|row| { - row.iter() - .map(|c: &calamine::DataType| c.to_string()) - .collect() - }) + .map(|row| row.iter().map(|c| c.to_string()).collect()) .unwrap_or_default(); (cols, None, Some(true)) } @@ -793,7 +779,6 @@ pub fn preview_import( let sheet_name = "Sheet1"; let range = workbook .worksheet_range(sheet_name) - .ok_or_else(|| format!("Sheet '{}' not found", sheet_name))? .map_err(|e| format!("Failed to read sheet: {:?}", e))?; let has_header = true; @@ -802,11 +787,7 @@ pub fn preview_import( columns = range .rows() .next() - .map(|row| { - row.iter() - .map(|c: &calamine::DataType| c.to_string()) - .collect() - }) + .map(|row| row.iter().map(|c| c.to_string()).collect()) .unwrap_or_default(); } @@ -817,10 +798,7 @@ pub fn preview_import( if sample_rows.len() >= preview_rows as usize { break; } - let row_values: Vec = row - .iter() - .map(|cell: &calamine::DataType| cell.to_string()) - .collect(); + let row_values: Vec = row.iter().map(|cell| cell.to_string()).collect(); sample_rows.push(row_values); } } diff --git a/src-tauri/src/transfer/mod.rs b/src-tauri/src/transfer/mod.rs index a9e74e95..ba10145f 100644 --- a/src-tauri/src/transfer/mod.rs +++ b/src-tauri/src/transfer/mod.rs @@ -9,6 +9,7 @@ pub mod import; pub mod migration; pub mod profile_store; pub mod progress; +pub mod restore; pub mod types; pub use ddl::*; @@ -18,6 +19,7 @@ pub use import::*; pub use migration::*; pub use profile_store::*; pub use progress::*; +pub use restore::*; pub use types::*; /// Returns the pagination clause for a given DB type. diff --git a/src-tauri/src/transfer/restore.rs b/src-tauri/src/transfer/restore.rs new file mode 100644 index 00000000..16b0233d --- /dev/null +++ b/src-tauri/src/transfer/restore.rs @@ -0,0 +1,671 @@ +use crate::database::{DatabaseAdapter, DatabaseType, DbError, DbResult}; +use calamine::{open_workbook, Data, Reader, Xlsx, XlsxError}; +use serde::Serialize; +use std::fs; +use std::io::BufReader; + +#[derive(Debug, Clone)] +pub struct RestoreOptions { + pub progress_every: usize, + pub csv_delimiter: u8, + pub csv_has_header: bool, + pub xlsx_sheet_name: Option, +} + +impl Default for RestoreOptions { + fn default() -> Self { + Self { + progress_every: 100, + csv_delimiter: b',', + csv_has_header: true, + xlsx_sheet_name: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct RestoreStats { + pub statements_total: u64, + pub statements_succeeded: u64, + pub rows_inserted: u64, + pub errors: Vec, +} + +const CSV_BATCH_SIZE: usize = 500; + +/// Restores a SQL dump by executing statements sequentially. +pub async fn restore_sql_file( + adapter: &A, + path: &str, + options: &RestoreOptions, +) -> DbResult { + restore_sql_file_with_progress(adapter, path, options, |_, _| {}).await +} + +pub(crate) async fn restore_sql_file_with_progress( + adapter: &A, + path: &str, + options: &RestoreOptions, + mut on_progress: F, +) -> DbResult { + let content = fs::read_to_string(path)?; + let statements = split_sql_statements(&content); + let total = statements.len() as u64; + + let mut index = 0usize; + let mut stats = RestoreStats { + statements_total: total, + ..RestoreStats::default() + }; + + while index < statements.len() { + let statement = statements[index].trim(); + if !statement.is_empty() { + match adapter.execute_query(statement).await { + Ok(_) => { + stats.statements_succeeded += 1; + } + Err(error) => { + stats + .errors + .push(format!("statement {}: {}", index + 1, error)); + } + } + } + + let current = index as u64 + 1; + if options.progress_every > 0 && (index + 1).is_multiple_of(options.progress_every) + || current == total + { + on_progress(current, total); + } + + index += 1; + } + + Ok(stats) +} + +/// Restores CSV data into a target table in 500-row insert batches. +pub async fn restore_csv_file( + adapter: &A, + path: &str, + target_schema: Option<&str>, + target_table: &str, + options: &RestoreOptions, +) -> DbResult { + restore_csv_file_with_progress( + adapter, + path, + target_schema, + target_table, + options, + |_, _| {}, + ) + .await +} + +pub(crate) async fn restore_csv_file_with_progress( + adapter: &A, + path: &str, + target_schema: Option<&str>, + target_table: &str, + options: &RestoreOptions, + mut on_progress: F, +) -> DbResult { + let mut reader = csv::ReaderBuilder::new() + .delimiter(options.csv_delimiter) + .has_headers(options.csv_has_header) + .from_path(path) + .map_err(|error| DbError::InvalidQuery(format!("Failed to parse CSV file: {}", error)))?; + + let columns = if options.csv_has_header { + reader + .headers() + .map_err(|error| { + DbError::InvalidQuery(format!("Failed to read CSV headers: {}", error)) + })? + .iter() + .map(|value| value.to_string()) + .collect::>() + } else { + let first_record = reader + .records() + .next() + .transpose() + .map_err(|error| DbError::InvalidQuery(format!("Failed to parse CSV row: {}", error)))? + .ok_or_else(|| { + DbError::InvalidQuery("CSV file is empty and no headers were provided".to_string()) + })?; + + (0..first_record.len()) + .map(|index| format!("column_{}", index + 1)) + .collect::>() + }; + + let mut records_reader = csv::ReaderBuilder::new() + .delimiter(options.csv_delimiter) + .has_headers(options.csv_has_header) + .from_path(path) + .map_err(|error| DbError::InvalidQuery(format!("Failed to parse CSV file: {}", error)))?; + let records = records_reader.records(); + let mut current_batch: Vec> = Vec::new(); + + if columns.is_empty() { + return Err(DbError::InvalidQuery( + "CSV file has no columns to restore".to_string(), + )); + } + + let mut stats = RestoreStats::default(); + + for record in records { + let record = record.map_err(|error| { + DbError::InvalidQuery(format!("Failed to parse CSV row: {}", error)) + })?; + + current_batch.push( + record + .iter() + .map(|value| value.to_string()) + .collect::>(), + ); + + if current_batch.len() >= CSV_BATCH_SIZE { + let batch_stats = restore_rows_in_batches( + adapter, + target_schema, + target_table, + &columns, + ¤t_batch, + &mut on_progress, + ) + .await?; + merge_stats(&mut stats, batch_stats); + current_batch.clear(); + } + } + + if !current_batch.is_empty() { + let batch_stats = restore_rows_in_batches( + adapter, + target_schema, + target_table, + &columns, + ¤t_batch, + &mut on_progress, + ) + .await?; + merge_stats(&mut stats, batch_stats); + } + + Ok(stats) +} + +/// Restores XLSX rows from one sheet into a target table in 500-row insert batches. +pub async fn restore_xlsx_file( + adapter: &A, + path: &str, + target_schema: Option<&str>, + target_table: &str, + options: &RestoreOptions, +) -> DbResult { + restore_xlsx_file_with_progress( + adapter, + path, + target_schema, + target_table, + options, + |_, _| {}, + ) + .await +} + +pub(crate) async fn restore_xlsx_file_with_progress( + adapter: &A, + path: &str, + target_schema: Option<&str>, + target_table: &str, + options: &RestoreOptions, + mut on_progress: F, +) -> DbResult { + let mut workbook: Xlsx> = + open_workbook::>, _>(path) + .map_err(|error: XlsxError| DbError::InvalidQuery(error.to_string()))?; + + let sheet_name = options + .xlsx_sheet_name + .clone() + .or_else(|| workbook.sheet_names().first().cloned()) + .ok_or_else(|| DbError::InvalidQuery("XLSX file has no worksheets".to_string()))?; + + let range = workbook + .worksheet_range(&sheet_name) + .map_err(|error: XlsxError| DbError::InvalidQuery(error.to_string()))?; + + let mut rows_iter = range.rows(); + let columns = rows_iter + .next() + .map(|row| { + row.iter() + .map(|cell: &Data| cell.to_string()) + .collect::>() + }) + .ok_or_else(|| DbError::InvalidQuery("XLSX sheet is empty".to_string()))?; + + let mut stats = RestoreStats::default(); + let mut current_batch: Vec> = Vec::new(); + + for row in rows_iter { + current_batch.push( + row.iter() + .map(|cell: &Data| cell.to_string()) + .collect::>(), + ); + + if current_batch.len() >= CSV_BATCH_SIZE { + let batch_stats = restore_rows_in_batches( + adapter, + target_schema, + target_table, + &columns, + ¤t_batch, + &mut on_progress, + ) + .await?; + merge_stats(&mut stats, batch_stats); + current_batch.clear(); + } + } + + if !current_batch.is_empty() { + let batch_stats = restore_rows_in_batches( + adapter, + target_schema, + target_table, + &columns, + ¤t_batch, + &mut on_progress, + ) + .await?; + merge_stats(&mut stats, batch_stats); + } + + Ok(stats) +} + +pub(crate) fn split_sql_statements(content: &str) -> Vec { + fn read_dollar_tag(chars: &[char], start: usize) -> Option<(String, usize)> { + if chars.get(start) != Some(&'$') { + return None; + } + + let mut end = start + 1; + while end < chars.len() { + if chars[end] == '$' { + let tag = chars[start + 1..end].iter().collect::(); + let valid = if tag.is_empty() { + true + } else { + let mut tag_chars = tag.chars(); + matches!(tag_chars.next(), Some(ch) if ch.is_ascii_alphabetic() || ch == '_') + && tag_chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') + }; + + return if valid { Some((tag, end + 1)) } else { None }; + } + end += 1; + } + + None + } + + let chars = content.chars().collect::>(); + let mut current = String::new(); + let mut statements: Vec = Vec::new(); + + let mut index = 0usize; + let mut in_single = false; + let mut in_double = false; + let mut in_line_comment = false; + let mut in_block_comment = false; + let mut in_dollar: Option = None; + + while index < chars.len() { + let ch = chars[index]; + let next = chars.get(index + 1).copied(); + + if let Some(dollar_tag) = in_dollar.as_ref() { + if ch == '$' { + if let Some((tag, next_index)) = read_dollar_tag(&chars, index) { + if &tag == dollar_tag { + current.push_str(&chars[index..next_index].iter().collect::()); + in_dollar = None; + index = next_index; + continue; + } + } + } + + current.push(ch); + index += 1; + continue; + } + + if in_line_comment { + current.push(ch); + if ch == '\n' { + in_line_comment = false; + } + index += 1; + continue; + } + + if in_block_comment { + current.push(ch); + if ch == '*' && next == Some('/') { + current.push('/'); + in_block_comment = false; + index += 2; + } else { + index += 1; + } + continue; + } + + if !in_single && !in_double && ch == '-' && next == Some('-') { + current.push(ch); + current.push('-'); + in_line_comment = true; + index += 2; + continue; + } + + if !in_single && !in_double && ch == '/' && next == Some('*') { + current.push(ch); + current.push('*'); + in_block_comment = true; + index += 2; + continue; + } + + if !in_single && !in_double && ch == '$' { + if let Some((tag, next_index)) = read_dollar_tag(&chars, index) { + current.push_str(&chars[index..next_index].iter().collect::()); + in_dollar = Some(tag); + index = next_index; + continue; + } + } + + if ch == '\'' && !in_double { + if in_single && next == Some('\'') { + current.push(ch); + current.push('\''); + index += 2; + continue; + } + in_single = !in_single; + current.push(ch); + index += 1; + continue; + } + + if ch == '"' && !in_single { + in_double = !in_double; + current.push(ch); + index += 1; + continue; + } + + if !in_single && !in_double && ch == ';' { + current.push(ch); + let statement = current.trim().trim_end_matches(';').trim().to_string(); + if !statement.is_empty() { + statements.push(statement); + } + current.clear(); + index += 1; + continue; + } + + current.push(ch); + index += 1; + } + + let trailing = current.trim().trim_end_matches(';').trim().to_string(); + if !trailing.is_empty() { + statements.push(trailing); + } + + statements +} + +async fn restore_rows_in_batches( + adapter: &A, + target_schema: Option<&str>, + target_table: &str, + columns: &[String], + rows: &[Vec], + on_progress: &mut F, +) -> DbResult { + if target_table.trim().is_empty() { + return Err(DbError::InvalidQuery( + "Target table is required for tabular restore".to_string(), + )); + } + + let db_type = adapter.get_config().db_type; + let total_batches = if rows.is_empty() { + 0 + } else { + rows.len().div_ceil(CSV_BATCH_SIZE) + } as u64; + + let mut batch_index = 0usize; + let mut stats = RestoreStats { + statements_total: total_batches, + ..RestoreStats::default() + }; + + while batch_index < total_batches as usize { + let start = batch_index * CSV_BATCH_SIZE; + let end = ((batch_index + 1) * CSV_BATCH_SIZE).min(rows.len()); + let batch = &rows[start..end]; + let statement = build_insert_statement(db_type, target_schema, target_table, columns); + + let batch_result: DbResult = async { + let mut inserted = 0u64; + let mut row_index = 0usize; + while row_index < batch.len() { + inserted += adapter + .execute_batch_with_params( + &statement, + columns.len(), + vec![batch[row_index].clone()], + ) + .await?; + row_index += 1; + } + Ok(inserted) + } + .await; + + match batch_result { + Ok(inserted) => { + stats.statements_succeeded += 1; + stats.rows_inserted += inserted; + } + Err(error) => { + stats + .errors + .push(format!("batch {}: {}", batch_index + 1, error)); + } + } + + batch_index += 1; + on_progress(batch_index as u64, total_batches); + } + + Ok(stats) +} + +fn merge_stats(target: &mut RestoreStats, source: RestoreStats) { + target.statements_total += source.statements_total; + target.statements_succeeded += source.statements_succeeded; + target.rows_inserted += source.rows_inserted; + target.errors.extend(source.errors); +} + +pub(crate) fn quote_identifier(name: &str, db_type: DatabaseType) -> String { + match db_type { + DatabaseType::MySQL => format!("`{}`", name.replace('`', "``")), + DatabaseType::SqlServer => format!("[{}]", name.replace(']', "]]")), + _ => format!("\"{}\"", name.replace('"', "\"\"")), + } +} + +fn qualified_table(db_type: DatabaseType, schema: Option<&str>, table: &str) -> String { + schema + .map(|schema_name| { + format!( + "{}.{}", + quote_identifier(schema_name, db_type), + quote_identifier(table, db_type) + ) + }) + .unwrap_or_else(|| quote_identifier(table, db_type)) +} + +fn build_insert_statement( + db_type: DatabaseType, + target_schema: Option<&str>, + target_table: &str, + columns: &[String], +) -> String { + let quoted_table = qualified_table(db_type, target_schema, target_table); + let quoted_columns = columns + .iter() + .map(|column| quote_identifier(column, db_type)) + .collect::>() + .join(", "); + + let placeholders = match db_type { + DatabaseType::PostgreSQL => (1..=columns.len()) + .map(|index| format!("${}", index)) + .collect::>() + .join(", "), + DatabaseType::SqlServer => (1..=columns.len()) + .map(|index| format!("@P{}", index)) + .collect::>() + .join(", "), + _ => std::iter::repeat_n("?", columns.len()) + .collect::>() + .join(", "), + }; + + format!( + "INSERT INTO {} ({}) VALUES ({})", + quoted_table, quoted_columns, placeholders + ) +} + +#[cfg(test)] +mod tests { + use super::split_sql_statements; + use csv::ReaderBuilder; + use std::fs; + use tempfile::NamedTempFile; + + #[test] + fn split_sql_statements_ignores_semicolons_in_strings() { + let sql = "INSERT INTO t VALUES ('a; b');\nSELECT 1;\n"; + let statements = split_sql_statements(sql); + + assert_eq!(statements.len(), 2); + assert_eq!(statements[0], "INSERT INTO t VALUES ('a; b')"); + assert_eq!(statements[1], "SELECT 1"); + } + + #[test] + fn split_sql_statements_ignores_semicolons_in_line_comment() { + let sql = "SELECT 1 -- keep ; in comment\n;\nSELECT 2;\n"; + let statements = split_sql_statements(sql); + + assert_eq!(statements.len(), 2); + assert!(statements[0].starts_with("SELECT 1")); + assert_eq!(statements[1], "SELECT 2"); + } + + #[test] + fn split_sql_statements_ignores_semicolons_in_block_comment() { + let sql = "/* ;;; */\nSELECT 1;\n/* x ; y */\nSELECT 2;\n"; + let statements = split_sql_statements(sql); + + assert_eq!(statements.len(), 2); + assert!(statements[0].contains("SELECT 1")); + assert!(statements[1].contains("SELECT 2")); + } + + #[test] + fn split_sql_statements_handles_same_line_semicolons() { + let statements = split_sql_statements("SELECT 1; SELECT 2;"); + assert_eq!(statements, vec!["SELECT 1", "SELECT 2"]); + } + + #[test] + fn split_sql_statements_handles_dollar_quoted_function() { + let sql = "CREATE FUNCTION demo() RETURNS void AS $$ BEGIN; END; $$ LANGUAGE plpgsql;"; + let statements = split_sql_statements(sql); + assert_eq!(statements.len(), 1); + } + + #[test] + fn split_sql_statements_handles_tagged_dollar() { + let sql = + "CREATE FUNCTION demo() RETURNS void AS $body$ BEGIN; PERFORM 1; END; $body$ LANGUAGE plpgsql;"; + let statements = split_sql_statements(sql); + assert_eq!(statements.len(), 1); + } + + #[test] + fn split_sql_statements_handles_nested_unmatched_dollar() { + let sql = "SELECT $tag1$ one $tag2$ two $tag1$; SELECT 2;"; + let statements = split_sql_statements(sql); + assert_eq!( + statements, + vec!["SELECT $tag1$ one $tag2$ two $tag1$", "SELECT 2"] + ); + } + + #[test] + fn csv_parser_handles_quoted_commas() { + let file = NamedTempFile::new().expect("temp file"); + let path = file.path().to_path_buf(); + let content = "name,note\n\"Jane\",\"hello, world\"\n"; + fs::write(&path, content).expect("write csv"); + + let mut reader = ReaderBuilder::new() + .delimiter(b',') + .has_headers(true) + .from_path(path) + .expect("csv reader"); + + let headers = reader + .headers() + .expect("headers") + .iter() + .map(|value| value.to_string()) + .collect::>(); + let record = reader + .records() + .next() + .expect("record") + .expect("valid record"); + + assert_eq!(headers, vec!["name".to_string(), "note".to_string()]); + assert_eq!(record.get(0).unwrap_or_default(), "Jane"); + assert_eq!(record.get(1).unwrap_or_default(), "hello, world"); + } +} diff --git a/src-tauri/tests/sqlserver_integration.rs b/src-tauri/tests/sqlserver_integration.rs index 99c5b7f1..9e4fe3c4 100644 --- a/src-tauri/tests/sqlserver_integration.rs +++ b/src-tauri/tests/sqlserver_integration.rs @@ -298,7 +298,7 @@ async fn test_xml_and_complex_types() { match row.get("unique_id").unwrap() { QueryValue::String(s) => { println!("GUID: {}", s); - assert!(s.len() > 0); + assert!(!s.is_empty()); } _ => panic!("Expected String for GUID"), } diff --git a/src/components/transfer/launcher/ActionPicker.vue b/src/components/transfer/launcher/ActionPicker.vue new file mode 100644 index 00000000..fa12dc53 --- /dev/null +++ b/src/components/transfer/launcher/ActionPicker.vue @@ -0,0 +1,51 @@ + + + diff --git a/src/components/transfer/launcher/JobsDrawer.vue b/src/components/transfer/launcher/JobsDrawer.vue new file mode 100644 index 00000000..ba588630 --- /dev/null +++ b/src/components/transfer/launcher/JobsDrawer.vue @@ -0,0 +1,167 @@ + + + diff --git a/src/components/transfer/launcher/OptionsPanel.vue b/src/components/transfer/launcher/OptionsPanel.vue new file mode 100644 index 00000000..970eda54 --- /dev/null +++ b/src/components/transfer/launcher/OptionsPanel.vue @@ -0,0 +1,212 @@ + + + diff --git a/src/components/transfer/launcher/PresetsBar.vue b/src/components/transfer/launcher/PresetsBar.vue new file mode 100644 index 00000000..453fe5b2 --- /dev/null +++ b/src/components/transfer/launcher/PresetsBar.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/src/components/transfer/launcher/SourcePicker.vue b/src/components/transfer/launcher/SourcePicker.vue new file mode 100644 index 00000000..9ecbde3a --- /dev/null +++ b/src/components/transfer/launcher/SourcePicker.vue @@ -0,0 +1,262 @@ + + + diff --git a/src/components/transfer/launcher/TargetPicker.vue b/src/components/transfer/launcher/TargetPicker.vue new file mode 100644 index 00000000..9ea91e51 --- /dev/null +++ b/src/components/transfer/launcher/TargetPicker.vue @@ -0,0 +1,97 @@ + + + diff --git a/src/components/transfer/launcher/TransferLauncher.vue b/src/components/transfer/launcher/TransferLauncher.vue new file mode 100644 index 00000000..a71d83cb --- /dev/null +++ b/src/components/transfer/launcher/TransferLauncher.vue @@ -0,0 +1,178 @@ + + + diff --git a/src/components/transfer/launcher/index.ts b/src/components/transfer/launcher/index.ts new file mode 100644 index 00000000..4167faf7 --- /dev/null +++ b/src/components/transfer/launcher/index.ts @@ -0,0 +1,8 @@ +export { default as ActionPicker } from './ActionPicker.vue' +export { default as JobsDrawer } from './JobsDrawer.vue' +export { default as OptionsPanel } from './OptionsPanel.vue' +export { default as PresetsBar } from './PresetsBar.vue' +export { default as SourcePicker } from './SourcePicker.vue' +export { default as TargetPicker } from './TargetPicker.vue' +export { default as TransferLauncher } from './TransferLauncher.vue' +export type { LauncherAction, LauncherFormat, LauncherOptions, LauncherScope, LauncherSource, LauncherState, LauncherTarget } from './types' diff --git a/src/components/transfer/launcher/types.ts b/src/components/transfer/launcher/types.ts new file mode 100644 index 00000000..9318737b --- /dev/null +++ b/src/components/transfer/launcher/types.ts @@ -0,0 +1,37 @@ +export type LauncherAction = 'backup' | 'restore' | 'migrate' | 'export' + +export type LauncherScope = 'server' | 'database' | 'table' + +export type LauncherFormat = 'sql' | 'csv' | 'excel' + +export type LauncherOptions = { + format?: LauncherFormat + destination?: string + parallelism?: number + filePath?: string + fileFormat?: LauncherFormat + targetTable?: string + dropTargetFirst?: boolean + useSourceNames?: boolean + customPrefix?: string +} + +export type LauncherSource = { + connectionId?: string + scope?: LauncherScope + database?: string + schema?: string + tables?: string[] +} + +export type LauncherTarget = { + connectionId?: string + database?: string +} + +export type LauncherState = { + action: LauncherAction + source: LauncherSource + target: LauncherTarget + options: LauncherOptions +} diff --git a/src/datasources/transferApi.ts b/src/datasources/transferApi.ts index c3944607..5202825a 100644 --- a/src/datasources/transferApi.ts +++ b/src/datasources/transferApi.ts @@ -60,14 +60,7 @@ export function generateDdl(request: DdlRequest) { return invoke('generate_ddl_for_objects', { request }) } -export function backupServer( - connectionId: string, - selection: ObjectSelection, - format: ExportFormat, - destination: string, - options: Record, - jobId?: string, -) { +export function backupServer(connectionId: string, selection: ObjectSelection, format: ExportFormat, destination: string, options: Record, jobId?: string) { return invoke('backup_server', { connectionId, selection, @@ -78,13 +71,7 @@ export function backupServer( }) } -export function migrateServer( - sourceConnectionId: string, - targetConnectionId: string, - selection: ObjectSelection, - options: Record, - jobId?: string, -) { +export function migrateServer(sourceConnectionId: string, targetConnectionId: string, selection: ObjectSelection, options: Record, jobId?: string) { return invoke('migrate_server', { sourceConnectionId, targetConnectionId, @@ -94,6 +81,18 @@ export function migrateServer( }) } +export function restoreBackup(connectionId: string, targetDatabase: string | undefined, filePath: string, fileFormat: 'sql' | 'csv' | 'excel', targetTable: string | undefined, dropTargetFirst: boolean, jobId?: string) { + return invoke('restore_backup', { + connectionId, + jobId, + targetDatabase, + filePath, + fileFormat, + targetTable, + dropTargetFirst, + }) +} + export function saveTransferProfile(profile: TransferProfile) { return invoke('save_transfer_profile', { profile }) } diff --git a/src/lang/enUS.ts b/src/lang/enUS.ts index cf508238..f5f0f7c5 100644 --- a/src/lang/enUS.ts +++ b/src/lang/enUS.ts @@ -53,6 +53,74 @@ export const enUS = { }, }, transfer: { + launcher: { + title: 'Activity Center', + whatDoYouWant: 'What do you want to do?', + source: 'Source', + target: 'Target', + options: 'Options', + connection: 'Connection', + selectConnection: 'Select connection...', + scope: 'Scope', + scopes: { + server: 'Server', + database: 'Database', + table: 'Table(s)', + }, + database: 'Database', + selectDatabase: 'Select database...', + newDatabase: 'New Database', + schema: 'Schema', + selectSchema: 'Select schema...', + tables: 'Tables', + noTables: 'No tables found.', + selected: 'selected', + selectAll: 'Select All', + clear: 'Clear', + format: 'Format', + destination: 'Destination File', + parallelism: 'Parallelism', + sourceFile: 'Source File', + fileFormat: 'File format', + targetTable: 'Target table', + targetTablePlaceholder: 'schema.table or table', + dropTargetFirst: 'Drop target objects first', + savePreset: 'Save current', + presetNamePrompt: 'Enter preset name:', + startSuccess: 'Task started', + jobStarted: 'Job has been added to the queue.', + startFailed: 'Task failed to start', + jobsRunning: '{count} jobs running', + allJobsCompleted: 'All jobs completed', + noRunningJobs: 'No running jobs', + noHistoryJobs: 'No history', + tabs: { + running: 'Running', + history: 'History', + }, + actions: { + backup: { + title: 'Backup', + desc: 'Export database to a file', + start: 'Start Backup', + }, + restore: { + title: 'Restore', + desc: 'Import from a backup file', + start: 'Start Restore', + }, + migrate: { + title: 'Migrate', + desc: 'Transfer to another server', + start: 'Start Migrate', + }, + export: { + title: 'Export', + desc: 'Export table data to CSV/Excel', + start: 'Start Export', + }, + }, + }, title: 'Transfer', subtitle: 'Import, export, and migrate your data', tabs: { diff --git a/src/lang/zhCN.ts b/src/lang/zhCN.ts index 84e9439a..624329af 100644 --- a/src/lang/zhCN.ts +++ b/src/lang/zhCN.ts @@ -53,6 +53,74 @@ export const zhCN = { }, }, transfer: { + launcher: { + title: '活动中心', + whatDoYouWant: '您想要做什么?', + source: '源', + target: '目标', + options: '选项', + connection: '连接', + selectConnection: '选择连接...', + scope: '范围', + scopes: { + server: '服务器', + database: '数据库', + table: '表', + }, + database: '数据库', + selectDatabase: '选择数据库...', + newDatabase: '新建数据库', + schema: '模式 (Schema)', + selectSchema: '选择模式...', + tables: '表', + noTables: '未找到表。', + selected: '已选择', + selectAll: '全选', + clear: '清空', + format: '格式', + destination: '目标文件', + parallelism: '并发数', + sourceFile: '源文件', + fileFormat: '文件格式', + targetTable: '目标表', + targetTablePlaceholder: 'schema.表名 或 表名', + dropTargetFirst: '导入前删除目标对象', + savePreset: '保存当前配置', + presetNamePrompt: '输入预设名称:', + startSuccess: '任务已开始', + jobStarted: '任务已加入队列。', + startFailed: '任务启动失败', + jobsRunning: '{count} 个任务运行中', + allJobsCompleted: '所有任务已完成', + noRunningJobs: '没有运行中的任务', + noHistoryJobs: '暂无历史记录', + tabs: { + running: '运行中', + history: '历史记录', + }, + actions: { + backup: { + title: '备份', + desc: '将数据库导出到文件', + start: '开始备份', + }, + restore: { + title: '恢复', + desc: '从备份文件导入', + start: '开始恢复', + }, + migrate: { + title: '迁移', + desc: '迁移到另一个服务器', + start: '开始迁移', + }, + export: { + title: '导出', + desc: '导出表数据到 CSV/Excel', + start: '开始导出', + }, + }, + }, title: '传输', subtitle: '导入、导出和迁移您的数据', tabs: { diff --git a/src/pages/TransferPage.vue b/src/pages/TransferPage.vue index 58f4636f..203b1f07 100644 --- a/src/pages/TransferPage.vue +++ b/src/pages/TransferPage.vue @@ -1,299 +1,28 @@ diff --git a/src/store/transferStore.ts b/src/store/transferStore.ts index 4f788837..148d3c2a 100644 --- a/src/store/transferStore.ts +++ b/src/store/transferStore.ts @@ -223,7 +223,7 @@ export const useTransferStore = defineStore('transfer', () => { job.id === jobId ? { ...job, - status: payload.status as any, + status: payload.status as TransferJob['status'], progress: payload.progress, error: payload.error, finishedAt: ['completed', 'failed', 'cancelled'].includes(payload.status) ? Date.now() : job.finishedAt, @@ -279,6 +279,79 @@ export const useTransferStore = defineStore('transfer', () => { return jobId } + const startExport = async (args: { + connectionId: string + name: string + selection: ObjectSelection + format: ExportFormat + destination: string + options: Record + }) => { + const requestedJobId = crypto.randomUUID() + await subscribeToJob(requestedJobId) + + const newJob: TransferJob = { + id: requestedJobId, + name: args.name, + kind: 'export', + scope: 'table', + connectionId: args.connectionId, + status: 'queued', + progress: { stage: 'Initializing...', current: 0, total: 1 }, + startedAt: Date.now(), + } + jobs.value = [...jobs.value, newJob] + + const jobId = await backupServer( + args.connectionId, + args.selection, + args.format, + args.destination, + args.options, + requestedJobId, + ) + + return jobId + } + + const startRestore = async (args: { + connectionId: string + name: string + targetDatabase: string | undefined + filePath: string + fileFormat: 'sql' | 'csv' | 'excel' + targetTable: string | undefined + dropTargetFirst: boolean + }) => { + const requestedJobId = crypto.randomUUID() + await subscribeToJob(requestedJobId) + + const newJob: TransferJob = { + id: requestedJobId, + name: args.name, + kind: 'import', + scope: 'server', + connectionId: args.connectionId, + status: 'queued', + progress: { stage: 'Initializing...', current: 0, total: 1 }, + startedAt: Date.now(), + } + jobs.value = [...jobs.value, newJob] + + const { restoreBackup } = await import('@/datasources/transferApi') + const jobId = await restoreBackup( + args.connectionId, + args.targetDatabase, + args.filePath, + args.fileFormat, + args.targetTable, + args.dropTargetFirst, + requestedJobId, + ) + + return jobId + } + const startMigrateServer = async (args: { sourceConnectionId: string targetConnectionId: string @@ -403,6 +476,8 @@ export const useTransferStore = defineStore('transfer', () => { savedProfiles, subscribeToJob, startBackupServer, + startExport, + startRestore, startMigrateServer, saveProfile, loadProfiles, From b9bdd67452c51849e3fbd51be24af78e2af23b34 Mon Sep 17 00:00:00 2001 From: blankll Date: Thu, 28 May 2026 16:49:47 +0800 Subject: [PATCH 3/3] feat(transfer): console-style UI redesign with industrial DBA aesthetic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign launcher from stacked cards to console-style split layout: - Context bar with live breadcrumb (CONN → SCOPE → DB → ACTION → FMT) - Side-by-side source/destination panels for at-a-glance awareness - Summary bar showing scope, tables, format, status with READY/INCOMPLETE - Action tiles replacing card grid, with accent highlight on selection - Activity bar replacing floating drawer, always visible at bottom - Monospace labels, steel-blue-gray palette, teal accents Design tokens in CSS variables across light/dark mode: - OKLCH color system with tinted neutrals - JetBrains Mono, Sofia Sans, Wix Madefor Text fonts - Transfer console component classes (panels, sections, tiles, bar) Also: - Add .impeccable.md design context document - Ignore .omo/ agent runtime data --- .gitignore | 3 + .impeccable.md | 20 ++ AGENTS.md | 24 ++ index.html | 3 + src/assets/index.css | 305 ++++++++++++++++++ .../transfer/launcher/ActionPicker.vue | 29 +- .../transfer/launcher/JobsDrawer.vue | 94 +++--- .../transfer/launcher/OptionsPanel.vue | 269 +++++++-------- .../transfer/launcher/TargetPicker.vue | 55 ++-- .../transfer/launcher/TransferLauncher.vue | 150 +++++++-- 10 files changed, 712 insertions(+), 240 deletions(-) create mode 100644 .impeccable.md diff --git a/.gitignore b/.gitignore index b6f311e6..6357faf9 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ coverage/ # TypeScript build info *.tsbuildinfo + +# Agent runtime data +.omo/ diff --git a/.impeccable.md b/.impeccable.md new file mode 100644 index 00000000..072a1103 --- /dev/null +++ b/.impeccable.md @@ -0,0 +1,20 @@ +## Design Context + +### Users +**DBAs and ops engineers** managing production database environments. They work under pressure during incidents, late-night maintenance windows, and routine backup/restore operations. Context: dark office, multiple monitors, monitoring dashboards open, Slack/PD alerts firing. They need speed, precision, and trust — the tool must feel reliable enough for `pg_dump`-level confidence. + +### Brand Personality +**Precise. Powerful. Clean.** The tool should feel like a high-end instrument — surgical, not decorative. Every pixel has intent. The interface communicates capability through density and clarity, not through ornamentation. + +### Aesthetic Direction +- **Visual tone**: Industrial/utilitarian control panel — think Grafana meets a high-end mixing console. Dark-first (DBAs work at night). Steel blue-gray palette with teal/amber accents. +- **Theme**: Dark mode as primary, light mode secondary +- **Key differentiation**: The transfer module should feel like a **control room** — not a form wizard. Source and destination visible simultaneously. Job monitoring persistent. Configuration feels like setting up a pipeline, not filling out paperwork. +- **Anti-references**: No border-left accent stripes, no glassmorphism, no gradient text, no card-within-card nesting, no hero metrics (big numbers with tiny labels) + +### Design Principles +1. **Surgical precision** — Every control is exactly where it needs to be. No hunting for options. Proximity groups related things. +2. **At-a-glance awareness** — Source, destination, and status visible simultaneously without scrolling or clicking through steps. +3. **Console not wizard** — DBAs don't need step-by-step handholding. They need configuration panels where all options are visible. +4. **Trust through transparency** — Show what will happen before it happens. Preview data, show row counts, expose SQL. +5. **Monitor without noise** — Job progress is always visible at the bottom but never interrupts flow. diff --git a/AGENTS.md b/AGENTS.md index a5ab7e76..913c79f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -228,6 +228,30 @@ git commit -m "feat: description of changes" git push ``` +## Transfer Module + +The transfer module spans frontend and backend with a **console → wizard → background job** architecture: + +**Frontend** (`src/components/transfer/`): +- `TransferPage.vue` - Console-style main page with context bar + operation panel + activity bar +- `components/transfer/launcher/` - Quick operation launcher (action picker, source/target picker, options) +- `components/transfer/export/`, `import/`, `migration/`, `structure/` - Detailed step wizards (4-step: source → config → preview → execute) +- `components/transfer/shared/` - Shared selectors (ConnectionSelector, TableSelector, ColumnSelector, etc.) +- `components/transfer/tasks/` - Task manager panel/cards +- `store/transferStore.ts` - Pinia store (jobs, profiles, progress, event subscriptions) +- `types/transfer.ts` - All transfer types (ExportRequest, ImportRequest, MigrationRequest, TransferJob, etc.) +- `datasources/transferApi.ts` - Tauri invoke wrappers (16+ commands) + +**Backend** (`src-tauri/src/transfer/`): +- `commands/transfer.rs` - 16 Tauri commands (preview/execute export/import/migration, backup/restore, DDL gen, profiles) +- `transfer/export.rs` - CSV/JSONL/SQL/Excel export with pagination +- `transfer/import.rs` - CSV/JSONL/SQL/Excel import with batch inserts +- `transfer/migration.rs` - Cross-engine migration with type mapping (16 source×target combinations) +- `transfer/restore.rs` - SQL/CSV/Excel restore +- `transfer/types.rs` - Rust types mirroring frontend types + +**Pattern**: Commands dispatch by `ActiveConnection` variant (Postgres/MySQL/SQLServer/SQLite). Progress emitted via Tauri events (`transfer://progress/{jobId}`). Profiles persisted via `profile_store.rs`. + ## Common Tasks **Adding a Tauri Command**: diff --git a/index.html b/index.html index a029359a..98d4e69b 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,9 @@ SqlKit + + + diff --git a/src/assets/index.css b/src/assets/index.css index 1bbedb9b..e3d6be92 100644 --- a/src/assets/index.css +++ b/src/assets/index.css @@ -73,3 +73,308 @@ body { opacity: 0.6; } } + +/* Transfer Console Design System — industrial/utilitarian, dark-first for DBA workflows */ +:root { + --transfer-surface: oklch(0.97 0.005 250); + --transfer-surface-alt: oklch(0.95 0.008 250); + --transfer-border: oklch(0.88 0.01 250); + --transfer-text: oklch(0.2 0.015 250); + --transfer-text-dim: oklch(0.55 0.02 250); + --transfer-accent: oklch(0.55 0.15 190); + --transfer-accent-soft: oklch(0.55 0.15 190 / 0.1); + --transfer-warn: oklch(0.7 0.18 75); + --transfer-success: oklch(0.6 0.15 160); + --transfer-console-bg: oklch(0.97 0.005 250); + --transfer-console-border: oklch(0.9 0.01 250); + --transfer-header-bg: oklch(0.99 0.003 250); + --transfer-action-bg: oklch(0.96 0.006 250); + --transfer-action-hover: oklch(0.94 0.01 250); + --transfer-action-active: oklch(0.55 0.15 190 / 0.08); + --transfer-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; + --transfer-display: 'Sofia Sans', 'Inter', system-ui, sans-serif; + --transfer-ui: 'Wix Madefor Text', system-ui, sans-serif; +} + +.dark { + --transfer-surface: oklch(0.13 0.015 250); + --transfer-surface-alt: oklch(0.16 0.018 250); + --transfer-border: oklch(0.2 0.015 250); + --transfer-text: oklch(0.92 0.008 250); + --transfer-text-dim: oklch(0.5 0.02 250); + --transfer-accent: oklch(0.65 0.15 190); + --transfer-accent-soft: oklch(0.65 0.15 190 / 0.1); + --transfer-warn: oklch(0.75 0.18 75); + --transfer-success: oklch(0.65 0.15 160); + --transfer-console-bg: oklch(0.09 0.01 250); + --transfer-console-border: oklch(0.18 0.015 250); + --transfer-header-bg: oklch(0.11 0.012 250); + --transfer-action-bg: oklch(0.14 0.015 250); + --transfer-action-hover: oklch(0.17 0.018 250); + --transfer-action-active: oklch(0.65 0.15 190 / 0.08); +} + +/* ── Transfer Console Component Styles ── */ +.transfer-panel { + background: var(--transfer-console-bg); + border: 1px solid var(--transfer-console-border); + border-radius: 8px; +} + +.transfer-card { + background: var(--transfer-surface); + border: 1px solid var(--transfer-border); + border-radius: 6px; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.transfer-card:hover { + border-color: var(--transfer-accent); + box-shadow: 0 0 0 1px var(--transfer-accent-soft); +} + +.transfer-card-active { + border-color: var(--transfer-accent); + background: var(--transfer-action-active); + box-shadow: 0 0 0 1px var(--transfer-accent-soft); +} + +.transfer-action-btn { + background: var(--transfer-action-bg); + border: 1px solid var(--transfer-border); + border-radius: 6px; + color: var(--transfer-text); + font-family: var(--transfer-ui); + transition: all 0.12s ease; + cursor: pointer; +} + +.transfer-action-btn:hover { + background: var(--transfer-action-hover); + border-color: var(--transfer-accent); +} + +.transfer-action-btn:active, +.transfer-action-btn-active { + background: var(--transfer-action-active); + border-color: var(--transfer-accent); + box-shadow: 0 0 0 1px var(--transfer-accent-soft); +} + +.transfer-divider { + border: none; + border-top: 1px solid var(--transfer-border); + margin: 0; +} + +.transfer-mono-label { + font-family: var(--transfer-mono); + font-size: 0.75rem; + letter-spacing: -0.01em; +} + +.transfer-status-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; +} + +.transfer-status-dot.running { + background: var(--transfer-accent); + box-shadow: 0 0 4px var(--transfer-accent); +} + +.transfer-status-dot.completed { + background: var(--transfer-success); +} + +.transfer-status-dot.failed { + background: var(--transfer-warn); +} + +/* ── Activity Bar ── */ +.transfer-activity-bar { + background: var(--transfer-header-bg); + border-top: 1px solid var(--transfer-border); + font-family: var(--transfer-ui); +} + +.transfer-activity-drawer { + background: var(--transfer-console-bg); + border-top: 1px solid var(--transfer-console-border); +} + +/* ── Context Bar ── */ +.transfer-context-bar { + background: var(--transfer-header-bg); + border-bottom: 1px solid var(--transfer-border); + display: flex; + align-items: center; + gap: 8px; + padding: 0 16px; + height: 40px; + font-family: var(--transfer-ui); + font-size: 0.8125rem; +} + +.transfer-context-segment { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: 4px; + color: var(--transfer-text-dim); + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.01em; +} + +.transfer-context-segment strong { + color: var(--transfer-text); + font-weight: 600; +} + +.transfer-context-separator { + color: var(--transfer-border); + font-size: 0.625rem; +} + +/* ── Console Action Buttons ── */ +.transfer-action-tile { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + padding: 12px 16px; + background: var(--transfer-action-bg); + border: 1px solid var(--transfer-border); + border-radius: 6px; + cursor: pointer; + transition: all 0.12s ease; + font-family: var(--transfer-ui); + text-align: left; + width: 100%; +} + +.transfer-action-tile:hover { + background: var(--transfer-action-hover); + border-color: var(--transfer-accent); + transform: translateY(-1px); +} + +.transfer-action-tile:active { + transform: translateY(0); +} + +.transfer-action-tile-active { + background: var(--transfer-action-active); + border-color: var(--transfer-accent); + box-shadow: 0 0 0 1px var(--transfer-accent-soft); +} + +.transfer-action-tile-icon { + width: 20px; + height: 20px; + color: var(--transfer-text-dim); + transition: color 0.12s ease; +} + +.transfer-action-tile:hover .transfer-action-tile-icon, +.transfer-action-tile-active .transfer-action-tile-icon { + color: var(--transfer-accent); +} + +.transfer-action-tile-title { + font-size: 0.8125rem; + font-weight: 600; + color: var(--transfer-text); + font-family: var(--transfer-display); +} + +.transfer-action-tile-desc { + font-size: 0.6875rem; + color: var(--transfer-text-dim); + line-height: 1.3; +} + +/* ── Split Console Panels ── */ +.transfer-console-split { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.transfer-console-section { + background: var(--transfer-surface); + border: 1px solid var(--transfer-border); + border-radius: 6px; + overflow: hidden; +} + +.transfer-console-section-header { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + border-bottom: 1px solid var(--transfer-border); + font-family: var(--transfer-display); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--transfer-text-dim); + background: var(--transfer-surface-alt); +} + +.transfer-console-section-body { + padding: 12px; +} + +/* ── Console Summary Bar ── */ +.transfer-summary-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + background: var(--transfer-surface-alt); + border: 1px solid var(--transfer-border); + border-radius: 6px; + font-family: var(--transfer-mono); + font-size: 0.6875rem; +} + +.transfer-summary-stat { + display: flex; + align-items: center; + gap: 6px; + color: var(--transfer-text-dim); +} + +.transfer-summary-stat strong { + color: var(--transfer-text); + font-weight: 600; +} + +/* ── Dialog Overrides ── */ +.transfer-dialog-content { + font-family: var(--transfer-ui); +} + +/* ── Scrollbar for transfer panels ── */ +.transfer-scroll::-webkit-scrollbar { + width: 4px; +} + +.transfer-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.transfer-scroll::-webkit-scrollbar-thumb { + background: var(--transfer-border); + border-radius: 2px; +} + +.transfer-scroll::-webkit-scrollbar-thumb:hover { + background: var(--transfer-text-dim); +} diff --git a/src/components/transfer/launcher/ActionPicker.vue b/src/components/transfer/launcher/ActionPicker.vue index fa12dc53..8be09b59 100644 --- a/src/components/transfer/launcher/ActionPicker.vue +++ b/src/components/transfer/launcher/ActionPicker.vue @@ -26,26 +26,23 @@ function handleSelect(id: LauncherAction) { + + diff --git a/src/components/transfer/launcher/JobsDrawer.vue b/src/components/transfer/launcher/JobsDrawer.vue index ba588630..bff218d6 100644 --- a/src/components/transfer/launcher/JobsDrawer.vue +++ b/src/components/transfer/launcher/JobsDrawer.vue @@ -57,29 +57,12 @@ function toggleExpanded() { + + diff --git a/src/components/transfer/launcher/OptionsPanel.vue b/src/components/transfer/launcher/OptionsPanel.vue index 970eda54..32e2120f 100644 --- a/src/components/transfer/launcher/OptionsPanel.vue +++ b/src/components/transfer/launcher/OptionsPanel.vue @@ -65,148 +65,159 @@ async function handlePickSourceFile() { + + diff --git a/src/components/transfer/launcher/TargetPicker.vue b/src/components/transfer/launcher/TargetPicker.vue index 9ea91e51..64b4a657 100644 --- a/src/components/transfer/launcher/TargetPicker.vue +++ b/src/components/transfer/launcher/TargetPicker.vue @@ -62,36 +62,45 @@ watch(() => state.value.connectionId, (newId) => { + + diff --git a/src/components/transfer/launcher/TransferLauncher.vue b/src/components/transfer/launcher/TransferLauncher.vue index a71d83cb..c7d6d4ed 100644 --- a/src/components/transfer/launcher/TransferLauncher.vue +++ b/src/components/transfer/launcher/TransferLauncher.vue @@ -5,7 +5,7 @@ import { computed, ref } from 'vue' import { useI18n } from 'vue-i18n' import { Button } from '@/components/ui/button' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' + import { toast } from '@/composables/useNotifications' import { useTransferStore } from '@/store/transferStore' import ActionPicker from './ActionPicker.vue' @@ -130,49 +130,129 @@ async function handleStart() { + +