use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Serialize}; use crate::models::connection::DatabaseType; use crate::types::{ ColumnInfo, ForeignKeyInfo, FunctionInfo, IndexInfo, OwnerInfo, RuleInfo, SequenceInfo, TableInfo, TriggerInfo, }; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ColumnDiff { #[serde(rename = "type")] pub diff_type: String, pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub source: Option, #[serde(skip_serializing_if = "Option::is_none")] pub target: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub changes: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct IndexDiff { #[serde(rename = "type")] pub diff_type: String, pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub source: Option, #[serde(skip_serializing_if = "Option::is_none")] pub target: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub changes: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ForeignKeyDiff { #[serde(rename = "type")] pub diff_type: String, pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub source: Option, #[serde(skip_serializing_if = "Option::is_none")] pub target: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub changes: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TriggerDiff { #[serde(rename = "type")] pub diff_type: String, pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub source: Option, #[serde(skip_serializing_if = "Option::is_none")] pub target: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub changes: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FunctionDiff { #[serde(rename = "type")] pub diff_type: String, pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub source: Option, #[serde(skip_serializing_if = "Option::is_none")] pub target: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub changes: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SequenceDiff { #[serde(rename = "type")] pub diff_type: String, pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub source: Option, #[serde(skip_serializing_if = "Option::is_none")] pub target: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub changes: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RuleDiff { #[serde(rename = "type")] pub diff_type: String, pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub source: Option, #[serde(skip_serializing_if = "Option::is_none")] pub target: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub changes: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OwnerDiff { #[serde(rename = "type")] pub diff_type: String, pub object_name: String, #[serde(skip_serializing_if = "Option::is_none")] pub source: Option, #[serde(skip_serializing_if = "Option::is_none")] pub target: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub changes: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TableDiff { #[serde(rename = "type")] pub diff_type: String, #[serde(skip_serializing_if = "Option::is_none")] pub object_type: Option, pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub columns: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub indexes: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub foreign_keys: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub triggers: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub ddl: Option, #[serde(skip_serializing_if = "Option::is_none")] pub target_ddl: Option, #[serde(skip_serializing_if = "Option::is_none")] pub source_table_comment: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub target_table_comment: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TableSchemaDetail { pub name: String, #[serde(default)] pub columns: Vec, #[serde(default)] pub indexes: Vec, #[serde(default)] pub foreign_keys: Vec, #[serde(default)] pub triggers: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub ddl: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SchemaDiffPreparationOptions { #[serde(default)] pub source_tables: Vec, #[serde(default)] pub target_tables: Vec, #[serde(default)] pub source_details: Vec, #[serde(default)] pub target_details: Vec, #[serde(default)] pub source_functions: Vec, #[serde(default)] pub target_functions: Vec, #[serde(default)] pub source_sequences: Vec, #[serde(default)] pub target_sequences: Vec, #[serde(default)] pub source_rules: Vec, #[serde(default)] pub target_rules: Vec, #[serde(default)] pub source_owners: Vec, #[serde(default)] pub target_owners: Vec, pub database_type: DatabaseType, #[serde(default, skip_serializing_if = "Option::is_none")] pub target_schema: Option, #[serde(default)] pub ignore_comments: bool, #[serde(default)] pub cascade_delete: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SchemaDiffPreparation { pub diffs: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub function_diffs: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub sequence_diffs: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub rule_diffs: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub owner_diffs: Vec, pub sync_sql: String, } pub fn prepare_schema_diff(options: SchemaDiffPreparationOptions) -> SchemaDiffPreparation { let diffs = diff_schema(&options); let function_diffs = diff_functions(&options.source_functions, &options.target_functions); let sequence_diffs = diff_sequences(&options.source_sequences, &options.target_sequences); let rule_diffs = diff_rules(&options.source_rules, &options.target_rules); let owner_diffs = diff_owners(&options.source_owners, &options.target_owners); let sync_sql = generate_schema_sync_sql( &diffs, &function_diffs, &sequence_diffs, &rule_diffs, &owner_diffs, options.database_type, options.target_schema.as_deref(), options.cascade_delete, ); SchemaDiffPreparation { diffs, function_diffs, sequence_diffs, rule_diffs, owner_diffs, sync_sql } } fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec { let source_details: HashMap<&str, &TableSchemaDetail> = options.source_details.iter().map(|detail| (detail.name.as_str(), detail)).collect(); let target_details: HashMap<&str, &TableSchemaDetail> = options.target_details.iter().map(|detail| (detail.name.as_str(), detail)).collect(); let source_table_comments: HashMap<&str, Option> = options.source_tables.iter().map(|table| (table.name.as_str(), table.comment.clone())).collect(); let target_table_comments: HashMap<&str, Option> = options.target_tables.iter().map(|table| (table.name.as_str(), table.comment.clone())).collect(); let source_table_names: Vec = options .source_tables .iter() .filter(|table| table.table_type != "VIEW") .map(|table| table.name.clone()) .collect(); let target_table_names: Vec = options .target_tables .iter() .filter(|table| table.table_type != "VIEW") .map(|table| table.name.clone()) .collect(); let source_view_names: Vec = options .source_tables .iter() .filter(|table| table.table_type == "VIEW") .map(|table| table.name.clone()) .collect(); let target_view_names: Vec = options .target_tables .iter() .filter(|table| table.table_type == "VIEW") .map(|table| table.name.clone()) .collect(); let (added, removed, common) = diff_names(&source_table_names, &target_table_names); let (added_views, removed_views, _) = diff_names(&source_view_names, &target_view_names); let mut result = Vec::new(); for name in added { result.push(TableDiff { diff_type: "added".to_string(), object_type: Some("table".to_string()), ddl: source_details.get(name.as_str()).and_then(|detail| detail.ddl.clone()), target_ddl: None, name, columns: None, indexes: None, foreign_keys: None, triggers: None, source_table_comment: None, target_table_comment: None, }); } for name in removed { let name_clone = name.clone(); result.push(TableDiff { diff_type: "removed".to_string(), object_type: Some("table".to_string()), name, columns: None, indexes: None, foreign_keys: None, triggers: None, ddl: None, target_ddl: target_details.get(name_clone.as_str()).and_then(|detail| detail.ddl.clone()), source_table_comment: None, target_table_comment: None, }); } for name in added_views { let name_clone = name.clone(); result.push(TableDiff { diff_type: "added".to_string(), object_type: Some("view".to_string()), name, columns: None, indexes: None, foreign_keys: None, triggers: None, ddl: source_details.get(name_clone.as_str()).and_then(|detail| detail.ddl.clone()), target_ddl: None, source_table_comment: None, target_table_comment: None, }); } for name in removed_views { let name_clone = name.clone(); result.push(TableDiff { diff_type: "removed".to_string(), object_type: Some("view".to_string()), name, columns: None, indexes: None, foreign_keys: None, triggers: None, ddl: None, target_ddl: target_details.get(name_clone.as_str()).and_then(|detail| detail.ddl.clone()), source_table_comment: None, target_table_comment: None, }); } for name in common { let Some(source) = source_details.get(name.as_str()) else { continue }; let Some(target) = target_details.get(name.as_str()) else { continue }; let column_diffs = diff_columns_with_options(&source.columns, &target.columns, options.ignore_comments); let index_diffs = diff_indexes(&source.indexes, &target.indexes); let foreign_key_diffs = diff_foreign_keys(&source.foreign_keys, &target.foreign_keys); let trigger_diffs = diff_triggers(&source.triggers, &target.triggers); let source_comment = source_table_comments.get(name.as_str()).cloned().unwrap_or(None); let target_comment = target_table_comments.get(name.as_str()).cloned().unwrap_or(None); let comment_changed = !options.ignore_comments && source_comment.clone().unwrap_or_default() != target_comment.clone().unwrap_or_default(); let has_diff = !column_diffs.is_empty() || !index_diffs.is_empty() || !foreign_key_diffs.is_empty() || !trigger_diffs.is_empty() || comment_changed; let name_clone = name.clone(); result.push(TableDiff { diff_type: if has_diff { "modified".to_string() } else { "none".to_string() }, object_type: Some("table".to_string()), name, columns: if has_diff { (!column_diffs.is_empty()).then_some(column_diffs) } else { None }, indexes: if has_diff { (!index_diffs.is_empty()).then_some(index_diffs) } else { None }, foreign_keys: if has_diff { (!foreign_key_diffs.is_empty()).then_some(foreign_key_diffs) } else { None }, triggers: if has_diff { (!trigger_diffs.is_empty()).then_some(trigger_diffs) } else { None }, ddl: source_details.get(name_clone.as_str()).and_then(|detail| detail.ddl.clone()), target_ddl: target_details.get(name_clone.as_str()).and_then(|detail| detail.ddl.clone()), source_table_comment: if has_diff { comment_changed.then_some(source_comment) } else { None }, target_table_comment: if has_diff { comment_changed.then_some(target_comment) } else { None }, }); } result.retain(|diff| diff.diff_type != "none"); result } fn diff_names(source: &[String], target: &[String]) -> (Vec, Vec, Vec) { let source_set: HashSet<&str> = source.iter().map(String::as_str).collect(); let target_set: HashSet<&str> = target.iter().map(String::as_str).collect(); ( source.iter().filter(|name| !target_set.contains(name.as_str())).cloned().collect(), target.iter().filter(|name| !source_set.contains(name.as_str())).cloned().collect(), source.iter().filter(|name| target_set.contains(name.as_str())).cloned().collect(), ) } pub fn diff_columns(source: &[ColumnInfo], target: &[ColumnInfo]) -> Vec { diff_columns_with_options(source, target, false) } fn diff_columns_with_options(source: &[ColumnInfo], target: &[ColumnInfo], ignore_comments: bool) -> Vec { let mut diffs = Vec::new(); let target_map: HashMap<&str, &ColumnInfo> = target.iter().map(|column| (column.name.as_str(), column)).collect(); let source_map: HashMap<&str, &ColumnInfo> = source.iter().map(|column| (column.name.as_str(), column)).collect(); for source_column in source { if let Some(target_column) = target_map.get(source_column.name.as_str()) { let mut changes = Vec::new(); if source_column.data_type.to_lowercase() != target_column.data_type.to_lowercase() { changes.push(format!("type: {} → {}", target_column.data_type, source_column.data_type)); } if source_column.is_nullable != target_column.is_nullable { changes.push(format!( "nullable: {} → {}", if target_column.is_nullable { "YES" } else { "NO" }, if source_column.is_nullable { "YES" } else { "NO" } )); } if source_column.column_default.as_deref().unwrap_or_default() != target_column.column_default.as_deref().unwrap_or_default() { changes.push(format!( "default: {} → {}", target_column.column_default.as_deref().unwrap_or("NULL"), source_column.column_default.as_deref().unwrap_or("NULL") )); } if !ignore_comments && source_column.comment.as_deref().unwrap_or_default() != target_column.comment.as_deref().unwrap_or_default() { changes.push(format!( "comment: {} → {}", target_column.comment.as_deref().unwrap_or_default(), source_column.comment.as_deref().unwrap_or_default() )); } if !changes.is_empty() { diffs.push(ColumnDiff { diff_type: "modified".to_string(), name: source_column.name.clone(), source: Some(source_column.clone()), target: Some((*target_column).clone()), changes, }); } } else { diffs.push(ColumnDiff { diff_type: "added".to_string(), name: source_column.name.clone(), source: Some(source_column.clone()), target: None, changes: Vec::new(), }); } } for target_column in target { if !source_map.contains_key(target_column.name.as_str()) { diffs.push(ColumnDiff { diff_type: "removed".to_string(), name: target_column.name.clone(), source: None, target: Some(target_column.clone()), changes: Vec::new(), }); } } diffs } pub fn diff_indexes(source: &[IndexInfo], target: &[IndexInfo]) -> Vec { let mut diffs = Vec::new(); let target_map: HashMap<&str, &IndexInfo> = target.iter().map(|index| (index.name.as_str(), index)).collect(); let source_map: HashMap<&str, &IndexInfo> = source.iter().map(|index| (index.name.as_str(), index)).collect(); for source_index in source { if source_index.is_primary { continue; } let Some(target_index) = target_map.get(source_index.name.as_str()) else { diffs.push(IndexDiff { diff_type: "added".to_string(), name: source_index.name.clone(), source: Some(source_index.clone()), target: None, changes: Vec::new(), }); continue; }; let mut changes = Vec::new(); if source_index.is_unique != target_index.is_unique { changes.push(format!( "unique: {} → {}", if target_index.is_unique { "YES" } else { "NO" }, if source_index.is_unique { "YES" } else { "NO" } )); } if source_index.columns.join(",") != target_index.columns.join(",") { changes.push(format!("columns: {} → {}", target_index.columns.join(", "), source_index.columns.join(", "))); } if source_index.index_type.as_deref().unwrap_or_default() != target_index.index_type.as_deref().unwrap_or_default() { changes.push(format!( "type: {} → {}", target_index.index_type.as_deref().unwrap_or("default"), source_index.index_type.as_deref().unwrap_or("default") )); } if source_index.filter.as_deref().unwrap_or_default() != target_index.filter.as_deref().unwrap_or_default() { changes.push(format!( "filter: {} → {}", target_index.filter.as_deref().unwrap_or("none"), source_index.filter.as_deref().unwrap_or("none") )); } let source_included = source_index.included_columns.clone().unwrap_or_default(); let target_included = target_index.included_columns.clone().unwrap_or_default(); if source_included.join(",") != target_included.join(",") { changes.push(format!( "include: {} → {}", if target_included.is_empty() { "none".to_string() } else { target_included.join(", ") }, if source_included.is_empty() { "none".to_string() } else { source_included.join(", ") } )); } if !changes.is_empty() { diffs.push(IndexDiff { diff_type: "modified".to_string(), name: source_index.name.clone(), source: Some(source_index.clone()), target: Some((*target_index).clone()), changes, }); } } for target_index in target { if target_index.is_primary { continue; } if !source_map.contains_key(target_index.name.as_str()) { diffs.push(IndexDiff { diff_type: "removed".to_string(), name: target_index.name.clone(), source: None, target: Some(target_index.clone()), changes: Vec::new(), }); } } diffs } pub fn diff_foreign_keys(source: &[ForeignKeyInfo], target: &[ForeignKeyInfo]) -> Vec { let mut diffs = Vec::new(); let target_map: HashMap<&str, &ForeignKeyInfo> = target.iter().map(|fk| (fk.name.as_str(), fk)).collect(); let source_map: HashMap<&str, &ForeignKeyInfo> = source.iter().map(|fk| (fk.name.as_str(), fk)).collect(); for source_fk in source { let Some(target_fk) = target_map.get(source_fk.name.as_str()) else { diffs.push(ForeignKeyDiff { diff_type: "added".to_string(), name: source_fk.name.clone(), source: Some(source_fk.clone()), target: None, changes: Vec::new(), }); continue; }; let mut changes = Vec::new(); if source_fk.column != target_fk.column { changes.push(format!("column: {} → {}", target_fk.column, source_fk.column)); } if source_fk.ref_table != target_fk.ref_table { changes.push(format!("ref table: {} → {}", target_fk.ref_table, source_fk.ref_table)); } if source_fk.ref_schema != target_fk.ref_schema { changes.push(format!( "ref schema: {} → {}", target_fk.ref_schema.as_deref().unwrap_or(""), source_fk.ref_schema.as_deref().unwrap_or("") )); } if source_fk.ref_column != target_fk.ref_column { changes.push(format!("ref column: {} → {}", target_fk.ref_column, source_fk.ref_column)); } if !changes.is_empty() { diffs.push(ForeignKeyDiff { diff_type: "modified".to_string(), name: source_fk.name.clone(), source: Some(source_fk.clone()), target: Some((*target_fk).clone()), changes, }); } } for target_fk in target { if !source_map.contains_key(target_fk.name.as_str()) { diffs.push(ForeignKeyDiff { diff_type: "removed".to_string(), name: target_fk.name.clone(), source: None, target: Some(target_fk.clone()), changes: Vec::new(), }); } } diffs } pub fn diff_triggers(source: &[TriggerInfo], target: &[TriggerInfo]) -> Vec { let mut diffs = Vec::new(); let target_map: HashMap<&str, &TriggerInfo> = target.iter().map(|trigger| (trigger.name.as_str(), trigger)).collect(); let source_map: HashMap<&str, &TriggerInfo> = source.iter().map(|trigger| (trigger.name.as_str(), trigger)).collect(); for source_trigger in source { let Some(target_trigger) = target_map.get(source_trigger.name.as_str()) else { diffs.push(TriggerDiff { diff_type: "added".to_string(), name: source_trigger.name.clone(), source: Some(source_trigger.clone()), target: None, changes: Vec::new(), }); continue; }; let mut changes = Vec::new(); if source_trigger.event != target_trigger.event { changes.push(format!("event: {} → {}", target_trigger.event, source_trigger.event)); } if source_trigger.timing != target_trigger.timing { changes.push(format!("timing: {} → {}", target_trigger.timing, source_trigger.timing)); } if !changes.is_empty() { diffs.push(TriggerDiff { diff_type: "modified".to_string(), name: source_trigger.name.clone(), source: Some(source_trigger.clone()), target: Some((*target_trigger).clone()), changes, }); } } for target_trigger in target { if !source_map.contains_key(target_trigger.name.as_str()) { diffs.push(TriggerDiff { diff_type: "removed".to_string(), name: target_trigger.name.clone(), source: None, target: Some(target_trigger.clone()), changes: Vec::new(), }); } } diffs } fn is_mysql_like(db_type: DatabaseType) -> bool { matches!( db_type, DatabaseType::Mysql | DatabaseType::Doris | DatabaseType::StarRocks | DatabaseType::Goldendb | DatabaseType::Sundb | DatabaseType::Databend | DatabaseType::Gbase ) } /// Normalize a function definition for comparison by: /// - Converting CRLF to LF /// - Collapsing all whitespace (tabs, multiple spaces) to single spaces /// - Trimming each line and rejoining fn normalize_definition(def: &str) -> String { def.replace("\r\n", "\n") .split('\n') .map(|line| line.split_whitespace().collect::>().join(" ")) .collect::>() .join("\n") } pub fn diff_functions(source: &[FunctionInfo], target: &[FunctionInfo]) -> Vec { let mut diffs = Vec::new(); // Use (name, arguments) as key to support PostgreSQL function overloading let target_map: HashMap<(&str, &str), &FunctionInfo> = target.iter().map(|f| ((f.name.as_str(), f.arguments.as_str()), f)).collect(); let source_map: HashMap<(&str, &str), &FunctionInfo> = source.iter().map(|f| ((f.name.as_str(), f.arguments.as_str()), f)).collect(); for source_fn in source { let key = (source_fn.name.as_str(), source_fn.arguments.as_str()); let Some(target_fn) = target_map.get(&key) else { diffs.push(FunctionDiff { diff_type: "added".to_string(), name: source_fn.name.clone(), source: Some(source_fn.clone()), target: None, changes: Vec::new(), }); continue; }; let mut changes = Vec::new(); if source_fn.function_type != target_fn.function_type { changes.push(format!("type: {} → {}", target_fn.function_type, source_fn.function_type)); } if source_fn.data_type != target_fn.data_type { changes.push(format!("return type: {} → {}", target_fn.data_type, source_fn.data_type)); } if normalize_definition(&source_fn.definition) != normalize_definition(&target_fn.definition) { changes.push("definition changed".to_string()); } if !changes.is_empty() { diffs.push(FunctionDiff { diff_type: "modified".to_string(), name: source_fn.name.clone(), source: Some(source_fn.clone()), target: Some((*target_fn).clone()), changes, }); } } for target_fn in target { let key = (target_fn.name.as_str(), target_fn.arguments.as_str()); if !source_map.contains_key(&key) { diffs.push(FunctionDiff { diff_type: "removed".to_string(), name: target_fn.name.clone(), source: None, target: Some(target_fn.clone()), changes: Vec::new(), }); } } diffs } pub fn diff_sequences(source: &[SequenceInfo], target: &[SequenceInfo]) -> Vec { let mut diffs = Vec::new(); let target_map: HashMap<&str, &SequenceInfo> = target.iter().map(|s| (s.name.as_str(), s)).collect(); let source_map: HashMap<&str, &SequenceInfo> = source.iter().map(|s| (s.name.as_str(), s)).collect(); for source_seq in source { let Some(target_seq) = target_map.get(source_seq.name.as_str()) else { diffs.push(SequenceDiff { diff_type: "added".to_string(), name: source_seq.name.clone(), source: Some(source_seq.clone()), target: None, changes: Vec::new(), }); continue; }; let mut changes = Vec::new(); if source_seq.data_type != target_seq.data_type { changes.push(format!("data_type: {} → {}", target_seq.data_type, source_seq.data_type)); } if source_seq.start_value != target_seq.start_value { changes.push(format!("start: {} → {}", target_seq.start_value, source_seq.start_value)); } if source_seq.min_value != target_seq.min_value { changes.push(format!("min: {} → {}", target_seq.min_value, source_seq.min_value)); } if source_seq.max_value != target_seq.max_value { changes.push(format!("max: {} → {}", target_seq.max_value, source_seq.max_value)); } if source_seq.increment != target_seq.increment { changes.push(format!("increment: {} → {}", target_seq.increment, source_seq.increment)); } if source_seq.cycle != target_seq.cycle { changes.push(format!("cycle: {} → {}", target_seq.cycle, source_seq.cycle)); } // Only compare last_value when both sides successfully retrieved it. // Avoid false positives when one side lacks permission (returns None). if let (Some(s), Some(t)) = (&source_seq.last_value, &target_seq.last_value) { if s != t { changes.push(format!("last_value: {} → {}", t, s)); } } if !changes.is_empty() { diffs.push(SequenceDiff { diff_type: "modified".to_string(), name: source_seq.name.clone(), source: Some(source_seq.clone()), target: Some((*target_seq).clone()), changes, }); } } for target_seq in target { if !source_map.contains_key(target_seq.name.as_str()) { diffs.push(SequenceDiff { diff_type: "removed".to_string(), name: target_seq.name.clone(), source: None, target: Some(target_seq.clone()), changes: Vec::new(), }); } } diffs } pub fn diff_rules(source: &[RuleInfo], target: &[RuleInfo]) -> Vec { let mut diffs = Vec::new(); let target_map: HashMap<&str, &RuleInfo> = target.iter().map(|r| (r.name.as_str(), r)).collect(); let source_map: HashMap<&str, &RuleInfo> = source.iter().map(|r| (r.name.as_str(), r)).collect(); for source_rule in source { let Some(target_rule) = target_map.get(source_rule.name.as_str()) else { diffs.push(RuleDiff { diff_type: "added".to_string(), name: source_rule.name.clone(), source: Some(source_rule.clone()), target: None, changes: Vec::new(), }); continue; }; let mut changes = Vec::new(); if source_rule.definition != target_rule.definition { changes.push("definition changed".to_string()); } if !changes.is_empty() { diffs.push(RuleDiff { diff_type: "modified".to_string(), name: source_rule.name.clone(), source: Some(source_rule.clone()), target: Some((*target_rule).clone()), changes, }); } } for target_rule in target { if !source_map.contains_key(target_rule.name.as_str()) { diffs.push(RuleDiff { diff_type: "removed".to_string(), name: target_rule.name.clone(), source: None, target: Some(target_rule.clone()), changes: Vec::new(), }); } } diffs } pub fn diff_owners(source: &[OwnerInfo], target: &[OwnerInfo]) -> Vec { let mut diffs = Vec::new(); let target_map: HashMap<&str, &OwnerInfo> = target.iter().map(|o| (o.object_name.as_str(), o)).collect(); let _source_map: HashMap<&str, &OwnerInfo> = source.iter().map(|o| (o.object_name.as_str(), o)).collect(); for source_owner in source { let Some(target_owner) = target_map.get(source_owner.object_name.as_str()) else { continue; // skip added/removed objects, only compare owners for common objects }; let mut changes = Vec::new(); if source_owner.owner != target_owner.owner { changes.push(format!("owner: {} → {}", target_owner.owner, source_owner.owner)); } if !changes.is_empty() { diffs.push(OwnerDiff { diff_type: "modified".to_string(), object_name: source_owner.object_name.clone(), source: Some(source_owner.clone()), target: Some((*target_owner).clone()), changes, }); } } diffs } fn quote_id(name: &str, db_type: DatabaseType) -> String { if is_mysql_like(db_type) { format!("`{}`", name.replace('`', "``")) } else { format!("\"{}\"", name.replace('"', "\"\"")) } } fn column_def(col: &ColumnInfo, db_type: DatabaseType) -> String { let mut definition = format!("{} {}", quote_id(&col.name, db_type), col.data_type); if !col.is_nullable { definition.push_str(" NOT NULL"); } if let Some(default) = &col.column_default { definition.push_str(&format!(" DEFAULT {default}")); } if is_mysql_like(db_type) { if let Some(comment) = &col.comment { definition.push_str(&format!(" COMMENT {}", comment_literal(comment))); } } definition } fn qualified_name(name: &str, db_type: DatabaseType, schema: Option<&str>) -> String { schema .map(|schema| format!("{}.{}", quote_id(schema, db_type), quote_id(name, db_type))) .unwrap_or_else(|| quote_id(name, db_type)) } fn drop_index_sql(table_name: &str, index_name: &str, db_type: DatabaseType, schema: Option<&str>) -> String { let table = qualified_name(table_name, db_type, schema); let index = qualified_name(index_name, db_type, schema); if is_mysql_like(db_type) { format!("DROP INDEX {} ON {table};", quote_id(index_name, db_type)) } else { format!("DROP INDEX IF EXISTS {index};") } } fn create_index_sql(table_name: &str, index: &IndexInfo, db_type: DatabaseType, schema: Option<&str>) -> String { let table = qualified_name(table_name, db_type, schema); let columns = index.columns.iter().map(|column| quote_id(column, db_type)).collect::>().join(", "); let unique = if index.is_unique { "UNIQUE " } else { "" }; let index_type = index.index_type.as_deref().unwrap_or_default(); let using_clause = if !index_type.is_empty() && db_type == DatabaseType::Postgres { format!(" USING {index_type}") } else { String::new() }; let type_prefix = if !index_type.is_empty() && db_type == DatabaseType::SqlServer { format!("{index_type} ") } else { String::new() }; let mysql_using = if !index_type.is_empty() && is_mysql_like(db_type) { format!(" USING {index_type}") } else { String::new() }; let included_columns = index.included_columns.clone().unwrap_or_default(); let include_clause = if !included_columns.is_empty() && matches!(db_type, DatabaseType::Postgres | DatabaseType::SqlServer) { format!( " INCLUDE ({})", included_columns.iter().map(|column| quote_id(column, db_type)).collect::>().join(", ") ) } else { String::new() }; let supports_where = matches!(db_type, DatabaseType::Postgres | DatabaseType::SqlServer | DatabaseType::Sqlite); let filter = if supports_where { index.filter.as_deref().unwrap_or_default() } else { "" }; let filter_clause = if filter.is_empty() { String::new() } else { format!(" WHERE {filter}") }; let comment = index.comment.as_deref().unwrap_or(""); let comment_clause = if !comment.trim().is_empty() && is_mysql_like(db_type) { format!(" COMMENT {}", comment_literal(comment)) } else { String::new() }; if is_mysql_like(db_type) { format!( "CREATE {unique}{type_prefix}INDEX {}{mysql_using} ON {table} ({columns}){comment_clause};", quote_id(&index.name, db_type) ) } else { format!( "CREATE {unique}{type_prefix}INDEX {} ON {table}{using_clause} ({columns}){include_clause}{filter_clause};", quote_id(&index.name, db_type) ) } } fn drop_foreign_key_sql(table_name: &str, fk_name: &str, db_type: DatabaseType, schema: Option<&str>) -> String { let table = qualified_name(table_name, db_type, schema); let fk = quote_id(fk_name, db_type); if is_mysql_like(db_type) { format!("ALTER TABLE {table} DROP FOREIGN KEY {fk};") } else { format!("ALTER TABLE {table} DROP CONSTRAINT {fk};") } } fn add_foreign_key_sql(table_name: &str, fk: &ForeignKeyInfo, db_type: DatabaseType, schema: Option<&str>) -> String { let table = qualified_name(table_name, db_type, schema); format!( "ALTER TABLE {table} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({});", quote_id(&fk.name, db_type), quote_id(&fk.column, db_type), quote_id(&fk.ref_table, db_type), quote_id(&fk.ref_column, db_type) ) } fn drop_object_sql(diff: &TableDiff, db_type: DatabaseType, schema: Option<&str>, cascade: &str) -> String { let object_type = if diff.object_type.as_deref() == Some("view") { "VIEW" } else { "TABLE" }; format!("DROP {object_type} IF EXISTS {}{cascade};", qualified_name(&diff.name, db_type, schema)) } fn comment_literal(comment: &str) -> String { format!("'{}'", comment.replace('\'', "''")) } fn column_comment_sql( table_name: &str, column_name: &str, comment: &str, db_type: DatabaseType, schema: Option<&str>, ) -> String { if is_mysql_like(db_type) { return format!( "-- Column comment for {column_name}: use ALTER TABLE ... MODIFY COLUMN to set comment in MySQL" ); } let table = qualified_name(table_name, db_type, schema); format!("COMMENT ON COLUMN {table}.{} IS {};", quote_id(column_name, db_type), comment_literal(comment)) } fn table_comment_sql(table_name: &str, comment: &str, db_type: DatabaseType, schema: Option<&str>) -> String { let table = qualified_name(table_name, db_type, schema); if is_mysql_like(db_type) { format!("ALTER TABLE {table} COMMENT = {};", comment_literal(comment)) } else { format!("COMMENT ON TABLE {table} IS {};", comment_literal(comment)) } } pub fn generate_schema_sync_sql( diffs: &[TableDiff], function_diffs: &[FunctionDiff], sequence_diffs: &[SequenceDiff], rule_diffs: &[RuleDiff], owner_diffs: &[OwnerDiff], db_type: DatabaseType, schema: Option<&str>, cascade_delete: bool, ) -> String { let mut lines = Vec::new(); let is_mysql = is_mysql_like(db_type); let cascade = if cascade_delete { " CASCADE" } else { "" }; for diff in diffs { let table = qualified_name(&diff.name, db_type, schema); if diff.diff_type == "added" && diff.ddl.is_some() { lines.push(format!("-- Create {}: {}", diff.object_type.as_deref().unwrap_or("table"), diff.name)); lines.push(format!("{};", diff.ddl.as_deref().unwrap_or_default())); lines.push(String::new()); continue; } if diff.diff_type == "added" && diff.object_type.as_deref() == Some("view") { lines.push(format!("-- View exists only in source: {}", diff.name)); lines.push("-- Source view definition is not available from this driver yet.".to_string()); lines.push(String::new()); continue; } if diff.diff_type == "removed" { lines.push(format!("-- Drop {}: {}", diff.object_type.as_deref().unwrap_or("table"), diff.name)); lines.push(drop_object_sql(diff, db_type, schema, cascade)); lines.push(String::new()); continue; } if diff.diff_type != "modified" { continue; } let mut parts = Vec::new(); if let Some(foreign_keys) = &diff.foreign_keys { for fk in foreign_keys { if fk.diff_type == "removed" || fk.diff_type == "modified" { lines.push(drop_foreign_key_sql(&diff.name, &fk.name, db_type, schema)); } } } if let Some(columns) = &diff.columns { for column in columns { match column.diff_type.as_str() { "added" => { if let Some(source) = &column.source { parts.push(format!(" ADD COLUMN {}", column_def(source, db_type))); } } "removed" => { parts.push(format!(" DROP COLUMN {}", quote_id(&column.name, db_type))); } "modified" => { if let Some(source) = &column.source { if is_mysql { parts.push(format!(" MODIFY COLUMN {}", column_def(source, db_type))); } else { let name = quote_id(&column.name, db_type); if column.changes.iter().any(|change| change.starts_with("type:")) { parts.push(format!(" ALTER COLUMN {name} TYPE {}", source.data_type)); } if column.changes.iter().any(|change| change.starts_with("nullable:")) { parts.push(if source.is_nullable { format!(" ALTER COLUMN {name} DROP NOT NULL") } else { format!(" ALTER COLUMN {name} SET NOT NULL") }); } if column.changes.iter().any(|change| change.starts_with("default:")) { parts.push(if let Some(default) = &source.column_default { format!(" ALTER COLUMN {name} SET DEFAULT {default}") } else { format!(" ALTER COLUMN {name} DROP DEFAULT") }); } } } } _ => {} } } } if !parts.is_empty() { lines.push(format!("-- Alter table: {}", diff.name)); if is_mysql { lines.push(format!("ALTER TABLE {table}")); lines.push(format!("{};", parts.join(",\n"))); } else { for part in parts { lines.push(format!("ALTER TABLE {table}{part};")); } } lines.push(String::new()); } if !is_mysql { if let Some(columns) = &diff.columns { for column in columns { if let Some(source) = &column.source { if column.changes.iter().any(|change| change.starts_with("comment:")) { lines.push(column_comment_sql( &diff.name, &column.name, source.comment.as_deref().unwrap_or_default(), db_type, schema, )); } if column.diff_type == "added" { if let Some(comment) = &source.comment { lines.push(column_comment_sql(&diff.name, &column.name, comment, db_type, schema)); } } } } } } if diff.source_table_comment.is_some() && diff.source_table_comment != diff.target_table_comment { let comment = diff.source_table_comment.as_ref().and_then(|comment| comment.as_deref()).unwrap_or_default(); lines.push(table_comment_sql(&diff.name, comment, db_type, schema)); } if let Some(indexes) = &diff.indexes { for index in indexes { match index.diff_type.as_str() { "added" => { if let Some(source) = &index.source { lines.push(create_index_sql(&diff.name, source, db_type, schema)); } } "removed" => lines.push(drop_index_sql(&diff.name, &index.name, db_type, schema)), "modified" => { if let Some(source) = &index.source { lines.push(drop_index_sql(&diff.name, &index.name, db_type, schema)); lines.push(create_index_sql(&diff.name, source, db_type, schema)); } } _ => {} } } } if let Some(foreign_keys) = &diff.foreign_keys { for fk in foreign_keys { if (fk.diff_type == "added" || fk.diff_type == "modified") && fk.source.is_some() { lines.push(add_foreign_key_sql(&diff.name, fk.source.as_ref().unwrap(), db_type, schema)); } } } if let Some(triggers) = &diff.triggers { for trigger in triggers { lines.push(format!( "-- Trigger {}: {} on {}; review trigger definition manually.", trigger.diff_type, trigger.name, diff.name )); } } if diff.indexes.as_ref().is_some_and(|indexes| !indexes.is_empty()) || diff.foreign_keys.as_ref().is_some_and(|foreign_keys| !foreign_keys.is_empty()) || diff.triggers.as_ref().is_some_and(|triggers| !triggers.is_empty()) { lines.push(String::new()); } if db_type == DatabaseType::Sqlite && diff.foreign_keys.as_ref().is_some_and(|foreign_keys| !foreign_keys.is_empty()) { lines.push(format!("-- SQLite foreign key synchronization may require table rebuild for: {}", diff.name)); lines.push(String::new()); } } // Function diffs if !function_diffs.is_empty() { lines.push(String::new()); lines.push("-- Functions".to_string()); for diff in function_diffs { match diff.diff_type.as_str() { "added" => { if let Some(source) = &diff.source { lines.push(format!("-- Create function: {}", diff.name)); lines.push(format!( "CREATE OR REPLACE FUNCTION {} {};", qualified_name(&diff.name, db_type, schema), source.definition )); } } "removed" => { lines.push(format!("-- Drop function: {}", diff.name)); lines.push(format!( "DROP FUNCTION IF EXISTS {}{cascade};", qualified_name(&diff.name, db_type, schema) )); } "modified" => { if let Some(source) = &diff.source { lines.push(format!("-- Alter function: {}", diff.name)); lines.push(format!( "CREATE OR REPLACE FUNCTION {} {};", qualified_name(&diff.name, db_type, schema), source.definition )); } } _ => {} } } } // Sequence diffs if !sequence_diffs.is_empty() { lines.push(String::new()); lines.push("-- Sequences".to_string()); for diff in sequence_diffs { match diff.diff_type.as_str() { "added" => { if let Some(source) = &diff.source { lines.push(format!("-- Create sequence: {}", diff.name)); lines.push(format!( "CREATE SEQUENCE {} AS {} START WITH {} INCREMENT BY {} MINVALUE {} MAXVALUE {} {};", qualified_name(&diff.name, db_type, schema), source.data_type, source.start_value, source.increment, source.min_value, source.max_value, if source.cycle { "CYCLE" } else { "NO CYCLE" } )); } } "removed" => { lines.push(format!("-- Drop sequence: {}", diff.name)); lines.push(format!("DROP SEQUENCE {}{cascade};", qualified_name(&diff.name, db_type, schema))); } "modified" => { if let Some(source) = &diff.source { lines.push(format!("-- Alter sequence: {}", diff.name)); lines.push(format!( "ALTER SEQUENCE {} AS {} START WITH {} INCREMENT BY {} MINVALUE {} MAXVALUE {} {};", qualified_name(&diff.name, db_type, schema), source.data_type, source.start_value, source.increment, source.min_value, source.max_value, if source.cycle { "CYCLE" } else { "NO CYCLE" } )); } } _ => {} } } } // Rule diffs if !rule_diffs.is_empty() { lines.push(String::new()); lines.push("-- Rules".to_string()); for diff in rule_diffs { match diff.diff_type.as_str() { "added" => { if let Some(source) = &diff.source { lines.push(format!("-- Create rule: {}", diff.name)); lines.push(source.definition.clone()); } } "removed" => { lines.push(format!("-- Drop rule: {}", diff.name)); if let Some(source) = &diff.source { lines.push(format!( "DROP RULE IF EXISTS {} ON {};", diff.name, qualified_name(&source.table_name, db_type, schema) )); } } "modified" => { if let Some(source) = &diff.source { lines.push(format!("-- Alter rule: {}", diff.name)); lines.push(format!( "DROP RULE IF EXISTS {} ON {};", diff.name, qualified_name(&source.table_name, db_type, schema) )); lines.push(source.definition.clone()); } } _ => {} } } } // Owner diffs if !owner_diffs.is_empty() { lines.push(String::new()); lines.push("-- Owners".to_string()); for diff in owner_diffs { if let (Some(source), Some(_target)) = (&diff.source, &diff.target) { let object_type = match source.object_type.as_str() { "TABLE" => "TABLE", "VIEW" => "VIEW", "SEQUENCE" => "SEQUENCE", _ => "TABLE", }; lines.push(format!( "ALTER {object_type} {} OWNER TO {};", qualified_name(&diff.object_name, db_type, schema), source.owner )); } } } lines.join("\n").trim().to_string() } #[cfg(test)] mod tests { use super::*; fn index(overrides: IndexInfo) -> IndexInfo { IndexInfo { name: if overrides.name.is_empty() { "idx_users_email".to_string() } else { overrides.name }, columns: if overrides.columns.is_empty() { vec!["email".to_string()] } else { overrides.columns }, is_unique: overrides.is_unique, is_primary: overrides.is_primary, filter: overrides.filter, index_type: overrides.index_type, included_columns: overrides.included_columns, comment: overrides.comment, } } fn foreign_key(overrides: ForeignKeyInfo) -> ForeignKeyInfo { ForeignKeyInfo { name: if overrides.name.is_empty() { "orders_user_id_fk".to_string() } else { overrides.name }, column: if overrides.column.is_empty() { "user_id".to_string() } else { overrides.column }, ref_schema: overrides.ref_schema, ref_table: if overrides.ref_table.is_empty() { "users".to_string() } else { overrides.ref_table }, ref_column: if overrides.ref_column.is_empty() { "id".to_string() } else { overrides.ref_column }, on_update: overrides.on_update, on_delete: overrides.on_delete, } } fn column(name: &str, data_type: &str, comment: Option<&str>) -> ColumnInfo { ColumnInfo { name: name.to_string(), data_type: data_type.to_string(), is_nullable: false, column_default: None, is_primary_key: false, extra: None, comment: comment.map(str::to_string), numeric_precision: None, numeric_scale: None, character_maximum_length: None, } } #[test] fn detects_modified_indexes_not_only_added_or_removed_indexes() { let diffs = diff_indexes( &[index(IndexInfo { name: "idx_orders_status".to_string(), columns: vec!["status".to_string(), "created_at".to_string()], is_unique: false, is_primary: false, filter: None, index_type: None, included_columns: None, comment: None, })], &[index(IndexInfo { name: "idx_orders_status".to_string(), columns: vec!["status".to_string()], is_unique: true, is_primary: false, filter: None, index_type: None, included_columns: None, comment: None, })], ); assert_eq!(diffs.len(), 1); assert_eq!(diffs[0].diff_type, "modified"); assert_eq!(diffs[0].changes, vec!["unique: YES → NO", "columns: status → status, created_at"]); } #[test] fn detects_foreign_key_additions_removals_and_target_changes() { let diffs = diff_foreign_keys( &[ foreign_key(ForeignKeyInfo { name: "orders_user_id_fk".to_string(), column: String::new(), ref_schema: None, ref_table: String::new(), ref_column: String::new(), on_update: None, on_delete: None, }), foreign_key(ForeignKeyInfo { name: "orders_account_id_fk".to_string(), column: "account_id".to_string(), ref_schema: None, ref_table: "accounts".to_string(), ref_column: String::new(), on_update: None, on_delete: None, }), ], &[ foreign_key(ForeignKeyInfo { name: "orders_user_id_fk".to_string(), column: String::new(), ref_schema: None, ref_table: "members".to_string(), ref_column: String::new(), on_update: None, on_delete: None, }), foreign_key(ForeignKeyInfo { name: "orders_region_id_fk".to_string(), column: "region_id".to_string(), ref_schema: None, ref_table: "regions".to_string(), ref_column: String::new(), on_update: None, on_delete: None, }), ], ); let summary: Vec<_> = diffs.iter().map(|diff| (diff.diff_type.as_str(), diff.name.as_str())).collect(); assert_eq!( summary, vec![ ("modified", "orders_user_id_fk"), ("added", "orders_account_id_fk"), ("removed", "orders_region_id_fk"), ] ); } #[test] fn generates_sync_sql_for_index_and_foreign_key_changes() { let diffs = vec![TableDiff { diff_type: "modified".to_string(), object_type: None, name: "orders".to_string(), columns: None, indexes: Some(vec![IndexDiff { diff_type: "modified".to_string(), name: "idx_orders_status".to_string(), source: Some(index(IndexInfo { name: "idx_orders_status".to_string(), columns: vec!["status".to_string(), "created_at".to_string()], is_unique: true, is_primary: false, filter: None, index_type: None, included_columns: None, comment: None, })), target: None, changes: Vec::new(), }]), foreign_keys: Some(vec![ForeignKeyDiff { diff_type: "modified".to_string(), name: "orders_user_id_fk".to_string(), source: Some(foreign_key(ForeignKeyInfo { name: "orders_user_id_fk".to_string(), column: String::new(), ref_schema: None, ref_table: "users".to_string(), ref_column: String::new(), on_update: None, on_delete: None, })), target: None, changes: Vec::new(), }]), triggers: None, ddl: None, target_ddl: None, source_table_comment: None, target_table_comment: None, }]; assert_eq!( generate_schema_sync_sql(&diffs, &[], &[], &[], &[], DatabaseType::Postgres, None, false), [ "ALTER TABLE \"orders\" DROP CONSTRAINT \"orders_user_id_fk\";", "DROP INDEX IF EXISTS \"idx_orders_status\";", "CREATE UNIQUE INDEX \"idx_orders_status\" ON \"orders\" (\"status\", \"created_at\");", "ALTER TABLE \"orders\" ADD CONSTRAINT \"orders_user_id_fk\" FOREIGN KEY (\"user_id\") REFERENCES \"users\" (\"id\");", ] .join("\n") ); } #[test] fn mysql_column_comment_changes_generate_modify_column_sql() { let diffs = vec![TableDiff { diff_type: "modified".to_string(), object_type: None, name: "users".to_string(), columns: Some(vec![ColumnDiff { diff_type: "modified".to_string(), name: "name".to_string(), source: Some(column("name", "varchar(64)", Some("用户姓名"))), target: Some(column("name", "varchar(64)", Some("Name"))), changes: vec!["comment: Name → 用户姓名".to_string()], }]), indexes: None, foreign_keys: None, triggers: None, ddl: None, target_ddl: None, source_table_comment: Some(Some("用户表".to_string())), target_table_comment: Some(Some("Users".to_string())), }]; assert_eq!( generate_schema_sync_sql(&diffs, &[], &[], &[], &[], DatabaseType::Mysql, None, false), [ "-- Alter table: users", "ALTER TABLE `users`", " MODIFY COLUMN `name` varchar(64) NOT NULL COMMENT '用户姓名';", "", "ALTER TABLE `users` COMMENT = '用户表';", ] .join("\n") ); } #[test] fn ignore_comments_skips_column_and_table_comment_diffs() { let options = SchemaDiffPreparationOptions { source_tables: vec![TableInfo { name: "users".to_string(), table_type: "BASE TABLE".to_string(), comment: Some("用户表".to_string()), parent_schema: None, parent_name: None, }], target_tables: vec![TableInfo { name: "users".to_string(), table_type: "BASE TABLE".to_string(), comment: Some("Users".to_string()), parent_schema: None, parent_name: None, }], source_details: vec![TableSchemaDetail { name: "users".to_string(), columns: vec![column("name", "varchar(64)", Some("用户姓名"))], indexes: Vec::new(), foreign_keys: Vec::new(), triggers: Vec::new(), ddl: None, }], target_details: vec![TableSchemaDetail { name: "users".to_string(), columns: vec![column("name", "varchar(64)", Some("Name"))], indexes: Vec::new(), foreign_keys: Vec::new(), triggers: Vec::new(), ddl: None, }], source_functions: Vec::new(), target_functions: Vec::new(), source_sequences: Vec::new(), target_sequences: Vec::new(), source_rules: Vec::new(), target_rules: Vec::new(), source_owners: Vec::new(), target_owners: Vec::new(), database_type: DatabaseType::Mysql, target_schema: None, ignore_comments: true, cascade_delete: false, }; let result = prepare_schema_diff(options); assert!(result.diffs.is_empty()); assert!(result.sync_sql.is_empty()); } #[test] fn qualifies_generated_schema_sync_sql_with_target_schema() { let diffs = vec![TableDiff { diff_type: "modified".to_string(), object_type: None, name: "orders".to_string(), columns: Some(vec![ColumnDiff { diff_type: "added".to_string(), name: "status".to_string(), source: Some(ColumnInfo { name: "status".to_string(), data_type: "text".to_string(), is_nullable: true, column_default: None, is_primary_key: false, extra: None, comment: None, numeric_precision: None, numeric_scale: None, character_maximum_length: None, }), target: None, changes: Vec::new(), }]), indexes: Some(vec![IndexDiff { diff_type: "added".to_string(), name: "idx_orders_status".to_string(), source: Some(index(IndexInfo { name: "idx_orders_status".to_string(), columns: vec!["status".to_string()], is_unique: false, is_primary: false, filter: None, index_type: None, included_columns: None, comment: None, })), target: None, changes: Vec::new(), }]), foreign_keys: None, triggers: None, ddl: None, target_ddl: None, source_table_comment: None, target_table_comment: None, }]; assert_eq!( generate_schema_sync_sql(&diffs, &[], &[], &[], &[], DatabaseType::Postgres, Some("sales"), false), [ "-- Alter table: orders", "ALTER TABLE \"sales\".\"orders\" ADD COLUMN \"status\" text;", "", "CREATE INDEX \"idx_orders_status\" ON \"sales\".\"orders\" (\"status\");", ] .join("\n") ); } }