diff --git a/apps/desktop/src/components/diff/DataCompareDialog.vue b/apps/desktop/src/components/diff/DataCompareDialog.vue index 8475051aa4..1cf8b60a3a 100644 --- a/apps/desktop/src/components/diff/DataCompareDialog.vue +++ b/apps/desktop/src/components/diff/DataCompareDialog.vue @@ -18,7 +18,7 @@ import type { DataCompareSyncPlan, DataCompareSyncPlanTableOptions, } from "@/lib/dataCompare"; -import type { DatabaseType } from "@/types/database"; +import type { ColumnInfo, DatabaseType } from "@/types/database"; import * as api from "@/lib/api"; import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; import { @@ -33,10 +33,7 @@ import { Square, } from "lucide-vue-next"; -interface CompareColumn { - name: string; - is_primary_key?: boolean; -} +type CompareColumn = ColumnInfo; interface DataCompareTableTask { sourceTable: string; @@ -65,6 +62,7 @@ interface DataCompareTableResult { targetTable: string; keyColumns: string[]; columns: string[]; + columnInfo: CompareColumn[]; status: DataCompareTableStatus; added: number; removed: number; @@ -584,6 +582,7 @@ function buildSyncPlanTables(): DataCompareSyncPlanTableOptions[] { schema: targetSchema.value, columns: table.columns, keyColumns: table.keyColumns, + columnInfo: table.columnInfo, diff: buildSelectedDiff(table), databaseType: table.databaseType, preSyncStatements: table.preSyncStatements ?? [], @@ -672,6 +671,7 @@ async function startCompare() { targetTable: task.targetTable, keyColumns: resolvedKeys, columns: sourceColumns.map((column) => column.name), + columnInfo: sourceColumns, status: "different", added: preparation.result.added.length, removed: 0, @@ -718,6 +718,9 @@ async function startCompare() { const columns = sourceColumns .map((column) => column.name) .filter((column) => targetColumns.some((target) => target.name === column)); + const columnInfo = columns + .map((column) => targetColumns.find((target) => target.name === column)) + .filter((column): column is CompareColumn => !!column); const missingKeys = resolvedKeys.filter((column) => !columns.includes(column)); if (missingKeys.length > 0) { throw new Error(t("dataCompare.missingKeyColumns", { columns: missingKeys.join(", ") })); @@ -749,6 +752,7 @@ async function startCompare() { targetTable: task.targetTable, keyColumns: resolvedKeys, columns, + columnInfo, status, added, removed, @@ -772,6 +776,7 @@ async function startCompare() { targetTable: task.targetTable, keyColumns: keyColumns.value, columns: [], + columnInfo: [], status: "error", added: 0, removed: 0, diff --git a/apps/desktop/src/lib/dataCompare.ts b/apps/desktop/src/lib/dataCompare.ts index 1c512d1682..4cfdb6ac17 100644 --- a/apps/desktop/src/lib/dataCompare.ts +++ b/apps/desktop/src/lib/dataCompare.ts @@ -1,4 +1,4 @@ -import type { DatabaseType, QueryResult } from "@/types/database"; +import type { ColumnInfo, DatabaseType, QueryResult } from "@/types/database"; export type DataCompareCellValue = QueryResult["rows"][number][number]; @@ -33,6 +33,7 @@ export interface DataComparePreparationOptions { schema?: string; columns: string[]; keyColumns: string[]; + columnInfo?: ColumnInfo[]; sourceRows: DataCompareCellValue[][]; targetRows: DataCompareCellValue[][]; databaseType?: DatabaseType; @@ -84,6 +85,7 @@ export interface DataCompareSyncPlanTableOptions { schema?: string; columns: string[]; keyColumns: string[]; + columnInfo?: ColumnInfo[]; diff: DataCompareResult; databaseType?: DatabaseType; preSyncStatements?: string[]; diff --git a/crates/dbx-core/src/data_compare.rs b/crates/dbx-core/src/data_compare.rs index 95f2858507..488de85e38 100644 --- a/crates/dbx-core/src/data_compare.rs +++ b/crates/dbx-core/src/data_compare.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::connection::AppState; +use crate::data_grid_sql::{format_grid_sql_literal as format_data_grid_sql_literal, DataGridColumnInfo}; use crate::models::connection::DatabaseType; use crate::query::{execute_sql_statement_with_options, QueryExecutionOptions}; use crate::schema::get_columns_core; @@ -30,6 +31,8 @@ pub struct DataComparePreparationOptions { pub schema: Option, pub columns: Vec, pub key_columns: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub column_info: Vec, #[serde(default)] pub source_rows: Vec>, #[serde(default)] @@ -122,6 +125,8 @@ pub struct DataCompareSyncPlanTableOptions { pub schema: Option, pub columns: Vec, pub key_columns: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub column_info: Vec, pub diff: DataCompareResult, #[serde(default, skip_serializing_if = "Option::is_none")] pub database_type: Option, @@ -174,6 +179,7 @@ pub fn prepare_data_compare(options: DataComparePreparationOptions) -> Result Data schema: table.schema, columns: table.columns, key_columns: table.key_columns, + column_info: table.column_info, diff: table.diff, database_type: table.database_type, })); @@ -518,6 +535,7 @@ struct GenerateDataSyncSqlOptions { schema: Option, columns: Vec, key_columns: Vec, + column_info: Vec, diff: DataCompareResult, database_type: Option, } @@ -554,6 +572,7 @@ fn generate_data_sync_statements(options: &GenerateDataSyncSqlOptions) -> Vec>() .join(", "); + let column_info = options.column_info.as_slice(); let added = options .diff .added @@ -563,7 +582,11 @@ fn generate_data_sync_statements(options: &GenerateDataSyncSqlOptions) -> Vec>() .join(", "); @@ -582,14 +605,18 @@ fn generate_data_sync_statements(options: &GenerateDataSyncSqlOptions) -> Vec>() .join(", "); format!( "UPDATE {table} SET {assignments} WHERE {};", - where_by_key(&row.key_values, &options.key_columns, options.database_type) + where_by_key(&row.key_values, &options.key_columns, options.database_type, column_info) ) }) .collect::>(); @@ -600,7 +627,7 @@ fn generate_data_sync_statements(options: &GenerateDataSyncSqlOptions) -> Vec>(); @@ -745,6 +772,7 @@ fn where_by_key( key_values: &HashMap, key_columns: &[String], database_type: Option, + column_info: &[DataGridColumnInfo], ) -> String { key_columns .iter() @@ -752,59 +780,39 @@ fn where_by_key( format!( "{} = {}", quote_table_identifier(database_type, column), - format_grid_sql_literal(key_values.get(column).unwrap_or(&Value::Null), database_type) + format_grid_sql_literal( + key_values.get(column).unwrap_or(&Value::Null), + database_type, + column_info_for(column_info, column), + ) ) }) .collect::>() .join(" AND ") } -fn format_grid_sql_literal(value: &Value, database_type: Option) -> String { - match value { - Value::Null => "NULL".to_string(), - Value::Bool(value) => { - if *value { - "TRUE".to_string() - } else { - "FALSE".to_string() - } - } - Value::Number(number) => number.to_string(), - Value::String(text) if text.is_empty() && database_type == Some(DatabaseType::SqlServer) => "N''".to_string(), - Value::String(text) if text.is_empty() => "''".to_string(), - Value::String(text) => { - let escaped = format!("'{}'", literal_text(text, database_type).replace('\\', "\\\\").replace('\'', "''")); - if database_type == Some(DatabaseType::SqlServer) { - format!("N{escaped}") - } else { - escaped - } - } - other => { - let text = other.to_string(); - let escaped = format!("'{}'", text.replace('\\', "\\\\").replace('\'', "''")); - if database_type == Some(DatabaseType::SqlServer) { - format!("N{escaped}") - } else { - escaped - } - } - } +fn format_grid_sql_literal( + value: &Value, + database_type: Option, + column_info: Option<&DataGridColumnInfo>, +) -> String { + format_data_grid_sql_literal(value, database_type, column_info) } -fn literal_text(text: &str, database_type: Option) -> String { - if database_type == Some(DatabaseType::Tdengine) { - return format_tdengine_timestamp_literal_text(text); - } - text.to_string() +fn column_info_for<'a>(columns: &'a [DataGridColumnInfo], name: &str) -> Option<&'a DataGridColumnInfo> { + let normalized = name.to_ascii_lowercase(); + columns.iter().find(|column| column.name.to_ascii_lowercase() == normalized) } -fn format_tdengine_timestamp_literal_text(text: &str) -> String { - // Keep non-timestamp text unchanged; TDengine timestamp normalization is UI-parity best effort in Rust. - if text.len() < 19 || text.as_bytes().get(10).is_none_or(|ch| *ch != b' ') { - return text.to_string(); +fn data_grid_column_info(column: crate::types::ColumnInfo) -> DataGridColumnInfo { + DataGridColumnInfo { + name: column.name, + data_type: column.data_type, + is_nullable: column.is_nullable, + is_primary_key: column.is_primary_key, + column_default: column.column_default, + extra: column.extra, } - text.replacen(' ', "T", 1) } #[cfg(test)] @@ -813,6 +821,17 @@ mod tests { use crate::models::connection::DatabaseType; use serde_json::json; + fn data_compare_column(name: &str, data_type: &str) -> DataGridColumnInfo { + DataGridColumnInfo { + name: name.to_string(), + data_type: data_type.to_string(), + is_nullable: true, + is_primary_key: false, + column_default: None, + extra: None, + } + } + #[test] fn compares_rows_by_primary_key_and_reports_added_removed_and_modified_rows() { let diff = compare_data_rows(CompareDataRowsOptions { @@ -851,6 +870,7 @@ mod tests { schema: Some("public".to_string()), columns: vec!["id".to_string(), "name".to_string(), "active".to_string()], key_columns: vec!["id".to_string()], + column_info: Vec::new(), source_rows: vec![vec![json!(1), json!("Ada"), json!(true)], vec![json!(2), json!("Bob"), json!(false)]], target_rows: vec![ vec![json!(1), json!("Ada Lovelace"), json!(true)], @@ -872,6 +892,27 @@ mod tests { assert_eq!(preparation.sync_statements.len(), 3); } + #[test] + fn generates_mysql_bit_synchronization_literals_without_string_quotes() { + let preparation = prepare_data_compare(DataComparePreparationOptions { + table_name: "users".to_string(), + schema: None, + columns: vec!["id".to_string(), "enabled".to_string(), "flags".to_string()], + key_columns: vec!["id".to_string()], + column_info: vec![ + data_compare_column("id", "int"), + data_compare_column("enabled", "bit(1)"), + data_compare_column("flags", "bit(8)"), + ], + source_rows: vec![vec![json!(1), json!("0"), json!("10101010")]], + target_rows: vec![vec![json!(1), json!("1"), json!("00000001")]], + database_type: Some(DatabaseType::Mysql), + }) + .expect("data compare preparation should succeed"); + + assert_eq!(preparation.sync_sql, "UPDATE `users` SET `enabled` = 0, `flags` = b'10101010' WHERE `id` = 1;"); + } + #[test] fn builds_batch_sync_plan_from_selected_diffs() { let plan = build_data_compare_sync_plan(DataCompareSyncPlanOptions { @@ -880,6 +921,7 @@ mod tests { schema: Some("public".to_string()), columns: vec!["id".to_string(), "name".to_string()], key_columns: vec!["id".to_string()], + column_info: Vec::new(), diff: DataCompareResult { added: vec![DataCompareRow { key: "1".to_string(), @@ -924,6 +966,7 @@ mod tests { schema: Some("public".to_string()), columns: vec!["id".to_string(), "name".to_string()], key_columns: Vec::new(), + column_info: Vec::new(), diff: DataCompareResult { added: vec![DataCompareRow { key: "0".to_string(), diff --git a/crates/dbx-core/src/data_grid_sql.rs b/crates/dbx-core/src/data_grid_sql.rs index dd22fd68ef..7be2821414 100644 --- a/crates/dbx-core/src/data_grid_sql.rs +++ b/crates/dbx-core/src/data_grid_sql.rs @@ -725,6 +725,17 @@ pub fn format_grid_sql_literal( if value.is_null() { return "NULL".to_string(); } + if is_mysql_bit_literal_column(database_type, column_info) { + if let Some(value) = value.as_bool() { + return if value { "1" } else { "0" }.to_string(); + } + if let Some(number) = value.as_number() { + return number.to_string(); + } + if let Some(text) = value.as_str().and_then(format_mysql_bit_literal_text) { + return text; + } + } if let Some(value) = value.as_bool() { return if value { "TRUE" } else { "FALSE" }.to_string(); } @@ -755,6 +766,43 @@ pub fn format_grid_sql_literal( } } +fn is_mysql_bit_literal_column(database_type: Option, column_info: Option<&DataGridColumnInfo>) -> bool { + is_mysql_datetime_literal_database(database_type) + && column_info.map(|column| is_bit_column_type(&column.data_type)).unwrap_or(false) +} + +fn is_bit_column_type(data_type: &str) -> bool { + let lower = data_type.to_ascii_lowercase(); + lower.split(|ch: char| !ch.is_ascii_alphanumeric()).any(|token| token == "bit") +} + +fn format_mysql_bit_literal_text(text: &str) -> Option { + let trimmed = text.trim(); + if trimmed.eq_ignore_ascii_case("true") { + return Some("1".to_string()); + } + if trimmed.eq_ignore_ascii_case("false") { + return Some("0".to_string()); + } + if trimmed.chars().all(|ch| ch.is_ascii_digit()) && !trimmed.is_empty() { + return Some(if trimmed.len() == 1 { + trimmed.to_string() + } else if trimmed.chars().all(|ch| matches!(ch, '0' | '1')) { + format!("b'{trimmed}'") + } else { + trimmed.to_string() + }); + } + let lower = trimmed.to_ascii_lowercase(); + if lower.starts_with("b'") && trimmed.ends_with('\'') { + let bits = &trimmed[2..trimmed.len() - 1]; + if !bits.is_empty() && bits.chars().all(|ch| matches!(ch, '0' | '1')) { + return Some(format!("b'{bits}'")); + } + } + None +} + fn is_mysql_datetime_literal_database(database_type: Option) -> bool { matches!( database_type, @@ -1346,6 +1394,21 @@ mod tests { ); } + #[test] + fn formats_mysql_bit_literals_without_string_quotes() { + let bit = column("flag", "bit(1)", true, None); + let bit_string = column("flags", "bit(8)", true, None); + + assert_eq!(format_grid_sql_literal(&json!("0"), Some(DatabaseType::Mysql), Some(&bit)), "0"); + assert_eq!(format_grid_sql_literal(&json!("1"), Some(DatabaseType::Mysql), Some(&bit)), "1"); + assert_eq!(format_grid_sql_literal(&json!(true), Some(DatabaseType::Mysql), Some(&bit)), "1"); + assert_eq!( + format_grid_sql_literal(&json!("10101010"), Some(DatabaseType::Mysql), Some(&bit_string)), + "b'10101010'" + ); + assert_eq!(format_grid_sql_literal(&json!("0"), Some(DatabaseType::Postgres), Some(&bit)), "'0'"); + } + #[test] fn prepares_sqlserver_save_statements() { let result = prepare_data_grid_save(DataGridSaveStatementOptions {