diff --git a/apps/desktop/src/components/transfer/DataTransferDialog.vue b/apps/desktop/src/components/transfer/DataTransferDialog.vue index dddc2f50e4..03e9e29caf 100644 --- a/apps/desktop/src/components/transfer/DataTransferDialog.vue +++ b/apps/desktop/src/components/transfer/DataTransferDialog.vue @@ -10,7 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { useConnectionStore } from "@/stores/connectionStore"; import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; import * as api from "@/lib/api"; -import type { TransferProgress, TransferMode } from "@/lib/api"; +import type { TransferProgress, TransferMode, TransferTableNameCase } from "@/lib/api"; import type { DatabaseType } from "@/types/database"; import { isSchemaAware, supportsTransfer } from "@/lib/databaseCapabilities"; import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions"; @@ -50,6 +50,7 @@ const targetSchema = ref(""); // Options const createTable = ref(true); const transferMode = ref("append"); +const targetTableNameCase = ref("preserve"); const batchSize = ref(1000); // Transfer state @@ -249,6 +250,7 @@ function resetState() { targetSchema.value = ""; createTable.value = true; transferMode.value = "append"; + targetTableNameCase.value = "preserve"; batchSize.value = 1000; isTransferring.value = false; transferProgress.value.clear(); @@ -281,6 +283,7 @@ async function startTransfer() { tables: [...selectedTables.value], createTable: createTable.value, mode: transferMode.value, + targetTableNameCase: targetTableNameCase.value, batchSize: batchSize.value, }; @@ -524,6 +527,19 @@ const overallRowsLabel = computed(() => (knownTotalRows.value > 0 ? `${formatRow +
+ + +
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 3d5f799bf1..126db3c619 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1706,6 +1706,10 @@ export default { modeAppend: "Append (INSERT)", modeOverwrite: "Overwrite (TRUNCATE + INSERT)", modeUpsert: "Upsert (INSERT or UPDATE by PK)", + targetTableNameCase: "Target table name case", + tableNameCasePreserve: "Preserve", + tableNameCaseLower: "Lowercase", + tableNameCaseUpper: "Uppercase", batchSize: "Batch size", start: "Start Transfer", cancel: "Cancel", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 034994266a..b8af26e276 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1423,6 +1423,10 @@ export default { modeAppend: "Agregar (INSERT)", modeOverwrite: "Sobrescribir (TRUNCATE + INSERT)", modeUpsert: "Upsert (INSERT o UPDATE por PK)", + targetTableNameCase: "Mayúsculas/minúsculas de tabla destino", + tableNameCasePreserve: "Conservar", + tableNameCaseLower: "Convertir a minúsculas", + tableNameCaseUpper: "Convertir a mayúsculas", batchSize: "Tamaño de lote", start: "Iniciar transferencia", cancel: "Cancelar", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 0f33d12b24..a8350d1d8a 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1546,6 +1546,10 @@ export default { modeAppend: "Accoda (INSERT)", modeOverwrite: "Sovrascrivi (TRUNCATE + INSERT)", modeUpsert: "Aggiorna/Inserisci (INSERT o UPDATE per chiave primaria)", + targetTableNameCase: "Maiuscole/minuscole tabella destinazione", + tableNameCasePreserve: "Mantieni", + tableNameCaseLower: "Converti in minuscolo", + tableNameCaseUpper: "Converti in maiuscolo", batchSize: "Dimensione lotto", start: "Avvia Trasferimento", cancel: "Annulla", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 889679bc53..815230e1e9 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1678,6 +1678,10 @@ export default { modeAppend: "追加 (INSERT)", modeOverwrite: "上書き (TRUNCATE + INSERT)", modeUpsert: "アップサート (主キーでINSERT or UPDATE)", + targetTableNameCase: "対象テーブル名の大小文字", + tableNameCasePreserve: "そのまま", + tableNameCaseLower: "小文字に変換", + tableNameCaseUpper: "大文字に変換", batchSize: "バッチサイズ", start: "転送開始", cancel: "キャンセル", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index c3fb4b432f..dd48a15111 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1557,6 +1557,10 @@ export default { modeAppend: "Anexar (INSERT)", modeOverwrite: "Sobrescrever (TRUNCATE + INSERT)", modeUpsert: "Upsert (INSERT ou UPDATE por PK)", + targetTableNameCase: "Maiúsculas/minúsculas da tabela de destino", + tableNameCasePreserve: "Preservar", + tableNameCaseLower: "Converter para minúsculas", + tableNameCaseUpper: "Converter para maiúsculas", batchSize: "Tamanho do lote", start: "Iniciar transferência", cancel: "Cancelar", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 86d10f071e..ede67772e6 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1705,6 +1705,10 @@ export default { modeAppend: "追加 (INSERT)", modeOverwrite: "覆盖 (TRUNCATE + INSERT)", modeUpsert: "更新插入 (按主键 INSERT 或 UPDATE)", + targetTableNameCase: "目标表名大小写", + tableNameCasePreserve: "保持原样", + tableNameCaseLower: "转为小写", + tableNameCaseUpper: "转为大写", batchSize: "每批行数", start: "开始传输", cancel: "取消", diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index dc37f376d9..66e5f8730b 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -411,6 +411,7 @@ export type { TransferRequest, TransferProgress, TransferMode, + TransferTableNameCase, TableImportMode, TableImportStatus, TableImportColumnMapping, diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 02b9a4a6a0..f8423628d5 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -1448,6 +1448,7 @@ export async function listenSqlFileProgress(handler: (progress: SqlFileProgress) // --- Data Transfer --- export type TransferMode = "append" | "overwrite" | "upsert"; +export type TransferTableNameCase = "preserve" | "lower" | "upper"; export interface TransferRequest { transferId: string; @@ -1460,6 +1461,7 @@ export interface TransferRequest { tables: string[]; createTable: boolean; mode: TransferMode; + targetTableNameCase: TransferTableNameCase; batchSize: number; } diff --git a/crates/dbx-core/src/transfer.rs b/crates/dbx-core/src/transfer.rs index 3686551887..9855fb8177 100644 --- a/crates/dbx-core/src/transfer.rs +++ b/crates/dbx-core/src/transfer.rs @@ -29,6 +29,15 @@ pub enum TransferMode { Upsert, } +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum TransferTableNameCase { + #[default] + Preserve, + Lower, + Upper, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TransferRequest { @@ -43,9 +52,21 @@ pub struct TransferRequest { pub create_table: bool, #[serde(default)] pub mode: TransferMode, + #[serde(default)] + pub target_table_name_case: TransferTableNameCase, pub batch_size: usize, } +impl TransferRequest { + pub fn target_table_name(&self, source_table: &str) -> String { + match self.target_table_name_case { + TransferTableNameCase::Preserve => source_table.to_string(), + TransferTableNameCase::Lower => source_table.to_lowercase(), + TransferTableNameCase::Upper => source_table.to_uppercase(), + } + } +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct TransferProgress { @@ -77,6 +98,19 @@ pub fn qualified_table(table: &str, schema: &str, db_type: &DatabaseType) -> Str qualified_transfer_table(table, schema, db_type) } +pub fn validate_transfer_target_table_names(request: &TransferRequest) -> Result<(), String> { + let mut targets: HashMap = HashMap::new(); + for source_table in &request.tables { + let target_table = request.target_table_name(source_table); + if let Some(first_source) = targets.insert(target_table.clone(), source_table.clone()) { + return Err(format!( + "Target table name collision after case conversion: '{first_source}' and '{source_table}' both map to '{target_table}'" + )); + } + } + Ok(()) +} + fn quote_string_literal(value: &str) -> String { format!("'{}'", value.replace('\'', "''")) } @@ -2597,6 +2631,7 @@ where F: FnMut(TransferProgress), { let total_tables = request.tables.len(); + let target_table = request.target_table_name(table); let batch_size = if request.batch_size == 0 { 1000 } else { request.batch_size }; let mut offset: u64 = 0; let mut total_transferred: u64 = 0; @@ -2607,9 +2642,14 @@ where } if is_mongodb_transfer_type(target_db_type) && request.mode == TransferMode::Overwrite { - overwrite_mongo_collection_for_transfer(state, &request.target_connection_id, &request.target_database, table) - .await - .map_err(|e| format!("Failed to clear MongoDB collection '{table}': {e}"))?; + overwrite_mongo_collection_for_transfer( + state, + &request.target_connection_id, + &request.target_database, + &target_table, + ) + .await + .map_err(|e| format!("Failed to clear MongoDB collection '{target_table}': {e}"))?; } let mut sql_target_column_names: Vec = Vec::new(); @@ -2672,11 +2712,11 @@ where state, &request.target_connection_id, &request.target_database, - table, + &target_table, &documents, ) .await - .map_err(|e| format!("Insert failed for MongoDB collection '{table}' at offset {offset}: {e}"))?; + .map_err(|e| format!("Insert failed for MongoDB collection '{target_table}' at offset {offset}: {e}"))?; } else { if !sql_target_prepared { let mut sql_target_columns = mongo_columns_from_documents(&documents); @@ -2701,7 +2741,7 @@ where if request.create_table { let ddl = generate_create_table_ddl( &sql_target_columns, - table, + &target_table, &request.source_schema, &request.target_schema, target_db_type, @@ -2722,7 +2762,7 @@ where if table_exists { for stmt in generate_comment_ddl( &sql_target_columns, - table, + &target_table, &request.target_schema, target_db_type, None, @@ -2730,7 +2770,7 @@ where if let Err(e) = execute_on_pool(state, target_pool_key, &stmt).await { log::warn!( "[transfer] failed to set MongoDB transfer column comment for {}: {}", - table, + target_table, e ); } @@ -2739,7 +2779,7 @@ where } if request.mode == TransferMode::Overwrite { - let full_table = qualified_table(table, &request.target_schema, target_db_type); + let full_table = qualified_table(&target_table, &request.target_schema, target_db_type); let truncate_sql = match target_db_type { DatabaseType::Sqlite | DatabaseType::DuckDb => format!("DELETE FROM {full_table}"), _ => format!("TRUNCATE TABLE {full_table}"), @@ -2762,7 +2802,7 @@ where &sql_target_column_names, &sql_target_column_types, &rows, - table, + &target_table, &request.target_schema, target_db_type, &[], @@ -2770,7 +2810,7 @@ where for (statement_index, batch_sql) in write_statements.iter().enumerate() { execute_on_pool(state, target_pool_key, batch_sql).await.map_err(|e| { format!( - "Insert failed for MongoDB collection '{table}' at offset {offset}, chunk {} of {}: {e}", + "Insert failed for MongoDB collection '{target_table}' at offset {offset}, chunk {} of {}: {e}", statement_index + 1, write_statements.len() ) @@ -2834,6 +2874,8 @@ where let total_tables = request.tables.len(); let pg_compat_transfer = is_postgres_compat_transfer(source_db_type, target_db_type); + let target_table = request.target_table_name(table); + let preserves_target_table_name = target_table == table; // Get source columns (deduplicate by name) let columns = { @@ -2882,7 +2924,7 @@ where &request.target_connection_id, &request.target_database, &request.target_schema, - Some(table), + Some(&target_table), Some(1), None, None, @@ -2891,16 +2933,18 @@ where .map(|tables| !tables.is_empty()) .unwrap_or(false); - let source_indexes = if request.create_table && pg_compat_transfer && !target_table_preexisting { - get_postgres_indexes_for_transfer(state, source_pool_key, &request.source_schema, table).await? - } else { - Vec::new() - }; - let source_foreign_keys = if request.create_table && pg_compat_transfer && !target_table_preexisting { - get_postgres_foreign_keys_for_transfer(state, source_pool_key, &request.source_schema, table).await? - } else { - Vec::new() - }; + let source_indexes = + if request.create_table && pg_compat_transfer && preserves_target_table_name && !target_table_preexisting { + get_postgres_indexes_for_transfer(state, source_pool_key, &request.source_schema, table).await? + } else { + Vec::new() + }; + let source_foreign_keys = + if request.create_table && pg_compat_transfer && preserves_target_table_name && !target_table_preexisting { + get_postgres_foreign_keys_for_transfer(state, source_pool_key, &request.source_schema, table).await? + } else { + Vec::new() + }; // Count source rows let total_rows = { @@ -2928,10 +2972,11 @@ where .await .map_err(|e| format!("Failed to ensure schema exists: {e}"))?; } - let ddl = if (source_db_type == target_db_type) - || (is_mysql_family_target(source_db_type) && is_mysql_family_target(target_db_type)) - || (is_postgres_family_target(source_db_type) && is_postgres_family_target(target_db_type)) - { + let can_reuse_source_ddl = preserves_target_table_name + && ((source_db_type == target_db_type) + || (is_mysql_family_target(source_db_type) && is_mysql_family_target(target_db_type)) + || (is_postgres_family_target(source_db_type) && is_postgres_family_target(target_db_type))); + let ddl = if can_reuse_source_ddl { crate::schema::get_table_ddl_core( &state, &request.source_connection_id, @@ -2941,19 +2986,21 @@ where None, ) .await - .unwrap_or(generate_create_table_ddl( - &columns, - table, - &request.source_schema, - &request.target_schema, - target_db_type, - source_db_type, - table_comment.as_deref(), - )) + .unwrap_or_else(|_| { + generate_create_table_ddl( + &columns, + &target_table, + &request.source_schema, + &request.target_schema, + target_db_type, + source_db_type, + table_comment.as_deref(), + ) + }) } else { generate_create_table_ddl( &columns, - table, + &target_table, &request.source_schema, &request.target_schema, target_db_type, @@ -2974,11 +3021,16 @@ where } }; if table_exists { - let comment_stmts = - generate_comment_ddl(&columns, table, &request.target_schema, target_db_type, table_comment.as_deref()); + let comment_stmts = generate_comment_ddl( + &columns, + &target_table, + &request.target_schema, + target_db_type, + table_comment.as_deref(), + ); for stmt in &comment_stmts { if let Err(e) = execute_on_pool(state, target_pool_key, stmt).await { - log::warn!("[transfer] failed to set column comment for {}: {}", table, e); + log::warn!("[transfer] failed to set column comment for {}: {}", target_table, e); } } } @@ -2986,7 +3038,7 @@ where // Truncate target if overwrite mode if request.mode == TransferMode::Overwrite { - let full_table = qualified_table(table, &request.target_schema, target_db_type); + let full_table = qualified_table(&target_table, &request.target_schema, target_db_type); let truncate_sql = match target_db_type { DatabaseType::Sqlite | DatabaseType::DuckDb => format!("DELETE FROM {full_table}"), _ => format!("TRUNCATE TABLE {full_table}"), @@ -3006,7 +3058,7 @@ where &request.target_connection_id, &request.target_database, &request.target_schema, - table, + &target_table, ) .await .unwrap_or_default(); @@ -3053,7 +3105,7 @@ where &col_names, &col_types, &result.rows, - table, + &target_table, &request.target_schema, target_db_type, &pk_columns, @@ -3063,12 +3115,12 @@ where let absolute_row = parse_mysql_row_error(&e).map(|row| offset + row); match absolute_row { Some(row) => format!( - "Insert failed for table '{table}' at row {row} (chunk {} of {}): {e}", + "Insert failed for table '{target_table}' at row {row} (chunk {} of {}): {e}", statement_index + 1, write_statements.len() ), None => format!( - "Insert failed for table '{table}' at offset {offset}, chunk {} of {}: {e}", + "Insert failed for table '{target_table}' at offset {offset}, chunk {} of {}: {e}", statement_index + 1, write_statements.len() ), @@ -3097,23 +3149,24 @@ where } if pg_compat_transfer { - for statement in generate_postgres_sequence_sync_sql(&columns, table, &request.target_schema) { + for statement in generate_postgres_sequence_sync_sql(&columns, &target_table, &request.target_schema) { execute_on_pool(state, target_pool_key, &statement) .await - .map_err(|e| format!("Failed to sync PostgreSQL sequence for {table}: {e}"))?; + .map_err(|e| format!("Failed to sync PostgreSQL sequence for {target_table}: {e}"))?; } } - if request.create_table && pg_compat_transfer && !target_table_preexisting { - for statement in generate_postgres_index_ddl(&source_indexes, table, &request.target_schema) { + if request.create_table && pg_compat_transfer && preserves_target_table_name && !target_table_preexisting { + for statement in generate_postgres_index_ddl(&source_indexes, &target_table, &request.target_schema) { execute_on_pool(state, target_pool_key, &statement) .await - .map_err(|e| format!("Failed to create PostgreSQL index for {table}: {e}"))?; + .map_err(|e| format!("Failed to create PostgreSQL index for {target_table}: {e}"))?; } - for statement in generate_postgres_foreign_key_ddl(&source_foreign_keys, table, &request.target_schema) { + for statement in generate_postgres_foreign_key_ddl(&source_foreign_keys, &target_table, &request.target_schema) + { execute_on_pool(state, target_pool_key, &statement) .await - .map_err(|e| format!("Failed to create PostgreSQL foreign key for {table}: {e}"))?; + .map_err(|e| format!("Failed to create PostgreSQL foreign key for {target_table}: {e}"))?; } } @@ -3524,6 +3577,63 @@ mod tests { } } + fn test_transfer_request(tables: Vec<&str>) -> TransferRequest { + TransferRequest { + transfer_id: "transfer-1".to_string(), + source_connection_id: "source".to_string(), + source_database: "source_db".to_string(), + source_schema: "source_schema".to_string(), + target_connection_id: "target".to_string(), + target_database: "target_db".to_string(), + target_schema: "target_schema".to_string(), + tables: tables.into_iter().map(str::to_string).collect(), + create_table: true, + mode: TransferMode::Append, + target_table_name_case: TransferTableNameCase::Preserve, + batch_size: 1000, + } + } + + #[test] + fn transfer_request_defaults_preserve_table_name_case() { + let request: TransferRequest = serde_json::from_value(json!({ + "transferId": "transfer-1", + "sourceConnectionId": "source", + "sourceDatabase": "source_db", + "sourceSchema": "source_schema", + "targetConnectionId": "target", + "targetDatabase": "target_db", + "targetSchema": "target_schema", + "tables": ["ORDERS"], + "createTable": true, + "mode": "append", + "batchSize": 1000 + })) + .unwrap(); + + assert_eq!(request.target_table_name_case, TransferTableNameCase::Preserve); + assert_eq!(request.target_table_name("ORDERS"), "ORDERS"); + } + + #[test] + fn transfer_table_name_case_transforms_target_names() { + let mut request = test_transfer_request(vec!["ORDERS"]); + request.target_table_name_case = TransferTableNameCase::Lower; + assert_eq!(request.target_table_name("ORDERS"), "orders"); + + request.target_table_name_case = TransferTableNameCase::Upper; + assert_eq!(request.target_table_name("orders"), "ORDERS"); + } + + #[test] + fn transfer_table_name_case_detects_target_collisions() { + let mut request = test_transfer_request(vec!["ORDERS", "orders"]); + request.target_table_name_case = TransferTableNameCase::Lower; + + let error = validate_transfer_target_table_names(&request).unwrap_err(); + assert!(error.contains("both map to 'orders'")); + } + #[test] fn mysql_create_table_includes_column_comments() { let cols = vec![ diff --git a/crates/dbx-core/tests/live_postgres_transfer.rs b/crates/dbx-core/tests/live_postgres_transfer.rs index 3a7f90c3bd..9ec4193dce 100644 --- a/crates/dbx-core/tests/live_postgres_transfer.rs +++ b/crates/dbx-core/tests/live_postgres_transfer.rs @@ -4,7 +4,7 @@ use dbx_core::models::connection::{ConnectionConfig, DatabaseType}; use dbx_core::storage::Storage; use dbx_core::transfer::{ get_db_type, transfer_postgres_schema_dependencies, transfer_postgres_schema_objects, transfer_table, TransferMode, - TransferRequest, + TransferRequest, TransferTableNameCase, }; use serde_json::json; @@ -190,6 +190,7 @@ async fn live_postgres_transfer_preserves_data_and_schema_objects() { tables: vec!["users".to_string(), "audit_logs".to_string()], create_table: true, mode: TransferMode::Append, + target_table_name_case: TransferTableNameCase::Preserve, batch_size: 100, }; diff --git a/crates/dbx-web/src/routes/transfer.rs b/crates/dbx-web/src/routes/transfer.rs index 2963644962..624d06413c 100644 --- a/crates/dbx-web/src/routes/transfer.rs +++ b/crates/dbx-web/src/routes/transfer.rs @@ -27,6 +27,7 @@ pub async fn start_transfer( Json(body): Json, ) -> Result, AppError> { let req = body.request; + transfer::validate_transfer_target_table_names(&req).map_err(AppError)?; // Reject transfer early if the target connection is read-only if let Some(name) = dbx_core::query::connection_readonly_name(&state.app, &req.target_connection_id).await { diff --git a/src-tauri/src/commands/transfer.rs b/src-tauri/src/commands/transfer.rs index cb663b9d64..54f17efc95 100644 --- a/src-tauri/src/commands/transfer.rs +++ b/src-tauri/src/commands/transfer.rs @@ -25,6 +25,7 @@ pub async fn start_transfer( // Validate connections exist let source_db_type = get_db_type(&state, &request.source_connection_id).await?; let target_db_type = get_db_type(&state, &request.target_connection_id).await?; + dbx_core::transfer::validate_transfer_target_table_names(&request)?; // Ensure pools let source_pool_key =