feat(transfer): add target table name case option

This commit is contained in:
t8y2
2026-06-19 12:16:38 +08:00
parent c546ba531a
commit ef25653aca
13 changed files with 209 additions and 53 deletions
@@ -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<TransferMode>("append");
const targetTableNameCase = ref<TransferTableNameCase>("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
</SelectContent>
</Select>
</div>
<div class="flex items-center gap-3">
<Label class="text-xs shrink-0">{{ t("transfer.targetTableNameCase") }}</Label>
<Select v-model="targetTableNameCase">
<SelectTrigger class="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="preserve">{{ t("transfer.tableNameCasePreserve") }}</SelectItem>
<SelectItem value="lower">{{ t("transfer.tableNameCaseLower") }}</SelectItem>
<SelectItem value="upper">{{ t("transfer.tableNameCaseUpper") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex items-center gap-3">
<Label class="text-xs shrink-0">{{ t("transfer.batchSize") }}</Label>
<Input v-model.number="batchSize" type="number" min="100" max="10000" step="100" class="h-7 text-xs w-24" />
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -1678,6 +1678,10 @@ export default {
modeAppend: "追加 (INSERT)",
modeOverwrite: "上書き (TRUNCATE + INSERT)",
modeUpsert: "アップサート (主キーでINSERT or UPDATE)",
targetTableNameCase: "対象テーブル名の大小文字",
tableNameCasePreserve: "そのまま",
tableNameCaseLower: "小文字に変換",
tableNameCaseUpper: "大文字に変換",
batchSize: "バッチサイズ",
start: "転送開始",
cancel: "キャンセル",
+4
View File
@@ -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",
+4
View File
@@ -1705,6 +1705,10 @@ export default {
modeAppend: "追加 (INSERT)",
modeOverwrite: "覆盖 (TRUNCATE + INSERT)",
modeUpsert: "更新插入 (按主键 INSERT 或 UPDATE)",
targetTableNameCase: "目标表名大小写",
tableNameCasePreserve: "保持原样",
tableNameCaseLower: "转为小写",
tableNameCaseUpper: "转为大写",
batchSize: "每批行数",
start: "开始传输",
cancel: "取消",
+1
View File
@@ -411,6 +411,7 @@ export type {
TransferRequest,
TransferProgress,
TransferMode,
TransferTableNameCase,
TableImportMode,
TableImportStatus,
TableImportColumnMapping,
+2
View File
@@ -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;
}
+161 -51
View File
@@ -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<String, String> = 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<String> = 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![
@@ -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,
};
+1
View File
@@ -27,6 +27,7 @@ pub async fn start_transfer(
Json(body): Json<StartTransferRequest>,
) -> Result<Json<serde_json::Value>, 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 {
+1
View File
@@ -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 =