use std::collections::{HashMap, HashSet, VecDeque}; use log; use rayon::prelude::*; use regex::Regex; use serde::{Deserialize, Serialize}; use crate::models::connection::DatabaseType; use crate::sql_dialect::ddl_profile::{profile_for, DdlDialectProfile}; use crate::sql_dialect::descriptor::DialectKind; use crate::sql_dialect::inference::{ColumnType, DefaultTypeInferenceEngine, TypeInferenceEngine}; use crate::sql_dialect::type_rewrite::{ apply_auto_inc_to_column_def, rewrite_column_type, type_looks_integer, AutoIncColumnBuild, }; use crate::sql_parser::ast_filter::AstTransmitFilter; 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, Default)] #[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>, #[serde(default, skip_serializing_if = "Option::is_none")] pub sync_sql: 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, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum ParamStrategy { Preserve, Strip, Custom, } fn default_param_strategy() -> ParamStrategy { ParamStrategy::Preserve } /// A custom field type mapping override: source_type → target_type. /// Used when source and target database types differ. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FieldMapping { pub source_type: String, pub target_type: String, #[serde(default = "default_param_strategy")] pub param_strategy: ParamStrategy, #[serde(default, skip_serializing_if = "Option::is_none")] pub custom_params: Option, } impl FieldMapping { pub fn apply<'a>(mappings: &'a [FieldMapping], source_type: &str) -> Option<&'a str> { let base_type = source_type.split('(').next().unwrap_or(source_type).trim(); mappings.iter().find(|m| m.source_type.eq_ignore_ascii_case(base_type)).map(|m| m.target_type.as_str()) } pub fn apply_with_params(mappings: &[FieldMapping], source_type: &str, target_kind: DialectKind) -> Option { let trimmed = source_type.trim(); let base_type = trimmed.split('(').next().unwrap_or(trimmed); let source_params = &trimmed[base_type.len()..]; let matched = mappings.iter().find(|m| m.source_type.eq_ignore_ascii_case(base_type))?; let result = match matched.param_strategy { ParamStrategy::Strip => Some(matched.target_type.clone()), ParamStrategy::Custom => match &matched.custom_params { Some(params) if !params.is_empty() => { let p = params.trim(); // Normalize: wrap bare params (e.g. "100") in parentheses so the // generated type becomes e.g. `character(100)` rather than `character100`. let formatted = if p.starts_with('(') { p.to_string() } else { format!("({})", p) }; Some(format!("{}{}", matched.target_type, formatted)) } _ => Some(matched.target_type.clone()), }, ParamStrategy::Preserve => { let supports = type_supports_params(target_kind, &matched.target_type); let has_params = !source_params.is_empty(); if has_params && supports { Some(format!("{}{}", matched.target_type, source_params)) } else { log::info!( "apply_with_params[Preserve] source={} target={} strategy={:?} has_params={} supports_params={} -> bare {}", source_type, matched.target_type, matched.param_strategy, has_params, supports, matched.target_type ); Some(matched.target_type.clone()) } } }; log::info!( "apply_with_params source={} target_type={} strategy={:?} result={:?}", source_type, matched.target_type, matched.param_strategy, result ); result } } fn type_supports_params(kind: DialectKind, type_name: &str) -> bool { crate::sql_dialect::dialect_loader::register_core_dialects(); let registry = crate::sql_dialect::dialect_loader::DialectRegistry::global(); let all = registry.get_all_by_kind(kind); if all.is_empty() { return true; } all.iter().any(|loaded| { loaded.yaml.types.iter().any(|t| { (t.name.eq_ignore_ascii_case(type_name) || t.aliases.iter().any(|a| a.eq_ignore_ascii_case(type_name))) && (t.has_length || t.has_precision || t.max_precision.is_some()) }) }) } #[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, #[serde(default)] pub compare_column_order: bool, #[serde(default)] pub detect_renames: bool, #[serde(default)] pub detect_table_renames: bool, #[serde(default)] pub rename_threshold: f64, #[serde(default)] pub enable_rollback: bool, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub batch_patterns: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub source_dialect: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub target_dialect: Option, #[serde(default)] pub compatibility_threshold: f64, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub source_permissions: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub target_permissions: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub shard_strategy: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub resource_constraint: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub field_mappings: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct MissingRollbackObject { pub kind: String, pub name: String, pub table: Option, pub reason: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum RollbackCompleteness { #[serde(rename = "complete")] Complete, #[serde(rename = "incomplete")] Incomplete, } impl RollbackCompleteness { pub fn as_str(&self) -> &'static str { match self { Self::Complete => "complete", Self::Incomplete => "incomplete", } } } #[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, #[serde(default, skip_serializing_if = "Option::is_none")] pub rollback_sync_sql: Option, /// Whether rollback SQL is complete enough to execute safely. #[serde(default = "default_rollback_complete")] pub rollback_completeness: RollbackCompleteness, /// Objects that could not be reconstructed for rollback (e.g. triggers without body). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub missing_rollback_objects: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub rename_candidates: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub rollback_graph: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub compatibility_warnings: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub permission_diffs: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub permission_sync_sql: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub dependency_graph: Option, } fn default_rollback_complete() -> RollbackCompleteness { RollbackCompleteness::Complete } // ============================================================================ // Phase 4.1: Dependency Graph & Rename Detection // ============================================================================ /// Regex-based text scanning for table references in SQL/DDL text. /// Used as fallback when no live DB query (YAML metadata_queries.dependencies) is available. fn extract_ddl_references(sql: &str, known_tables: &HashSet<&str>) -> Vec { let upper = sql.to_uppercase(); let mut refs: Vec = Vec::new(); for table in known_tables { let table_up = table.to_uppercase(); // Match after SQL keywords that indicate table references let patterns = [ format!(" FROM {table_up}"), format!(" JOIN {table_up}"), format!(" INTO {table_up}"), format!(" TABLE {table_up}"), format!(" REFERENCES {table_up}"), format!(" UPDATE {table_up}"), format!("DELETE FROM {table_up}"), format!("FROM {table_up} ("), format!(" {table_up}."), ]; if patterns.iter().any(|p| upper.contains(p.as_str())) { refs.push(table.to_string()); } } refs } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DependencyNode { pub table_name: String, pub depends_on: Vec, pub depended_by: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DependencyGraph { pub nodes: HashMap, pub topological_order: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CoverageReport { pub level1_score: f64, pub level2_score: f64, pub composite_score: f64, pub level1_covered: u64, pub level1_total: u64, pub level2_covered: u64, pub level2_total: u64, pub uncovered_edges: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UncoveredEdge { pub from_table: String, pub to_table: String, pub level: u32, } impl DependencyGraph { pub fn build(details: &[TableSchemaDetail], tables: &[TableInfo]) -> Self { Self::build_with_functions(details, tables, &[], &[]) } /// Extended build: also extracts dependencies from view DDLs, triggers, and function/sequence definitions. /// Falls back to regex-based text scanning when no live DB query is available. pub fn build_with_functions( details: &[TableSchemaDetail], tables: &[TableInfo], functions: &[FunctionInfo], _sequences: &[SequenceInfo], ) -> Self { let table_names: HashSet<&str> = tables.iter().filter(|t| !t.table_type.contains("VIEW")).map(|t| t.name.as_str()).collect(); let view_names: HashSet<&str> = tables.iter().filter(|t| t.table_type.contains("VIEW")).map(|t| t.name.as_str()).collect(); let all_names: HashSet<&str> = tables.iter().map(|t| t.name.as_str()).collect(); let mut nodes: HashMap = all_names .iter() .map(|name| { ( name.to_string(), DependencyNode { table_name: name.to_string(), depends_on: Vec::new(), depended_by: Vec::new() }, ) }) .collect(); let detail_map: HashMap<&str, &TableSchemaDetail> = details.iter().map(|d| (d.name.as_str(), d)).collect(); let function_by_name: HashMap<&str, &FunctionInfo> = functions.iter().map(|f| (f.name.as_str(), f)).collect(); // Phase 1: FK-based dependencies (existing logic) for table_name in &table_names { if let Some(detail) = detail_map.get(table_name) { for fk in &detail.foreign_keys { if table_names.contains(fk.ref_table.as_str()) { if let Some(node) = nodes.get_mut(*table_name) { if !node.depends_on.contains(&fk.ref_table) { node.depends_on.push(fk.ref_table.clone()); } } if let Some(ref_node) = nodes.get_mut(&fk.ref_table) { if !ref_node.depended_by.iter().any(|d| d == *table_name) { ref_node.depended_by.push((*table_name).to_string()); } } } } } } // Phase 2: View DDL text scanning for view_name in &view_names { if let Some(detail) = detail_map.get(view_name) { if let Some(ddl) = &detail.ddl { let refs = extract_ddl_references(ddl, &table_names); for ref_table in refs { if let Some(node) = nodes.get_mut(*view_name) { if !node.depends_on.contains(&ref_table) { node.depends_on.push(ref_table.clone()); } } if let Some(ref_node) = nodes.get_mut(&ref_table) { if !ref_node.depended_by.iter().any(|d| d == *view_name) { ref_node.depended_by.push((*view_name).to_string()); } } } } } } // Phase 3: Trigger statement text scanning for table_name in &all_names { if let Some(detail) = detail_map.get(table_name) { for trigger in &detail.triggers { if let Some(stmt) = &trigger.statement { let refs = extract_ddl_references(stmt, &table_names); for ref_table in refs { if let Some(node) = nodes.get_mut(*table_name) { if !node.depends_on.contains(&ref_table) { node.depends_on.push(ref_table.clone()); } } if let Some(ref_node) = nodes.get_mut(&ref_table) { if !ref_node.depended_by.iter().any(|d| d == *table_name) { ref_node.depended_by.push((*table_name).to_string()); } } } } } } } // Phase 4: Function definition text scanning for (_func_name, func) in &function_by_name { let refs = extract_ddl_references(&func.definition, &table_names); for ref_table in &refs { if let Some(ref_node) = nodes.get_mut(ref_table) { if !ref_node.depended_by.iter().any(|d| d == _func_name) { ref_node.depended_by.push(_func_name.to_string()); } } } } let topological_order = Self::topological_sort(&nodes); DependencyGraph { nodes, topological_order } } fn topological_sort(nodes: &HashMap) -> Vec { let mut in_degree: HashMap<&str, usize> = nodes.keys().map(|k| (k.as_str(), 0usize)).collect(); for node in nodes.values() { in_degree.entry(node.table_name.as_str()).or_insert(0); for _dep in &node.depends_on { *in_degree.entry(node.table_name.as_str()).or_insert(0) += 1; } } let mut queue: VecDeque<&str> = in_degree.iter().filter(|(_, °)| deg == 0).map(|(&name, _)| name).collect(); let mut result = Vec::new(); while let Some(name) = queue.pop_front() { result.push(name.to_string()); if let Some(node) = nodes.get(name) { for dependent in &node.depended_by { if let Some(deg) = in_degree.get_mut(dependent.as_str()) { *deg -= 1; if *deg == 0 { queue.push_back(dependent.as_str()); } } } } } if result.len() != nodes.len() { let remaining: Vec = nodes.keys().filter(|k| !result.contains(k)).cloned().collect(); result.extend(remaining); } result } pub fn build_order(&self) -> Vec { self.topological_order.clone() } pub fn drop_order(&self) -> Vec { let mut order = self.topological_order.clone(); order.reverse(); order } pub fn coverage_score(&self, diffed_tables: &[String]) -> f64 { self.coverage_score_level1(diffed_tables) } pub fn coverage_score_level1(&self, diffed_tables: &[String]) -> f64 { if self.nodes.is_empty() { return 1.0; } let diffed_set: HashSet<&str> = diffed_tables.iter().map(|s| s.as_str()).collect(); let mut covered_edges = 0u64; let mut total_edges = 0u64; for node in self.nodes.values() { for dep in &node.depends_on { total_edges += 1; if diffed_set.contains(node.table_name.as_str()) && diffed_set.contains(dep.as_str()) { covered_edges += 1; } } } if total_edges == 0 { 1.0 } else { covered_edges as f64 / total_edges as f64 } } pub fn coverage_score_level2(&self, diffed_tables: &[String]) -> f64 { if self.nodes.is_empty() { return 1.0; } let diffed_set: HashSet<&str> = diffed_tables.iter().map(|s| s.as_str()).collect(); let mut transitive_edges = 0u64; let mut covered_transitive = 0u64; for node in self.nodes.values() { let table_name = node.table_name.as_str(); if !diffed_set.contains(table_name) { continue; } for indirect in &node.depends_on { if let Some(inner) = self.nodes.get(indirect) { for grand in &inner.depends_on { transitive_edges += 1; if diffed_set.contains(table_name) && diffed_set.contains(grand.as_str()) { covered_transitive += 1; } } } } } if transitive_edges == 0 { 1.0 } else { covered_transitive as f64 / transitive_edges as f64 } } pub fn composite_coverage_score(&self, diffed_tables: &[String]) -> CoverageReport { let diffed_set: HashSet<&str> = diffed_tables.iter().map(|s| s.as_str()).collect(); let (l1_covered, l1_total) = self.count_edges(diffed_tables, &diffed_set, 1); let (l2_covered, l2_total) = self.count_transitive_edges(diffed_tables, &diffed_set); let l1_score = if l1_total == 0 { 1.0 } else { l1_covered as f64 / l1_total as f64 }; let l2_score = if l2_total == 0 { 1.0 } else { l2_covered as f64 / l2_total as f64 }; let composite_score = 0.6 * l1_score + 0.4 * l2_score; let uncovered = self.collect_uncovered_edges(diffed_tables, &diffed_set); CoverageReport { level1_score: l1_score, level2_score: l2_score, composite_score, level1_covered: l1_covered, level1_total: l1_total, level2_covered: l2_covered, level2_total: l2_total, uncovered_edges: uncovered, } } fn count_edges(&self, _diffed_tables: &[String], diffed_set: &HashSet<&str>, _level: u32) -> (u64, u64) { let mut covered = 0u64; let mut total = 0u64; for node in self.nodes.values() { for dep in &node.depends_on { total += 1; if diffed_set.contains(node.table_name.as_str()) && diffed_set.contains(dep.as_str()) { covered += 1; } } } (covered, total) } fn count_transitive_edges(&self, _diffed_tables: &[String], diffed_set: &HashSet<&str>) -> (u64, u64) { let mut covered = 0u64; let mut total = 0u64; for node in self.nodes.values() { let table_name = node.table_name.as_str(); if !diffed_set.contains(table_name) { continue; } for indirect in &node.depends_on { if let Some(inner) = self.nodes.get(indirect) { for grand in &inner.depends_on { total += 1; if diffed_set.contains(table_name) && diffed_set.contains(grand.as_str()) { covered += 1; } } } } } (covered, total) } fn collect_uncovered_edges(&self, _diffed_tables: &[String], diffed_set: &HashSet<&str>) -> Vec { let mut uncovered = Vec::new(); for node in self.nodes.values() { for dep in &node.depends_on { let both_covered = diffed_set.contains(node.table_name.as_str()) && diffed_set.contains(dep.as_str()); if !both_covered { uncovered.push(UncoveredEdge { from_table: node.table_name.clone(), to_table: dep.clone(), level: 1, }); } } for indirect in &node.depends_on { if let Some(inner) = self.nodes.get(indirect) { for grand in &inner.depends_on { let all_covered = diffed_set.contains(node.table_name.as_str()) && diffed_set.contains(grand.as_str()); if !all_covered { uncovered.push(UncoveredEdge { from_table: node.table_name.clone(), to_table: grand.clone(), level: 2, }); } } } } } uncovered } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RenameCandidate { pub source_name: String, pub target_name: String, pub score: f64, pub column_jaccard: f64, pub type_similarity: f64, } fn jaccard_similarity(a: &HashSet, b: &HashSet) -> f64 { if a.is_empty() && b.is_empty() { return 1.0; } let intersection = a.intersection(b).count(); let union = a.union(b).count(); if union == 0 { 1.0 } else { intersection as f64 / union as f64 } } fn column_type_similarity(source_cols: &[ColumnInfo], target_cols: &[ColumnInfo]) -> f64 { if source_cols.is_empty() || target_cols.is_empty() { return 0.0; } let engine = DefaultTypeInferenceEngine; let source_map: HashMap<&str, &ColumnInfo> = source_cols.iter().map(|c| (c.name.as_str(), c)).collect(); let target_map: HashMap<&str, &ColumnInfo> = target_cols.iter().map(|c| (c.name.as_str(), c)).collect(); let common_names: HashSet<&str> = source_map.keys().filter(|k| target_map.contains_key(**k)).copied().collect(); if common_names.is_empty() { return 0.0; } let total: f64 = common_names .iter() .map(|name| { let s = ColumnType::parse(&source_map[name].data_type); let t = ColumnType::parse(&target_map[name].data_type); engine.type_compatibility_score(&s, &t) }) .sum(); total / common_names.len() as f64 } pub fn detect_renames( removed: &[String], added: &[String], source_details: &[TableSchemaDetail], target_details: &[TableSchemaDetail], threshold: f64, ) -> Vec { let source_detail_map: HashMap<&str, &TableSchemaDetail> = source_details.iter().map(|d| (d.name.as_str(), d)).collect(); let target_detail_map: HashMap<&str, &TableSchemaDetail> = target_details.iter().map(|d| (d.name.as_str(), d)).collect(); let mut candidates = Vec::new(); for target_name in removed { let Some(target_detail) = target_detail_map.get(target_name.as_str()) else { continue }; for source_name in added { let Some(source_detail) = source_detail_map.get(source_name.as_str()) else { continue }; let col_names_source: HashSet = source_detail.columns.iter().map(|c| c.name.clone()).collect(); let col_names_target: HashSet = target_detail.columns.iter().map(|c| c.name.clone()).collect(); let column_jaccard = jaccard_similarity(&col_names_target, &col_names_source); if column_jaccard < threshold { continue; } let type_sim = column_type_similarity(&target_detail.columns, &source_detail.columns); let score = column_jaccard * 0.6 + type_sim * 0.4; if score >= threshold { candidates.push(RenameCandidate { source_name: source_name.clone(), target_name: target_name.clone(), score, column_jaccard, type_similarity: type_sim, }); } } } candidates.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)); let mut final_candidates = Vec::new(); let mut used_target: HashSet = HashSet::new(); let mut used_source: HashSet = HashSet::new(); for c in &candidates { if !used_target.contains(&c.target_name) && !used_source.contains(&c.source_name) { final_candidates.push(c.clone()); used_target.insert(c.target_name.clone()); used_source.insert(c.source_name.clone()); } } final_candidates } // ============================================================================ // Phase 4.2: Batch Naming Pattern Recognition // ============================================================================ #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BatchPattern { pub pattern: String, pub is_regex: bool, pub description: String, } pub fn diff_names_with_patterns( source: &[String], target: &[String], patterns: &[BatchPattern], ) -> (Vec, Vec, Vec, Vec>) { let (added, removed, common) = diff_names(source, target); let mut pattern_matches: Vec> = Vec::new(); for pattern in patterns { let mut matches = Vec::new(); if pattern.is_regex { if let Ok(re) = Regex::new(&pattern.pattern) { for name in source { if re.is_match(name) { matches.push(name.clone()); } } } } else { let glob_pattern = pattern.pattern.replace('*', ".*").replace('?', "."); if let Ok(re) = Regex::new(&format!("^{}$", glob_pattern)) { for name in source { if re.is_match(name) { matches.push(name.clone()); } } } } if !matches.is_empty() { pattern_matches.push(matches); } } (added, removed, common, pattern_matches) } pub fn detect_pattern_conflicts(patterns: &[BatchPattern], names: &[String]) -> Vec> { let mut conflicts = Vec::new(); for i in 0..patterns.len() { for j in (i + 1)..patterns.len() { let pi = &patterns[i]; let pj = &patterns[j]; let pattern_i = if pi.is_regex { pi.pattern.clone() } else { pi.pattern.replace('*', ".*").replace('?', ".") }; let pattern_j = if pj.is_regex { pj.pattern.clone() } else { pj.pattern.replace('*', ".*").replace('?', ".") }; let re_i = Regex::new(&format!("^{}$", pattern_i)); let re_j = Regex::new(&format!("^{}$", pattern_j)); if let (Ok(ri), Ok(rj)) = (re_i, re_j) { for name in names { if ri.is_match(name) && rj.is_match(name) { conflicts.push(vec![pi.description.clone(), pj.description.clone()]); break; } } } } } conflicts } // ============================================================================ // Phase 4.3: Dialect-Aware Type Compatibility Scoring // ============================================================================ pub fn diff_columns_with_compatibility( source: &[ColumnInfo], target: &[ColumnInfo], ignore_comments: bool, compare_column_order: bool, source_dialect: DialectKind, target_dialect: DialectKind, compatibility_threshold: f64, field_mappings: &[FieldMapping], ) -> (Vec, Vec) { use crate::sql_dialect::descriptor::TypeMappingMatrix; let matrix = TypeMappingMatrix::for_dialects(source_dialect, target_dialect); let engine = DefaultTypeInferenceEngine; let basic_diffs = diff_columns_with_options(source, target, ignore_comments, compare_column_order, false, 0.5); let mut warnings = Vec::new(); let mut enhanced_diffs = Vec::new(); for diff in basic_diffs { let mut warning = None; if diff.diff_type == "modified" { if let (Some(src), Some(tgt)) = (&diff.source, &diff.target) { let src_parsed = ColumnType::parse(&src.data_type); let tgt_parsed = ColumnType::parse(&tgt.data_type); let compatibility = engine.type_compatibility_score(&src_parsed, &tgt_parsed); let (mapped_type, requires_cast) = if let Some(user_target) = FieldMapping::apply_with_params(field_mappings, &src.data_type, target_dialect) { (user_target, false) } else { matrix.convert_type(&tgt.data_type) }; let risk = if compatibility >= 0.9 { ColumnConversionRisk::None } else if compatibility >= 0.7 { ColumnConversionRisk::Low } else if compatibility >= 0.5 { ColumnConversionRisk::Medium } else { ColumnConversionRisk::High }; if compatibility < compatibility_threshold { warning = Some(ColumnCompatibilityWarning { column_name: diff.name.clone(), source_type: src.data_type.clone(), target_type: tgt.data_type.clone(), compatibility_score: compatibility, suggested_mapping: mapped_type, requires_cast, risk, }); } } } enhanced_diffs.push(diff); if let Some(w) = warning { warnings.push(w); } } (enhanced_diffs, warnings) } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ColumnCompatibilityWarning { pub column_name: String, pub source_type: String, pub target_type: String, pub compatibility_score: f64, pub suggested_mapping: String, pub requires_cast: bool, pub risk: ColumnConversionRisk, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum ColumnConversionRisk { None, Low, Medium, High, } // ============================================================================ // Phase 4.4: Bidirectional Diff & Rollback Graph // ============================================================================ #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DiffNode { pub table_diff: TableDiff, pub direction: DiffDirection, pub dependency_order: usize, #[serde(skip_serializing_if = "Option::is_none")] pub rename_source: Option, #[serde(skip_serializing_if = "Option::is_none")] pub rename_target: Option, #[serde(skip_serializing_if = "Option::is_none")] pub rename_score: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum DiffDirection { Forward, Rollback, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RollbackGraph { pub forward_nodes: Vec, pub rollback_nodes: Vec, pub is_consistent: bool, pub consistency_issues: Vec, } impl RollbackGraph { pub fn from_forward_diffs( forward_diffs: &[TableDiff], renames: &[RenameCandidate], dep_graph: &DependencyGraph, ) -> Self { let mut forward_nodes = Vec::new(); let mut rollback_nodes = Vec::new(); let consistency_issues = Vec::new(); let rename_map: HashMap<&str, &RenameCandidate> = renames.iter().map(|r| (r.target_name.as_str(), r)).collect(); let rename_reverse: HashMap<&str, &str> = renames.iter().map(|r| (r.source_name.as_str(), r.target_name.as_str())).collect(); let order_map: HashMap<&str, usize> = dep_graph.topological_order.iter().enumerate().map(|(i, name)| (name.as_str(), i)).collect(); for diff in forward_diffs { let order = order_map.get(diff.name.as_str()).copied().unwrap_or(usize::MAX); let (rename_source, rename_target, rename_score) = if diff.diff_type == "added" { if let Some(rc) = rename_reverse.get(diff.name.as_str()) { (Some(rc.to_string()), Some(diff.name.clone()), None) } else { (None, None, None) } } else if diff.diff_type == "removed" { if let Some(rc) = rename_map.get(diff.name.as_str()) { (Some(diff.name.clone()), Some(rc.source_name.clone()), Some(rc.score)) } else { (None, None, None) } } else { (None, None, None) }; forward_nodes.push(DiffNode { table_diff: diff.clone(), direction: DiffDirection::Forward, dependency_order: order, rename_source, rename_target, rename_score, }); let rollback_diff = Self::invert_diff(diff); rollback_nodes.push(DiffNode { table_diff: rollback_diff, direction: DiffDirection::Rollback, dependency_order: order, rename_source: None, rename_target: None, rename_score: None, }); } RollbackGraph { forward_nodes, rollback_nodes, is_consistent: false, consistency_issues } } fn invert_diff_type(dt: &str) -> &str { match dt { "added" => "removed", "removed" => "added", "renamed" => "renamed", _ => "modified", } } fn invert_change_string(ch: &str) -> String { if let Some(_pos) = ch.find(" → ") { let parts: Vec<&str> = ch.split(" → ").collect(); if parts.len() == 2 { format!("{} → {}", parts[1], parts[0]) } else { ch.to_string() } } else { ch.to_string() } } fn invert_columns(cols: &[ColumnDiff]) -> Vec { cols.iter() .map(|c| { let inverted_name = if c.diff_type == "renamed" { c.target.as_ref().map(|t| t.name.clone()).unwrap_or_else(|| c.name.clone()) } else { c.name.clone() }; ColumnDiff { diff_type: Self::invert_diff_type(&c.diff_type).to_string(), name: inverted_name, source: c.target.clone(), target: c.source.clone(), changes: c.changes.iter().map(|ch| Self::invert_change_string(ch)).collect(), } }) .collect() } fn invert_indexes(idxs: &[IndexDiff]) -> Vec { idxs.iter() .map(|i| IndexDiff { diff_type: Self::invert_diff_type(&i.diff_type).to_string(), name: i.name.clone(), source: i.target.clone(), target: i.source.clone(), changes: i.changes.clone(), }) .collect() } fn invert_fks(fks: &[ForeignKeyDiff]) -> Vec { fks.iter() .map(|fk| ForeignKeyDiff { diff_type: Self::invert_diff_type(&fk.diff_type).to_string(), name: fk.name.clone(), source: fk.target.clone(), target: fk.source.clone(), changes: fk.changes.clone(), }) .collect() } fn invert_triggers(trgs: &[TriggerDiff]) -> Vec { trgs.iter() .map(|t| TriggerDiff { diff_type: Self::invert_diff_type(&t.diff_type).to_string(), name: t.name.clone(), source: t.target.clone(), target: t.source.clone(), changes: t.changes.clone(), }) .collect() } fn invert_diff(diff: &TableDiff) -> TableDiff { let inverted_type = Self::invert_diff_type(&diff.diff_type).to_string(); let inverted_columns = diff.columns.as_ref().map(|cols| Self::invert_columns(cols)); let inverted_indexes = diff.indexes.as_ref().map(|idxs| Self::invert_indexes(idxs)); let inverted_fks = diff.foreign_keys.as_ref().map(|fks| Self::invert_fks(fks)); let inverted_triggers = diff.triggers.as_ref().map(|trgs| Self::invert_triggers(trgs)); let (source_comment, target_comment) = match inverted_type.as_str() { "added" => (diff.target_table_comment.clone(), diff.source_table_comment.clone()), "removed" => (diff.source_table_comment.clone(), diff.target_table_comment.clone()), _ => (diff.target_table_comment.clone(), diff.source_table_comment.clone()), }; let recreates_removed_table = diff.diff_type == "removed" && inverted_type == "added" && diff.object_type.as_deref() == Some("table"); TableDiff { diff_type: inverted_type, object_type: diff.object_type.clone(), name: diff.name.clone(), columns: inverted_columns, indexes: inverted_indexes, foreign_keys: inverted_fks, triggers: inverted_triggers, // Rollback recreation must use the structured snapshot first. Keep // native target DDL isolated as a same-target-dialect fallback. ddl: if recreates_removed_table { None } else { diff.target_ddl.clone() }, target_ddl: if recreates_removed_table { diff.target_ddl.clone() } else { diff.ddl.clone() }, source_table_comment: source_comment, target_table_comment: target_comment, sync_sql: None, } } pub fn validate_consistency(&mut self) -> bool { self.consistency_issues.clear(); for fwd in &self.forward_nodes { let has_rollback = self.rollback_nodes.iter().any(|rbk| { rbk.table_diff.name == fwd.table_diff.name && matches!( (fwd.table_diff.diff_type.as_str(), rbk.table_diff.diff_type.as_str()), ("added", "removed") | ("removed", "added") | ("modified", "modified") | ("none", "none") ) }); if !has_rollback { self.consistency_issues.push(format!( "No rollback entry for forward {}: {}", fwd.table_diff.diff_type, fwd.table_diff.name )); } let rollback_of_rollback: Vec<_> = self .rollback_nodes .iter() .filter(|rbk| rbk.table_diff.name == fwd.table_diff.name) .map(|rbk| Self::invert_diff(&rbk.table_diff)) .collect(); for ror in &rollback_of_rollback { if ror.diff_type != fwd.table_diff.diff_type { self.consistency_issues.push(format!( "Forward∘Rollback mismatch for {}: forward={}, rollback∘rollback={}", fwd.table_diff.name, fwd.table_diff.diff_type, ror.diff_type )); } } } self.is_consistent = self.consistency_issues.is_empty(); self.is_consistent } } pub fn generate_rollback_sync_sql( rollback_graph: &RollbackGraph, db_type: DatabaseType, schema: Option<&str>, cascade_delete: bool, ) -> String { generate_rollback_sync_sql_with_missing(rollback_graph, db_type, schema, cascade_delete).0 } pub fn generate_rollback_sync_sql_with_missing( rollback_graph: &RollbackGraph, db_type: DatabaseType, schema: Option<&str>, cascade_delete: bool, ) -> (String, Vec) { let rollback_diffs: Vec = rollback_graph.rollback_nodes.iter().map(|n| n.table_diff.clone()).collect(); generate_schema_sync_sql_inner(&rollback_diffs, &[], &[], &[], &[], db_type, schema, cascade_delete, None, &[]) } // ============================================================================ // Phase 4.5: Shard-Parallel Comparison // ============================================================================ #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ShardStrategy { pub shard_count: usize, pub shard_by: ShardBy, } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ShardBy { Table, Schema, RoundRobin, } pub fn shard_diff(options: &SchemaDiffPreparationOptions, shard_strategy: &ShardStrategy) -> Vec { let table_count = options.source_tables.len().max(options.target_tables.len()); let shard_count = shard_strategy.shard_count.min(table_count.max(1)); if shard_count <= 1 { return diff_schema(options); } let source_table_names: Vec<&str> = options.source_tables.iter().filter(|t| !t.table_type.contains("VIEW")).map(|t| t.name.as_str()).collect(); let source_view_names: Vec<&str> = options.source_tables.iter().filter(|t| t.table_type.contains("VIEW")).map(|t| t.name.as_str()).collect(); let _target_table_names: Vec<&str> = options.target_tables.iter().filter(|t| !t.table_type.contains("VIEW")).map(|t| t.name.as_str()).collect(); let _target_view_names: Vec<&str> = options.target_tables.iter().filter(|t| t.table_type.contains("VIEW")).map(|t| t.name.as_str()).collect(); let source_all: Vec<&str> = source_table_names.iter().chain(source_view_names.iter()).copied().collect(); let shards: Vec> = match &shard_strategy.shard_by { ShardBy::Table | ShardBy::RoundRobin => { let mut s: Vec> = vec![Vec::new(); shard_count]; for (i, name) in source_all.iter().enumerate() { s[i % shard_count].push(*name); } s } ShardBy::Schema => { let mut schema_groups: HashMap<&str, Vec<&str>> = HashMap::new(); for table in &options.source_tables { let schema = table.parent_schema.as_deref().unwrap_or("default"); schema_groups.entry(schema).or_default().push(table.name.as_str()); } let mut s: Vec> = vec![Vec::new(); shard_count]; for (i, (_schema, names)) in schema_groups.iter().enumerate() { s[i % shard_count].extend(names); } s } }; let shard_results: Vec> = shards .par_iter() .filter(|shard| !shard.is_empty()) .map(|shard| { let shard_set: HashSet<&str> = shard.iter().copied().collect(); let shard_options = SchemaDiffPreparationOptions { source_tables: options .source_tables .iter() .filter(|t| shard_set.contains(t.name.as_str())) .cloned() .collect(), target_tables: options .target_tables .iter() .filter(|t| shard_set.contains(t.name.as_str())) .cloned() .collect(), source_details: options .source_details .iter() .filter(|d| shard_set.contains(d.name.as_str())) .cloned() .collect(), target_details: options .target_details .iter() .filter(|d| shard_set.contains(d.name.as_str())) .cloned() .collect(), source_functions: options.source_functions.clone(), target_functions: options.target_functions.clone(), source_sequences: options.source_sequences.clone(), target_sequences: options.target_sequences.clone(), source_rules: options.source_rules.clone(), target_rules: options.target_rules.clone(), source_owners: options.source_owners.clone(), target_owners: options.target_owners.clone(), database_type: options.database_type, target_schema: options.target_schema.clone(), ignore_comments: options.ignore_comments, cascade_delete: options.cascade_delete, compare_column_order: options.compare_column_order, ..Default::default() }; diff_schema(&shard_options) }) .collect(); let mut merged: Vec = Vec::new(); for shard_result in shard_results { merged.extend(shard_result); } merged.sort_by(|a, b| a.name.cmp(&b.name)); merged.dedup_by(|a, b| a.name == b.name && a.diff_type == b.diff_type); merged } // ============================================================================ // Phase 4.6: Permission & Role-Aware Sync // ============================================================================ #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PermissionInfo { pub grantee: String, pub object_type: String, pub object_name: String, pub privilege: String, pub is_grantable: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PermissionDiff { #[serde(rename = "type")] pub diff_type: String, pub grantee: String, pub object_name: String, pub privilege: String, #[serde(skip_serializing_if = "Option::is_none")] pub source: Option, #[serde(skip_serializing_if = "Option::is_none")] pub target: Option, } pub fn diff_permissions(source: &[PermissionInfo], target: &[PermissionInfo]) -> Vec { let mut diffs = Vec::new(); let target_map: HashMap<(&str, &str, &str), &PermissionInfo> = target.iter().map(|p| ((p.grantee.as_str(), p.object_name.as_str(), p.privilege.as_str()), p)).collect(); let source_map: HashMap<(&str, &str, &str), &PermissionInfo> = source.iter().map(|p| ((p.grantee.as_str(), p.object_name.as_str(), p.privilege.as_str()), p)).collect(); for sp in source { let key = (sp.grantee.as_str(), sp.object_name.as_str(), sp.privilege.as_str()); if !target_map.contains_key(&key) { diffs.push(PermissionDiff { diff_type: "added".to_string(), grantee: sp.grantee.clone(), object_name: sp.object_name.clone(), privilege: sp.privilege.clone(), source: Some(sp.clone()), target: None, }); } } for tp in target { let key = (tp.grantee.as_str(), tp.object_name.as_str(), tp.privilege.as_str()); if !source_map.contains_key(&key) { diffs.push(PermissionDiff { diff_type: "removed".to_string(), grantee: tp.grantee.clone(), object_name: tp.object_name.clone(), privilege: tp.privilege.clone(), source: None, target: Some(tp.clone()), }); } } diffs } pub fn generate_permission_sync_sql(diffs: &[PermissionDiff], db_type: DatabaseType, schema: Option<&str>) -> String { let mut lines: Vec = Vec::new(); let profile = profile_for(db_type); for diff in diffs { match diff.diff_type.as_str() { "added" => { if let Some(source) = &diff.source { if profile.grant_uses_mysql_user_syntax { let object_path = if let Some(sch) = schema { format!("`{}`.`{}`", sch.replace('`', "``"), source.object_name.replace('`', "``")) } else { format!("`{}`", source.object_name.replace('`', "``")) }; let with_grant = if source.is_grantable { " WITH GRANT OPTION" } else { "" }; let grantee_escaped = source.grantee.replace('\'', "''"); lines.push(format!( "GRANT {} ON {} TO '{}'{};", source.privilege, object_path, grantee_escaped, with_grant )); } else { let obj_escaped = source.object_name.replace('"', "\"\""); let object_path = if let Some(sch) = schema { format!("{} \"{}\".\"{}\"", source.object_type, sch, obj_escaped) } else { format!("{} \"{}\"", source.object_type, obj_escaped) }; let with_grant = if source.is_grantable { " WITH GRANT OPTION" } else { "" }; let grantee_escaped = source.grantee.replace('"', "\"\""); lines.push(format!( "GRANT {} ON {} TO \"{}\"{};", source.privilege, object_path, grantee_escaped, with_grant )); } } } "removed" => { if let Some(target) = &diff.target { if profile.grant_uses_mysql_user_syntax { let object_path = if let Some(sch) = schema { format!("`{}`.`{}`", sch.replace('`', "``"), target.object_name.replace('`', "``")) } else { format!("`{}`", target.object_name.replace('`', "``")) }; let grantee_escaped = target.grantee.replace('\'', "''"); lines.push(format!( "REVOKE {} ON {} FROM '{}';", target.privilege, object_path, grantee_escaped )); } else { let obj_escaped = target.object_name.replace('"', "\"\""); let object_path = if let Some(sch) = schema { format!("{} \"{}\".\"{}\"", target.object_type, sch, obj_escaped) } else { format!("{} \"{}\"", target.object_type, obj_escaped) }; let grantee_escaped = target.grantee.replace('"', "\"\""); lines.push(format!( "REVOKE {} ON {} FROM \"{}\";", target.privilege, object_path, grantee_escaped )); } } } _ => {} } } lines.join("\n") } // ============================================================================ // Phase 4.7: Metadata Resource-Aware Scheduling // ============================================================================ #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ResourceConstraint { pub max_concurrent_connections: usize, pub max_memory_mb: u64, pub max_tables_per_batch: usize, pub throttle_delay_ms: u64, } impl Default for ResourceConstraint { fn default() -> Self { Self { max_concurrent_connections: 4, max_memory_mb: 512, max_tables_per_batch: 50, throttle_delay_ms: 100 } } } #[derive(Debug, Clone)] pub struct AdaptiveScheduler { pub constraint: ResourceConstraint, pub current_connections: usize, pub estimated_table_count: usize, } impl AdaptiveScheduler { pub fn new(constraint: ResourceConstraint, table_count: usize) -> Self { Self { constraint, current_connections: 0, estimated_table_count: table_count } } pub fn optimal_batch_size(&self) -> usize { let conn_limit = self.constraint.max_concurrent_connections; let mem_limit = self.constraint.max_memory_mb as usize * 50; let table_limit = self.constraint.max_tables_per_batch; let batches = self.estimated_table_count.max(1); let per_batch = (self.estimated_table_count / conn_limit).max(1); per_batch.min(mem_limit / batches).min(table_limit) } pub fn recommended_shard_count(&self) -> usize { let per_batch = self.optimal_batch_size(); let count = (self.estimated_table_count as f64 / per_batch as f64).ceil() as usize; count.min(self.constraint.max_concurrent_connections).max(1) } pub fn throttle_delay_ms(&self) -> u64 { self.constraint.throttle_delay_ms } } // ============================================================================ // Phase 4: Extended SchemaDiffPreparationOptions & SchemaDiffPreparation // ============================================================================ impl Default for SchemaDiffPreparationOptions { fn default() -> Self { Self { source_tables: Vec::new(), target_tables: Vec::new(), source_details: Vec::new(), target_details: Vec::new(), 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::Sqlite, target_schema: None, ignore_comments: false, cascade_delete: false, compare_column_order: false, detect_renames: false, detect_table_renames: false, rename_threshold: 0.5, enable_rollback: false, batch_patterns: Vec::new(), source_dialect: None, target_dialect: None, compatibility_threshold: 0.5, source_permissions: Vec::new(), target_permissions: Vec::new(), shard_strategy: None, resource_constraint: None, field_mappings: Vec::new(), } } } // Add new optional fields to SchemaDiffPreparationOptions // These are added as separate impl blocks to avoid breaking existing construction sites impl SchemaDiffPreparationOptions { pub fn with_rename_detection(mut self, detect: bool, threshold: f64) -> Self { self.detect_renames = detect; self.rename_threshold = threshold; self } pub fn with_rollback(mut self, enable: bool) -> Self { self.enable_rollback = enable; self } pub fn with_batch_patterns(mut self, patterns: Vec) -> Self { self.batch_patterns = patterns; self } pub fn with_dialects(mut self, source: Option, target: Option) -> Self { self.source_dialect = source; self.target_dialect = target; self } pub fn with_compatibility_threshold(mut self, threshold: f64) -> Self { self.compatibility_threshold = threshold; self } pub fn with_permissions(mut self, source: Vec, target: Vec) -> Self { self.source_permissions = source; self.target_permissions = target; self } pub fn with_shard_strategy(mut self, strategy: ShardStrategy) -> Self { self.shard_strategy = Some(strategy); self } pub fn with_resource_constraint(mut self, constraint: ResourceConstraint) -> Self { self.resource_constraint = Some(constraint); self } pub fn with_field_mappings(mut self, mappings: Vec) -> Self { self.field_mappings = mappings; self } } pub fn prepare_schema_diff(options: SchemaDiffPreparationOptions) -> SchemaDiffPreparation { if !options.field_mappings.is_empty() { log::info!("prepare_schema_diff field_mappings:"); for m in &options.field_mappings { log::info!( " {} -> {} (strategy={:?}, custom={:?})", m.source_type, m.target_type, m.param_strategy, m.custom_params ); } log::info!(" source_dialect={:?} target_dialect={:?}", options.source_dialect, options.target_dialect); } let dialect_str = options.source_dialect.map(|d| d.label().to_string()).unwrap_or_else(|| "generic".to_string()); let options = AstTransmitFilter::filter_diff_preparation_options(options, &dialect_str); let dep_graph = DependencyGraph::build(&options.source_details, &options.source_tables); let mut diffs = if let Some(ref strategy) = options.shard_strategy { shard_diff(&options, strategy) } else { diff_schema(&options) }; let rename_candidates = if options.detect_renames && options.detect_table_renames { let removed: Vec = diffs.iter().filter(|d| d.diff_type == "removed").map(|d| d.name.clone()).collect(); let added: Vec = diffs.iter().filter(|d| d.diff_type == "added").map(|d| d.name.clone()).collect(); let candidates = detect_renames( &removed, &added, &options.source_details, &options.target_details, options.rename_threshold, ); let target_renamed: HashSet<&str> = candidates.iter().map(|r| r.target_name.as_str()).collect(); let source_renamed: HashSet<&str> = candidates.iter().map(|r| r.source_name.as_str()).collect(); diffs.retain(|d| { !((d.diff_type == "removed" && target_renamed.contains(d.name.as_str())) || (d.diff_type == "added" && source_renamed.contains(d.name.as_str()))) }); for c in &candidates { let source_detail = options.source_details.iter().find(|d| d.name == c.source_name); let target_detail = options.target_details.iter().find(|d| d.name == c.target_name); diffs.push(TableDiff { diff_type: "renamed".to_string(), object_type: Some("table".to_string()), name: c.source_name.clone(), columns: None, indexes: None, foreign_keys: None, triggers: None, ddl: source_detail.and_then(|d| d.ddl.clone()), target_ddl: target_detail.and_then(|d| d.ddl.clone()), source_table_comment: None, target_table_comment: None, sync_sql: None, }); } candidates } else { Vec::new() }; let compatibility_warnings = if options.source_dialect.is_some() || options.target_dialect.is_some() { let src_dialect = options.source_dialect.unwrap_or(DialectKind::Mysql); let tgt_dialect = options.target_dialect.unwrap_or(DialectKind::Mysql); let mut all_warnings = Vec::new(); for diff in &diffs { if diff.diff_type == "modified" { if let Some(source_detail) = options.source_details.iter().find(|d| d.name == diff.name) { if let Some(target_detail) = options.target_details.iter().find(|d| d.name == diff.name) { let (_, warnings) = diff_columns_with_compatibility( &source_detail.columns, &target_detail.columns, options.ignore_comments, options.compare_column_order, src_dialect, tgt_dialect, options.compatibility_threshold, &options.field_mappings, ); all_warnings.extend(warnings); } } } } all_warnings } else { Vec::new() }; let rollback_graph = if options.enable_rollback { let mut graph = RollbackGraph::from_forward_diffs(&diffs, &rename_candidates, &dep_graph); let _ = graph.validate_consistency(); Some(graph) } else { None }; 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); for diff in &mut diffs { let (sync_sql, _) = generate_schema_sync_sql_inner( std::slice::from_ref(diff), &[], &[], &[], &[], options.database_type, options.target_schema.as_deref(), options.cascade_delete, options.source_dialect, &options.field_mappings, ); if !sync_sql.is_empty() { diff.sync_sql = Some(sync_sql); } } let (sync_sql, _) = generate_schema_sync_sql_inner( &diffs, &function_diffs, &sequence_diffs, &rule_diffs, &owner_diffs, options.database_type, options.target_schema.as_deref(), options.cascade_delete, options.source_dialect, &options.field_mappings, ); let (rollback_sync_sql, missing_rollback_objects) = match &rollback_graph { Some(graph) => { let (sql, missing) = generate_rollback_sync_sql_with_missing( graph, options.database_type, options.target_schema.as_deref(), options.cascade_delete, ); (Some(sql), missing) } None => (None, Vec::new()), }; let rollback_completeness = if missing_rollback_objects.is_empty() { RollbackCompleteness::Complete } else { RollbackCompleteness::Incomplete }; let permission_diffs = if !options.source_permissions.is_empty() || !options.target_permissions.is_empty() { diff_permissions(&options.source_permissions, &options.target_permissions) } else { Vec::new() }; let permission_sync_sql = if !permission_diffs.is_empty() { Some(generate_permission_sync_sql(&permission_diffs, options.database_type, options.target_schema.as_deref())) } else { None }; SchemaDiffPreparation { diffs, function_diffs, sequence_diffs, rule_diffs, owner_diffs, sync_sql, rollback_sync_sql, rollback_completeness, missing_rollback_objects, rename_candidates, rollback_graph, compatibility_warnings, permission_diffs, permission_sync_sql, dependency_graph: Some(dep_graph), } } 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.contains("VIEW")) .map(|table| table.name.clone()) .collect(); let target_table_names: Vec = options .target_tables .iter() .filter(|table| !table.table_type.contains("VIEW")) .map(|table| table.name.clone()) .collect(); let source_view_names: Vec = options .source_tables .iter() .filter(|table| table.table_type.contains("VIEW")) .map(|table| table.name.clone()) .collect(); let target_view_names: Vec = options .target_tables .iter() .filter(|table| table.table_type.contains("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 { let source_detail = source_details.get(name.as_str()); result.push(TableDiff { diff_type: "added".to_string(), object_type: Some("table".to_string()), name, ddl: source_detail.and_then(|detail| detail.ddl.clone()), target_ddl: None, columns: source_detail.map(|detail| { detail .columns .iter() .map(|c| ColumnDiff { diff_type: "added".to_string(), name: c.name.clone(), source: Some(c.clone()), target: None, changes: vec![], }) .collect() }), indexes: source_detail.map(|detail| { detail .indexes .iter() .map(|i| IndexDiff { diff_type: "added".to_string(), name: i.name.clone(), source: Some(i.clone()), target: None, changes: vec![], }) .collect() }), foreign_keys: source_detail.map(|detail| { detail .foreign_keys .iter() .map(|fk| ForeignKeyDiff { diff_type: "added".to_string(), name: fk.name.clone(), source: Some(fk.clone()), target: None, changes: vec![], }) .collect() }), triggers: source_detail.and_then(|detail| { if detail.triggers.is_empty() { None } else { Some( detail .triggers .iter() .map(|t| TriggerDiff { diff_type: "added".to_string(), name: t.name.clone(), source: Some(t.clone()), target: None, changes: vec![], }) .collect(), ) } }), source_table_comment: None, target_table_comment: None, sync_sql: None, }); } for name in removed { let name_clone = name.clone(); let target_detail = target_details.get(name_clone.as_str()).copied(); result.push(TableDiff { diff_type: "removed".to_string(), object_type: Some("table".to_string()), name, columns: target_detail.map(|detail| { detail .columns .iter() .map(|column| ColumnDiff { diff_type: "removed".to_string(), name: column.name.clone(), source: None, target: Some(column.clone()), changes: vec![], }) .collect() }), indexes: target_detail.map(|detail| { detail .indexes .iter() .map(|index| IndexDiff { diff_type: "removed".to_string(), name: index.name.clone(), source: None, target: Some(index.clone()), changes: vec![], }) .collect() }), foreign_keys: target_detail.map(|detail| { detail .foreign_keys .iter() .map(|foreign_key| ForeignKeyDiff { diff_type: "removed".to_string(), name: foreign_key.name.clone(), source: None, target: Some(foreign_key.clone()), changes: vec![], }) .collect() }), triggers: target_detail.map(|detail| { detail .triggers .iter() .map(|trigger| TriggerDiff { diff_type: "removed".to_string(), name: trigger.name.clone(), source: None, target: Some(trigger.clone()), changes: vec![], }) .collect() }), ddl: None, target_ddl: target_detail.and_then(|detail| detail.ddl.clone()), source_table_comment: None, target_table_comment: target_table_comments.get(name_clone.as_str()).cloned(), sync_sql: 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, sync_sql: 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, sync_sql: 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, options.compare_column_order, options.detect_renames, options.rename_threshold, ); 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 }, sync_sql: 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, false, false, 0.5) } fn column_type_similarity_score(source_type: &str, target_type: &str) -> f64 { let s = ColumnType::parse(source_type).base_type.to_ascii_lowercase(); let t = ColumnType::parse(target_type).base_type.to_ascii_lowercase(); if s == t { return 1.0; } let exact_matches = [ ("int", "integer"), ("integer", "int"), ("float", "real"), ("real", "float"), ("double", "double precision"), ("double precision", "double"), ("bool", "boolean"), ("boolean", "bool"), ("timestamp", "datetime"), ("datetime", "timestamp"), ]; if exact_matches.contains(&(s.as_str(), t.as_str())) { return 1.0; } let integer_family = ["tinyint", "smallint", "mediumint", "int", "integer", "bigint", "serial", "bigserial"]; let text_family = ["char", "varchar", "text", "tinytext", "mediumtext", "longtext", "clob", "nclob"]; if integer_family.contains(&s.as_str()) && integer_family.contains(&t.as_str()) { return 0.8; } if text_family.contains(&s.as_str()) && text_family.contains(&t.as_str()) { return 0.8; } 0.0 } fn diff_columns_with_options( source: &[ColumnInfo], target: &[ColumnInfo], ignore_comments: bool, compare_column_order: bool, detect_renames: bool, rename_threshold: f64, ) -> 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(); let target_position_map: HashMap<&str, usize> = target.iter().enumerate().map(|(index, column)| (column.name.as_str(), index)).collect(); let can_compare_order = compare_column_order && source.len() == target.len() && source.iter().all(|column| target_map.contains_key(column.name.as_str())); for (source_index, source_column) in source.iter().enumerate() { 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 can_compare_order { if let Some(target_index) = target_position_map.get(source_column.name.as_str()) { if source_index != *target_index { changes.push(format!("order: {} → {}", *target_index + 1, source_index + 1)); } } } 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(), }); } } if detect_renames && rename_threshold > 0.0 { let removed_indices: Vec = diffs.iter().enumerate().filter(|(_, d)| d.diff_type == "removed").map(|(i, _)| i).collect(); let added_indices: Vec = diffs.iter().enumerate().filter(|(_, d)| d.diff_type == "added").map(|(i, _)| i).collect(); let mut matched_added: HashSet = HashSet::new(); let mut matched_removed: HashSet = HashSet::new(); let mut rename_pairs: Vec<(usize, usize, f64)> = Vec::new(); for &ri in &removed_indices { if let Some(removed_col) = &diffs[ri].target { let mut best_score = 0.0_f64; let mut best_ai = None; for &ai in &added_indices { if matched_added.contains(&ai) { continue; } if let Some(added_col) = &diffs[ai].source { let type_score = column_type_similarity_score(&removed_col.data_type, &added_col.data_type); if type_score < rename_threshold { continue; } let mut score = type_score; if removed_col.is_nullable == added_col.is_nullable { score *= 1.0; } else { score *= 0.8; } if score > best_score { best_score = score; best_ai = Some(ai); } } } if let Some(ai) = best_ai { rename_pairs.push((ri, ai, best_score)); matched_removed.insert(ri); matched_added.insert(ai); } } } for (ri, ai, _score) in &rename_pairs { let old_name = diffs[*ri].name.clone(); let old_col = diffs[*ri].target.clone().unwrap(); let new_col = diffs[*ai].source.clone().unwrap(); let new_name = new_col.name.clone(); diffs[*ri] = ColumnDiff { diff_type: "renamed".to_string(), name: new_name.clone(), source: Some(new_col), target: Some(old_col), changes: vec![format!("{} → {}", old_name, new_name)], }; diffs[*ai] = ColumnDiff { diff_type: "_matched_rename".to_string(), name: String::new(), source: None, target: None, changes: Vec::new(), }; } diffs.retain(|d| d.diff_type != "_matched_rename"); } 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 } fn normalized_foreign_key_action(action: Option<&str>) -> Option { action .map(|value| value.split_whitespace().collect::>().join(" ").to_ascii_uppercase()) .filter(|value| !value.is_empty()) } 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)); } let source_on_delete = normalized_foreign_key_action(source_fk.on_delete.as_deref()); let target_on_delete = normalized_foreign_key_action(target_fk.on_delete.as_deref()); if source_on_delete != target_on_delete { changes.push(format!( "delete: {} → {}", target_on_delete.as_deref().unwrap_or(""), source_on_delete.as_deref().unwrap_or("") )); } let source_on_update = normalized_foreign_key_action(source_fk.on_update.as_deref()); let target_on_update = normalized_foreign_key_action(target_fk.on_update.as_deref()); if source_on_update != target_on_update { changes.push(format!( "update: {} → {}", target_on_update.as_deref().unwrap_or(""), source_on_update.as_deref().unwrap_or("") )); } 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 } /// 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 pub(crate) 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 { profile_for(db_type).quote_ident(name) } fn column_def(col: &ColumnInfo, db_type: DatabaseType) -> String { let profile = profile_for(db_type); 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 profile.inline_column_comment { 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(str::trim) .filter(|schema| !schema.is_empty()) .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 profile = profile_for(db_type); let table = qualified_name(table_name, db_type, schema); let index = qualified_name(index_name, db_type, schema); if profile.drop_index_uses_on_table { format!("DROP INDEX {} ON {table};", quote_id(index_name, db_type)) } else { format!("DROP INDEX IF EXISTS {index};") } } fn mysql_index_column_sql(column: &str) -> String { let trimmed = column.trim(); // MySQL metadata represents a functional key part as an expression wrapped for CREATE INDEX. if trimmed.starts_with("((") && trimmed.ends_with("))") { trimmed.to_string() } else { quote_id(column, DatabaseType::Mysql) } } fn create_index_sql(table_name: &str, index: &IndexInfo, db_type: DatabaseType, schema: Option<&str>) -> String { use crate::sql_dialect::ddl_profile::IndexTypePlacement; let profile = profile_for(db_type); let table = qualified_name(table_name, db_type, schema); let columns = index .columns .iter() .map(|column| { if db_type == DatabaseType::Mysql { mysql_index_column_sql(column) } else { 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 (type_prefix, using_before_on, using_suffix) = if index_type.is_empty() { (String::new(), String::new(), String::new()) } else { match profile.index_type_placement { IndexTypePlacement::None => (String::new(), String::new(), String::new()), IndexTypePlacement::TypePrefix => (format!("{index_type} "), String::new(), String::new()), IndexTypePlacement::UsingBeforeOn => (String::new(), format!(" USING {index_type}"), String::new()), IndexTypePlacement::UsingSuffix => (String::new(), String::new(), format!(" USING {index_type}")), } }; let included_columns = index.included_columns.clone().unwrap_or_default(); let include_clause = if !included_columns.is_empty() && profile.index_supports_include { format!( " INCLUDE ({})", included_columns.iter().map(|column| quote_id(column, db_type)).collect::>().join(", ") ) } else { String::new() }; let filter = if profile.index_supports_filter { 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() && profile.index_supports_comment { format!(" COMMENT {}", comment_literal(comment)) } else { String::new() }; // MySQL-style puts USING before ON and omits INCLUDE/WHERE placement used by PG/SS. if profile.drop_index_uses_on_table { format!( "CREATE {unique}{type_prefix}INDEX {}{using_before_on} ON {table} ({columns}){comment_clause};", quote_id(&index.name, db_type) ) } else { format!( "CREATE {unique}{type_prefix}INDEX {} ON {table}{using_suffix} ({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 profile = profile_for(db_type); let table = qualified_name(table_name, db_type, schema); let fk = quote_id(fk_name, db_type); if profile.drop_fk_as_foreign_key { 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); let ref_table = qualified_name(&fk.ref_table, db_type, fk.ref_schema.as_deref().or(schema)); let on_delete = fk.on_delete.as_ref().map(|action| format!(" ON DELETE {action}")).unwrap_or_default(); let on_update = fk.on_update.as_ref().map(|action| format!(" ON UPDATE {action}")).unwrap_or_default(); format!( "ALTER TABLE {table} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {ref_table} ({}){on_delete}{on_update};", quote_id(&fk.name, db_type), quote_id(&fk.column, 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 { let profile = profile_for(db_type); if profile.column_comment_via_modify_only { return format!("-- Column comment for {column_name}: use ALTER TABLE ... MODIFY COLUMN to set comment"); } 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 profile = profile_for(db_type); let table = qualified_name(table_name, db_type, schema); if profile.table_comment_via_alter { format!("ALTER TABLE {table} COMMENT = {};", comment_literal(comment)) } else { format!("COMMENT ON TABLE {table} IS {};", comment_literal(comment)) } } fn create_trigger_sql( profile: &crate::sql_dialect::ddl_profile::DdlDialectProfile, name: &str, timing: &str, event: &str, table: &str, body: &str, ) -> String { use crate::sql_dialect::ddl_profile::TriggerTemplate; let qname = profile.quote_ident(name); match profile.trigger_template { TriggerTemplate::MysqlStyle => { format!("CREATE TRIGGER {qname} {timing} {event} ON {table} FOR EACH ROW BEGIN\n{body} END;") } TriggerTemplate::PostgresStyle => { format!( "CREATE TRIGGER {qname} {timing} {event} ON {table} FOR EACH ROW EXECUTE FUNCTION {};", body.trim_end_matches(';') ) } TriggerTemplate::SqlServerStyle => { format!("CREATE TRIGGER {qname} ON {table} {timing} {event} AS BEGIN {body} END;") } TriggerTemplate::GenericRowBody => { format!("CREATE TRIGGER {qname} {timing} {event} ON {table} FOR EACH ROW BEGIN {body} END;") } } } fn generate_create_table_sql( name: &str, columns: &[ColumnDiff], indexes: &[IndexDiff], foreign_keys: &[ForeignKeyDiff], table_comment: Option<&str>, db_type: DatabaseType, schema: Option<&str>, source_dialect: Option, field_mappings: &[FieldMapping], triggers: &[TriggerInfo], ) -> (String, Vec) { let mut lines = Vec::new(); let target_dialect = DialectKind::from_database_type(db_type); let profile = profile_for(db_type); // Type rewrite: user mappings → profile type_map → DialectKind matrix → normalize. // Call sites must not branch on individual DatabaseType values. let map_type = |source_type: &str| -> String { if let Some(user_target) = FieldMapping::apply_with_params(field_mappings, source_type, target_dialect) { return user_target; } rewrite_column_type(source_type, db_type, source_dialect) }; let table = qualified_name(name, db_type, schema); // Collect column definitions let mut col_defs = Vec::new(); let mut pk_cols = Vec::new(); let mut has_int_pk = false; let mut auto_col_name: Option = None; for col_diff in columns { let Some(col) = &col_diff.source else { continue; }; let col_name = quote_id(&col.name, db_type); let mapped_type = map_type(&col.data_type); let is_int = type_looks_integer(&mapped_type); let auto_build = apply_auto_inc_to_column_def(&profile, &col_name, &mapped_type, col, is_int); match auto_build { AutoIncColumnBuild::Complete { def, .. } => { col_defs.push(def); if col.is_primary_key { pk_cols.push(col_name); } continue; } AutoIncColumnBuild::AppendSuffix { suffix, skip_default, postgres_sequence } => { let mut def = format!("{} {}", col_name, mapped_type); if !col.is_nullable { def.push_str(" NOT NULL"); } if !skip_default { if let Some(default) = &col.column_default { def.push_str(&format!(" DEFAULT {default}")); } } if profile.inline_column_comment { if let Some(comment) = col.comment.as_deref().filter(|c| !c.is_empty()) { def.push_str(&format!(" COMMENT {}", comment_literal(comment))); } } if !suffix.is_empty() { def.push_str(suffix); } if postgres_sequence { has_int_pk = true; auto_col_name = Some(col.name.clone()); } col_defs.push(def); if col.is_primary_key { pk_cols.push(quote_id(&col.name, db_type)); } } AutoIncColumnBuild::Normal { skip_default } => { let mut def = format!("{} {}", col_name, mapped_type); if !col.is_nullable { def.push_str(" NOT NULL"); } if !skip_default { if let Some(default) = &col.column_default { def.push_str(&format!(" DEFAULT {default}")); } } if profile.inline_column_comment { if let Some(comment) = col.comment.as_deref().filter(|c| !c.is_empty()) { def.push_str(&format!(" COMMENT {}", comment_literal(comment))); } } col_defs.push(def); if col.is_primary_key { pk_cols.push(quote_id(&col.name, db_type)); } } } } if profile.foreign_keys_inline_in_create { for fk_diff in foreign_keys { let Some(fk) = &fk_diff.source else { continue; }; let ref_table = qualified_name(&fk.ref_table, db_type, fk.ref_schema.as_deref().or(schema)); let on_delete = fk.on_delete.as_ref().map(|action| format!(" ON DELETE {action}")).unwrap_or_default(); let on_update = fk.on_update.as_ref().map(|action| format!(" ON UPDATE {action}")).unwrap_or_default(); col_defs.push(format!( "CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {}({}){}{}", quote_id(&fk.name, db_type), quote_id(&fk.column, db_type), ref_table, quote_id(&fk.ref_column, db_type), on_delete, on_update )); } } let mut create = format!("CREATE TABLE {} (\n", table); create.push_str(&format!(" {}", col_defs.join(",\n "))); if !pk_cols.is_empty() { create.push_str(&format!(",\n PRIMARY KEY ({})", pk_cols.join(", "))); } create.push_str("\n);"); lines.push(format!("-- Create table: {}", name)); lines.push(create); lines.push(String::new()); // Postgres identity / sequence if has_int_pk { if let Some(seq_col) = auto_col_name { let seq_name = format!("{}_{}_seq", name, seq_col); let quoted_seq = quote_id(&seq_name, db_type); let quoted_col = quote_id(&seq_col, db_type); lines.push(format!("CREATE SEQUENCE IF NOT EXISTS {} OWNED BY {}.{};", quoted_seq, table, quoted_col)); lines.push(format!( "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT nextval('{}');", table, quoted_col, seq_name )); lines.push(format!("ALTER SEQUENCE {} START WITH 1;", quoted_seq)); lines.push(String::new()); } } // Indexes for idx_diff in indexes { let Some(idx) = &idx_diff.source else { continue; }; if idx.is_primary { continue; } lines.push(create_index_sql(name, idx, db_type, schema)); } if !indexes.is_empty() { lines.push(String::new()); } // Foreign Keys (skipped when already inlined into CREATE TABLE via profile) for fk_diff in foreign_keys { if profile.foreign_keys_inline_in_create { continue; } let Some(fk) = &fk_diff.source else { continue; }; let fk_name = quote_id(&fk.name, db_type); let fk_col = quote_id(&fk.column, db_type); let ref_table = qualified_name(&fk.ref_table, db_type, fk.ref_schema.as_deref().or(schema)); let ref_col = quote_id(&fk.ref_column, db_type); let on_delete = fk.on_delete.as_ref().map(|a| format!(" ON DELETE {}", a)).unwrap_or_default(); let on_update = fk.on_update.as_ref().map(|a| format!(" ON UPDATE {}", a)).unwrap_or_default(); lines.push(format!( "ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {}({}){}{};", table, fk_name, fk_col, ref_table, ref_col, on_delete, on_update )); } if !foreign_keys.is_empty() { lines.push(String::new()); } // Column comments (ANSI COMMENT ON … when profile does not use inline COMMENT) for col_diff in columns { let Some(col) = &col_diff.source else { continue; }; if let Some(comment) = &col.comment { if !comment.is_empty() { let col_name = quote_id(&col.name, db_type); let esc_comment = comment.replace('\'', "''"); if !profile.inline_column_comment { lines.push(format!("COMMENT ON COLUMN {}.{} IS '{}';", table, col_name, esc_comment)); } } } } // Table comment if let Some(comment) = table_comment { if !comment.is_empty() { lines.push(table_comment_sql(name, comment, db_type, schema)); } } // Trigger recreation — collect structured missing objects (do not rely on SQL comments alone). let mut missing: Vec = Vec::new(); if !triggers.is_empty() { lines.push(String::new()); for trigger in triggers { let event_desc = if trigger.event.to_uppercase().contains("INSERT") { "INSERT" } else if trigger.event.to_uppercase().contains("UPDATE") { "UPDATE" } else if trigger.event.to_uppercase().contains("DELETE") { "DELETE" } else { &trigger.event }; let timing = &trigger.timing; if let Some(stmt) = &trigger.statement { if !stmt.trim().is_empty() { lines.push(create_trigger_sql(&profile, &trigger.name, timing, event_desc, &table, stmt)); } else { missing.push(MissingRollbackObject { kind: "trigger".to_string(), name: trigger.name.clone(), table: Some(name.to_string()), reason: "trigger body is empty; cannot reconstruct CREATE TRIGGER".to_string(), }); } } else { missing.push(MissingRollbackObject { kind: "trigger".to_string(), name: trigger.name.clone(), table: Some(name.to_string()), reason: "trigger statement/body missing from schema snapshot".to_string(), }); } } if !missing.is_empty() { lines.push(String::new()); lines.push(format!( "-- WARNING: Rollback DDL is INCOMPLETE — one or more triggers on table '{}' could not be reconstructed.", name )); lines.push("-- Manual intervention required before executing this rollback script.".to_string()); for m in &missing { lines.push(format!("-- missing {}: {} ({})", m.kind, m.name, m.reason)); } } } (lines.join("\n"), missing) } #[allow(clippy::too_many_arguments)] 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, source_dialect: Option, field_mappings: &[FieldMapping], ) -> String { generate_schema_sync_sql_inner( diffs, function_diffs, sequence_diffs, rule_diffs, owner_diffs, db_type, schema, cascade_delete, source_dialect, field_mappings, ) .0 } fn generate_schema_sync_sql_inner( diffs: &[TableDiff], function_diffs: &[FunctionDiff], sequence_diffs: &[SequenceDiff], rule_diffs: &[RuleDiff], owner_diffs: &[OwnerDiff], db_type: DatabaseType, schema: Option<&str>, cascade_delete: bool, source_dialect: Option, field_mappings: &[FieldMapping], ) -> (String, Vec) { let mut lines = Vec::new(); let mut missing_objects: Vec = Vec::new(); let profile = profile_for(db_type); let cascade = if cascade_delete { " CASCADE" } else { "" }; let map_type = |source_type: &str| -> String { let tgt = DialectKind::from_database_type(db_type); if let Some(user_target) = FieldMapping::apply_with_params(field_mappings, source_type, tgt) { return user_target; } rewrite_column_type(source_type, db_type, source_dialect) }; for diff in diffs { let table = qualified_name(&diff.name, db_type, schema); if diff.diff_type == "added" && diff.object_type.as_deref() != Some("view") { let has_structured_snapshot = diff.columns.as_ref().is_some_and(|columns| !columns.is_empty()); let is_rollback_recreation = diff.ddl.is_none() && diff.target_ddl.is_some(); let is_same_dialect = source_dialect.map(|src| DialectKind::from_database_type(db_type) == src).unwrap_or(false); if is_rollback_recreation { if has_structured_snapshot { let trigger_infos: Vec = diff .triggers .as_ref() .map_or_else(Vec::new, |triggers| triggers.iter().filter_map(|t| t.source.clone()).collect()); let (generated, missing) = generate_create_table_sql( &diff.name, diff.columns.as_ref().map_or(&[] as &[ColumnDiff], |columns| columns.as_slice()), diff.indexes.as_ref().map_or(&[] as &[IndexDiff], |indexes| indexes.as_slice()), diff.foreign_keys .as_ref() .map_or(&[] as &[ForeignKeyDiff], |foreign_keys| foreign_keys.as_slice()), diff.source_table_comment.as_ref().and_then(|comment| comment.as_deref()), db_type, schema, None, field_mappings, &trigger_infos, ); if !generated.is_empty() { lines.push(generated); } missing_objects.extend(missing); } else if let Some(ddl) = diff.target_ddl.as_deref() { // Inversion places only the removed target table's native // DDL here, validating that it belongs to the dialect restored. lines.push(format!("-- Recreate table from native target DDL: {}", diff.name)); lines.push(format!("{};", ddl.trim_end_matches(';'))); lines.push(String::new()); } } else if is_same_dialect || (source_dialect.is_none() && diff.ddl.is_some() && (profile.prefers_native_source_ddl || !has_structured_snapshot)) { // Prefer native source DDL when the target profile wants it // (MySQL-family), or as fallback without a structured snapshot. if let Some(ddl) = &diff.ddl { lines.push(format!("-- Create {}: {}", diff.object_type.as_deref().unwrap_or("table"), diff.name)); lines.push(format!("{};", ddl)); lines.push(String::new()); } else if let Some(cols) = &diff.columns { let trigger_infos: Vec = diff .triggers .as_ref() .map_or_else(Vec::new, |triggers| triggers.iter().filter_map(|t| t.source.clone()).collect()); let (gen, missing) = generate_create_table_sql( &diff.name, cols, diff.indexes.as_ref().map_or(&[] as &[IndexDiff], |v| v.as_slice()), diff.foreign_keys.as_ref().map_or(&[] as &[ForeignKeyDiff], |v| v.as_slice()), diff.source_table_comment.as_ref().and_then(|c| c.as_deref()), db_type, schema, source_dialect, field_mappings, &trigger_infos, ); if !gen.is_empty() { lines.push(gen); } missing_objects.extend(missing); } } else if has_structured_snapshot { // Cross-dialect → generate CREATE TABLE from column info let _cols: &[ColumnDiff] = diff.columns.as_ref().map_or(&[] as &[ColumnDiff], |v| v.as_slice()); let _idxs: &[IndexDiff] = diff.indexes.as_ref().map_or(&[] as &[IndexDiff], |v| v.as_slice()); let _fks: &[ForeignKeyDiff] = diff.foreign_keys.as_ref().map_or(&[] as &[ForeignKeyDiff], |v| v.as_slice()); let trigger_infos: Vec = diff .triggers .as_ref() .map_or_else(Vec::new, |triggers| triggers.iter().filter_map(|t| t.source.clone()).collect()); let (gen, missing) = generate_create_table_sql( &diff.name, diff.columns.as_ref().map_or(&[] as &[ColumnDiff], |v| v.as_slice()), diff.indexes.as_ref().map_or(&[] as &[IndexDiff], |v| v.as_slice()), diff.foreign_keys.as_ref().map_or(&[] as &[ForeignKeyDiff], |v| v.as_slice()), diff.source_table_comment.as_ref().and_then(|c| c.as_deref()), db_type, schema, source_dialect, field_mappings, &trigger_infos, ); if !gen.is_empty() { lines.push(gen); } missing_objects.extend(missing); } 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(); let mut standalone_statements = 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 { let convert_col = |col: &ColumnInfo| -> ColumnInfo { ColumnInfo { data_type: map_type(&col.data_type), ..col.clone() } }; for column in columns { match column.diff_type.as_str() { "added" => { if let Some(source) = &column.source { parts.push(format!(" ADD COLUMN {}", column_def(&convert_col(source), db_type))); } } "removed" => { parts.push(format!(" DROP COLUMN {}", quote_id(&column.name, db_type))); } "modified" => { if let Some(source) = &column.source { let mapped = convert_col(source); if profile.alter_uses_modify_column { if column.changes.iter().any(|change| !change.starts_with("order:")) { parts.push(format!(" MODIFY COLUMN {}", column_def(&mapped, 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 {}", mapped.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") }); } } } } "renamed" => { if let (Some(source), Some(target_col)) = (&column.source, &column.target) { use crate::sql_dialect::ddl_profile::RenameColumnSyntax; let mapped = convert_col(source); match profile.rename_column { RenameColumnSyntax::MysqlChangeColumn => { let old_name = quote_id(&target_col.name, db_type); parts.push(format!( " CHANGE COLUMN {} {}", old_name, column_def(&mapped, db_type) )); } RenameColumnSyntax::RenameColumn => { let old_name = quote_id(&target_col.name, db_type); let new_name = quote_id(&column.name, db_type); parts.push(format!(" RENAME COLUMN {old_name} TO {new_name}")); if source.data_type.to_lowercase() != target_col.data_type.to_lowercase() { parts.push(format!(" ALTER COLUMN {new_name} TYPE {}", mapped.data_type)); } if source.is_nullable != target_col.is_nullable { let action = if source.is_nullable { "DROP NOT NULL" } else { "SET NOT NULL" }; parts.push(format!(" ALTER COLUMN {new_name} {action}")); } } RenameColumnSyntax::AlterColumnRenameTo => { let old_name = quote_id(&target_col.name, db_type); let new_name = quote_id(&column.name, db_type); parts.push(format!(" ALTER COLUMN {old_name} RENAME TO {new_name}")); if source.data_type.to_lowercase() != target_col.data_type.to_lowercase() { parts.push(format!( " ALTER COLUMN {new_name} SET DATA TYPE {}", mapped.data_type )); } } RenameColumnSyntax::SqlServerSpRename => { let target_table = qualified_name(&diff.name, db_type, schema); let full_obj_path = format!("{target_table}.{}", quote_id(&target_col.name, db_type)); standalone_statements.push(format!( "EXEC sp_rename '{}', '{}', 'COLUMN';", full_obj_path.replace('\'', "''"), column.name.replace('\'', "''") )); } } } } _ => {} } } } if !standalone_statements.is_empty() || !parts.is_empty() { lines.push(format!("-- Alter table: {}", diff.name)); lines.extend(standalone_statements); if !parts.is_empty() { if profile.alter_batches_clauses { 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 !profile.column_comment_via_modify_only { 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 column.diff_type == "renamed" { 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" { if let Some(source) = &fk.source { lines.push(add_foreign_key_sql(&diff.name, source, 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 profile.warn_fk_needs_table_rebuild && diff.foreign_keys.as_ref().is_some_and(|foreign_keys| !foreign_keys.is_empty()) { lines.push(format!("-- Foreign key synchronization may require table rebuild for: {}", diff.name)); lines.push(String::new()); } } // Function diffs — only emit executable SQL when profile has templates 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" | "modified" => { if let Some(source) = &diff.source { if let Some(template) = profile.function_create_template { let verb = if diff.diff_type == "added" { "Create" } else { "Alter" }; lines.push(format!("-- {verb} function: {}", diff.name)); let create_kw = if profile.create_function_or_replace { "CREATE OR REPLACE FUNCTION" } else { "CREATE FUNCTION" }; let name = qualified_name(&diff.name, db_type, schema); lines.push(DdlDialectProfile::render_template( template, &[("create_kw", create_kw), ("name", &name), ("definition", &source.definition)], )); } else { lines.push(format!( "-- Skip function {}: target database does not support function DDL generation", diff.name )); } } } "removed" => { if let Some(template) = profile.function_drop_template { lines.push(format!("-- Drop function: {}", diff.name)); let name = qualified_name(&diff.name, db_type, schema); lines.push(DdlDialectProfile::render_template( template, &[("name", &name), ("cascade", cascade)], )); } else { lines.push(format!("-- Skip drop function {}: unsupported on target", diff.name)); } } _ => {} } } } // 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 { if let Some(template) = profile.sequence_create_template { lines.push(format!("-- Create sequence: {}", diff.name)); let name = qualified_name(&diff.name, db_type, schema); let cycle = if source.cycle { "CYCLE" } else { "NO CYCLE" }; lines.push(DdlDialectProfile::render_template( template, &[ ("name", &name), ("data_type", &source.data_type), ("start_value", &source.start_value), ("increment", &source.increment), ("min_value", &source.min_value), ("max_value", &source.max_value), ("cycle", cycle), ], )); } else { lines.push(format!( "-- Skip sequence {}: target database does not support sequence DDL generation", diff.name )); } } } "removed" => { if let Some(template) = profile.sequence_drop_template { lines.push(format!("-- Drop sequence: {}", diff.name)); let name = qualified_name(&diff.name, db_type, schema); lines.push(DdlDialectProfile::render_template( template, &[("name", &name), ("cascade", cascade)], )); } else { lines.push(format!("-- Skip drop sequence {}: unsupported on target", diff.name)); } } "modified" => { if let Some(source) = &diff.source { if let Some(template) = profile.sequence_alter_template { lines.push(format!("-- Alter sequence: {}", diff.name)); let name = qualified_name(&diff.name, db_type, schema); let cycle = if source.cycle { "CYCLE" } else { "NO CYCLE" }; lines.push(DdlDialectProfile::render_template( template, &[ ("name", &name), ("data_type", &source.data_type), ("start_value", &source.start_value), ("increment", &source.increment), ("min_value", &source.min_value), ("max_value", &source.max_value), ("cycle", cycle), ], )); } else { lines.push(format!("-- Skip alter sequence {}: unsupported on target", diff.name)); } } } _ => {} } } } // Rule diffs (PostgreSQL RULE) if !rule_diffs.is_empty() { lines.push(String::new()); lines.push("-- Rules".to_string()); for diff in rule_diffs { if profile.rule_drop_template.is_none() && !profile.supports_rule_ddl { lines.push(format!("-- Skip rule {}: target database does not support RULE DDL", diff.name)); continue; } match diff.diff_type.as_str() { "added" => { if let Some(source) = &diff.source { if profile.supports_rule_ddl { lines.push(format!("-- Create rule: {}", diff.name)); lines.push(source.definition.clone()); } else { lines .push(format!("-- Skip rule {}: target database does not support RULE DDL", diff.name)); } } } "removed" => { if let Some(template) = profile.rule_drop_template { lines.push(format!("-- Drop rule: {}", diff.name)); // Removed diffs store the object on `target`; tests may put it on `source`. if let Some(rule) = diff.source.as_ref().or(diff.target.as_ref()) { let table_name = qualified_name(&rule.table_name, db_type, schema); lines.push(DdlDialectProfile::render_template( template, &[("rule_name", &diff.name), ("table_name", &table_name), ("cascade", cascade)], )); } } else { lines.push(format!("-- Skip rule {}: target database does not support RULE DDL", diff.name)); } } "modified" => { if let Some(source) = &diff.source { if let Some(template) = profile.rule_drop_template { lines.push(format!("-- Alter rule: {}", diff.name)); let table_name = qualified_name(&source.table_name, db_type, schema); lines.push(DdlDialectProfile::render_template( template, &[("rule_name", &diff.name), ("table_name", &table_name), ("cascade", cascade)], )); lines.push(source.definition.clone()); } else { lines .push(format!("-- Skip rule {}: target database does not support RULE DDL", diff.name)); } } } _ => {} } } } // 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) { if let Some(template) = profile.owner_alter_template { let object_type = match source.object_type.as_str() { "TABLE" => "TABLE", "VIEW" => "VIEW", "SEQUENCE" => "SEQUENCE", _ => "TABLE", }; let name = qualified_name(&diff.object_name, db_type, schema); lines.push(DdlDialectProfile::render_template( template, &[("object_type", object_type), ("name", &name), ("owner", &source.owner)], )); } else { lines.push(format!("-- Skip OWNER change for {}: unsupported on target", diff.object_name)); } } } } (lines.join("\n").trim().to_string(), missing_objects) } #[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, enum_values: None, character_set: None, collation: None, } } #[test] fn ignores_column_order_when_option_is_disabled() { let diffs = diff_columns_with_options( &[column("id", "int", None), column("name", "varchar(64)", None), column("status", "varchar(16)", None)], &[column("status", "varchar(16)", None), column("id", "int", None), column("name", "varchar(64)", None)], false, false, false, 0.5, ); assert!(diffs.is_empty()); } #[test] fn detects_column_order_when_option_is_enabled() { let diffs = diff_columns_with_options( &[column("id", "int", None), column("name", "varchar(64)", None), column("status", "varchar(16)", None)], &[column("status", "varchar(16)", None), column("id", "int", None), column("name", "varchar(64)", None)], false, true, false, 0.5, ); assert_eq!(diffs.len(), 3); assert_eq!(diffs[0].changes, vec!["order: 2 → 1"]); } #[test] fn detects_column_rename_with_same_type() { let source = vec![ column("id", "int", None), column("name2", "varchar(120)", None), column("del_flag", "tinyint", None), column("create_at", "datetime", None), ]; let target = vec![column("id", "int", None), column("name", "varchar(120)", None), column("del_flag", "tinyint", None)]; let diffs = diff_columns_with_options(&source, &target, false, false, true, 0.5); let renamed: Vec<_> = diffs.iter().filter(|d| d.diff_type == "renamed").collect(); let added: Vec<_> = diffs.iter().filter(|d| d.diff_type == "added").collect(); let removed: Vec<_> = diffs.iter().filter(|d| d.diff_type == "removed").collect(); assert_eq!(renamed.len(), 1, "should detect one renamed column"); assert_eq!(renamed[0].name, "name2"); assert_eq!(renamed[0].changes, vec!["name → name2"]); assert_eq!(added.len(), 1, "should have one truly added column (create_at)"); assert_eq!(added[0].name, "create_at"); assert!(removed.is_empty(), "should have no removed columns"); } #[test] fn detects_column_rename_with_compatible_type() { let source = vec![column("col_a", "varchar(64)", None), column("col_b", "int", None)]; let target = vec![column("col_a_old", "varchar(100)", None), column("col_b", "int", None)]; let diffs = diff_columns_with_options(&source, &target, false, false, true, 0.5); let renamed: Vec<_> = diffs.iter().filter(|d| d.diff_type == "renamed").collect(); assert_eq!(renamed.len(), 1, "should detect rename across varchar family"); assert_eq!(renamed[0].changes, vec!["col_a_old → col_a"]); } #[test] fn no_rename_detection_when_disabled() { let source = vec![ column("id", "int", None), column("name2", "varchar(120)", None), column("create_at", "datetime", None), ]; let target = vec![column("id", "int", None), column("name", "varchar(120)", None)]; let diffs = diff_columns_with_options(&source, &target, false, false, false, 0.5); let renamed: Vec<_> = diffs.iter().filter(|d| d.diff_type == "renamed").collect(); let added: Vec<_> = diffs.iter().filter(|d| d.diff_type == "added").collect(); let removed: Vec<_> = diffs.iter().filter(|d| d.diff_type == "removed").collect(); assert!(renamed.is_empty(), "should not detect renames when disabled"); assert_eq!(added.len(), 2); assert_eq!(removed.len(), 1); } #[test] fn rename_not_detected_with_unrelated_types() { let source = vec![column("col_a", "varchar(120)", None), column("col_b", "int", None)]; let target = vec![column("col_old", "int", None), column("col_b", "int", None)]; let diffs = diff_columns_with_options(&source, &target, false, false, true, 0.5); let renamed: Vec<_> = diffs.iter().filter(|d| d.diff_type == "renamed").collect(); assert!(renamed.is_empty(), "should not rename across unrelated types"); } #[test] fn rename_with_rollback_graph_inversion() { let source = vec![column("id", "int", None), column("new_name", "varchar(120)", None)]; let target = vec![column("id", "int", None), column("old_name", "varchar(120)", None)]; let diffs = diff_columns_with_options(&source, &target, false, false, true, 0.5); let inverted = RollbackGraph::invert_columns(&diffs); let renamed_inv: Vec<_> = inverted.iter().filter(|d| d.diff_type == "renamed").collect(); assert_eq!(renamed_inv.len(), 1, "inverted rename should exist"); assert_eq!(renamed_inv[0].name, "old_name"); assert_eq!(renamed_inv[0].changes, vec!["new_name → old_name"]); } // -- helpers ---------------------------------------------- fn make_col_diffs(source: &[(&str, &str)], target: &[(&str, &str)], detect_renames: bool) -> Vec { let s: Vec = source.iter().map(|(n, t)| column(n, t, None)).collect(); let t: Vec = target.iter().map(|(n, t)| column(n, t, None)).collect(); diff_columns_with_options(&s, &t, false, false, detect_renames, 0.5) } fn wrap_table_diff(name: &str, columns: Vec) -> TableDiff { TableDiff { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: name.to_string(), columns: Some(columns), indexes: None, foreign_keys: None, triggers: None, ddl: None, target_ddl: None, source_table_comment: None, target_table_comment: None, sync_sql: None, } } fn gen_sql(diff: TableDiff, db_type: DatabaseType, source_dialect: Option) -> String { generate_schema_sync_sql(&[diff], &[], &[], &[], &[], db_type, None, false, source_dialect, &[]) } // -- 1. Same-dialect: MySQL (backticks, MODIFY/CHANGE/ADD COLUMN) -- #[test] fn mysql_same_dialect_rename_and_add() { let diffs = make_col_diffs( &[("id", "int(11)"), ("name2", "varchar(120)"), ("del_flag", "tinyint(2)"), ("create_at", "datetime")], &[("id", "int"), ("name", "varchar(120)"), ("del_flag", "tinyint")], true, ); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Mysql, None); assert!(sql.contains("CHANGE COLUMN `name` `name2`"), "MySQL rename: {sql}"); assert!(sql.contains("ADD COLUMN `create_at`"), "MySQL new col: {sql}"); assert!(sql.contains("MODIFY COLUMN `id`"), "MySQL modify type: {sql}"); assert!(!sql.contains("DROP COLUMN"), "MySQL no drop: {sql}"); } #[test] fn mysql_same_dialect_add_drop_modified() { let diffs = make_col_diffs( &[("id", "int"), ("new_col", "varchar(50)")], &[("id", "bigint"), ("old_col", "int")], false, ); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Mysql, None); assert!(sql.contains("ADD COLUMN `new_col`"), "MySQL add: {sql}"); assert!(sql.contains("DROP COLUMN `old_col`"), "MySQL drop: {sql}"); assert!(sql.contains("MODIFY COLUMN `id`"), "MySQL modify: {sql}"); } // -- 2. Same-dialect: PostgreSQL (double-quotes, ALTER COLUMN … TYPE) -- #[test] fn postgresql_same_dialect_modify_and_rename() { let diffs = make_col_diffs( &[("id", "int"), ("name2", "varchar(120)"), ("create_at", "timestamp")], &[("id", "int"), ("name", "varchar(120)")], true, ); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Postgres, None); assert!(sql.contains("RENAME COLUMN"), "PG rename: {sql}"); assert!(sql.contains("ADD COLUMN"), "PG add: {sql}"); assert!(sql.contains("\"t\""), "PG double-quote table: {sql}"); assert!(!sql.contains('`'), "PG no backticks: {sql}"); } // -- 3. Cross-dialect type conversion: MySQL → PostgreSQL -- #[test] fn mysql_to_postgresql_type_conversion_full() { let diffs = make_col_diffs( &[("id", "int(11)"), ("name2", "varchar(120)"), ("flag", "tinyint(2)"), ("ts", "datetime")], &[("id", "integer"), ("name", "varchar(120)"), ("flag", "smallint")], true, ); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Postgres, Some(DialectKind::Mysql)); assert!(sql.contains("RENAME COLUMN"), "PG rename: {sql}"); assert!(sql.contains("ADD COLUMN"), "PG add: {sql}"); assert!(sql.contains("INTEGER"), "int(11)→INTEGER: {sql}"); assert!(sql.contains("SMALLINT"), "tinyint→SMALLINT: {sql}"); assert!(sql.contains("TIMESTAMP"), "datetime→TIMESTAMP: {sql}"); assert!(!sql.contains('`'), "PG no backticks: {sql}"); } #[test] fn mysql_to_postgresql_type_conversion_modified_only() { let diffs = make_col_diffs( &[("id", "int(11)"), ("amount", "decimal(10,2)"), ("created", "datetime")], &[("id", "integer"), ("amount", "numeric(10,2)"), ("created", "timestamp")], false, ); let sql_with = gen_sql(wrap_table_diff("t", diffs.clone()), DatabaseType::Postgres, Some(DialectKind::Mysql)); let sql_without = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Postgres, None); assert!(sql_with.contains("INTEGER"), "with: int(11)→INTEGER: {sql_with}"); assert!(sql_with.contains("TIMESTAMP"), "with: datetime→TIMESTAMP: {sql_with}"); // No source dialect: matrix skipped, but target profile still strips display width. assert!(sql_without.contains("INT"), "without: display width stripped: {sql_without}"); assert!(!sql_without.contains("int(11)"), "without: no MySQL display width: {sql_without}"); assert!(sql_without.contains("datetime"), "without: datetime preserved: {sql_without}"); } // -- 4. Reverse: PostgreSQL → MySQL -- #[test] fn postgresql_to_mysql_type_conversion_reverse() { let diffs = make_col_diffs( &[("id", "integer"), ("label", "text"), ("active", "boolean")], &[("id", "int"), ("label", "varchar(255)")], false, ); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Mysql, Some(DialectKind::Postgres)); assert!(sql.contains('`'), "MySQL backticks: {sql}"); assert!(sql.contains("MODIFY COLUMN"), "MySQL modify: {sql}"); assert!(sql.contains("LONGTEXT"), "text→LONGTEXT: {sql}"); assert!(sql.contains("TINYINT(1)"), "boolean→TINYINT(1): {sql}"); assert!(sql.contains("INT"), "integer→INT: {sql}"); } // -- 5. MySQL → SQLite type conversion -- #[test] fn mysql_to_sqlite_type_conversion() { let diffs = make_col_diffs( &[("id", "int(11)"), ("renamed", "varchar(255)")], &[("id", "integer"), ("old", "text")], true, ); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Sqlite, Some(DialectKind::Mysql)); assert!(sql.contains("RENAME COLUMN"), "SQLite rename: {sql}"); assert!(sql.contains("INTEGER"), "int(11)→INTEGER: {sql}"); assert!(!sql.contains('`'), "SQLite no backticks: {sql}"); } // -- 6. Database-specific rename syntax -- #[test] fn sqlserver_rename_uses_sp_rename() { let diffs = make_col_diffs( &[("id", "int"), ("new_name", "varchar(100)")], &[("id", "int"), ("old_name", "varchar(100)")], true, ); let sql = gen_sql(wrap_table_diff("orders", diffs), DatabaseType::SqlServer, None); assert!(sql.contains("sp_rename"), "SQL Server uses sp_rename: {sql}"); assert!(sql.contains("\"orders\""), "sp_rename table path: {sql}"); assert!(!sql.contains("ALTER TABLE \"orders\" EXEC sp_rename"), "sp_rename must be standalone: {sql}"); assert!(sql.lines().any(|line| line.starts_with("EXEC sp_rename")), "standalone sp_rename: {sql}"); assert!(!sql.contains('`'), "SQL Server no backticks: {sql}"); } #[test] fn h2_rename_uses_alter_column_rename_to() { let diffs = make_col_diffs( &[("id", "int"), ("new_name", "varchar(100)")], &[("id", "int"), ("old_name", "varchar(100)")], true, ); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::H2, None); assert!(sql.contains("ALTER COLUMN"), "H2 ALTER COLUMN: {sql}"); assert!(sql.contains("RENAME TO"), "H2 RENAME TO: {sql}"); } #[test] fn default_rename_all_other_databases() { let databases = [ DatabaseType::ClickHouse, DatabaseType::Oracle, DatabaseType::DuckDb, DatabaseType::Informix, DatabaseType::Questdb, ]; for db in databases { let diffs = make_col_diffs( &[("id", "int"), ("new_name", "varchar(100)")], &[("id", "int"), ("old_name", "varchar(100)")], true, ); let sql = gen_sql(wrap_table_diff("t", diffs), db, None); assert!(sql.contains("RENAME COLUMN"), "{db:?} uses RENAME COLUMN: {sql}"); assert!(!sql.contains('`'), "{db:?} no backticks: {sql}"); } } // -- 7. MySQL-like databases (Doris, StarRocks) -- #[test] fn mysql_like_databases_use_mysql_syntax() { let mysql_likes = [ DatabaseType::Doris, DatabaseType::StarRocks, DatabaseType::Goldendb, DatabaseType::Sundb, DatabaseType::Databend, DatabaseType::Gbase, ]; for db in mysql_likes { let diffs = make_col_diffs( &[("id", "int"), ("name2", "varchar(50)")], &[("id", "int"), ("name", "varchar(50)")], true, ); let sql = gen_sql(wrap_table_diff("t", diffs), db, None); assert!(sql.contains('`'), "{db:?} uses backticks: {sql}"); assert!(sql.contains("CHANGE COLUMN"), "{db:?} uses CHANGE COLUMN: {sql}"); } } // -- 8. No type conversion for unsupported dialect pairs -- #[test] fn mysql_to_unsupported_dialect_types_pass_through() { let targets = [ DatabaseType::SqlServer, DatabaseType::ClickHouse, DatabaseType::Oracle, DatabaseType::DuckDb, DatabaseType::Informix, DatabaseType::H2, DatabaseType::Questdb, ]; for target in targets { let diffs = make_col_diffs( &[("id", "int(11)"), ("name2", "varchar(50)")], &[("id", "integer"), ("name", "varchar(50)")], true, ); let sql = gen_sql(wrap_table_diff("t", diffs), target, Some(DialectKind::Mysql)); // No dialect-pair matrix for these targets; profile still strips MySQL display width. assert!(sql.contains("INT"), "{target:?} strips display width to INT: {sql}"); assert!(!sql.contains("int(11)"), "{target:?} no MySQL display width: {sql}"); assert!(!sql.contains('`'), "{target:?} no backticks: {sql}"); } } // -- 9. Passthrough when source_dialect is None -- #[test] fn without_source_dialect_types_preserved() { let diffs = make_col_diffs(&[("id", "int(11)"), ("flag", "tinyint")], &[("id", "bigint")], false); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Postgres, None); // No source dialect → no matrix mapping; PG profile still strips display width. assert!(sql.contains("INT"), "display width stripped to INT: {sql}"); assert!(!sql.contains("int(11)"), "no MySQL display width: {sql}"); assert!(sql.contains("tinyint"), "passthrough tinyint: {sql}"); } // -- 10. All MySQL→PostgreSQL type mapping rules -- #[test] fn mysql_to_postgresql_specific_type_conversions() { let cases = [ ("ti1", "tinyint(1)", "BOOLEAN"), ("tiny", "tinyint", "SMALLINT"), ("med", "mediumint", "INTEGER"), ("int_", "int", "INTEGER"), ("big", "bigint", "BIGINT"), ("flt", "float", "REAL"), ("dbl", "double", "DOUBLE PRECISION"), ("txs", "tinytext", "TEXT"), ("tx", "text", "TEXT"), ("txm", "mediumtext", "TEXT"), ("txl", "longtext", "TEXT"), ("blb", "blob", "BYTEA"), ("bls", "tinyblob", "BYTEA"), ("blm", "mediumblob", "BYTEA"), ("bll", "longblob", "BYTEA"), ("dt", "datetime", "TIMESTAMP"), ]; for (name, mysql_type, expected_pg) in cases { let diffs = make_col_diffs(&[(name, mysql_type)], &[], false); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Postgres, Some(DialectKind::Mysql)); assert!(sql.contains(expected_pg), "MySQL {mysql_type} → PostgreSQL {expected_pg}: {sql}"); } } // -- 11. PostgreSQL→MySQL all reverse type mappings -- #[test] fn postgresql_to_mysql_all_type_mappings() { let mappings = [ ("sml", "smallint", "SMALLINT"), ("int_", "integer", "INT"), ("big", "bigint", "BIGINT"), ("real", "real", "FLOAT"), ("dp", "double precision", "DOUBLE"), ("tx", "text", "LONGTEXT"), ("ba", "bytea", "BLOB"), ("bl", "boolean", "TINYINT(1)"), ("ts", "timestamp", "DATETIME"), ("tstz", "timestamptz", "DATETIME"), ("uuid", "uuid", "CHAR(36)"), ("jb", "jsonb", "JSON"), ]; for (name, pg_type, expected_mysql) in mappings { let diffs = make_col_diffs(&[(name, pg_type)], &[], false); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Mysql, Some(DialectKind::Postgres)); assert!(sql.contains(expected_mysql), "PG {pg_type} → MySQL {expected_mysql}: {sql}"); } } // -- 12. MySQL→SQLite all type mappings -- #[test] fn mysql_to_sqlite_all_type_mappings() { let mappings = [ ("int", "int", "INTEGER"), ("bi", "bigint", "INTEGER"), ("ti", "tinyint", "INTEGER"), ("si", "smallint", "INTEGER"), ("mi", "mediumint", "INTEGER"), ("db", "double", "REAL"), ("fl", "float", "REAL"), ("dt", "datetime", "TEXT"), ("ts", "timestamp", "TEXT"), ("tx", "text", "TEXT"), ("bl", "blob", "BLOB"), ]; for (name, mysql_type, expected_sqlite) in mappings { let diffs = make_col_diffs(&[(name, mysql_type)], &[], false); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Sqlite, Some(DialectKind::Mysql)); assert!(sql.contains(expected_sqlite), "MySQL {mysql_type} → SQLite {expected_sqlite}: {sql}"); } } // -- 13. Same-dialect: ClickHouse (double-quotes, default rename) -- #[test] fn clickhouse_same_dialect_operations() { let diffs = make_col_diffs(&[("id", "Int32"), ("new_col", "String")], &[("id", "Int32")], false); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::ClickHouse, None); assert!(sql.contains("ADD COLUMN"), "ClickHouse add: {sql}"); assert!(!sql.contains('`'), "ClickHouse no backticks: {sql}"); } // -- 14. Same-dialect: Oracle -- #[test] fn oracle_same_dialect_operations() { let diffs = make_col_diffs(&[("id", "NUMBER"), ("name", "VARCHAR2(100)")], &[("id", "NUMBER")], false); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Oracle, None); assert!(sql.contains("ADD COLUMN"), "Oracle add: {sql}"); assert!(!sql.contains('`'), "Oracle no backticks: {sql}"); } // -- 15. Same-dialect: SQL Server with rename -- #[test] fn sqlserver_same_dialect_rename() { let diffs = make_col_diffs( &[("id", "int"), ("new_name", "nvarchar(100)")], &[("id", "int"), ("old_name", "nvarchar(100)")], true, ); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::SqlServer, None); assert!(sql.contains("sp_rename"), "SQL Server rename: {sql}"); } // -- 16. Same-dialect: H2 -- #[test] fn h2_same_dialect_rename() { let diffs = make_col_diffs( &[("id", "INT"), ("new_name", "VARCHAR(100)")], &[("id", "INT"), ("old_name", "VARCHAR(100)")], true, ); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::H2, None); assert!(sql.contains("ALTER COLUMN"), "H2 rename: {sql}"); assert!(sql.contains("RENAME TO"), "H2 rename to: {sql}"); } // -- 17. Same-dialect: DuckDB -- #[test] fn duckdb_same_dialect_add() { let diffs = make_col_diffs(&[("id", "INTEGER"), ("name", "VARCHAR")], &[("id", "INTEGER")], false); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::DuckDb, None); assert!(sql.contains("ADD COLUMN"), "DuckDB add: {sql}"); assert!(!sql.contains('`'), "DuckDB no backticks: {sql}"); } // -- 18. Schema-qualified SQL output -- #[test] fn schema_qualified_output() { let diffs = make_col_diffs(&[("name2", "varchar(50)")], &[("name", "varchar(50)")], true); let table_diff = wrap_table_diff("users", diffs); let sql = generate_schema_sync_sql( &[table_diff], &[], &[], &[], &[], DatabaseType::Postgres, Some("public"), false, None, &[], ); assert!(sql.contains("\"public\".\"users\""), "schema prefixed: {sql}"); } #[test] fn schema_qualified_mysql() { let diffs = make_col_diffs(&[("name2", "varchar(50)")], &[("name", "varchar(50)")], true); let table_diff = wrap_table_diff("users", diffs); let sql = generate_schema_sync_sql( &[table_diff], &[], &[], &[], &[], DatabaseType::Mysql, Some("mydb"), false, None, &[], ); assert!(sql.contains("`mydb`.`users`"), "schema prefixed MySQL: {sql}"); } // -- 19. Multiple operations in one diff -- #[test] fn multiple_concurrent_operations() { let diffs = make_col_diffs( &[("id", "int"), ("name2", "varchar(50)"), ("new_col", "text"), ("keep", "boolean")], &[("id", "bigint"), ("name", "varchar(50)"), ("old_col", "int"), ("keep", "boolean")], true, ); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Postgres, None); assert!(sql.contains("RENAME COLUMN"), "rename: {sql}"); assert!(sql.contains("ADD COLUMN"), "add: {sql}"); assert!(sql.contains("DROP COLUMN"), "drop: {sql}"); assert!(sql.contains("ALTER COLUMN"), "modify type: {sql}"); } #[test] fn multiple_concurrent_operations_mysql() { let diffs = make_col_diffs( &[("id", "int"), ("name2", "varchar(50)"), ("new_col", "text")], &[("id", "bigint"), ("name", "varchar(50)")], true, ); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Mysql, None); assert!(sql.contains("CHANGE COLUMN"), "MySQL rename: {sql}"); assert!(sql.contains("ADD COLUMN"), "MySQL add: {sql}"); assert!(sql.contains("MODIFY COLUMN"), "MySQL modify: {sql}"); } // -- 20. All-removed and all-added edge cases -- #[test] fn all_columns_removed() { let diffs = make_col_diffs(&[], &[("old1", "int"), ("old2", "varchar(10)")], false); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Mysql, None); assert_eq!(sql.matches("DROP COLUMN").count(), 2, "two drops: {sql}"); } #[test] fn all_columns_added() { let diffs = make_col_diffs(&[("new1", "int"), ("new2", "varchar(10)")], &[], false); for db in [DatabaseType::Mysql, DatabaseType::Postgres, DatabaseType::Oracle] { let sql = gen_sql(wrap_table_diff("t", diffs.clone()), db, None); assert_eq!(sql.matches("ADD COLUMN").count(), 2, "{db:?} two adds: {sql}"); } } // -- 21. Rename + type change in one column -- #[test] fn rename_with_simultaneous_type_change() { let diffs = make_col_diffs( &[("id", "int"), ("new_name", "varchar(200)")], &[("id", "int"), ("old_name", "varchar(50)")], true, ); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Postgres, None); assert!(sql.contains("RENAME COLUMN"), "rename: {sql}"); assert!(sql.contains("TYPE varchar(200)"), "type change: {sql}"); } // -- 22. PostgreSQL→PostgreSQL: nullability changes -- #[test] fn postgres_nullable_change() { let source = vec![ColumnInfo { name: "name".into(), data_type: "text".into(), is_nullable: true, ..column("name", "text", None) }]; let target = vec![ColumnInfo { name: "name".into(), data_type: "text".into(), is_nullable: false, ..column("name", "text", None) }]; let diffs = diff_columns_with_options(&source, &target, false, false, false, 0.5); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Postgres, None); assert!(sql.contains("DROP NOT NULL"), "nullable change: {sql}"); } #[test] fn postgres_not_nullable_change() { let source = vec![ColumnInfo { name: "name".into(), data_type: "text".into(), is_nullable: false, ..column("name", "text", None) }]; let target = vec![ColumnInfo { name: "name".into(), data_type: "text".into(), is_nullable: true, ..column("name", "text", None) }]; let diffs = diff_columns_with_options(&source, &target, false, false, false, 0.5); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Postgres, None); assert!(sql.contains("SET NOT NULL"), "not null change: {sql}"); } // -- 23. PostgreSQL→SQLite (no existing mapping rules) -- #[test] fn postgresql_to_sqlite_no_type_mapping() { let diffs = make_col_diffs(&[("id", "integer"), ("data", "text"), ("ts", "timestamp")], &[], false); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Sqlite, Some(DialectKind::Postgres)); // No PG→SQLite dialect matrix, but SQLite profile type_map still rewrites affinities. assert!(sql.contains("INTEGER"), "integer→INTEGER: {sql}"); assert!(sql.contains("TEXT"), "text/timestamp→TEXT: {sql}"); assert!(!sql.contains("timestamp"), "timestamp mapped away: {sql}"); } // -- 24. All MySQL-like databases with rename + type change -- #[test] fn mysql_like_rename_with_type_conversion() { let mysql_likes: [(DatabaseType, &str); 7] = [ (DatabaseType::Mysql, "Mysql"), (DatabaseType::Doris, "Doris"), (DatabaseType::StarRocks, "StarRocks"), (DatabaseType::Goldendb, "Goldendb"), (DatabaseType::Sundb, "Sundb"), (DatabaseType::Databend, "Databend"), (DatabaseType::Gbase, "Gbase"), ]; for (db, label) in mysql_likes { let diffs = make_col_diffs( &[("id", "int(11)"), ("name2", "varchar(50)")], &[("id", "int"), ("name", "varchar(50)")], true, ); let sql = gen_sql(wrap_table_diff("t", diffs), db, Some(DialectKind::Mysql)); assert!(sql.contains("CHANGE COLUMN"), "{label} CHANGE COLUMN: {sql}"); assert!(sql.contains('`'), "{label} backticks: {sql}"); assert!(sql.contains("MODIFY COLUMN"), "{label} MODIFY: {sql}"); } } // -- 25. Rename with nullable change -- #[test] fn rename_with_nullable_change() { let source = vec![ColumnInfo { name: "new_col".into(), data_type: "text".into(), is_nullable: true, ..column("new_col", "text", None) }]; let target = vec![ColumnInfo { name: "old_col".into(), data_type: "text".into(), is_nullable: false, ..column("old_col", "text", None) }]; let diffs = diff_columns_with_options(&source, &target, false, false, true, 0.5); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Postgres, None); assert!(sql.contains("RENAME COLUMN"), "rename: {sql}"); assert!(sql.contains("DROP NOT NULL"), "nullable: {sql}"); } // -- 26. Empty diff generates no SQL -- #[test] fn empty_diff_generates_no_sql() { let diffs: Vec = vec![]; let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Mysql, None); assert_eq!(sql, "", "empty diff should generate no SQL"); let sql = gen_sql(wrap_table_diff("t", vec![]), DatabaseType::Postgres, None); assert_eq!(sql, "", "empty diff should generate no SQL for postgres"); } // -- 27. Databend and Gbase (MySQL-like) -- #[test] fn databend_gbase_mysql_like() { for db in [DatabaseType::Databend, DatabaseType::Gbase] { let diffs = make_col_diffs( &[("id", "int"), ("name2", "varchar(50)")], &[("id", "int"), ("name", "varchar(50)")], true, ); let sql = gen_sql(wrap_table_diff("t", diffs), db, None); assert!(sql.contains('`'), "{db:?} backticks: {sql}"); } } // -- 28. Postgres-compatible databases (GaussDB, openGauss, etc.) -- #[test] fn postgres_like_databases() { let pg_likes = [ DatabaseType::Gaussdb, DatabaseType::Kwdb, DatabaseType::OpenGauss, DatabaseType::Highgo, DatabaseType::Vastbase, DatabaseType::Kingbase, DatabaseType::Firebird, DatabaseType::Redshift, DatabaseType::Vertica, DatabaseType::Exasol, ]; for db in pg_likes { let diffs = make_col_diffs( &[("id", "int"), ("name2", "varchar(50)")], &[("id", "int"), ("name", "varchar(50)")], true, ); let sql = gen_sql(wrap_table_diff("t", diffs), db, None); assert!(sql.contains("RENAME COLUMN"), "{db:?} RENAME COLUMN: {sql}"); assert!(!sql.contains('`'), "{db:?} no backticks: {sql}"); } } // -- 29. SQLite-compatible databases -- #[test] fn sqlite_like_databases() { let sqlite_likes = [DatabaseType::Rqlite, DatabaseType::Turso]; for db in sqlite_likes { let diffs = make_col_diffs( &[("id", "int"), ("name2", "varchar(50)")], &[("id", "int"), ("name", "varchar(50)")], true, ); let sql = gen_sql(wrap_table_diff("t", diffs), db, None); assert!(sql.contains("RENAME COLUMN"), "{db:?} RENAME COLUMN: {sql}"); assert!(!sql.contains('`'), "{db:?} no backticks: {sql}"); } } // -- 30. ManticoreSearch (separate dialect) -- #[test] fn manticore_search_sql() { let diffs = make_col_diffs(&[("id", "bigint"), ("title", "text")], &[("id", "bigint")], false); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::ManticoreSearch, None); assert!(sql.contains("ADD COLUMN"), "Manticore add: {sql}"); assert!(sql.contains('`'), "Manticore uses backtick identifiers (MySQL-compatible): {sql}"); } #[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_mysql_functional_index_changes_and_preserves_expression_ddl() { let functional_key_part = "((case when (`STATUS` = _utf8mb4'online') then _utf8mb4'online' else NULL end))"; let source_index = index(IndexInfo { name: "test_UNIQUE".to_string(), columns: vec!["attr".to_string(), "attr2".to_string(), functional_key_part.to_string()], is_unique: true, is_primary: false, filter: None, index_type: None, included_columns: None, comment: None, }); let target_index = index(IndexInfo { name: "test_UNIQUE".to_string(), columns: vec!["attr".to_string(), "attr2".to_string()], is_unique: true, is_primary: false, filter: None, index_type: None, included_columns: None, comment: None, }); let diffs = diff_indexes(std::slice::from_ref(&source_index), &[target_index]); assert_eq!(diffs.len(), 1); assert_eq!(diffs[0].diff_type, "modified"); assert_eq!(diffs[0].changes, vec![format!("columns: attr, attr2 → attr, attr2, {functional_key_part}")]); let sql = generate_schema_sync_sql( &[TableDiff { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "test".to_string(), columns: None, indexes: Some(diffs), foreign_keys: None, triggers: None, ddl: None, target_ddl: None, source_table_comment: None, target_table_comment: None, sync_sql: None, }], &[], &[], &[], &[], DatabaseType::Mysql, Some("dbx_issue_4114"), false, None, &[], ); assert!(sql.contains("DROP INDEX `test_UNIQUE` ON `dbx_issue_4114`.`test`;")); assert!(sql.contains(&format!( "CREATE UNIQUE INDEX `test_UNIQUE` ON `dbx_issue_4114`.`test` (`attr`, `attr2`, {functional_key_part});" ))); assert!(!sql.contains("`((case")); } #[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, sync_sql: None, }]; assert_eq!( generate_schema_sync_sql(&diffs, &[], &[], &[], &[], DatabaseType::Postgres, None, false, None, &[]), [ "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())), sync_sql: None, }]; assert_eq!( generate_schema_sync_sql(&diffs, &[], &[], &[], &[], DatabaseType::Mysql, None, false, None, &[]), [ "-- Alter table: users", "ALTER TABLE `users`", " MODIFY COLUMN `name` varchar(64) NOT NULL COMMENT '用户姓名';", "", "ALTER TABLE `users` COMMENT = '用户表';", ] .join("\n") ); } #[test] fn mysql_schema_sync_sql_qualifies_tables_with_target_database() { let diffs = vec![TableDiff { diff_type: "modified".to_string(), object_type: None, name: "notify_channel_config".to_string(), columns: Some(vec![ColumnDiff { diff_type: "modified".to_string(), name: "config_json".to_string(), source: Some(column("config_json", "json", Some("渠道配置"))), target: Some(column("config_json", "json", Some("Config"))), changes: vec!["comment: Config → 渠道配置".to_string()], }]), indexes: None, foreign_keys: None, triggers: None, ddl: None, target_ddl: None, source_table_comment: None, target_table_comment: None, sync_sql: None, }]; assert_eq!( generate_schema_sync_sql( &diffs, &[], &[], &[], &[], DatabaseType::Mysql, Some("target_db"), false, None, &[] ), [ "-- Alter table: notify_channel_config", "ALTER TABLE `target_db`.`notify_channel_config`", " MODIFY COLUMN `config_json` json NOT NULL COMMENT '渠道配置';", ] .join("\n") ); } #[test] fn blank_target_schema_does_not_generate_empty_qualifier() { let diffs = vec![TableDiff { diff_type: "modified".to_string(), object_type: None, name: "notify_channel_config".to_string(), columns: Some(vec![ColumnDiff { diff_type: "modified".to_string(), name: "config_json".to_string(), source: Some(column("config_json", "json", Some("渠道配置"))), target: Some(column("config_json", "json", Some("Config"))), changes: vec!["comment: Config → 渠道配置".to_string()], }]), indexes: None, foreign_keys: None, triggers: None, ddl: None, target_ddl: None, source_table_comment: None, target_table_comment: None, sync_sql: None, }]; let sql = generate_schema_sync_sql(&diffs, &[], &[], &[], &[], DatabaseType::Mysql, Some(" "), false, None, &[]); assert!(sql.contains("ALTER TABLE `notify_channel_config`")); assert!(!sql.contains("``.")); } #[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, compare_column_order: false, ..Default::default() }; let result = prepare_schema_diff(options); assert!(result.diffs.is_empty()); assert!(result.sync_sql.is_empty()); } #[test] fn prepare_schema_diff_attaches_per_table_sync_sql() { let options = SchemaDiffPreparationOptions { source_tables: vec![TableInfo { name: "users".to_string(), table_type: "BASE TABLE".to_string(), comment: None, parent_schema: None, parent_name: None, }], target_tables: vec![TableInfo { name: "users".to_string(), table_type: "BASE TABLE".to_string(), comment: None, parent_schema: None, parent_name: None, }], source_details: vec![TableSchemaDetail { name: "users".to_string(), columns: vec![column("name", "varchar(128)", None)], indexes: Vec::new(), foreign_keys: Vec::new(), triggers: Vec::new(), ddl: Some("CREATE TABLE `users` (`name` varchar(128));".to_string()), }], target_details: vec![TableSchemaDetail { name: "users".to_string(), columns: vec![column("name", "varchar(64)", None)], indexes: Vec::new(), foreign_keys: Vec::new(), triggers: Vec::new(), ddl: Some("CREATE TABLE `users` (`name` varchar(64));".to_string()), }], 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: false, cascade_delete: false, compare_column_order: false, ..Default::default() }; let result = prepare_schema_diff(options); let table_sync_sql = result.diffs[0].sync_sql.as_deref().unwrap_or_default(); assert!(table_sync_sql.contains("ALTER TABLE `users`")); assert!(!table_sync_sql.contains("CREATE TABLE")); } #[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, enum_values: None, character_set: None, collation: 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, sync_sql: None, }]; assert_eq!( generate_schema_sync_sql( &diffs, &[], &[], &[], &[], DatabaseType::Postgres, Some("sales"), false, None, &[] ), [ "-- Alter table: orders", "ALTER TABLE \"sales\".\"orders\" ADD COLUMN \"status\" text;", "", "CREATE INDEX \"idx_orders_status\" ON \"sales\".\"orders\" (\"status\");", ] .join("\n") ); } // ======================================================================== // Phase 4.1: Dependency Graph Tests // ======================================================================== #[test] fn dependency_graph_builds_dag_from_foreign_keys() { let details = vec![ TableSchemaDetail { name: "orders".to_string(), columns: vec![], indexes: vec![], foreign_keys: vec![ForeignKeyInfo { name: "fk_orders_users".to_string(), column: "user_id".to_string(), ref_schema: None, ref_table: "users".to_string(), ref_column: "id".to_string(), on_update: None, on_delete: None, }], triggers: vec![], ddl: None, }, TableSchemaDetail { name: "users".to_string(), columns: vec![], indexes: vec![], foreign_keys: vec![], triggers: vec![], ddl: None, }, ]; let tables = vec![ TableInfo { name: "orders".to_string(), table_type: "BASE TABLE".to_string(), comment: None, parent_schema: None, parent_name: None, }, TableInfo { name: "users".to_string(), table_type: "BASE TABLE".to_string(), comment: None, parent_schema: None, parent_name: None, }, ]; let graph = DependencyGraph::build(&details, &tables); assert_eq!(graph.nodes.len(), 2); assert!(graph.nodes["orders"].depends_on.contains(&"users".to_string())); assert_eq!(graph.nodes["orders"].depends_on.len(), 1); assert_eq!(graph.nodes["users"].depends_on.len(), 0); } #[test] fn dependency_graph_topological_sort_drop_order() { let details = vec![ TableSchemaDetail { name: "order_items".to_string(), columns: vec![], indexes: vec![], foreign_keys: vec![ForeignKeyInfo { name: "fk_items_orders".to_string(), column: "order_id".to_string(), ref_schema: None, ref_table: "orders".to_string(), ref_column: "id".to_string(), on_update: None, on_delete: None, }], triggers: vec![], ddl: None, }, TableSchemaDetail { name: "orders".to_string(), columns: vec![], indexes: vec![], foreign_keys: vec![ForeignKeyInfo { name: "fk_orders_users".to_string(), column: "user_id".to_string(), ref_schema: None, ref_table: "users".to_string(), ref_column: "id".to_string(), on_update: None, on_delete: None, }], triggers: vec![], ddl: None, }, TableSchemaDetail { name: "users".to_string(), columns: vec![], indexes: vec![], foreign_keys: vec![], triggers: vec![], ddl: None, }, ]; let tables = vec![ TableInfo { name: "order_items".to_string(), table_type: "BASE TABLE".to_string(), comment: None, parent_schema: None, parent_name: None, }, TableInfo { name: "orders".to_string(), table_type: "BASE TABLE".to_string(), comment: None, parent_schema: None, parent_name: None, }, TableInfo { name: "users".to_string(), table_type: "BASE TABLE".to_string(), comment: None, parent_schema: None, parent_name: None, }, ]; let graph = DependencyGraph::build(&details, &tables); let drop_order = graph.drop_order(); let di = drop_order.iter().position(|n| n == "order_items").unwrap(); let oi = drop_order.iter().position(|n| n == "orders").unwrap(); assert!(di < oi, "order_items should be dropped before orders"); } #[test] fn coverage_score_empty_graph_returns_one() { let graph = DependencyGraph { nodes: HashMap::new(), topological_order: vec![] }; assert_eq!(graph.coverage_score(&[]), 1.0); } #[test] fn coverage_score_partial_coverage() { let mut nodes = HashMap::new(); nodes.insert( "a".to_string(), DependencyNode { table_name: "a".to_string(), depends_on: vec!["b".to_string()], depended_by: vec![] }, ); nodes.insert( "b".to_string(), DependencyNode { table_name: "b".to_string(), depends_on: vec!["c".to_string()], depended_by: vec!["a".to_string()], }, ); nodes.insert( "c".to_string(), DependencyNode { table_name: "c".to_string(), depends_on: vec![], depended_by: vec!["b".to_string()] }, ); let graph = DependencyGraph { nodes, topological_order: vec!["c".to_string(), "b".to_string(), "a".to_string()] }; let score = graph.coverage_score(&["a".to_string(), "b".to_string()]); assert!((score - 0.5).abs() < 0.01); } #[test] fn coverage_score_level2_transitive_edges() { let mut nodes = HashMap::new(); nodes.insert( "a".to_string(), DependencyNode { table_name: "a".to_string(), depends_on: vec!["b".to_string()], depended_by: vec![] }, ); nodes.insert( "b".to_string(), DependencyNode { table_name: "b".to_string(), depends_on: vec!["c".to_string()], depended_by: vec!["a".to_string()], }, ); nodes.insert( "c".to_string(), DependencyNode { table_name: "c".to_string(), depends_on: vec![], depended_by: vec!["b".to_string()] }, ); let graph = DependencyGraph { nodes, topological_order: vec!["c".to_string(), "b".to_string(), "a".to_string()] }; let l2_score = graph.coverage_score_level2(&["a".to_string(), "b".to_string(), "c".to_string()]); assert!((l2_score - 1.0).abs() < 0.01, "full coverage should give 1.0"); } #[test] fn coverage_score_level2_partial() { let mut nodes = HashMap::new(); nodes.insert( "a".to_string(), DependencyNode { table_name: "a".to_string(), depends_on: vec!["b".to_string()], depended_by: vec![] }, ); nodes.insert( "b".to_string(), DependencyNode { table_name: "b".to_string(), depends_on: vec!["c".to_string()], depended_by: vec!["a".to_string()], }, ); nodes.insert( "c".to_string(), DependencyNode { table_name: "c".to_string(), depends_on: vec![], depended_by: vec!["b".to_string()] }, ); let graph = DependencyGraph { nodes, topological_order: vec!["c".to_string(), "b".to_string(), "a".to_string()] }; let l2_score = graph.coverage_score_level2(&["a".to_string(), "b".to_string()]); assert!((l2_score - 0.0).abs() < 0.01, "missing grandparent c means 0 transitive coverage"); } #[test] fn composite_coverage_full_coverage() { let mut nodes = HashMap::new(); nodes.insert( "a".to_string(), DependencyNode { table_name: "a".to_string(), depends_on: vec!["b".to_string()], depended_by: vec![] }, ); nodes.insert( "b".to_string(), DependencyNode { table_name: "b".to_string(), depends_on: vec!["c".to_string()], depended_by: vec!["a".to_string()], }, ); nodes.insert( "c".to_string(), DependencyNode { table_name: "c".to_string(), depends_on: vec![], depended_by: vec!["b".to_string()] }, ); let graph = DependencyGraph { nodes, topological_order: vec!["c".to_string(), "b".to_string(), "a".to_string()] }; let report = graph.composite_coverage_score(&["a".to_string(), "b".to_string(), "c".to_string()]); assert!((report.level1_score - 1.0).abs() < 0.01); assert!((report.level2_score - 1.0).abs() < 0.01); assert!((report.composite_score - 1.0).abs() < 0.01); assert_eq!(report.level1_covered, 2); assert_eq!(report.level1_total, 2); assert_eq!(report.level2_covered, 1); assert_eq!(report.level2_total, 1); } #[test] fn composite_coverage_partial() { let mut nodes = HashMap::new(); nodes.insert( "a".to_string(), DependencyNode { table_name: "a".to_string(), depends_on: vec!["b".to_string()], depended_by: vec![] }, ); nodes.insert( "b".to_string(), DependencyNode { table_name: "b".to_string(), depends_on: vec!["c".to_string()], depended_by: vec!["a".to_string()], }, ); nodes.insert( "c".to_string(), DependencyNode { table_name: "c".to_string(), depends_on: vec![], depended_by: vec!["b".to_string()] }, ); let graph = DependencyGraph { nodes, topological_order: vec!["c".to_string(), "b".to_string(), "a".to_string()] }; let report = graph.composite_coverage_score(&["a".to_string(), "b".to_string()]); assert!((report.level1_score - 0.5).abs() < 0.01); assert!((report.level2_score - 0.0).abs() < 0.01); assert!((report.composite_score - 0.3).abs() < 0.01, "0.6*0.5 + 0.4*0.0 = 0.3"); assert_eq!(report.level1_covered, 1); assert_eq!(report.level1_total, 2); assert!(!report.uncovered_edges.is_empty()); } #[test] fn composite_coverage_no_dependencies() { let mut nodes = HashMap::new(); nodes.insert( "t1".to_string(), DependencyNode { table_name: "t1".to_string(), depends_on: vec![], depended_by: vec![] }, ); nodes.insert( "t2".to_string(), DependencyNode { table_name: "t2".to_string(), depends_on: vec![], depended_by: vec![] }, ); let graph = DependencyGraph { nodes, topological_order: vec!["t1".to_string(), "t2".to_string()] }; let report = graph.composite_coverage_score(&["t1".to_string()]); assert!((report.level1_score - 1.0).abs() < 0.01); assert!((report.level2_score - 1.0).abs() < 0.01); assert!((report.composite_score - 1.0).abs() < 0.01); assert!(report.uncovered_edges.is_empty()); } // ======================================================================== // Phase 4.1: Rename Detection Tests // ======================================================================== #[test] fn detect_renames_high_similarity_columns() { let source_details = vec![TableSchemaDetail { name: "users_old".to_string(), columns: vec![ column("id", "int", None), column("name", "varchar(100)", None), column("email", "varchar(255)", None), column("created_at", "datetime", None), ], indexes: vec![], foreign_keys: vec![], triggers: vec![], ddl: None, }]; let target_details = vec![TableSchemaDetail { name: "users_new".to_string(), columns: vec![ column("id", "integer", None), column("name", "varchar(100)", None), column("email", "varchar(255)", None), column("updated_at", "datetime", None), ], indexes: vec![], foreign_keys: vec![], triggers: vec![], ddl: None, }]; let candidates = detect_renames( &["users_new".to_string()], &["users_old".to_string()], &source_details, &target_details, 0.5, ); assert_eq!(candidates.len(), 1); assert_eq!(candidates[0].source_name, "users_old"); assert_eq!(candidates[0].target_name, "users_new"); assert!(candidates[0].score >= 0.5); } #[test] fn detect_renames_low_similarity_below_threshold() { let source_details = vec![TableSchemaDetail { name: "users".to_string(), columns: vec![column("id", "int", None)], indexes: vec![], foreign_keys: vec![], triggers: vec![], ddl: None, }]; let target_details = vec![TableSchemaDetail { name: "products".to_string(), columns: vec![column("sku", "varchar(50)", None), column("price", "decimal", None)], indexes: vec![], foreign_keys: vec![], triggers: vec![], ddl: None, }]; let candidates = detect_renames(&["users".to_string()], &["products".to_string()], &source_details, &target_details, 0.5); assert!(candidates.is_empty()); } #[test] fn jaccard_similarity_identical_sets() { let a: HashSet = ["a", "b", "c"].iter().map(|s| s.to_string()).collect(); let b: HashSet = ["a", "b", "c"].iter().map(|s| s.to_string()).collect(); assert!((jaccard_similarity(&a, &b) - 1.0).abs() < f64::EPSILON); } // ======================================================================== // Phase 4.2: Batch Naming Pattern Tests // ======================================================================== #[test] fn batch_pattern_matching_wildcard() { let source = vec!["log_2024_01".to_string(), "log_2024_02".to_string(), "users".to_string()]; let target = vec!["log_2024_03".to_string()]; let patterns = vec![BatchPattern { pattern: "log_*".to_string(), is_regex: false, description: "all log tables".to_string(), }]; let (_added, removed, common, match_results) = diff_names_with_patterns(&source, &target, &patterns); assert_eq!(removed, vec!["log_2024_03"]); assert_eq!(common.len(), 0); assert_eq!(match_results.len(), 1); assert_eq!(match_results[0].len(), 2); } #[test] fn batch_pattern_regex_matching() { let source = vec!["tbl_001".to_string(), "tbl_002".to_string(), "other".to_string()]; let target = vec![]; let patterns = vec![BatchPattern { pattern: r"tbl_\d{3}".to_string(), is_regex: true, description: "numbered tables".to_string(), }]; let (_added, _removed, _common, match_results) = diff_names_with_patterns(&source, &target, &patterns); assert_eq!(match_results[0].len(), 2); } #[test] fn pattern_conflict_detection() { let patterns = vec![ BatchPattern { pattern: "user_*".to_string(), is_regex: false, description: "user tables".to_string() }, BatchPattern { pattern: "user_data".to_string(), is_regex: false, description: "specific user data".to_string(), }, ]; let names = vec!["user_data".to_string(), "user_log".to_string()]; let conflicts = detect_pattern_conflicts(&patterns, &names); assert!(!conflicts.is_empty()); } // ======================================================================== // Phase 4.3: Type Compatibility Tests // ======================================================================== #[test] fn diff_columns_with_compatibility_integer_family() { let (_diffs, warnings) = diff_columns_with_compatibility( &[column("id", "INT", None)], &[column("id", "BIGINT", None)], false, false, DialectKind::Mysql, DialectKind::Mysql, 0.9, &[], ); assert!(!warnings.is_empty()); assert_eq!(warnings[0].risk, ColumnConversionRisk::Low); } #[test] fn diff_columns_with_compatibility_exact_match_no_warning() { let (_diffs, warnings) = diff_columns_with_compatibility( &[column("id", "INT", None)], &[column("id", "INT", None)], false, false, DialectKind::Mysql, DialectKind::Mysql, 0.5, &[], ); assert!(warnings.is_empty()); } // ======================================================================== // Phase 4.4: Bidirectional Diff & Rollback Tests // ======================================================================== fn make_diff(diff_type: &str, name: &str) -> TableDiff { TableDiff { diff_type: diff_type.to_string(), object_type: Some("table".to_string()), name: name.to_string(), columns: None, indexes: None, foreign_keys: None, triggers: None, ddl: None, target_ddl: None, source_table_comment: None, target_table_comment: None, sync_sql: None, } } #[test] fn rollback_graph_add_becomes_drop() { let diffs = vec![make_diff("added", "new_table")]; let dep_graph = DependencyGraph { nodes: HashMap::new(), topological_order: vec![] }; let graph = RollbackGraph::from_forward_diffs(&diffs, &[], &dep_graph); assert_eq!(graph.forward_nodes.len(), 1); assert_eq!(graph.rollback_nodes.len(), 1); assert_eq!(graph.forward_nodes[0].table_diff.diff_type, "added"); assert_eq!(graph.rollback_nodes[0].table_diff.diff_type, "removed"); } #[test] fn rollback_graph_remove_becomes_add() { let diffs = vec![make_diff("removed", "old_table")]; let dep_graph = DependencyGraph { nodes: HashMap::new(), topological_order: vec![] }; let graph = RollbackGraph::from_forward_diffs(&diffs, &[], &dep_graph); assert_eq!(graph.rollback_nodes[0].table_diff.diff_type, "added"); assert_eq!(graph.rollback_nodes[0].table_diff.ddl, None); } fn rollback_removed_table_sql( database_type: DatabaseType, target_schema: Option<&str>, source_dialect: Option, target_dialect: DialectKind, table_name: &str, table_comment: Option<&str>, target_detail: TableSchemaDetail, ) -> SchemaDiffPreparation { prepare_schema_diff(SchemaDiffPreparationOptions { target_tables: vec![TableInfo { name: table_name.to_string(), table_type: "BASE TABLE".to_string(), comment: table_comment.map(str::to_string), parent_schema: None, parent_name: None, }], target_details: vec![target_detail], database_type, target_schema: target_schema.map(str::to_string), enable_rollback: true, source_dialect, target_dialect: Some(target_dialect), ..Default::default() }) } #[test] fn dropped_postgres_table_rollback_preserves_structured_snapshot() { let table_name = "Order Items"; let result = rollback_removed_table_sql( DatabaseType::Postgres, Some("sales"), Some(DialectKind::Postgres), DialectKind::Postgres, table_name, Some("order line history"), TableSchemaDetail { name: table_name.to_string(), columns: vec![ ColumnInfo { is_primary_key: true, column_default: Some("gen_random_uuid()".to_string()), ..column("Item ID", "uuid", Some("stable row id")) }, ColumnInfo { column_default: Some("'new'::text".to_string()), ..column("Status", "text", Some("workflow state")) }, column("User ID", "bigint", None), ], indexes: vec![index(IndexInfo { name: "Order Status IDX".to_string(), columns: vec!["Status".to_string()], is_unique: true, is_primary: false, filter: Some("\"Status\" <> 'deleted'".to_string()), index_type: Some("btree".to_string()), included_columns: Some(vec!["User ID".to_string()]), comment: None, })], foreign_keys: vec![foreign_key(ForeignKeyInfo { name: "Order User FK".to_string(), column: "User ID".to_string(), ref_schema: Some("auth".to_string()), ref_table: "Users".to_string(), ref_column: "ID".to_string(), on_update: None, on_delete: Some("CASCADE".to_string()), })], triggers: vec![], ddl: Some("CREATE TABLE native_postgres_fallback (ignored int)".to_string()), }, ); let rollback = result.rollback_sync_sql.unwrap(); assert!(rollback.contains("CREATE TABLE \"sales\".\"Order Items\""), "{rollback}"); assert!(rollback.contains("\"Item ID\" uuid NOT NULL DEFAULT gen_random_uuid()"), "{rollback}"); assert!(rollback.contains("PRIMARY KEY (\"Item ID\")"), "{rollback}"); assert!(rollback.contains("CREATE UNIQUE INDEX \"Order Status IDX\""), "{rollback}"); assert!(rollback.contains("USING btree"), "{rollback}"); assert!(rollback.contains("INCLUDE (\"User ID\")"), "{rollback}"); assert!(rollback.contains("WHERE \"Status\" <> 'deleted'"), "{rollback}"); assert!(rollback.contains("REFERENCES \"auth\".\"Users\"(\"ID\") ON DELETE CASCADE"), "{rollback}"); assert!(rollback.contains("COMMENT ON COLUMN \"sales\".\"Order Items\".\"Status\" IS 'workflow state'")); assert!(rollback.contains("COMMENT ON TABLE \"sales\".\"Order Items\" IS 'order line history'")); assert!(!rollback.contains("native_postgres_fallback"), "{rollback}"); } #[test] fn dropped_mysql_table_rollback_preserves_defaults_comments_indexes_and_fk() { let table_name = "order-items"; let result = rollback_removed_table_sql( DatabaseType::Mysql, Some("shop"), Some(DialectKind::Mysql), DialectKind::Mysql, table_name, Some("order item history"), TableSchemaDetail { name: table_name.to_string(), columns: vec![ ColumnInfo { is_primary_key: true, column_default: Some("(uuid())".to_string()), ..column("item-id", "varchar(36)", Some("stable item id")) }, ColumnInfo { column_default: Some("'new'".to_string()), ..column("status", "varchar(32)", Some("workflow state")) }, column("user-id", "bigint", None), ], indexes: vec![index(IndexInfo { name: "status-index".to_string(), columns: vec!["status".to_string()], is_unique: true, is_primary: false, filter: None, index_type: Some("BTREE".to_string()), included_columns: None, comment: Some("status lookup".to_string()), })], foreign_keys: vec![foreign_key(ForeignKeyInfo { name: "user-fk".to_string(), column: "user-id".to_string(), ref_schema: Some("identity".to_string()), ref_table: "users".to_string(), ref_column: "id".to_string(), on_update: Some("CASCADE".to_string()), on_delete: Some("RESTRICT".to_string()), })], triggers: vec![], ddl: Some("CREATE TABLE native_mysql_fallback (ignored int)".to_string()), }, ); let rollback = result.rollback_sync_sql.unwrap(); assert!(rollback.contains("CREATE TABLE `shop`.`order-items`"), "{rollback}"); assert!(rollback.contains("`item-id` varchar(36) NOT NULL DEFAULT (uuid()) COMMENT 'stable item id'")); assert!(rollback.contains("PRIMARY KEY (`item-id`)"), "{rollback}"); assert!(rollback.contains("CREATE UNIQUE INDEX `status-index` USING BTREE ON `shop`.`order-items` (`status`)")); assert!(rollback.contains("COMMENT 'status lookup'"), "{rollback}"); assert!(rollback.contains("REFERENCES `identity`.`users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE")); assert!(rollback.contains("ALTER TABLE `shop`.`order-items` COMMENT = 'order item history'")); assert!(!rollback.contains("native_mysql_fallback"), "{rollback}"); } #[test] fn dropped_sqlite_table_rollback_preserves_quoted_pk_index_fk_and_default() { let table_name = "select \"items"; let result = rollback_removed_table_sql( DatabaseType::Sqlite, None, Some(DialectKind::Sqlite), DialectKind::Sqlite, table_name, None, TableSchemaDetail { name: table_name.to_string(), columns: vec![ ColumnInfo { is_primary_key: true, ..column("item \"id", "TEXT", None) }, ColumnInfo { column_default: Some("'new'".to_string()), ..column("status", "TEXT", None) }, column("parent id", "TEXT", None), ], indexes: vec![index(IndexInfo { name: "active status index".to_string(), columns: vec!["status".to_string()], is_unique: false, is_primary: false, filter: Some("status <> 'deleted'".to_string()), index_type: None, included_columns: None, comment: None, })], foreign_keys: vec![foreign_key(ForeignKeyInfo { name: "parent item fk".to_string(), column: "parent id".to_string(), ref_schema: None, ref_table: "parent items".to_string(), ref_column: "id".to_string(), on_update: None, on_delete: Some("SET NULL".to_string()), })], triggers: vec![], ddl: Some("CREATE TABLE native_sqlite_fallback (ignored int)".to_string()), }, ); let rollback = result.rollback_sync_sql.unwrap(); assert!(rollback.contains("CREATE TABLE \"select \"\"items\""), "{rollback}"); assert!(rollback.contains("\"item \"\"id\" TEXT NOT NULL"), "{rollback}"); assert!(rollback.contains("\"status\" TEXT NOT NULL DEFAULT 'new'"), "{rollback}"); assert!(rollback.contains("PRIMARY KEY (\"item \"\"id\")"), "{rollback}"); assert!(rollback.contains("CONSTRAINT \"parent item fk\" FOREIGN KEY (\"parent id\")"), "{rollback}"); assert!(rollback.contains("REFERENCES \"parent items\"(\"id\") ON DELETE SET NULL"), "{rollback}"); assert!(rollback.contains("CREATE INDEX \"active status index\""), "{rollback}"); assert!(rollback.contains("WHERE status <> 'deleted'"), "{rollback}"); assert!(!rollback.contains("ALTER TABLE"), "SQLite FK must be part of CREATE TABLE: {rollback}"); assert!(!rollback.contains("native_sqlite_fallback"), "{rollback}"); } #[test] fn dropped_table_cross_dialect_rollback_uses_target_snapshot_and_syntax() { let result = rollback_removed_table_sql( DatabaseType::Mysql, Some("archive"), Some(DialectKind::Postgres), DialectKind::Mysql, "Audit Log", None, TableSchemaDetail { name: "Audit Log".to_string(), columns: vec![ColumnInfo { is_primary_key: true, column_default: Some("0".to_string()), ..column("Event ID", "BIGINT UNSIGNED", None) }], indexes: vec![], foreign_keys: vec![], triggers: vec![], ddl: Some("CREATE TABLE native_cross_dialect_fallback (ignored int)".to_string()), }, ); let rollback = result.rollback_sync_sql.unwrap(); assert!(rollback.contains("CREATE TABLE `archive`.`Audit Log`"), "{rollback}"); assert!(rollback.contains("`Event ID` BIGINT UNSIGNED NOT NULL DEFAULT 0 AUTO_INCREMENT"), "{rollback}"); assert!(!rollback.contains('"'), "rollback must use target MySQL quoting: {rollback}"); assert!(!rollback.contains("native_cross_dialect_fallback"), "{rollback}"); } #[test] fn dropped_table_rollback_uses_native_target_ddl_only_without_structured_columns() { let result = rollback_removed_table_sql( DatabaseType::Postgres, Some("archive"), Some(DialectKind::Postgres), DialectKind::Postgres, "legacy_table", None, TableSchemaDetail { name: "legacy_table".to_string(), columns: vec![], indexes: vec![], foreign_keys: vec![], triggers: vec![], ddl: Some("CREATE TABLE \"archive\".\"legacy_table\" (\"id\" bigint PRIMARY KEY)".to_string()), }, ); let rollback = result.rollback_sync_sql.unwrap(); assert!(rollback.contains("-- Recreate table from native target DDL: legacy_table")); assert!(rollback.contains("CREATE TABLE \"archive\".\"legacy_table\" (\"id\" bigint PRIMARY KEY);")); assert!(!rollback.contains("CREATE TABLE \"archive\".\"legacy_table\" (\n \n)"), "{rollback}"); } #[test] fn dropped_table_incomplete_trigger_sets_structured_missing_objects() { let result = rollback_removed_table_sql( DatabaseType::Mysql, None, Some(DialectKind::Mysql), DialectKind::Mysql, "orders", None, TableSchemaDetail { name: "orders".to_string(), columns: vec![column("id", "int", None)], indexes: vec![], foreign_keys: vec![], triggers: vec![crate::types::TriggerInfo { name: "trg_orders".to_string(), event: "INSERT".to_string(), timing: "AFTER".to_string(), statement: None, }], ddl: None, }, ); assert_eq!(result.rollback_completeness, RollbackCompleteness::Incomplete); assert!(!result.missing_rollback_objects.is_empty()); assert_eq!(result.missing_rollback_objects[0].kind, "trigger"); assert_eq!(result.missing_rollback_objects[0].name, "trg_orders"); assert!(result.missing_rollback_objects[0].table.as_deref() == Some("orders")); } #[test] fn rollback_graph_modified_stays_modified_swapped() { let source_col = column("name", "varchar(100)", None); let target_col = column("name", "varchar(50)", None); let diffs = vec![TableDiff { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "users".to_string(), columns: Some(vec![ColumnDiff { diff_type: "modified".to_string(), name: "name".to_string(), source: Some(source_col.clone()), target: Some(target_col.clone()), changes: vec!["type: varchar(50) → varchar(100)".to_string()], }]), indexes: None, foreign_keys: None, triggers: None, ddl: None, target_ddl: None, source_table_comment: None, target_table_comment: None, sync_sql: None, }]; let dep_graph = DependencyGraph { nodes: HashMap::new(), topological_order: vec![] }; let graph = RollbackGraph::from_forward_diffs(&diffs, &[], &dep_graph); let rollback = &graph.rollback_nodes[0]; assert_eq!(rollback.table_diff.diff_type, "modified"); let rb_cols = rollback.table_diff.columns.as_ref().unwrap(); assert_eq!(rb_cols[0].diff_type, "modified"); assert_eq!(rb_cols[0].source.as_ref().unwrap().data_type, "varchar(50)"); assert_eq!(rb_cols[0].target.as_ref().unwrap().data_type, "varchar(100)"); } #[test] fn rollback_consistency_validation() { let diffs = vec![make_diff("added", "t1"), make_diff("removed", "t2")]; let dep_graph = DependencyGraph { nodes: HashMap::new(), topological_order: vec![] }; let mut graph = RollbackGraph::from_forward_diffs(&diffs, &[], &dep_graph); assert!(graph.validate_consistency()); assert!(graph.consistency_issues.is_empty()); } // ======================================================================== // Phase 4.6: Permission Tests // ======================================================================== #[test] fn diff_permissions_detects_added_and_removed() { let source = vec![PermissionInfo { grantee: "app_user".to_string(), object_type: "TABLE".to_string(), object_name: "orders".to_string(), privilege: "SELECT".to_string(), is_grantable: false, }]; let target = vec![PermissionInfo { grantee: "app_user".to_string(), object_type: "TABLE".to_string(), object_name: "orders".to_string(), privilege: "INSERT".to_string(), is_grantable: false, }]; let diffs = diff_permissions(&source, &target); assert_eq!(diffs.len(), 2); assert!(diffs.iter().any(|d| d.diff_type == "added")); assert!(diffs.iter().any(|d| d.diff_type == "removed")); } #[test] fn generate_permission_sql_mysql() { let diffs = vec![PermissionDiff { diff_type: "added".to_string(), grantee: "app_user".to_string(), object_name: "orders".to_string(), privilege: "SELECT".to_string(), source: Some(PermissionInfo { grantee: "app_user".to_string(), object_type: "TABLE".to_string(), object_name: "orders".to_string(), privilege: "SELECT".to_string(), is_grantable: true, }), target: None, }]; let sql = generate_permission_sync_sql(&diffs, DatabaseType::Mysql, Some("mydb")); assert!(sql.contains("GRANT SELECT ON `mydb`.`orders` TO 'app_user' WITH GRANT OPTION")); } #[test] fn generate_permission_sql_postgres_revoke() { let diffs = vec![PermissionDiff { diff_type: "removed".to_string(), grantee: "old_user".to_string(), object_name: "users".to_string(), privilege: "INSERT".to_string(), source: None, target: Some(PermissionInfo { grantee: "old_user".to_string(), object_type: "TABLE".to_string(), object_name: "users".to_string(), privilege: "INSERT".to_string(), is_grantable: false, }), }]; let sql = generate_permission_sync_sql(&diffs, DatabaseType::Postgres, Some("public")); assert!(sql.contains("REVOKE INSERT ON TABLE \"public\".\"users\" FROM \"old_user\"")); } // ======================================================================== // Phase 4.7: Resource Scheduling Tests // ======================================================================== #[test] fn adaptive_scheduler_optimal_batch_size() { let constraint = ResourceConstraint::default(); let scheduler = AdaptiveScheduler::new(constraint, 400); let batch = scheduler.optimal_batch_size(); assert!(batch > 0); assert!(batch <= 50); } #[test] fn adaptive_scheduler_shard_count() { let constraint = ResourceConstraint::default(); let scheduler = AdaptiveScheduler::new(constraint, 200); let count = scheduler.recommended_shard_count(); assert!(count >= 1); assert!(count <= 4); } // ======================================================================== // Phase 4.8: Backward Compatibility Tests // ======================================================================== #[test] fn new_options_default_values_do_not_affect_basic_diff() { let options = SchemaDiffPreparationOptions::default(); let result = prepare_schema_diff(options); assert!(result.diffs.is_empty()); assert!(result.sync_sql.is_empty()); assert!(result.rollback_sync_sql.is_none()); assert!(result.rename_candidates.is_empty()); assert!(result.rollback_graph.is_none()); assert!(result.compatibility_warnings.is_empty()); assert!(result.permission_diffs.is_empty()); } #[test] fn prepare_schema_diff_with_rename_detection() { let options = SchemaDiffPreparationOptions { source_tables: vec![TableInfo { name: "users_old".to_string(), table_type: "BASE TABLE".to_string(), comment: None, parent_schema: None, parent_name: None, }], target_tables: vec![TableInfo { name: "users_new".to_string(), table_type: "BASE TABLE".to_string(), comment: None, parent_schema: None, parent_name: None, }], source_details: vec![TableSchemaDetail { name: "users_old".to_string(), columns: vec![column("id", "int", None), column("name", "varchar(100)", None)], indexes: vec![], foreign_keys: vec![], triggers: vec![], ddl: None, }], target_details: vec![TableSchemaDetail { name: "users_new".to_string(), columns: vec![column("id", "int", None), column("name", "varchar(100)", None)], indexes: vec![], foreign_keys: vec![], triggers: vec![], ddl: None, }], source_functions: vec![], target_functions: vec![], source_sequences: vec![], target_sequences: vec![], source_rules: vec![], target_rules: vec![], source_owners: vec![], target_owners: vec![], database_type: DatabaseType::Mysql, target_schema: None, ignore_comments: false, cascade_delete: false, compare_column_order: false, detect_renames: true, detect_table_renames: true, rename_threshold: 0.5, ..Default::default() }; let result = prepare_schema_diff(options); assert!(!result.rename_candidates.is_empty()); assert!(result.rename_candidates[0].score >= 0.5); } #[test] fn prepare_schema_diff_with_rollback_generates_rollback_sql() { let options = SchemaDiffPreparationOptions { source_tables: vec![TableInfo { name: "new_table".to_string(), table_type: "BASE TABLE".to_string(), comment: None, parent_schema: None, parent_name: None, }], target_tables: vec![], source_details: vec![TableSchemaDetail { name: "new_table".to_string(), columns: vec![column("id", "int", None)], indexes: vec![], foreign_keys: vec![], triggers: vec![], ddl: Some("CREATE TABLE new_table (id int);".to_string()), }], target_details: vec![], source_functions: vec![], target_functions: vec![], source_sequences: vec![], target_sequences: vec![], source_rules: vec![], target_rules: vec![], source_owners: vec![], target_owners: vec![], database_type: DatabaseType::Mysql, target_schema: None, ignore_comments: false, cascade_delete: false, compare_column_order: false, enable_rollback: true, ..Default::default() }; let result = prepare_schema_diff(options); assert!(result.rollback_sync_sql.is_some()); assert!(result.rollback_graph.is_some()); let graph = result.rollback_graph.unwrap(); assert!(graph.is_consistent); assert_eq!(graph.forward_nodes.len(), 1); assert_eq!(graph.rollback_nodes.len(), 1); assert_eq!(graph.rollback_nodes[0].table_diff.diff_type, "removed"); } // -- 31. column_type_similarity_score unit tests -- #[test] fn column_type_similarity_exact_match() { assert_eq!(column_type_similarity_score("int", "int"), 1.0); assert_eq!(column_type_similarity_score("VARCHAR(255)", "varchar(255)"), 1.0); assert_eq!(column_type_similarity_score("datetime", "datetime"), 1.0); } #[test] fn column_type_similarity_synonym() { assert_eq!(column_type_similarity_score("int", "integer"), 1.0); assert_eq!(column_type_similarity_score("boolean", "bool"), 1.0); assert_eq!(column_type_similarity_score("datetime", "timestamp"), 1.0); assert_eq!(column_type_similarity_score("double", "double precision"), 1.0); } #[test] fn column_type_similarity_family() { assert_eq!(column_type_similarity_score("tinyint", "bigint"), 0.8); assert_eq!(column_type_similarity_score("char", "text"), 0.8); assert_eq!(column_type_similarity_score("mediumtext", "clob"), 0.8); } #[test] fn column_type_similarity_unrelated() { assert_eq!(column_type_similarity_score("int", "varchar"), 0.0); assert_eq!(column_type_similarity_score("boolean", "text"), 0.0); assert_eq!(column_type_similarity_score("blob", "date"), 0.0); } #[test] fn column_type_similarity_parameterized_ignored() { assert_eq!(column_type_similarity_score("int(11)", "int(11)"), 1.0); assert_eq!(column_type_similarity_score("int(11)", "integer"), 1.0); assert_eq!(column_type_similarity_score("varchar(255)", "varchar(64)"), 1.0); } // -- 32. Multiple renames in one table -- #[test] fn multiple_renames_in_one_table() { let diffs = make_col_diffs( &[("id", "int"), ("new_a", "varchar(50)"), ("new_b", "int")], &[("id", "int"), ("old_a", "varchar(50)"), ("old_b", "int")], true, ); let renamed: Vec<_> = diffs.iter().filter(|d| d.diff_type == "renamed").collect(); assert_eq!(renamed.len(), 2, "should detect two renames: {renamed:?}"); assert_eq!(renamed[0].name, "new_a"); assert_eq!(renamed[1].name, "new_b"); } #[test] fn multiple_renames_sql() { let diffs = make_col_diffs( &[("id", "int"), ("new_a", "varchar(50)"), ("new_b", "int")], &[("id", "int"), ("old_a", "varchar(50)"), ("old_b", "int")], true, ); for (db, label) in [(DatabaseType::Mysql, "MySQL"), (DatabaseType::Postgres, "PG")] { let sql = gen_sql(wrap_table_diff("t", diffs.clone()), db, None); // Two renames → two CHANGE COLUMN / RENAME COLUMN operations let _n: u64 = 2; assert!(sql.contains("COLUMN"), "{label}: {sql}"); } } // -- 33. Rename threshold edge cases -- #[test] fn rename_threshold_zero_detects_all() { let s: Vec = vec![column("a", "int", None), column("b2", "varchar(10)", None)]; let t: Vec = vec![column("a", "int", None), column("b1", "varchar(10)", None)]; // rename detection is skipped when threshold <= 0.0, use a tiny threshold let diffs = diff_columns_with_options(&s, &t, false, false, true, 0.001); let renamed: Vec<_> = diffs.iter().filter(|d| d.diff_type == "renamed").collect(); assert_eq!(renamed.len(), 1, "threshold near-zero should detect: {renamed:?}"); } #[test] fn rename_threshold_one_detects_exact_only() { let s: Vec = vec![column("a", "varchar(10)", None), column("b2", "text", None)]; let t: Vec = vec![column("a", "varchar(10)", None), column("b1", "varchar(10)", None)]; let diffs = diff_columns_with_options(&s, &t, false, false, true, 1.0); let renamed: Vec<_> = diffs.iter().filter(|d| d.diff_type == "renamed").collect(); assert_eq!(renamed.len(), 0, "threshold 1 should not match text≠varchar: {renamed:?}"); } #[test] fn rename_threshold_mid_detects_family_only() { let s: Vec = vec![column("a", "tinyint", None), column("b2", "int", None)]; let t: Vec = vec![column("a", "tinyint", None), column("b1", "bigint", None)]; let diffs = diff_columns_with_options(&s, &t, false, false, true, 0.9); let renamed: Vec<_> = diffs.iter().filter(|d| d.diff_type == "renamed").collect(); assert_eq!(renamed.len(), 0, "threshold 0.9 should not match tinyint≠bigint: {renamed:?}"); let diffs2 = diff_columns_with_options(&s, &t, false, false, true, 0.5); let renamed2: Vec<_> = diffs2.iter().filter(|d| d.diff_type == "renamed").collect(); assert_eq!(renamed2.len(), 1, "threshold 0.5 should detect integer family: {renamed2:?}"); } // -- 34. Default value changes -- #[test] fn default_value_change_mysql() { let source = vec![ColumnInfo { column_default: Some("'guest'".into()), ..column("name", "varchar(50)", None) }]; let target = vec![ColumnInfo { column_default: None, ..column("name", "varchar(50)", None) }]; let diffs = diff_columns_with_options(&source, &target, false, false, false, 0.5); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Mysql, None); assert!(sql.contains("MODIFY COLUMN"), "default change: {sql}"); } #[test] fn default_value_change_postgres() { let source = vec![ColumnInfo { column_default: Some("'guest'".into()), ..column("name", "varchar(50)", None) }]; let target = vec![ColumnInfo { column_default: None, ..column("name", "varchar(50)", None) }]; let diffs = diff_columns_with_options(&source, &target, false, false, false, 0.5); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Postgres, None); assert!(sql.contains("SET DEFAULT"), "default add: {sql}"); } #[test] fn default_value_drop_postgres() { let source = vec![ColumnInfo { column_default: None, ..column("name", "varchar(50)", None) }]; let target = vec![ColumnInfo { column_default: Some("'old'".into()), ..column("name", "varchar(50)", None) }]; let diffs = diff_columns_with_options(&source, &target, false, false, false, 0.5); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Postgres, None); assert!(sql.contains("DROP DEFAULT"), "default drop: {sql}"); } // -- 35. Column order changes -- #[test] fn column_order_change_only_no_type_change() { let source = vec![column("id", "int", None), column("name", "text", None), column("age", "int", None)]; let target = vec![column("age", "int", None), column("name", "text", None), column("id", "int", None)]; let diffs = diff_columns_with_options(&source, &target, false, true, false, 0.5); assert!(!diffs.is_empty(), "should detect order changes"); assert!(diffs.iter().all(|d| d.diff_type == "modified"), "all should be modified"); assert!(diffs.iter().all(|d| d.changes.iter().any(|c| c.starts_with("order:"))), "all order changes"); } #[test] fn column_order_changes_with_source_dialect() { let source = vec![column("id", "int(11)", None), column("name", "varchar(50)", None)]; let target = vec![column("name", "text", None), column("id", "int", None)]; let diffs = diff_columns_with_options(&source, &target, false, true, false, 0.5); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Postgres, Some(DialectKind::Mysql)); assert!(sql.contains("INTEGER"), "type converted: {sql}"); } // -- 36. prepare_schema_diff integration with source_dialect -- #[test] fn prepare_schema_diff_with_source_dialect() { let options = SchemaDiffPreparationOptions { source_tables: vec![TableInfo { name: "users".into(), table_type: "BASE TABLE".into(), comment: None, parent_schema: None, parent_name: None, }], target_tables: vec![TableInfo { name: "users".into(), table_type: "BASE TABLE".into(), comment: None, parent_schema: None, parent_name: None, }], source_details: vec![TableSchemaDetail { name: "users".into(), columns: vec![column("name2", "varchar(100)", None), column("id", "int(11)", None)], indexes: vec![], foreign_keys: vec![], triggers: vec![], ddl: None, }], target_details: vec![TableSchemaDetail { name: "users".into(), columns: vec![column("name", "varchar(100)", None), column("id", "int", None)], indexes: vec![], foreign_keys: vec![], triggers: vec![], ddl: None, }], database_type: DatabaseType::Mysql, target_schema: None, ignore_comments: false, cascade_delete: false, compare_column_order: false, detect_renames: true, detect_table_renames: false, rename_threshold: 0.5, enable_rollback: false, source_dialect: Some(DialectKind::Mysql), target_dialect: Some(DialectKind::Mysql), ..Default::default() }; let result = prepare_schema_diff(options); assert!(!result.diffs.is_empty(), "should have diffs"); let sql = &result.sync_sql; assert!(sql.contains("CHANGE COLUMN"), "detected rename: {sql}"); assert!(!sql.contains("DROP COLUMN"), "no false drop: {sql}"); } #[test] fn prepare_schema_diff_integration_cross_dialect() { let options = SchemaDiffPreparationOptions { source_tables: vec![TableInfo { name: "t".into(), table_type: "BASE TABLE".into(), comment: None, parent_schema: None, parent_name: None, }], target_tables: vec![TableInfo { name: "t".into(), table_type: "BASE TABLE".into(), comment: None, parent_schema: None, parent_name: None, }], source_details: vec![TableSchemaDetail { name: "t".into(), columns: vec![column("flag", "tinyint", None), column("name2", "varchar(50)", None)], indexes: vec![], foreign_keys: vec![], triggers: vec![], ddl: None, }], target_details: vec![TableSchemaDetail { name: "t".into(), columns: vec![column("flag", "smallint", None), column("name", "varchar(50)", None)], indexes: vec![], foreign_keys: vec![], triggers: vec![], ddl: None, }], database_type: DatabaseType::Postgres, detect_renames: true, rename_threshold: 0.5, source_dialect: Some(DialectKind::Mysql), target_dialect: Some(DialectKind::Postgres), ..Default::default() }; let result = prepare_schema_diff(options); let sql = &result.sync_sql; assert!(sql.contains("RENAME COLUMN"), "PG rename: {sql}"); assert!(sql.contains("SMALLINT"), "tinyint→SMALLINT: {sql}"); assert!(!sql.contains('`'), "PG no backticks: {sql}"); } // -- 37. Index + column rename combined -- #[test] fn index_and_rename_combined() { let col_diffs = make_col_diffs(&[("id", "int"), ("name2", "varchar(50)")], &[("id", "int"), ("name", "varchar(50)")], true); let table_diff = TableDiff { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "t".to_string(), columns: Some(col_diffs), indexes: Some(vec![IndexDiff { diff_type: "added".to_string(), name: "idx_name".to_string(), source: Some(index(IndexInfo { name: "idx_name".to_string(), columns: vec!["name2".to_string()], is_unique: false, is_primary: false, filter: None, index_type: None, included_columns: None, comment: None, })), target: None, changes: vec![], }]), foreign_keys: None, triggers: None, ddl: None, target_ddl: None, source_table_comment: None, target_table_comment: None, sync_sql: None, }; let sql = generate_schema_sync_sql(&[table_diff], &[], &[], &[], &[], DatabaseType::Postgres, None, false, None, &[]); assert!(sql.contains("RENAME COLUMN"), "rename: {sql}"); assert!(sql.contains("CREATE INDEX"), "index: {sql}"); } #[test] fn index_and_rename_combined_mysql() { let col_diffs = make_col_diffs(&[("id", "int"), ("name2", "varchar(50)")], &[("id", "int"), ("name", "varchar(50)")], true); let table_diff = TableDiff { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "t".to_string(), columns: Some(col_diffs), indexes: Some(vec![IndexDiff { diff_type: "removed".to_string(), name: "idx_old".to_string(), source: None, target: Some(index(IndexInfo { name: "idx_old".to_string(), columns: vec!["name".to_string()], is_unique: false, is_primary: false, filter: None, index_type: None, included_columns: None, comment: None, })), changes: vec![], }]), foreign_keys: None, triggers: None, ddl: None, target_ddl: None, source_table_comment: None, target_table_comment: None, sync_sql: None, }; let sql = generate_schema_sync_sql(&[table_diff], &[], &[], &[], &[], DatabaseType::Mysql, None, false, None, &[]); assert!(sql.contains("CHANGE COLUMN"), "rename: {sql}"); assert!(sql.contains("DROP INDEX"), "drop index: {sql}"); } // -- 38. diff_columns_with_compatibility cross-dialect -- #[test] fn diff_columns_with_compatibility_cross_dialect() { let source = vec![column("id", "int(11)", None)]; let target = vec![column("id", "integer", None)]; let (_diffs, warnings) = diff_columns_with_compatibility( &source, &target, false, false, DialectKind::Mysql, DialectKind::Postgres, 0.5, &[], ); // int(11)→integer should be compatible let has_warning = warnings.iter().any(|w| w.column_name == "id"); assert!(!has_warning, "int(11)→integer should be compatible"); } #[test] fn diff_columns_with_compatibility_warning() { let source = vec![column("id", "int", None)]; let target = vec![column("id", "text", None)]; let (_diffs, warnings) = diff_columns_with_compatibility( &source, &target, false, false, DialectKind::Mysql, DialectKind::Postgres, 0.9, &[], ); let has_warning = warnings.iter().any(|w| w.column_name == "id"); assert!(has_warning, "int→text should generate warning"); } // -- 39. Type mapping prefix matching edge cases -- #[test] fn convert_type_prefix_matches_parameterized() { use crate::sql_dialect::descriptor::TypeMappingMatrix; let matrix = TypeMappingMatrix::for_dialects(DialectKind::Mysql, DialectKind::Postgres); let (result, _) = matrix.convert_type("tinyint(1)"); assert_eq!(result, "BOOLEAN", "tinyint(1) → BOOLEAN"); // tinyint(4) matches TINYINT prefix rule → SMALLINT (not BOOLEAN) let (result, _) = matrix.convert_type("tinyint(4)"); assert_eq!(result, "SMALLINT", "tinyint(4) → SMALLINT"); } #[test] fn convert_type_unknown_type_passthrough() { use crate::sql_dialect::descriptor::TypeMappingMatrix; let matrix = TypeMappingMatrix::for_dialects(DialectKind::Mysql, DialectKind::Postgres); let (result, requires_cast) = matrix.convert_type("geometry"); assert_eq!(result, "geometry", "unknown type passthrough"); assert!(requires_cast, "unknown type requires cast"); } #[test] fn convert_type_empty_string() { use crate::sql_dialect::descriptor::TypeMappingMatrix; let matrix = TypeMappingMatrix::for_dialects(DialectKind::Mysql, DialectKind::Postgres); let (result, _) = matrix.convert_type(""); assert_eq!(result, "", "empty string passthrough"); } // -- 40. Rollback SQL with column renames -- #[test] fn rollback_graph_with_renames() { let diffs = vec![TableDiff { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "t".to_string(), columns: Some(vec![ColumnDiff { diff_type: "renamed".to_string(), name: "new_name".to_string(), source: Some(column("new_name", "varchar(50)", None)), target: Some(column("old_name", "varchar(50)", None)), changes: vec!["old_name → new_name".to_string()], }]), indexes: None, foreign_keys: None, triggers: None, ddl: None, target_ddl: None, source_table_comment: None, target_table_comment: None, sync_sql: None, }]; let dep_graph = DependencyGraph::build(&[], &[]); let graph = RollbackGraph::from_forward_diffs(&diffs, &[], &dep_graph); let rollback_sql = generate_rollback_sync_sql(&graph, DatabaseType::Mysql, None, false); assert!( rollback_sql.contains("CHANGE COLUMN `new_name` `old_name`"), "rollback should reverse rename: {rollback_sql}" ); } #[test] fn rollback_with_cross_dialect_type_conversion() { let diffs = vec![TableDiff { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "t".to_string(), columns: Some(vec![ColumnDiff { diff_type: "added".to_string(), name: "id".to_string(), source: Some(column("id", "int(11)", None)), target: None, changes: vec![], }]), indexes: None, foreign_keys: None, triggers: None, ddl: None, target_ddl: None, source_table_comment: None, target_table_comment: None, sync_sql: None, }]; let dep_graph = DependencyGraph::build(&[], &[]); let graph = RollbackGraph::from_forward_diffs(&diffs, &[], &dep_graph); let rollback_sql = generate_rollback_sync_sql(&graph, DatabaseType::Postgres, None, false); assert!(rollback_sql.contains("DROP COLUMN"), "rollback add→drop: {rollback_sql}"); } // -- 41. Multiple MySQL→PG type conversion in combined SQL -- #[test] fn cross_dialect_multiple_type_conversions_in_one_alter() { let diffs = make_col_diffs( &[("a1", "tinyint"), ("b1", "mediumint"), ("c1", "float"), ("d1", "double"), ("e1", "datetime")], &[], false, ); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Postgres, Some(DialectKind::Mysql)); assert!(sql.contains("SMALLINT"), "tinyint→SMALLINT: {sql}"); assert!(sql.contains("INTEGER"), "mediumint→INTEGER: {sql}"); assert!(sql.contains("REAL"), "float→REAL: {sql}"); assert!(sql.contains("DOUBLE PRECISION"), "double→DOUBLE PRECISION: {sql}"); assert!(sql.contains("TIMESTAMP"), "datetime→TIMESTAMP: {sql}"); } // -- 42. ADD COLUMN with default value -- #[test] fn add_column_with_default_value() { let source = vec![ColumnInfo { column_default: Some("0".into()), ..column("status", "int", None) }]; let target: Vec = vec![]; let diffs = diff_columns_with_options(&source, &target, false, false, false, 0.5); for (db, label) in [(DatabaseType::Mysql, "MySQL"), (DatabaseType::Postgres, "PG")] { let sql = gen_sql(wrap_table_diff("t", diffs.clone()), db, None); assert!(sql.contains("DEFAULT 0"), "{label} default: {sql}"); } } // -- 43. Comment changes for non-MySQL databases -- #[test] fn column_comment_change_non_mysql() { let source = vec![column("name", "text", Some("new comment"))]; let target = vec![column("name", "text", Some("old comment"))]; let diffs = diff_columns_with_options(&source, &target, false, false, false, 0.5); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Postgres, None); assert!(sql.contains("COMMENT ON COLUMN"), "PG comment: {sql}"); } #[test] fn table_comment_change_mysql() { let _diffs: Vec = vec![]; let table_diff = TableDiff { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "t".to_string(), columns: None, indexes: None, foreign_keys: None, triggers: None, ddl: None, target_ddl: None, source_table_comment: Some(Some("new".to_string())), target_table_comment: Some(Some("old".to_string())), sync_sql: None, }; let sql = generate_schema_sync_sql(&[table_diff], &[], &[], &[], &[], DatabaseType::Mysql, None, false, None, &[]); assert!(sql.contains("COMMENT ="), "MySQL table comment: {sql}"); } // -- 44. Detect renames function (table-level) -- #[test] fn table_detect_renames_exact_match() { let removed = vec!["old_table".to_string()]; let added = vec!["new_table".to_string()]; let source_details = vec![TableSchemaDetail { name: "new_table".into(), columns: vec![column("id", "int", None), column("name", "text", None)], indexes: vec![], foreign_keys: vec![], triggers: vec![], ddl: None, }]; let target_details = vec![TableSchemaDetail { name: "old_table".into(), columns: vec![column("id", "int", None), column("name", "text", None)], indexes: vec![], foreign_keys: vec![], triggers: vec![], ddl: None, }]; let candidates = detect_renames(&removed, &added, &source_details, &target_details, 0.5); assert_eq!(candidates.len(), 1, "should detect table rename"); assert_eq!(candidates[0].source_name, "new_table"); assert_eq!(candidates[0].target_name, "old_table"); } // -- 45. Column rename detection: greedy matching avoids conflicts -- #[test] fn rename_greedy_matching() { let s: Vec = vec![column("a", "int", None), column("b", "varchar(10)", None)]; let t: Vec = vec![column("x", "int", None), column("y", "int", None)]; let diffs = diff_columns_with_options(&s, &t, false, false, true, 0.5); let renamed: Vec<_> = diffs.iter().filter(|d| d.diff_type == "renamed").collect(); // Only one should be renamed (greedy: best score), the other stays added/removed assert!(renamed.len() <= 1, "greedy should avoid double matching: {renamed:?}"); } // -- 46. Column precision/scale changes -- #[test] fn column_precision_scale_change() { let s = vec![column("amount", "decimal(10,2)", None)]; let t = vec![column("amount", "decimal(8,0)", None)]; let diffs = diff_columns_with_options(&s, &t, false, false, false, 0.5); assert_eq!(diffs.len(), 1, "should detect precision change"); assert!( diffs[0].changes.iter().any(|c| c.contains("decimal(8,0) → decimal(10,2)")), "precision diff: {:?}", diffs[0].changes ); } #[test] fn column_precision_change_generates_sql() { let s = vec![column("price", "decimal(10,2)", None)]; let t = vec![column("price", "decimal(8,2)", None)]; let diffs = diff_columns_with_options(&s, &t, false, false, false, 0.5); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Mysql, None); assert!(sql.contains("decimal(10,2)"), "precision in sql: {sql}"); } // -- 47. Column length changes -- #[test] fn column_length_change_detected() { let s = vec![column("name", "varchar(255)", None)]; let t = vec![column("name", "varchar(100)", None)]; let diffs = diff_columns_with_options(&s, &t, false, false, false, 0.5); assert_eq!(diffs.len(), 1, "should detect length change"); assert!( diffs[0].changes.iter().any(|c| c.contains("varchar(100) → varchar(255)")), "length diff: {:?}", diffs[0].changes ); } #[test] fn column_length_change_generates_modify_sql() { let s = vec![column("name", "varchar(255)", None)]; let t = vec![column("name", "varchar(100)", None)]; let diffs = diff_columns_with_options(&s, &t, false, false, false, 0.5); for (db, label) in [(DatabaseType::Mysql, "MySQL"), (DatabaseType::Postgres, "PG")] { let sql = gen_sql(wrap_table_diff("t", diffs.clone()), db, None); assert!(sql.contains("varchar(255)"), "{label} length: {sql}"); } } // -- 48. Column comment changes with ignore_comments option -- #[test] fn column_comment_change_detected() { let s = vec![column("name", "int", Some("new comment"))]; let t = vec![column("name", "int", Some("old comment"))]; let diffs = diff_columns_with_options(&s, &t, false, false, false, 0.5); assert_eq!(diffs.len(), 1, "should detect comment change"); assert!(diffs[0].changes.iter().any(|c| c.starts_with("comment:")), "comment diff: {:?}", diffs[0].changes); } #[test] fn column_comment_ignored_when_option_set() { let s = vec![column("name", "int", Some("new"))]; let t = vec![column("name", "int", Some("old"))]; let diffs = diff_columns_with_options(&s, &t, true, false, false, 0.5); assert!(diffs.is_empty(), "should ignore comment when option set: {diffs:?}"); } #[test] fn column_comment_change_mysql_sql() { let s = vec![column("name", "varchar(50)", Some("中文注释"))]; let t = vec![column("name", "varchar(50)", Some("old"))]; let diffs = diff_columns_with_options(&s, &t, false, false, false, 0.5); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Mysql, None); assert!(sql.contains("COMMENT"), "MySQL comment: {sql}"); assert!(sql.contains("中文注释"), "Chinese comment: {sql}"); } #[test] fn table_comment_change_mysql_sql() { let _diffs: Vec = vec![]; let table_diff = TableDiff { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "t".to_string(), columns: None, indexes: None, foreign_keys: None, triggers: None, ddl: None, target_ddl: None, source_table_comment: Some(Some("新表".to_string())), target_table_comment: Some(Some("旧表".to_string())), sync_sql: None, }; let sql = generate_schema_sync_sql(&[table_diff], &[], &[], &[], &[], DatabaseType::Mysql, None, false, None, &[]); assert!(sql.contains("COMMENT ="), "MySQL table comment: {sql}"); assert!(sql.contains("新表"), "Chinese table comment: {sql}"); } #[test] fn table_comment_ignored_with_option() { let options = SchemaDiffPreparationOptions { source_tables: vec![TableInfo { name: "t".into(), table_type: "BASE TABLE".into(), comment: Some("new".into()), parent_schema: None, parent_name: None, }], target_tables: vec![TableInfo { name: "t".into(), table_type: "BASE TABLE".into(), comment: Some("old".into()), parent_schema: None, parent_name: None, }], source_details: vec![TableSchemaDetail { name: "t".into(), columns: vec![], indexes: vec![], foreign_keys: vec![], triggers: vec![], ddl: None, }], target_details: vec![TableSchemaDetail { name: "t".into(), columns: vec![], indexes: vec![], foreign_keys: vec![], triggers: vec![], ddl: None, }], database_type: DatabaseType::Mysql, ignore_comments: true, ..Default::default() }; let result = prepare_schema_diff(options); assert!(result.diffs.is_empty(), "should ignore table comment: {:?}", result.diffs); } // -- 49. Index type differences -- #[test] fn index_type_diff_btree_vs_hash() { let diffs = diff_indexes( &[index(IndexInfo { name: "idx_t".into(), columns: vec!["a".into()], is_unique: false, is_primary: false, filter: None, index_type: Some("BTREE".into()), included_columns: None, comment: None, })], &[index(IndexInfo { name: "idx_t".into(), columns: vec!["a".into()], is_unique: false, is_primary: false, filter: None, index_type: Some("HASH".into()), included_columns: None, comment: None, })], ); assert_eq!(diffs.len(), 1, "index type diff detected"); assert!(diffs[0].changes.iter().any(|c| c.contains("type:")), "type change: {:?}", diffs[0].changes); } #[test] fn index_type_fulltext_detected() { let diffs = diff_indexes( &[index(IndexInfo { name: "idx_t".into(), columns: vec!["content".into()], is_unique: false, is_primary: false, filter: None, index_type: Some("FULLTEXT".into()), included_columns: None, comment: None, })], &[index(IndexInfo { name: "idx_t".into(), columns: vec!["content".into()], is_unique: false, is_primary: false, filter: None, index_type: None, included_columns: None, comment: None, })], ); assert_eq!(diffs[0].changes.iter().filter(|c| c.contains("FULLTEXT")).count(), 1, "fulltext change"); } // -- 50. Index column ordering -- #[test] fn index_column_order_different() { let diffs = diff_indexes( &[index(IndexInfo { name: "idx_t".into(), columns: vec!["a".into(), "b".into()], is_unique: false, is_primary: false, filter: None, index_type: None, included_columns: None, comment: None, })], &[index(IndexInfo { name: "idx_t".into(), columns: vec!["b".into(), "a".into()], is_unique: false, is_primary: false, filter: None, index_type: None, included_columns: None, comment: None, })], ); assert_eq!(diffs.len(), 1, "order diff detected"); assert!(diffs[0].changes.iter().any(|c| c.contains("columns:")), "column order change: {:?}", diffs[0].changes); } // -- 51. Included columns in indexes -- #[test] fn index_included_columns_diff() { let diffs = diff_indexes( &[index(IndexInfo { name: "idx_t".into(), columns: vec!["a".into()], is_unique: true, is_primary: false, filter: None, index_type: None, included_columns: Some(vec!["b".into(), "c".into()]), comment: None, })], &[index(IndexInfo { name: "idx_t".into(), columns: vec!["a".into()], is_unique: true, is_primary: false, filter: None, index_type: None, included_columns: Some(vec!["b".into()]), comment: None, })], ); assert_eq!(diffs.len(), 1, "included columns diff detected"); assert!(diffs[0].changes.iter().any(|c| c.contains("include:")), "include change: {:?}", diffs[0].changes); } #[test] fn index_included_columns_added() { let diffs = diff_indexes( &[index(IndexInfo { name: "idx_t".into(), columns: vec!["a".into()], is_unique: true, is_primary: false, filter: None, index_type: None, included_columns: Some(vec!["b".into()]), comment: None, })], &[index(IndexInfo { name: "idx_t".into(), columns: vec!["a".into()], is_unique: true, is_primary: false, filter: None, index_type: None, included_columns: None, comment: None, })], ); assert_eq!(diffs.len(), 1, "included added"); } // -- 52. Filtered/partial indexes -- #[test] fn index_filter_change() { let diffs = diff_indexes( &[index(IndexInfo { name: "idx_t".into(), columns: vec!["status".into()], is_unique: false, is_primary: false, filter: Some("status > 0".into()), index_type: None, included_columns: None, comment: None, })], &[index(IndexInfo { name: "idx_t".into(), columns: vec!["status".into()], is_unique: false, is_primary: false, filter: None, index_type: None, included_columns: None, comment: None, })], ); assert_eq!(diffs.len(), 1, "filter diff"); assert!(diffs[0].changes.iter().any(|c| c.contains("filter:")), "filter change: {:?}", diffs[0].changes); } // -- 53. Multiple index operations in one diff -- #[test] fn multiple_index_operations() { let diffs = diff_indexes( &[ index(IndexInfo { name: "idx_new".into(), columns: vec!["a".into()], is_unique: true, is_primary: false, filter: None, index_type: None, included_columns: None, comment: None, }), index(IndexInfo { name: "idx_modified".into(), columns: vec!["a".into(), "b".into()], is_unique: false, is_primary: false, filter: None, index_type: Some("BTREE".into()), included_columns: None, comment: None, }), ], &[ index(IndexInfo { name: "idx_removed".into(), columns: vec!["c".into()], is_unique: false, is_primary: false, filter: None, index_type: None, included_columns: None, comment: None, }), index(IndexInfo { name: "idx_modified".into(), columns: vec!["a".into()], is_unique: true, is_primary: false, filter: None, index_type: None, included_columns: None, comment: None, }), ], ); assert_eq!(diffs.len(), 3, "add + modify + remove: {diffs:?}"); let types: Vec<&str> = diffs.iter().map(|d| d.diff_type.as_str()).collect(); assert!(types.contains(&"added"), "should have added"); assert!(types.contains(&"removed"), "should have removed"); assert!(types.contains(&"modified"), "should have modified"); } // -- 54. Foreign key ref_table / ref_column changes -- #[test] fn foreign_key_reference_table_change() { let diffs = diff_foreign_keys( &[foreign_key(ForeignKeyInfo { name: "fk_t".into(), column: "user_id".into(), ref_schema: None, ref_table: "users".into(), ref_column: "id".into(), on_update: None, on_delete: None, })], &[foreign_key(ForeignKeyInfo { name: "fk_t".into(), column: "user_id".into(), ref_schema: None, ref_table: "employees".into(), ref_column: "id".into(), on_update: None, on_delete: None, })], ); assert_eq!(diffs.len(), 1, "ref_table change"); assert!(diffs[0].changes.iter().any(|c| c.contains("ref table")), "ref table: {:?}", diffs[0].changes); } #[test] fn foreign_key_reference_column_change() { let diffs = diff_foreign_keys( &[foreign_key(ForeignKeyInfo { name: "fk_t".into(), column: "user_id".into(), ref_schema: None, ref_table: "users".into(), ref_column: "id".into(), on_update: None, on_delete: None, })], &[foreign_key(ForeignKeyInfo { name: "fk_t".into(), column: "user_id".into(), ref_schema: None, ref_table: "users".into(), ref_column: "uid".into(), on_update: None, on_delete: None, })], ); assert_eq!(diffs.len(), 1, "ref_column change"); } #[test] fn foreign_key_local_column_change() { let diffs = diff_foreign_keys( &[foreign_key(ForeignKeyInfo { name: "fk_t".into(), column: "user_id".into(), ref_schema: None, ref_table: "users".into(), ref_column: "id".into(), on_update: None, on_delete: None, })], &[foreign_key(ForeignKeyInfo { name: "fk_t".into(), column: "member_id".into(), ref_schema: None, ref_table: "users".into(), ref_column: "id".into(), on_update: None, on_delete: None, })], ); assert_eq!(diffs.len(), 1, "local column change"); } #[test] fn foreign_key_referential_action_change() { let diffs = diff_foreign_keys( &[foreign_key(ForeignKeyInfo { name: "fk_t".into(), column: "user_id".into(), ref_schema: Some("auth".into()), ref_table: "users".into(), ref_column: "id".into(), on_update: Some("cascade".into()), on_delete: Some("SET NULL".into()), })], &[foreign_key(ForeignKeyInfo { name: "fk_t".into(), column: "user_id".into(), ref_schema: Some("auth".into()), ref_table: "users".into(), ref_column: "id".into(), on_update: Some("NO ACTION".into()), on_delete: Some("RESTRICT".into()), })], ); assert_eq!(diffs.len(), 1, "referential action change: {diffs:?}"); assert!(diffs[0].changes.iter().any(|change| change == "delete: RESTRICT → SET NULL")); assert!(diffs[0].changes.iter().any(|change| change == "update: NO ACTION → CASCADE")); } #[test] fn modified_foreign_key_sql_preserves_reference_schema_and_actions() { let table_diff = TableDiff { diff_type: "modified".into(), object_type: Some("table".into()), name: "orders".into(), columns: None, indexes: None, foreign_keys: Some(vec![ForeignKeyDiff { diff_type: "modified".into(), name: "orders_user_fk".into(), source: Some(foreign_key(ForeignKeyInfo { name: "orders_user_fk".into(), column: "user_id".into(), ref_schema: Some("identity".into()), ref_table: "users".into(), ref_column: "id".into(), on_update: Some("CASCADE".into()), on_delete: Some("SET NULL".into()), })), target: None, changes: vec![], }]), triggers: None, ddl: None, target_ddl: None, source_table_comment: None, target_table_comment: None, sync_sql: None, }; let sql = generate_schema_sync_sql( &[table_diff], &[], &[], &[], &[], DatabaseType::Postgres, Some("sales"), false, None, &[], ); assert!(sql.contains("REFERENCES \"identity\".\"users\" (\"id\")"), "schema: {sql}"); assert!(sql.contains("ON DELETE SET NULL ON UPDATE CASCADE"), "actions: {sql}"); } #[test] fn column_index_fk_combined_diff() { let col_diffs = make_col_diffs( &[("id", "int"), ("name2", "varchar(100)")], &[("id", "int"), ("name", "varchar(50)")], true, ); let table_diff = TableDiff { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "t".to_string(), columns: Some(col_diffs), indexes: Some(vec![ IndexDiff { diff_type: "added".to_string(), name: "idx_name".into(), source: Some(index(IndexInfo { name: "idx_name".into(), columns: vec!["name2".into()], is_unique: false, is_primary: false, filter: None, index_type: Some("BTREE".into()), included_columns: None, comment: None, })), target: None, changes: vec![], }, IndexDiff { diff_type: "removed".to_string(), name: "idx_old".into(), source: None, target: Some(index(IndexInfo { name: "idx_old".into(), columns: vec!["name".into()], is_unique: false, is_primary: false, filter: None, index_type: None, included_columns: None, comment: None, })), changes: vec![], }, ]), foreign_keys: Some(vec![ForeignKeyDiff { diff_type: "modified".to_string(), name: "fk_ref".into(), source: Some(foreign_key(ForeignKeyInfo { name: "fk_ref".into(), column: "id".into(), ref_schema: None, ref_table: "users".into(), ref_column: "id".into(), on_update: None, on_delete: Some("CASCADE".into()), })), target: Some(foreign_key(ForeignKeyInfo { name: "fk_ref".into(), column: "id".into(), ref_schema: None, ref_table: "users".into(), ref_column: "id".into(), on_update: None, on_delete: Some("SET NULL".into()), })), changes: vec!["delete: SET NULL → CASCADE".into()], }]), triggers: None, ddl: None, target_ddl: None, source_table_comment: None, target_table_comment: None, sync_sql: None, }; let sql = generate_schema_sync_sql(&[table_diff], &[], &[], &[], &[], DatabaseType::Postgres, None, false, None, &[]); assert!(sql.contains("RENAME COLUMN"), "rename: {sql}"); assert!(sql.contains("CREATE INDEX"), "add index: {sql}"); assert!(sql.contains("DROP INDEX"), "drop index: {sql}"); assert!(sql.contains("DROP CONSTRAINT"), "fk drop: {sql}"); assert!(sql.contains("ADD CONSTRAINT"), "fk add: {sql}"); } // -- 56. Column order changes with comment option -- #[test] fn column_order_ignored_when_disabled_but_comment_detected() { let s = vec![column("a", "int", Some("x")), column("b", "varchar(10)", None)]; let t = vec![column("b", "varchar(10)", None), column("a", "int", None)]; let diffs = diff_columns_with_options(&s, &t, false, false, false, 0.5); // order compare disabled, so only comment change on "a" should be detected assert!(!diffs.is_empty(), "comment change should be detected: {diffs:?}"); } // -- 57. Type conversion with precision types (decimal, numeric) -- #[test] fn decimal_type_conversion_mysql_to_postgres() { use crate::sql_dialect::descriptor::TypeMappingMatrix; let matrix = TypeMappingMatrix::for_dialects(DialectKind::Mysql, DialectKind::Postgres); // decimal is not in the mapping rules → passes through let (result, _) = matrix.convert_type("decimal(10,2)"); assert_eq!(result, "decimal(10,2)", "decimal passthrough"); } // -- 58. Column diff with all attributes different -- #[test] fn column_all_attributes_changed() { let s = vec![ColumnInfo { name: "c".into(), data_type: "varchar(100)".into(), is_nullable: true, column_default: Some("'default'".into()), comment: Some("new".into()), is_primary_key: false, extra: None, numeric_precision: None, numeric_scale: None, character_maximum_length: None, enum_values: None, character_set: None, collation: None, }]; let t = vec![ColumnInfo { name: "c".into(), data_type: "varchar(50)".into(), is_nullable: false, column_default: None, comment: Some("old".into()), is_primary_key: false, extra: None, numeric_precision: None, numeric_scale: None, character_maximum_length: None, enum_values: None, character_set: None, collation: None, }]; let diffs = diff_columns_with_options(&s, &t, false, false, false, 0.5); assert_eq!(diffs.len(), 1, "all changes in one diff"); let changes = &diffs[0].changes; assert!(changes.iter().any(|c| c.starts_with("type:")), "type: {changes:?}"); assert!(changes.iter().any(|c| c.starts_with("nullable:")), "nullable: {changes:?}"); assert!(changes.iter().any(|c| c.starts_with("default:")), "default: {changes:?}"); assert!(changes.iter().any(|c| c.starts_with("comment:")), "comment: {changes:?}"); } // -- 56. Foreign key with multiple changes (ref_table + ref_column) -- #[test] fn foreign_key_multiple_changes() { let diffs = diff_foreign_keys( &[foreign_key(ForeignKeyInfo { name: "fk".into(), column: "id".into(), ref_schema: None, ref_table: "users".into(), ref_column: "id".into(), on_update: None, on_delete: None, })], &[foreign_key(ForeignKeyInfo { name: "fk".into(), column: "id".into(), ref_schema: None, ref_table: "employees".into(), ref_column: "uid".into(), on_update: None, on_delete: None, })], ); assert_eq!(diffs.len(), 1, "multiple FK changes"); assert!(diffs[0].changes.iter().any(|c| c.contains("ref table")), "ref table: {:?}", diffs[0].changes); assert!(diffs[0].changes.iter().any(|c| c.contains("ref column")), "ref column: {:?}", diffs[0].changes); } // -- 60. With and without ignore_comments on prepare_schema_diff -- #[test] fn prepare_schema_diff_comment_option_toggle() { fn run_test(ignore: bool, expect_diffs: bool) { let options = SchemaDiffPreparationOptions { source_tables: vec![TableInfo { name: "t".into(), table_type: "BASE TABLE".into(), comment: Some("new_comment".into()), parent_schema: None, parent_name: None, }], target_tables: vec![TableInfo { name: "t".into(), table_type: "BASE TABLE".into(), comment: Some("old_comment".into()), parent_schema: None, parent_name: None, }], source_details: vec![TableSchemaDetail { name: "t".into(), columns: vec![column("c", "int", Some("col_new"))], indexes: vec![], foreign_keys: vec![], triggers: vec![], ddl: None, }], target_details: vec![TableSchemaDetail { name: "t".into(), columns: vec![column("c", "int", Some("col_old"))], indexes: vec![], foreign_keys: vec![], triggers: vec![], ddl: None, }], database_type: DatabaseType::Mysql, ignore_comments: ignore, ..Default::default() }; let result = prepare_schema_diff(options); if expect_diffs { assert!(!result.diffs.is_empty(), "should have diffs when ignore={ignore}"); } else { assert!(result.diffs.is_empty(), "should be empty when ignore={ignore}"); } } run_test(true, false); run_test(false, true); } // ═══════════════════════════════════════════════════════════════ // Cross-dialect CREATE TABLE tests (all 11×11 pairs) // ═══════════════════════════════════════════════════════════════ fn _dialect_from_db(db: DatabaseType) -> DialectKind { DialectKind::from_database_type(db) } fn all_kinds() -> Vec { vec![ DialectKind::Mysql, DialectKind::Postgres, DialectKind::Sqlite, DialectKind::SqlServer, DialectKind::Oracle, DialectKind::H2, DialectKind::ClickHouse, DialectKind::DuckDb, DialectKind::ManticoreSearch, DialectKind::Informix, DialectKind::Questdb, ] } fn kind_to_db(kind: DialectKind) -> Option { match kind { DialectKind::Mysql => Some(DatabaseType::Mysql), DialectKind::Postgres => Some(DatabaseType::Postgres), DialectKind::Sqlite => Some(DatabaseType::Sqlite), DialectKind::SqlServer => Some(DatabaseType::SqlServer), DialectKind::Oracle => Some(DatabaseType::Oracle), DialectKind::H2 => Some(DatabaseType::H2), DialectKind::ClickHouse => Some(DatabaseType::ClickHouse), DialectKind::DuckDb => Some(DatabaseType::DuckDb), DialectKind::ManticoreSearch => Some(DatabaseType::ManticoreSearch), DialectKind::Informix => Some(DatabaseType::Informix), DialectKind::Questdb => Some(DatabaseType::Questdb), _ => None, } } fn col_pk(name: &str, data_type: &str) -> ColumnInfo { ColumnInfo { is_primary_key: true, ..column(name, data_type, None) } } fn _make_added_table_detail( name: &str, columns: Vec, indexes: Vec, fks: Vec, ddl: Option<&str>, ) -> TableSchemaDetail { TableSchemaDetail { name: name.to_string(), columns, indexes, foreign_keys: fks, triggers: vec![], ddl: ddl.map(|s| s.to_string()), } } fn _prepare_create_table( columns: Vec, indexes: Vec, fks: Vec, source_kind: DialectKind, target_kind: DialectKind, ddl: Option<&str>, ) -> String { let Some(db) = kind_to_db(target_kind) else { return String::new() }; let _src_db = kind_to_db(source_kind).unwrap_or(DatabaseType::Mysql); let options = SchemaDiffPreparationOptions { source_tables: vec![TableInfo { name: "t".into(), table_type: "BASE TABLE".into(), comment: None, parent_schema: None, parent_name: None, }], target_tables: vec![], source_details: vec![_make_added_table_detail("t", columns, indexes, fks, ddl)], target_details: vec![], source_functions: vec![], target_functions: vec![], source_sequences: vec![], target_sequences: vec![], source_rules: vec![], target_rules: vec![], source_owners: vec![], target_owners: vec![], database_type: db, target_schema: None, ignore_comments: false, cascade_delete: false, compare_column_order: false, detect_renames: false, detect_table_renames: false, rename_threshold: 0.5, enable_rollback: false, source_dialect: Some(source_kind), target_dialect: Some(target_kind), batch_patterns: vec![], compatibility_threshold: 0.5, source_permissions: vec![], target_permissions: vec![], shard_strategy: None, resource_constraint: None, field_mappings: vec![], }; let result = prepare_schema_diff(options); result.sync_sql } fn check_identifiers(sql: &str, tgt: DialectKind) { match tgt { DialectKind::Mysql | DialectKind::ManticoreSearch => { assert!(sql.contains('`'), "{tgt:?} should use backticks"); } DialectKind::Oracle => { assert!(!sql.contains('`'), "Oracle no backticks"); assert!(!sql.contains('"'), "Oracle no double-quotes"); } _ => { assert!(!sql.contains('`'), "{tgt:?} should NOT use backticks: {sql}"); } } } fn check_no_mysql_residue(sql: &str, tgt: DialectKind) { if !matches!(tgt, DialectKind::Mysql | DialectKind::ManticoreSearch) { assert!(!sql.contains("ENGINE="), "residual ENGINE= in {tgt:?}: {sql}"); assert!(!sql.contains("CHARSET"), "residual CHARSET in {tgt:?}: {sql}"); } } #[test] fn mysql_to_access_create_table_uses_access_types_and_counter() { let columns = vec![ ColumnDiff { diff_type: "added".into(), name: "id".into(), source: Some(ColumnInfo { name: "id".into(), data_type: "int(11)".into(), is_nullable: false, is_primary_key: true, extra: Some("auto_increment".into()), ..Default::default() }), target: None, changes: vec![], }, ColumnDiff { diff_type: "added".into(), name: "name2".into(), source: Some(ColumnInfo { name: "name2".into(), data_type: "varchar(120)".into(), is_nullable: false, ..Default::default() }), target: None, changes: vec![], }, ColumnDiff { diff_type: "added".into(), name: "del_flag".into(), source: Some(ColumnInfo { name: "del_flag".into(), data_type: "tinyint(2)".into(), is_nullable: false, ..Default::default() }), target: None, changes: vec![], }, ColumnDiff { diff_type: "added".into(), name: "create_at".into(), source: Some(ColumnInfo { name: "create_at".into(), data_type: "datetime".into(), is_nullable: true, ..Default::default() }), target: None, changes: vec![], }, ]; let indexes = vec![IndexDiff { diff_type: "added".into(), name: "idx_del_flag".into(), source: Some(IndexInfo { name: "idx_del_flag".into(), columns: vec!["del_flag".into()], is_unique: false, is_primary: false, index_type: None, filter: None, included_columns: None, comment: None, }), target: None, changes: vec![], }]; let (sql, missing) = generate_create_table_sql( "tb_user", &columns, &indexes, &[], None, DatabaseType::Access, None, Some(DialectKind::Mysql), &[], &[], ); assert!(missing.is_empty(), "{missing:?}"); assert!(sql.contains("COUNTER"), "Access PK should use COUNTER: {sql}"); assert!(!sql.contains("IDENTITY"), "Access must not emit SQL Server IDENTITY: {sql}"); assert!(!sql.contains("int(11)"), "MySQL display width must be stripped: {sql}"); assert!(sql.contains("TEXT(120)") || sql.contains("TEXT (120)"), "varchar→TEXT(n): {sql}"); assert!( sql.contains("BYTE") || sql.contains("\"del_flag\" BYTE") || sql.to_ascii_uppercase().contains("BYTE"), "tinyint→BYTE: {sql}" ); assert!(sql.to_ascii_uppercase().contains("DATETIME"), "datetime stays DATETIME: {sql}"); assert!(sql.contains("CREATE INDEX"), "index: {sql}"); } fn check_auto_increment(sql: &str, tgt: DialectKind) { match tgt { DialectKind::Mysql | DialectKind::ManticoreSearch => { assert!(sql.contains("AUTO_INCREMENT"), "{tgt:?} should have AUTO_INCREMENT: {sql}"); } DialectKind::Postgres => { assert!(sql.contains("SEQUENCE"), "{tgt:?} should use SEQUENCE: {sql}"); } DialectKind::SqlServer => { assert!(sql.contains("IDENTITY"), "{tgt:?} should use IDENTITY: {sql}"); } DialectKind::Oracle => { assert!( sql.contains("GENERATED BY DEFAULT AS IDENTITY"), "{tgt:?} should use GENERATED BY DEFAULT AS IDENTITY: {sql}" ); } _ => { // Other dialects may or may not have auto-increment } } } fn check_type_conversion(sql: &str, src: DialectKind, tgt: DialectKind) { match (src, tgt) { (DialectKind::Mysql, DialectKind::Postgres) => { assert!(sql.contains("INTEGER"), "int→INTEGER in PG: {sql}"); // Only check if source has these types (S2 has tinyint, datetime) if sql.contains("tinyint") || sql.contains("TINYINT") { assert!(sql.contains("SMALLINT"), "tinyint→SMALLINT in PG: {sql}"); } if sql.contains("datetime") || sql.contains("DATETIME") { assert!(sql.contains("TIMESTAMP"), "datetime→TIMESTAMP in PG: {sql}"); } } (DialectKind::Mysql, DialectKind::Sqlite) => { assert!(sql.contains("INTEGER"), "int→INTEGER in SQLite: {sql}"); if sql.contains("datetime") || sql.contains("DATETIME") { assert!(sql.contains("TEXT"), "datetime→TEXT in SQLite: {sql}"); } } (DialectKind::Postgres, DialectKind::Mysql) if sql.contains("text") || sql.contains("TEXT") => {} _ => {} } } fn check_table_sql_structure(sql: &str, tgt: DialectKind) { assert!(sql.contains("CREATE TABLE"), "{tgt:?} missing CREATE TABLE"); match tgt { DialectKind::Mysql | DialectKind::ManticoreSearch => { assert!(sql.contains("PRIMARY KEY"), "{tgt:?} missing PK"); } _ => { assert!(sql.contains("PRIMARY KEY"), "{tgt:?} missing PK: {sql}"); } } } // -- S1: simple table (id INT PK AUTO_INCREMENT, name VARCHAR) -- fn s1_diffs() -> Vec { vec![ ColumnDiff { diff_type: "added".into(), name: "id".into(), source: Some(col_pk("id", "int")), target: None, changes: vec![], }, ColumnDiff { diff_type: "added".into(), name: "name".into(), source: Some(ColumnInfo { name: "name".into(), data_type: "varchar(100)".into(), is_nullable: false, ..column("name", "varchar(100)", None) }), target: None, changes: vec![], }, ] } fn s1_table_diff(src_kind: DialectKind, tgt_kind: DialectKind) -> TableDiff { let Some(_db) = kind_to_db(tgt_kind) else { panic!("no db for {tgt_kind:?}") }; let is_mysql_tgt = matches!(tgt_kind, DialectKind::Mysql | DialectKind::ManticoreSearch); let ddl = if is_mysql_tgt { Some("CREATE TABLE `t` (`id` int NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB".into()) } else if src_kind == tgt_kind { Some( "CREATE TABLE \"t\" (\"id\" INTEGER NOT NULL, \"name\" varchar(100) NOT NULL, PRIMARY KEY (\"id\"));" .into(), ) } else { None }; TableDiff { diff_type: "added".into(), object_type: Some("table".into()), name: "t".into(), columns: Some(s1_diffs()), indexes: None, foreign_keys: None, triggers: None, ddl, target_ddl: None, source_table_comment: None, target_table_comment: None, sync_sql: None, } } #[test] fn cross_dialect_s1_all_pairs_simple_table() { let kinds = all_kinds(); for src in &kinds { for tgt in &kinds { let Some(db) = kind_to_db(*tgt) else { continue }; let src_dialect = if src == tgt { None } else { Some(*src) }; let td = s1_table_diff(*src, *tgt); let sql = generate_schema_sync_sql(&[td], &[], &[], &[], &[], db, None, false, src_dialect, &[]); check_table_sql_structure(&sql, *tgt); check_identifiers(&sql, *tgt); check_no_mysql_residue(&sql, *tgt); check_auto_increment(&sql, *tgt); check_type_conversion(&sql, *src, *tgt); } } } // -- S2: full table (multiple types, index) -- fn s2_diffs() -> (Vec, Vec) { let cols = vec![ ColumnDiff { diff_type: "added".into(), name: "id".into(), source: Some(col_pk("id", "int")), target: None, changes: vec![], }, ColumnDiff { diff_type: "added".into(), name: "title".into(), source: Some(ColumnInfo { name: "title".into(), data_type: "varchar(200)".into(), is_nullable: false, ..column("title", "varchar(200)", None) }), target: None, changes: vec![], }, ColumnDiff { diff_type: "added".into(), name: "body".into(), source: Some(ColumnInfo { name: "body".into(), data_type: "text".into(), is_nullable: true, ..column("body", "text", None) }), target: None, changes: vec![], }, ColumnDiff { diff_type: "added".into(), name: "views".into(), source: Some(ColumnInfo { name: "views".into(), data_type: "int".into(), is_nullable: true, column_default: Some("0".into()), ..column("views", "int", None) }), target: None, changes: vec![], }, ColumnDiff { diff_type: "added".into(), name: "is_pub".into(), source: Some(ColumnInfo { name: "is_pub".into(), data_type: "tinyint".into(), is_nullable: true, ..column("is_pub", "tinyint", None) }), target: None, changes: vec![], }, ColumnDiff { diff_type: "added".into(), name: "created".into(), source: Some(ColumnInfo { name: "created".into(), data_type: "datetime".into(), is_nullable: true, ..column("created", "datetime", None) }), target: None, changes: vec![], }, ]; let idxs = vec![IndexDiff { diff_type: "added".into(), name: "idx_views".into(), source: Some(IndexInfo { name: "idx_views".into(), columns: vec!["views".into()], is_unique: false, is_primary: false, filter: None, index_type: None, included_columns: None, comment: None, }), target: None, changes: vec![], }]; (cols, idxs) } #[test] fn cross_dialect_s2_full_table() { let kinds = all_kinds(); for src in &kinds { for tgt in &kinds { let Some(db) = kind_to_db(*tgt) else { continue }; let src_dialect = if src == tgt { None } else { Some(*src) }; let (cols, idxs) = s2_diffs(); let td = TableDiff { diff_type: "added".into(), object_type: Some("table".into()), name: "t".into(), columns: Some(cols), indexes: Some(idxs), foreign_keys: None, triggers: None, ddl: None, target_ddl: None, source_table_comment: None, target_table_comment: None, sync_sql: None, }; let sql = generate_schema_sync_sql(&[td], &[], &[], &[], &[], db, None, false, src_dialect, &[]); check_table_sql_structure(&sql, *tgt); check_identifiers(&sql, *tgt); check_no_mysql_residue(&sql, *tgt); check_auto_increment(&sql, *tgt); check_type_conversion(&sql, *src, *tgt); match tgt { DialectKind::Mysql | DialectKind::ManticoreSearch => { assert!(sql.contains("KEY "), "{tgt:?} index missing: {sql}"); } _ => { assert!(sql.contains("CREATE INDEX"), "{tgt:?} CREATE INDEX missing: {sql}"); } } } } } // -- S3: table with foreign keys -- fn s3_diffs() -> (Vec, Vec) { let cols = vec![ ColumnDiff { diff_type: "added".into(), name: "id".into(), source: Some(col_pk("id", "int")), target: None, changes: vec![], }, ColumnDiff { diff_type: "added".into(), name: "user_id".into(), source: Some(ColumnInfo { name: "user_id".into(), data_type: "int".into(), is_nullable: false, ..column("user_id", "int", None) }), target: None, changes: vec![], }, ]; let fks = vec![ForeignKeyDiff { diff_type: "added".into(), name: "fk_user".into(), source: Some(ForeignKeyInfo { name: "fk_user".into(), column: "user_id".into(), ref_schema: None, ref_table: "users".into(), ref_column: "id".into(), on_update: None, on_delete: Some("CASCADE".into()), }), target: None, changes: vec![], }]; (cols, fks) } #[test] fn cross_dialect_s3_foreign_key_table() { let kinds = all_kinds(); for src in &kinds { for tgt in &kinds { let Some(db) = kind_to_db(*tgt) else { continue }; let src_dialect = if src == tgt { None } else { Some(*src) }; let (cols, fks) = s3_diffs(); let td = TableDiff { diff_type: "added".into(), object_type: Some("table".into()), name: "t".into(), columns: Some(cols), indexes: None, foreign_keys: Some(fks), triggers: None, ddl: None, target_ddl: None, source_table_comment: None, target_table_comment: None, sync_sql: None, }; let sql = generate_schema_sync_sql(&[td], &[], &[], &[], &[], db, None, false, src_dialect, &[]); check_table_sql_structure(&sql, *tgt); check_identifiers(&sql, *tgt); check_no_mysql_residue(&sql, *tgt); assert!(sql.contains("FOREIGN KEY"), "{tgt:?} FK constraint missing: {sql}"); assert!(sql.contains("REFERENCES"), "{tgt:?} REFERENCES missing: {sql}"); } } } // -- source_dialect=None: verify original DDL preservation -- fn _prepare_create_table_no_dialect( columns: Vec, indexes: Vec, target_kind: DialectKind, ddl: Option<&str>, ) -> String { let Some(db) = kind_to_db(target_kind) else { return String::new() }; let options = SchemaDiffPreparationOptions { source_tables: vec![TableInfo { name: "t".into(), table_type: "BASE TABLE".into(), comment: None, parent_schema: None, parent_name: None, }], target_tables: vec![], source_details: vec![_make_added_table_detail("t", columns, indexes, vec![], ddl)], target_details: vec![], database_type: db, ..Default::default() }; let result = prepare_schema_diff(options); result.sync_sql } #[test] fn cross_dialect_none_source_dialect_original_ddl() { let kinds = all_kinds(); for tgt in &kinds { let Some(db) = kind_to_db(*tgt) else { continue }; let is_mysql_tgt = matches!(tgt, DialectKind::Mysql | DialectKind::ManticoreSearch); let ddl_str = "CREATE TABLE `t` (`id` int NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB"; let td = TableDiff { diff_type: "added".into(), object_type: Some("table".into()), name: "t".into(), columns: Some(s1_diffs()), indexes: None, foreign_keys: None, triggers: None, ddl: Some(ddl_str.into()), target_ddl: None, source_table_comment: None, target_table_comment: None, sync_sql: None, }; let sql = generate_schema_sync_sql(&[td], &[], &[], &[], &[], db, None, false, None, &[]); check_table_sql_structure(&sql, *tgt); if is_mysql_tgt { assert!(sql.contains("ENGINE=InnoDB"), "original DDL preserved for {tgt:?}"); } else { assert!(!sql.contains("ENGINE="), "ENGINE stripped for {tgt:?}: {sql}"); } } } // -- Reverse cross-dialect: non-MySQL source → MySQL target -- #[test] fn cross_dialect_postgres_source_to_mysql_target() { let src = DialectKind::Postgres; let tgt = DialectKind::Mysql; let Some(db) = kind_to_db(tgt) else { return }; let cols = vec![ ColumnDiff { diff_type: "added".into(), name: "id".into(), source: Some(col_pk("id", "integer")), target: None, changes: vec![], }, ColumnDiff { diff_type: "added".into(), name: "label".into(), source: Some(ColumnInfo { name: "label".into(), data_type: "text".into(), is_nullable: false, ..column("label", "text", None) }), target: None, changes: vec![], }, ]; let td = TableDiff { diff_type: "added".into(), object_type: Some("table".into()), name: "t".into(), columns: Some(cols), indexes: None, foreign_keys: None, triggers: None, ddl: None, target_ddl: None, source_table_comment: None, target_table_comment: None, sync_sql: None, }; let sql = generate_schema_sync_sql(&[td], &[], &[], &[], &[], db, None, false, Some(src), &[]); check_table_sql_structure(&sql, tgt); check_identifiers(&sql, tgt); check_type_conversion(&sql, src, tgt); assert!(sql.contains("AUTO_INCREMENT"), "MySQL auto_increment: {sql}"); assert!(sql.contains("LONGTEXT"), "text→LONGTEXT in MySQL: {sql}"); } #[test] fn cross_dialect_sqlserver_source_to_postgres_target() { let src = DialectKind::SqlServer; let tgt = DialectKind::Postgres; let Some(db) = kind_to_db(tgt) else { return }; let cols = vec![ ColumnDiff { diff_type: "added".into(), name: "id".into(), source: Some(col_pk("id", "int")), target: None, changes: vec![], }, ColumnDiff { diff_type: "added".into(), name: "data".into(), source: Some(ColumnInfo { name: "data".into(), data_type: "nvarchar(255)".into(), is_nullable: true, ..column("data", "nvarchar(255)", None) }), target: None, changes: vec![], }, ]; let td = TableDiff { diff_type: "added".into(), object_type: Some("table".into()), name: "t".into(), columns: Some(cols), indexes: None, foreign_keys: None, triggers: None, ddl: None, target_ddl: None, source_table_comment: None, target_table_comment: None, sync_sql: None, }; let sql = generate_schema_sync_sql(&[td], &[], &[], &[], &[], db, None, false, Some(src), &[]); check_table_sql_structure(&sql, tgt); check_identifiers(&sql, tgt); // No mapping rules for SQL Server→PG → types pass through assert!(sql.contains("nvarchar(255)"), "passthrough nvarchar: {sql}"); } #[test] fn cross_dialect_clickhouse_source_to_mysql_target() { let src = DialectKind::ClickHouse; let tgt = DialectKind::Mysql; let Some(db) = kind_to_db(tgt) else { return }; let cols = vec![ ColumnDiff { diff_type: "added".into(), name: "id".into(), source: Some(col_pk("id", "Int32")), target: None, changes: vec![], }, ColumnDiff { diff_type: "added".into(), name: "data".into(), source: Some(ColumnInfo { name: "data".into(), data_type: "String".into(), is_nullable: true, ..column("data", "String", None) }), target: None, changes: vec![], }, ]; let td = TableDiff { diff_type: "added".into(), object_type: Some("table".into()), name: "t".into(), columns: Some(cols), indexes: None, foreign_keys: None, triggers: None, ddl: None, target_ddl: None, source_table_comment: None, target_table_comment: None, sync_sql: None, }; let sql = generate_schema_sync_sql(&[td], &[], &[], &[], &[], db, None, false, Some(src), &[]); check_table_sql_structure(&sql, tgt); check_identifiers(&sql, tgt); // No mapping rules → types pass through assert!(sql.contains("Int32"), "passthrough Int32: {sql}"); assert!(sql.contains("String"), "passthrough String: {sql}"); } #[test] fn mysql_to_postgres_direct_generate() { let diffs = vec![ ColumnDiff { diff_type: "added".into(), name: "id".into(), source: Some(col_pk("id", "int")), target: None, changes: vec![], }, ColumnDiff { diff_type: "added".into(), name: "name".into(), source: Some(ColumnInfo { name: "name".into(), data_type: "varchar(100)".into(), is_nullable: false, ..column("name", "varchar(100)", None) }), target: None, changes: vec![], }, ]; let table_diff = TableDiff { diff_type: "added".into(), object_type: Some("table".into()), name: "t".into(), columns: Some(diffs), indexes: None, foreign_keys: None, triggers: None, ddl: Some("CREATE TABLE `t` (`id` int NOT NULL AUTO_INCREMENT, PRIMARY KEY (`id`)) ENGINE=InnoDB".into()), target_ddl: None, source_table_comment: None, target_table_comment: None, sync_sql: None, }; let sql = generate_schema_sync_sql( &[table_diff], &[], &[], &[], &[], DatabaseType::Postgres, None, false, Some(DialectKind::Mysql), &[], ); assert!(!sql.contains('`'), "PG SQL should not have backticks: {sql}"); assert!(sql.contains("INTEGER"), "int→INTEGER: {sql}"); assert!(!sql.contains("ENGINE="), "no MySQL ENGINE: {sql}"); } // -- FieldMapping apply_with_params tests -- #[test] fn field_mapping_preserve_params() { let mappings = vec![FieldMapping { source_type: "VARCHAR".into(), target_type: "VARCHAR2".into(), param_strategy: ParamStrategy::Preserve, custom_params: None, }]; let result = FieldMapping::apply_with_params(&mappings, "VARCHAR(255)", DialectKind::Oracle); assert_eq!(result, Some("VARCHAR2(255)".to_string())); } #[test] fn field_mapping_strip_params() { let mappings = vec![FieldMapping { source_type: "VARCHAR".into(), target_type: "TEXT".into(), param_strategy: ParamStrategy::Strip, custom_params: None, }]; let result = FieldMapping::apply_with_params(&mappings, "VARCHAR(255)", DialectKind::Oracle); assert_eq!(result, Some("TEXT".to_string())); } #[test] fn field_mapping_custom_params() { let mappings = vec![FieldMapping { source_type: "VARCHAR".into(), target_type: "VARCHAR2".into(), param_strategy: ParamStrategy::Custom, custom_params: Some("(500)".to_string()), }]; let result = FieldMapping::apply_with_params(&mappings, "VARCHAR(255)", DialectKind::Oracle); assert_eq!(result, Some("VARCHAR2(500)".to_string())); } #[test] fn field_mapping_custom_empty_params_falls_back() { let mappings = vec![FieldMapping { source_type: "VARCHAR".into(), target_type: "TEXT".into(), param_strategy: ParamStrategy::Custom, custom_params: Some("".to_string()), }]; let result = FieldMapping::apply_with_params(&mappings, "VARCHAR(255)", DialectKind::Oracle); assert_eq!(result, Some("TEXT".to_string())); } #[test] fn field_mapping_no_match_returns_none() { let mappings = vec![FieldMapping { source_type: "INT".into(), target_type: "INTEGER".into(), param_strategy: ParamStrategy::Preserve, custom_params: None, }]; let result = FieldMapping::apply_with_params(&mappings, "VARCHAR(255)", DialectKind::Oracle); assert_eq!(result, None); } #[test] fn field_mapping_preserve_with_yaml_char_type() { crate::sql_dialect::dialect_loader::register_core_dialects(); let mappings = vec![FieldMapping { source_type: "VARCHAR".into(), target_type: "CHAR".into(), param_strategy: ParamStrategy::Preserve, custom_params: None, }]; let result = FieldMapping::apply_with_params(&mappings, "VARCHAR(200)", DialectKind::Oracle); assert_eq!( result, Some("CHAR(200)".to_string()), "Preserve should keep params for CHAR which has has_length in Oracle YAML" ); } #[test] fn field_mapping_mysql_varchar_to_postgres_character_keeps_params() { crate::sql_dialect::dialect_loader::register_core_dialects(); let mappings = vec![FieldMapping { source_type: "VARCHAR".into(), target_type: "character".into(), param_strategy: ParamStrategy::Preserve, custom_params: None, }]; let result = FieldMapping::apply_with_params(&mappings, "varchar(120)", DialectKind::Postgres); assert_eq!( result, Some("character(120)".to_string()), "Preserve should keep (120) for PostgreSQL CHARACTER which has has_length in YAML" ); } #[test] fn field_mapping_custom_params_without_parens_is_normalized() { let mappings = vec![FieldMapping { source_type: "VARCHAR".into(), target_type: "character".into(), param_strategy: ParamStrategy::Custom, custom_params: Some("100".to_string()), }]; let result = FieldMapping::apply_with_params(&mappings, "VARCHAR(120)", DialectKind::Postgres); assert_eq!( result, Some("character(100)".to_string()), "Custom params without parentheses should be wrapped as (100)" ); } #[test] fn field_mapping_custom_params_with_parens_preserved() { let mappings = vec![FieldMapping { source_type: "VARCHAR".into(), target_type: "character".into(), param_strategy: ParamStrategy::Custom, custom_params: Some("(100)".to_string()), }]; let result = FieldMapping::apply_with_params(&mappings, "VARCHAR(120)", DialectKind::Postgres); assert_eq!( result, Some("character(100)".to_string()), "Custom params already wrapped in parentheses should be kept as-is" ); } #[test] fn postgres_function_sequence_rule_owner_use_profile_templates() { let fn_diff = FunctionDiff { diff_type: "added".into(), name: "f1".into(), source: Some(FunctionInfo { name: "f1".into(), function_type: "FUNCTION".into(), data_type: "int".into(), definition: "RETURNS int LANGUAGE sql AS $$ SELECT 1 $$".into(), arguments: "".into(), }), target: None, changes: vec![], }; let seq_diff = SequenceDiff { diff_type: "added".into(), name: "s1".into(), source: Some(SequenceInfo { name: "s1".into(), data_type: "bigint".into(), start_value: "1".into(), min_value: "1".into(), max_value: "100".into(), increment: "1".into(), cycle: false, last_value: None, }), target: None, changes: vec![], }; let rule_diff = RuleDiff { diff_type: "removed".into(), name: "r1".into(), source: Some(RuleInfo { name: "r1".into(), table_name: "t1".into(), definition: "CREATE RULE r1 AS ON INSERT TO t1 DO NOTHING".into(), }), target: None, changes: vec![], }; let owner_diff = OwnerDiff { diff_type: "modified".into(), object_name: "t1".into(), source: Some(OwnerInfo { object_name: "t1".into(), object_type: "TABLE".into(), owner: "app".into() }), target: Some(OwnerInfo { object_name: "t1".into(), object_type: "TABLE".into(), owner: "old".into() }), changes: vec![], }; let sql = generate_schema_sync_sql( &[], &[fn_diff], &[seq_diff], &[rule_diff], &[owner_diff], DatabaseType::Postgres, Some("public"), true, None, &[], ); assert!(sql.contains("CREATE OR REPLACE FUNCTION"), "{sql}"); assert!(sql.contains("CREATE SEQUENCE"), "{sql}"); assert!(sql.contains("NO CYCLE"), "{sql}"); assert!(sql.contains("DROP RULE IF EXISTS r1 ON"), "{sql}"); assert!(sql.contains("OWNER TO app"), "{sql}"); assert!(sql.contains("CASCADE"), "{sql}"); } #[test] fn mysql_skips_function_sequence_when_templates_absent() { let fn_diff = FunctionDiff { diff_type: "added".into(), name: "f1".into(), source: Some(FunctionInfo { name: "f1".into(), function_type: "FUNCTION".into(), data_type: "int".into(), definition: "RETURNS int RETURN 1".into(), arguments: "".into(), }), target: None, changes: vec![], }; let sql = generate_schema_sync_sql(&[], &[fn_diff], &[], &[], &[], DatabaseType::Mysql, None, false, None, &[]); assert!(sql.contains("-- Skip function f1"), "{sql}"); assert!(!sql.contains("CREATE FUNCTION"), "{sql}"); } }