use std::collections::{BTreeSet, 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, AutoIncSyntax, 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, column_is_auto_increment, rewrite_column_type, type_looks_integer, AutoIncColumnBuild, }; use crate::sql_parser::ast_filter::AstTransmitFilter; use crate::table_structure_sql::{ build_sqlserver_alter_column_preserving_default_sql, build_sqlserver_column_comment_sql, build_sqlserver_drop_default_constraint_sql, build_sqlserver_table_comment_sql, sqlserver_unicode_string_literal, }; use crate::types::{ ColumnInfo, ForeignKeyInfo, FunctionInfo, IndexInfo, OwnerInfo, RuleInfo, SequenceInfo, TableInfo, TriggerInfo, }; mod sqlserver_dependencies; use sqlserver_dependencies::build_dependency_aware_alter_column_batch; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum ColumnAddPosition { First, After(String), } #[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, #[serde(default, skip_serializing_if = "Option::is_none")] pub add_position: Option, } #[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, /// Source-side display/key name. `target_name` is set for an explicit /// source→target table mapping and is the physical target used by DDL. #[serde(default, skip_serializing_if = "Option::is_none")] pub target_name: Option, #[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, } /// Canonicalizes a handful of ANSI-SQL type synonyms that name the same /// underlying type across dialects (e.g. Postgres/Kingbase report the base /// type as `character varying` via `format_type()`, while the field-mapping /// UI's type catalog lists the shorter `varchar`). Without this, a user /// mapping configured against one spelling silently never matches a column /// reported under the other. fn canonical_type_name(base: &str) -> std::borrow::Cow<'_, str> { match base.trim().to_ascii_uppercase().as_str() { "CHARACTER VARYING" => std::borrow::Cow::Borrowed("VARCHAR"), "CHARACTER" => std::borrow::Cow::Borrowed("CHAR"), _ => std::borrow::Cow::Owned(base.trim().to_ascii_uppercase()), } } /// Finds the mapping for `base_type`, preferring an exact (case-insensitive) /// match over an alias match. The field-mapping panel auto-generates one row /// per catalog type — `char`, `character`, `varchar` and `character varying` /// commonly coexist as separate rows with independently chosen targets — so /// treating aliases as interchangeable on the first hit alone would let an /// earlier row (e.g. `char`) silently shadow a later exact row (e.g. /// `character`) that shares the same canonical name. Alias matching is only /// a fallback for when no row exactly names the reported type. fn find_mapping<'a>(mappings: &'a [FieldMapping], base_type: &str) -> Option<&'a FieldMapping> { mappings .iter() .find(|m| m.source_type.eq_ignore_ascii_case(base_type)) .or_else(|| mappings.iter().find(|m| canonical_type_name(&m.source_type) == canonical_type_name(base_type))) } /// Splices a driver-reported column length back into its type string when /// the type name itself omits it. Postgres/Kingbase can report a varying /// column as bare `character varying` (no length) via `format_type()` while /// still exposing the real length separately as `character_maximum_length`. /// Without this, the cross-dialect rewrite below has no way to tell "no /// length was ever declared" (safe to default) apart from "the length just /// isn't embedded in this dialect's type string" (issue #8011) — silently /// falling back to a generic default in the latter case would replace a /// known-correct length with a possibly wrong one. fn with_known_length(source_type: &str, character_maximum_length: Option) -> String { let trimmed = source_type.trim(); if trimmed.contains('(') { return trimmed.to_string(); } // `character_maximum_length` is populated by several drivers for types // where it does NOT mean "declared length in this position" — MySQL's // information_schema fills it in for TEXT/BLOB family columns (e.g. // TEXT -> 65535), and Oracle's DATA_LENGTH is filled in for every // column, including DATE (byte length, e.g. 7) and NUMBER. Splicing // those in verbatim is wrong twice over: `TEXT(65535)` is silently // *reinterpreted* as MEDIUMTEXT by MySQL (real DB verified), and // `DATE(7)` sent to a MySQL target is a straight syntax error. // // CHAR/CHARACTER/NCHAR belong on this whitelist alongside the VARCHAR // family, unlike in type_rewrite's *default*-to-255 list: that list // invents a length out of thin air (where CHAR must be excluded — a // bare CHAR is already valid MySQL, meaning CHAR(1)), whereas this // function only *restores* a length the driver already reported // separately. MySQL's own information_schema does this for CHAR too // (DATA_TYPE="char", CHARACTER_MAXIMUM_LENGTH=10 for a CHAR(10) column, // real DB verified) — excluding CHAR here would silently truncate a // real CHAR(10) column down to CHAR(1). Mirrors the same whitelist // `columnDDLDataType` uses in agents/drivers/kingbase-go/kingbase_metadata.go. let base_upper = trimmed.to_ascii_uppercase(); if !matches!( base_upper.as_str(), "VARCHAR" | "CHARACTER VARYING" | "NVARCHAR" | "CHAR" | "CHARACTER" | "NCHAR" | "VARCHAR2" | "NVARCHAR2" ) { return trimmed.to_string(); } match character_maximum_length { Some(len) if len > 0 => format!("{trimmed}({len})"), _ => trimmed.to_string(), } } /// Numeric base types whose `(precision[, scale])` is part of the *declared* type on every /// dialect that spells the name that way. Oracle's `ALL_TAB_COLUMNS` — and therefore every /// Oracle introspection path, native agent and JDBC alike — reports a bare `NUMBER` with /// `DATA_PRECISION`/`DATA_SCALE` beside it, so comparing the type strings alone cannot tell /// `NUMBER(10,2)` from `NUMBER(12,2)` and the difference is silently dropped from the /// result (#9261). Integer families are deliberately excluded: MySQL's `information_schema` /// fills `numeric_precision` in for `int` (10) and rendering that back as `int(10)` would /// invent a display width that a Postgres target rejects. const NUMERIC_TYPES_WITH_DECLARED_PRECISION: [&str; 5] = ["NUMBER", "NUMERIC", "DECIMAL", "DEC", "FIXED"]; /// Counterpart of [`with_known_length`] for the numeric family: splices a driver-reported /// precision/scale back into a type name that omits them. A negative scale (`NUMBER(10,-2)` /// on Oracle) is preserved because it changes the value range, while a scale of zero is /// left out — `NUMBER(10)` and `NUMBER(10,0)` are the same column. fn with_known_numeric_precision( source_type: &str, numeric_precision: Option, numeric_scale: Option, ) -> String { let trimmed = source_type.trim(); if trimmed.contains('(') { return trimmed.to_string(); } let base = trimmed.split([' ', '(']).next().unwrap_or_default().to_ascii_uppercase(); if !NUMERIC_TYPES_WITH_DECLARED_PRECISION.contains(&base.as_str()) { return trimmed.to_string(); } let Some(precision) = numeric_precision.filter(|value| *value > 0) else { return trimmed.to_string(); }; match numeric_scale { Some(scale) if scale != 0 => format!("{trimmed}({precision},{scale})"), _ => format!("{trimmed}({precision})"), } } /// The declared shape of a column: the driver's type string with every length/precision it /// reports *beside* the string spliced back in. Comparison needs it so that a changed /// precision or length is a difference at all, and scripts need it so that an emitted /// `ADD COLUMN`/`MODIFY` keeps the parameters the user declared. fn declared_column_type(column: &ColumnInfo) -> String { with_known_numeric_precision( &with_known_length(&column.data_type, column.character_maximum_length), column.numeric_precision, column.numeric_scale, ) } 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(); find_mapping(mappings, 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 = find_mapping(mappings, 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, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct SchemaDiffTableMapping { pub source_table: String, pub target_table: String, } #[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 ignore_table_name_case: bool, #[serde(default)] pub ignore_column_name_case: 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, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub table_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, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SchemaSyncSqlPlan { pub sync_sql: String, #[serde(skip_serializing_if = "Option::is_none")] pub rollback_sync_sql: Option, pub rollback_completeness: RollbackCompleteness, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub missing_rollback_objects: Vec, } 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, &[], &[]) } pub fn build_with_options( details: &[TableSchemaDetail], tables: &[TableInfo], ignore_table_name_case: bool, ) -> Self { Self::build_with_functions_and_options(details, tables, &[], &[], ignore_table_name_case) } /// 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 { Self::build_with_functions_and_options(details, tables, functions, sequences, false) } pub fn build_with_functions_and_options( details: &[TableSchemaDetail], tables: &[TableInfo], functions: &[FunctionInfo], _sequences: &[SequenceInfo], ignore_table_name_case: bool, ) -> 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 { let referenced_table = if table_names.contains(fk.ref_table.as_str()) { Some(fk.ref_table.as_str()) } else if ignore_table_name_case { let mut candidates = table_names.iter().copied().filter(|name| name.eq_ignore_ascii_case(&fk.ref_table)); let candidate = candidates.next(); candidate.filter(|_| candidates.next().is_none()) } else { None }; if let Some(referenced_table) = referenced_table { if let Some(node) = nodes.get_mut(*table_name) { if !node.depends_on.iter().any(|name| name == referenced_table) { node.depends_on.push(referenced_table.to_string()); } } if let Some(ref_node) = nodes.get_mut(referenced_table) { if !ref_node.depended_by.iter().any(|name| name == *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) { diff_columns_with_compatibility_options( source, target, ignore_comments, compare_column_order, source_dialect, target_dialect, compatibility_threshold, field_mappings, false, ) } #[allow(clippy::too_many_arguments)] fn diff_columns_with_compatibility_options( source: &[ColumnInfo], target: &[ColumnInfo], ignore_comments: bool, compare_column_order: bool, source_dialect: DialectKind, target_dialect: DialectKind, compatibility_threshold: f64, field_mappings: &[FieldMapping], ignore_column_name_case: bool, ) -> (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_identifier_options( source, target, ignore_comments, compare_column_order, false, 0.5, None, None, ignore_column_name_case, ); 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 { let Some((before, after)) = ch.split_once(" → ") else { return ch.to_string(); }; if let Some((kind, value)) = before.split_once(": ") { format!("{kind}: {after} → {value}") } else { format!("{after} → {before}") } } 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(), add_position: c.add_position.clone(), } }) .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(), target_name: diff.target_name.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()) || options.table_mappings.iter().any(|mapping| { shard_set.contains(mapping.source_table.as_str()) && mapping.target_table == t.name }) }) .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()) || options.table_mappings.iter().any(|mapping| { shard_set.contains(mapping.source_table.as_str()) && mapping.target_table == d.name }) }) .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, ignore_table_name_case: options.ignore_table_name_case, ignore_column_name_case: options.ignore_column_name_case, source_dialect: options.source_dialect, target_dialect: options.target_dialect, table_mappings: options.table_mappings.clone(), ..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 } fn sqlserver_permission_securable(permission: &PermissionInfo, schema: Option<&str>) -> String { match permission.object_type.trim().to_ascii_uppercase().as_str() { "SCHEMA" => format!("SCHEMA::{}", quote_id(&permission.object_name, DatabaseType::SqlServer)), "DATABASE" => format!("DATABASE::{}", quote_id(&permission.object_name, DatabaseType::SqlServer)), _ => { let object_name = if schema.is_none() { permission .object_name .split_once('.') .filter(|(_, object)| !object.contains('.')) .map(|(object_schema, object)| { format!( "{}.{}", quote_id( object_schema.trim_matches(|ch| matches!(ch, '[' | ']' | '"')), DatabaseType::SqlServer, ), quote_id(object.trim_matches(|ch| matches!(ch, '[' | ']' | '"')), DatabaseType::SqlServer,) ) }) .unwrap_or_else(|| qualified_name(&permission.object_name, DatabaseType::SqlServer, schema)) } else { qualified_name(&permission.object_name, DatabaseType::SqlServer, schema) }; format!("OBJECT::{object_name}") } } } 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 db_type == DatabaseType::SqlServer { let securable = sqlserver_permission_securable(source, schema); let grantee = quote_id(&source.grantee, db_type); let with_grant = if source.is_grantable { " WITH GRANT OPTION" } else { "" }; lines .push(format!("GRANT {} ON {} TO {}{};", source.privilege, securable, grantee, with_grant)); } else 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 db_type == DatabaseType::SqlServer { let securable = sqlserver_permission_securable(target, schema); let grantee = quote_id(&target.grantee, db_type); lines.push(format!("REVOKE {} ON {} FROM {};", target.privilege, securable, grantee)); } else 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, ignore_table_name_case: false, ignore_column_name_case: 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(), table_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 with_table_mappings(mut self, mappings: Vec) -> Self { self.table_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_with_options( &options.source_details, &options.source_tables, options.ignore_table_name_case, ); 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()), target_name: Some(c.target_name.clone()), 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) { let target_name = diff.target_name.as_deref().unwrap_or(&diff.name); if let Some(target_detail) = options.target_details.iter().find(|d| d.name == target_name) { let (_, warnings) = diff_columns_with_compatibility_options( &source_detail.columns, &target_detail.columns, options.ignore_comments, options.compare_column_order, src_dialect, tgt_dialect, options.compatibility_threshold, &options.field_mappings, options.ignore_column_name_case, ); 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), } } #[derive(Debug, Clone, PartialEq, Eq)] struct SchemaDiffTableNameResolution { pairs: Vec<(String, String)>, source_only: Vec, target_only: Vec, } fn identifiers_equal(left: &str, right: &str, ignore_case: bool) -> bool { if ignore_case { left.eq_ignore_ascii_case(right) } else { left == right } } fn resolve_schema_diff_table_names( source_names: &[String], target_names: &[String], mappings: &[SchemaDiffTableMapping], ignore_table_name_case: bool, ) -> SchemaDiffTableNameResolution { let target_set: HashSet<&str> = target_names.iter().map(String::as_str).collect(); let mut explicit_by_source: HashMap<&str, &str> = HashMap::new(); for mapping in mappings { if !mapping.source_table.is_empty() && !mapping.target_table.is_empty() { explicit_by_source.entry(mapping.source_table.as_str()).or_insert(mapping.target_table.as_str()); } } // Reserve valid explicit targets before automatic matching so an explicit mapping // wins even when another source table appears earlier in the input list. let mut explicit_targets_by_source = HashMap::new(); let mut reserved_explicit_targets = HashSet::new(); for source_name in source_names { let Some(target_name) = explicit_by_source.get(source_name.as_str()).copied() else { continue }; if target_set.contains(target_name) && reserved_explicit_targets.insert(target_name) { explicit_targets_by_source.insert(source_name.clone(), target_name.to_string()); } } let mut resolved_targets_by_source = explicit_targets_by_source.clone(); let mut used_targets: HashSet<&str> = reserved_explicit_targets.clone(); // Resolve every exact match before considering case-insensitive candidates. // This keeps an exact match from losing its target to an earlier case-only match. for source_name in source_names { if resolved_targets_by_source.contains_key(source_name) { continue; } if target_set.contains(source_name.as_str()) && used_targets.insert(source_name.as_str()) { resolved_targets_by_source.insert(source_name.clone(), source_name.clone()); } } if ignore_table_name_case { for source_name in source_names { if resolved_targets_by_source.contains_key(source_name) { continue; } let mut candidates = target_names.iter().filter(|target_name| { !used_targets.contains(target_name.as_str()) && identifiers_equal(source_name, target_name, true) }); let candidate = candidates.next(); if let Some(target_name) = candidate.filter(|_| candidates.next().is_none()) { used_targets.insert(target_name.as_str()); resolved_targets_by_source.insert(source_name.clone(), target_name.clone()); } } } let mut pairs = Vec::new(); let mut source_only = Vec::new(); for source_name in source_names { if let Some(target_name) = resolved_targets_by_source.get(source_name) { pairs.push((source_name.clone(), target_name.clone())); } else { source_only.push(source_name.clone()); } } let target_only = target_names.iter().filter(|name| !used_targets.contains(name.as_str())).cloned().collect(); SchemaDiffTableNameResolution { pairs, source_only, target_only } } 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 table_resolution = resolve_schema_diff_table_names( &source_table_names, &target_table_names, &options.table_mappings, options.ignore_table_name_case, ); let view_resolution = resolve_schema_diff_table_names( &source_view_names, &target_view_names, &options.table_mappings, options.ignore_table_name_case, ); let table_pairs = table_resolution.pairs.clone(); let mut result = Vec::new(); // A foreign key whose `ref_table` is itself one of the tables being compared is a // same-database self-reference. Its `ref_schema` is always the literal source/target // database name (MySQL's information_schema reports it unconditionally, even for // self-references), so source and target will almost always disagree even though the // relationship is structurally identical. Clearing it here makes such FKs compare and // regenerate against the *other side's own* database instead of being flagged as // "different" and then rewritten to literally reference the source database name. // Genuine cross-database references (ref_table not part of this database) are left as-is. let source_table_name_set: HashSet<&str> = source_table_names.iter().map(String::as_str).collect(); let target_table_name_set: HashSet<&str> = target_table_names.iter().map(String::as_str).collect(); for name in table_resolution.source_only { let source_detail = source_details.get(name.as_str()); result.push(TableDiff { diff_type: "added".to_string(), object_type: Some("table".to_string()), target_name: None, name, ddl: source_detail.and_then(|detail| detail.ddl.clone()), target_ddl: None, columns: source_detail.map(|detail| { detail .columns .iter() .enumerate() .map(|(index, c)| ColumnDiff { diff_type: "added".to_string(), name: c.name.clone(), source: Some(c.clone()), target: None, changes: vec![], add_position: Some(column_add_position(&detail.columns, index)), }) .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| { let fk = normalize_mapped_foreign_key( fk, &source_table_name_set, &target_table_name_set, &table_pairs, &options.table_mappings, options.ignore_table_name_case, ); ForeignKeyDiff { diff_type: "added".to_string(), name: fk.name.clone(), source: Some(fk), 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 table_resolution.target_only { 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()), target_name: None, name, columns: target_detail.map(|detail| { detail .columns .iter() .enumerate() .map(|(index, column)| ColumnDiff { diff_type: "removed".to_string(), name: column.name.clone(), source: None, target: Some(column.clone()), changes: vec![], add_position: Some(column_add_position(&detail.columns, index)), }) .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 view_resolution.source_only { let name_clone = name.clone(); result.push(TableDiff { diff_type: "added".to_string(), object_type: Some("view".to_string()), target_name: None, 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 view_resolution.target_only { let name_clone = name.clone(); result.push(TableDiff { diff_type: "removed".to_string(), object_type: Some("view".to_string()), target_name: None, 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, target_name) in view_resolution.pairs { let Some(source_ddl) = source_details.get(name.as_str()).and_then(|detail| detail.ddl.as_ref()) else { continue; }; let Some(target_ddl) = target_details.get(target_name.as_str()).and_then(|detail| detail.ddl.as_ref()) else { continue; }; if !view_definitions_differ(source_ddl, target_ddl, options.source_dialect, options.target_dialect) { continue; } result.push(TableDiff { diff_type: "modified".to_string(), object_type: Some("view".to_string()), target_name: (name != target_name).then_some(target_name), name, columns: None, indexes: None, foreign_keys: None, triggers: None, ddl: Some(source_ddl.clone()), target_ddl: Some(target_ddl.clone()), source_table_comment: None, target_table_comment: None, sync_sql: None, }); } for (name, target_name) in table_resolution.pairs { let Some(source) = source_details.get(name.as_str()) else { continue }; let Some(target) = target_details.get(target_name.as_str()) else { continue }; let column_diffs = diff_columns_with_identifier_options( &source.columns, &target.columns, options.ignore_comments, options.compare_column_order, options.detect_renames, options.rename_threshold, options.source_dialect, options.target_dialect, options.ignore_column_name_case, ); let index_diffs = diff_indexes_with_options(&source.indexes, &target.indexes, options.ignore_column_name_case); let normalized_source_fks: Vec = source .foreign_keys .iter() .map(|fk| { normalize_mapped_foreign_key( fk, &source_table_name_set, &target_table_name_set, &table_pairs, &options.table_mappings, options.ignore_table_name_case, ) }) .collect(); let normalized_target_fks: Vec = target .foreign_keys .iter() .map(|fk| normalize_self_referencing_fk(fk, &target_table_name_set, options.ignore_table_name_case)) .collect(); let foreign_key_diffs = diff_foreign_keys_with_options( &normalized_source_fks, &normalized_target_fks, options.ignore_table_name_case, options.ignore_column_name_case, ); 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(target_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; result.push(TableDiff { diff_type: if has_diff { "modified".to_string() } else { "none".to_string() }, object_type: Some("table".to_string()), target_name: (name != target_name).then_some(target_name.clone()), 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.ddl.clone(), target_ddl: target.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(), ) } /// Whether two captured view definitions differ. /// /// Only dialects whose captured text `dbx` can normalize are compared: a view body is /// dialect-specific SQL, so a cross-dialect pair (or an engine whose text nobody taught /// this function to read) is left alone rather than reported as a difference. fn view_definitions_differ( source_ddl: &str, target_ddl: &str, source_dialect: Option, target_dialect: Option, ) -> bool { if source_dialect != target_dialect { return false; } match source_dialect { Some(DialectKind::Mysql) => normalize_mysql_view_ddl(source_ddl) != normalize_mysql_view_ddl(target_ddl), Some(DialectKind::Oracle) => { // Each side's view lives in its own schema, and Oracle hands the body back exactly // as it was written. dbx's own generated script rewrites only the header, so after // applying it the target body can still qualify the *source* schema — normalising // references to either compared schema keeps the comparison idempotent instead of // reporting the view as permanently modified. let schemas = oracle_view_schemas(source_ddl, target_ddl); let schemas: Vec<&str> = schemas.iter().map(String::as_str).collect(); normalize_oracle_view_ddl_with_schemas(source_ddl, &schemas) != normalize_oracle_view_ddl_with_schemas(target_ddl, &schemas) } _ => false, } } #[derive(Clone, Copy, PartialEq, Eq)] enum MysqlViewTokenKind { Atom, Symbol, } fn normalize_mysql_view_ddl(ddl: &str) -> String { let ddl = strip_mysql_view_definer(ddl); let schema = mysql_view_schema(&ddl); let mut normalized = String::with_capacity(ddl.len()); let mut previous = None; let mut pending_whitespace = false; let bytes = ddl.as_bytes(); let mut index = 0; while index < bytes.len() { if bytes[index].is_ascii_whitespace() { pending_whitespace = true; index += 1; continue; } let (end, kind, replacement) = match bytes[index] { b'\'' | b'"' => { let end = mysql_quoted_token_end(&ddl, index); (end, MysqlViewTokenKind::Atom, None) } b'`' => { let end = mysql_quoted_token_end(&ddl, index); let identifier = decode_mysql_quoted_identifier(&ddl[index..end]); let is_schema_qualifier = schema.as_deref() == Some(identifier.as_str()) && ddl[end..].trim_start().starts_with('.'); (end, MysqlViewTokenKind::Atom, is_schema_qualifier.then_some("`__dbx_schema__`")) } b'#' => { let end = ddl[index..].find('\n').map_or(bytes.len(), |offset| index + offset); (end, MysqlViewTokenKind::Atom, None) } b'-' if bytes.get(index + 1) == Some(&b'-') && bytes.get(index + 2).is_some_and(|next| next.is_ascii_whitespace()) => { let end = ddl[index..].find('\n').map_or(bytes.len(), |offset| index + offset); (end, MysqlViewTokenKind::Atom, None) } b'/' if bytes.get(index + 1) == Some(&b'*') => { let end = ddl[index + 2..].find("*/").map_or(bytes.len(), |offset| index + 2 + offset + 2); (end, MysqlViewTokenKind::Atom, None) } byte if view_ddl_symbol(byte) => (index + 1, MysqlViewTokenKind::Symbol, None), _ => { let mut end = index + 1; while end < bytes.len() && !bytes[end].is_ascii_whitespace() && !matches!(bytes[end], b'\'' | b'"' | b'`' | b'#') && !view_ddl_symbol(bytes[end]) { end += 1; } (end, MysqlViewTokenKind::Atom, None) } }; if pending_whitespace && previous == Some(kind) { normalized.push(' '); } normalized.push_str(replacement.unwrap_or(&ddl[index..end])); previous = Some(kind); pending_whitespace = false; index = end; } normalized } /// Punctuation shared by the view-text tokenizers: MySQL and Oracle agree on every /// operator and delimiter the server can put between two atoms, so one predicate keeps /// the two normalizers from drifting apart. fn view_ddl_symbol(byte: u8) -> bool { matches!( byte, b'(' | b')' | b'[' | b']' | b'{' | b'}' | b',' | b'.' | b';' | b'+' | b'-' | b'*' | b'/' | b'%' | b'<' | b'>' | b'=' | b'!' | b'|' | b'&' | b'^' | b'~' | b'?' | b':' | b'@' ) } fn mysql_view_schema(ddl: &str) -> Option { let view = find_mysql_header_keyword(ddl, "VIEW", ddl.len())?; let mut index = skip_ascii_whitespace(ddl, view + "VIEW".len()); let (identifier, end) = parse_mysql_identifier(ddl, index)?; index = skip_ascii_whitespace(ddl, end); (ddl.as_bytes().get(index) == Some(&b'.')).then_some(identifier) } fn strip_mysql_view_definer(ddl: &str) -> String { let Some(view) = find_mysql_header_keyword(ddl, "VIEW", ddl.len()) else { return ddl.to_string(); }; let Some(definer) = find_mysql_header_keyword(ddl, "DEFINER", view) else { return ddl.to_string(); }; let mut index = skip_ascii_whitespace(ddl, definer + "DEFINER".len()); if ddl.as_bytes().get(index) != Some(&b'=') { return ddl.to_string(); } index = skip_ascii_whitespace(ddl, index + 1); let Some(mut end) = parse_mysql_definer_principal(ddl, index) else { return ddl.to_string(); }; end = skip_ascii_whitespace(ddl, end); if ddl.as_bytes().get(end) == Some(&b'@') { end = skip_ascii_whitespace(ddl, end + 1); let Some(host_end) = parse_mysql_definer_principal(ddl, end) else { return ddl.to_string(); }; end = host_end; } else if ddl[index..end].eq_ignore_ascii_case("CURRENT_USER") { let open = skip_ascii_whitespace(ddl, end); if ddl.as_bytes().get(open) == Some(&b'(') { let close = skip_ascii_whitespace(ddl, open + 1); if ddl.as_bytes().get(close) == Some(&b')') { end = close + 1; } } } else { return ddl.to_string(); } end = skip_ascii_whitespace(ddl, end); let mut stripped = String::with_capacity(ddl.len() - (end - definer)); stripped.push_str(&ddl[..definer]); stripped.push_str(&ddl[end..]); stripped } fn parse_mysql_definer_principal(ddl: &str, index: usize) -> Option { match *ddl.as_bytes().get(index)? { b'`' | b'\'' | b'"' => Some(mysql_quoted_token_end(ddl, index)), _ => { let mut end = index; while let Some(byte) = ddl.as_bytes().get(end) { if byte.is_ascii_whitespace() || matches!(byte, b'@' | b'(' | b')') { break; } end += 1; } (end > index).then_some(end) } } } fn parse_mysql_identifier(ddl: &str, index: usize) -> Option<(String, usize)> { if ddl.as_bytes().get(index) == Some(&b'`') { let end = mysql_quoted_token_end(ddl, index); return Some((decode_mysql_quoted_identifier(&ddl[index..end]), end)); } let mut end = index; while let Some(byte) = ddl.as_bytes().get(end) { if byte.is_ascii_whitespace() || view_ddl_symbol(*byte) { break; } end += 1; } (end > index).then(|| (ddl[index..end].to_string(), end)) } fn decode_mysql_quoted_identifier(identifier: &str) -> String { identifier.strip_prefix('`').and_then(|value| value.strip_suffix('`')).unwrap_or(identifier).replace("``", "`") } fn find_mysql_header_keyword(ddl: &str, keyword: &str, limit: usize) -> Option { let bytes = ddl.as_bytes(); let mut index = 0; while index < limit { match bytes[index] { b'\'' | b'"' | b'`' => { index = mysql_quoted_token_end(ddl, index); } byte if byte.is_ascii_alphabetic() || byte == b'_' => { let start = index; index += 1; while index < limit && (bytes[index].is_ascii_alphanumeric() || matches!(bytes[index], b'_' | b'$')) { index += 1; } if ddl[start..index].eq_ignore_ascii_case(keyword) { return Some(start); } } _ => index += 1, } } None } fn mysql_quoted_token_end(ddl: &str, start: usize) -> usize { let bytes = ddl.as_bytes(); let quote = bytes[start]; let mut index = start + 1; while index < bytes.len() { if bytes[index] == b'\\' && quote != b'`' { index = (index + 2).min(bytes.len()); continue; } if bytes[index] == quote { if bytes.get(index + 1) == Some("e) { index += 2; continue; } return index + 1; } index += 1; } bytes.len() } /// How `normalize_oracle_view_ddl` groups a token: anything the server reads as a /// single value is an atom, everything else is punctuation. #[derive(Clone, Copy, PartialEq, Eq)] enum OracleViewTokenKind { Atom, Symbol, } struct OracleViewToken { kind: OracleViewTokenKind, text: String, /// Bare words and `"quoted"` identifiers are the ones that can name the owner; /// `'literals'` never are, so a literal that happens to spell the schema stays a /// literal. identifier: bool, /// Quoted identifiers keep their case and never spell a keyword. quoted: bool, /// Whether whitespace separated this token from the previous one. spaced: bool, } /// Normalizes Oracle view text so that two schemas holding the *same* view compare equal /// and two holding different views do not (#9261). /// /// Oracle reports it through `DBMS_METADATA`, which repeats the owner in the header /// (`CREATE OR REPLACE FORCE VIEW "DBX_TEST"."V_SAME"`) and qualifies every reference in /// the body with it (`FROM DBX_TEST.CMP_SAME`), so the owner is replaced by a placeholder /// exactly like `normalize_mysql_view_ddl` does for its backtick schema qualifier. The /// header (including the column list) stays part of the comparison because a view that /// exposes different columns is a different view, and comments are dropped because they /// carry no query semantics. /// Test-facing shorthand for the single-sided normalisation: collapse only this statement's /// own owner (production comparisons use `oracle_view_schemas` and normalise both sides /// against every compared schema). #[cfg(test)] fn normalize_oracle_view_ddl(ddl: &str) -> String { let owner = oracle_view_owner(&oracle_view_tokens(ddl)); let schemas: Vec<&str> = owner.as_deref().into_iter().collect(); normalize_oracle_view_ddl_with_schemas(ddl, &schemas) } /// Every schema a pair of captured view definitions is allowed to qualify: each side's own /// owner, so the header qualifier and a body that names either schema collapse to the same /// placeholder. fn oracle_view_schemas(source_ddl: &str, target_ddl: &str) -> Vec { let mut schemas = Vec::new(); for ddl in [source_ddl, target_ddl] { let Some(owner) = oracle_view_owner(&oracle_view_tokens(ddl)) else { continue }; if !schemas.iter().any(|known: &String| known.eq_ignore_ascii_case(&owner)) { schemas.push(owner); } } schemas } fn normalize_oracle_view_ddl_with_schemas(ddl: &str, schemas: &[&str]) -> String { // `DBMS_METADATA` may or may not hand the statement over with its trailing `;` — the // terminator carries no meaning for the comparison. (A literal's own semicolon is // inside quotes, so the statement never ends with one there.) let ddl = ddl.trim_end(); let ddl = ddl.strip_suffix(';').map_or(ddl, str::trim_end); let tokens = oracle_view_tokens(ddl); let mut normalized = String::with_capacity(ddl.len()); let mut previous = None; for (position, token) in tokens.iter().enumerate() { let replacement = if token.identifier && schemas.iter().any(|schema| token.text.eq_ignore_ascii_case(schema)) && oracle_view_tokens_continue_with_dot(&tokens, position) { "__dbx_schema__".to_string() } else { token.text.clone() }; if token.spaced && previous == Some(token.kind) { normalized.push(' '); } normalized.push_str(&replacement); previous = Some(token.kind); } normalized } fn oracle_view_tokens_continue_with_dot(tokens: &[OracleViewToken], position: usize) -> bool { tokens.get(position + 1).is_some_and(|next| next.kind == OracleViewTokenKind::Symbol && next.text == ".") } /// The schema in `CREATE OR REPLACE FORCE VIEW "OWNER"."NAME"`, when the captured text /// qualifies the view at all. fn oracle_view_owner(tokens: &[OracleViewToken]) -> Option { let view = tokens.iter().position(|token| !token.quoted && token.text.eq_ignore_ascii_case("VIEW"))?; let name = tokens.get(view + 1)?; oracle_view_tokens_continue_with_dot(tokens, view + 1).then(|| name.text.clone()) } fn oracle_view_tokens(ddl: &str) -> Vec { let bytes = ddl.as_bytes(); let mut tokens = Vec::new(); let mut index = 0; let mut spaced = false; while index < bytes.len() { if bytes[index].is_ascii_whitespace() { spaced = true; index += 1; continue; } let (end, kind, text, identifier, quoted, keep) = match bytes[index] { b'\'' => { let end = oracle_quoted_token_end(ddl, index); (end, OracleViewTokenKind::Atom, ddl[index..end].to_string(), false, false, true) } b'"' => { let end = oracle_quoted_token_end(ddl, index); (end, OracleViewTokenKind::Atom, decode_oracle_quoted_identifier(&ddl[index..end]), true, true, true) } b'-' if bytes.get(index + 1) == Some(&b'-') => { let end = ddl[index..].find('\n').map_or(bytes.len(), |offset| index + offset); (end, OracleViewTokenKind::Atom, String::new(), false, false, false) } b'/' if bytes.get(index + 1) == Some(&b'*') => { let end = ddl[index + 2..].find("*/").map_or(bytes.len(), |offset| index + 2 + offset + 2); (end, OracleViewTokenKind::Atom, String::new(), false, false, false) } byte if view_ddl_symbol(byte) => { (index + 1, OracleViewTokenKind::Symbol, ddl[index..index + 1].to_string(), false, false, true) } _ => { let mut end = index + 1; while end < bytes.len() && !bytes[end].is_ascii_whitespace() && !matches!(bytes[end], b'\'' | b'"') && !view_ddl_symbol(bytes[end]) { end += 1; } (end, OracleViewTokenKind::Atom, ddl[index..end].to_string(), true, false, true) } }; if keep { tokens.push(OracleViewToken { kind, text, identifier, quoted, spaced }); } spaced = false; index = end; } tokens } /// Oracle ends a quoted token at the matching quote; a doubled quote is an escape /// (there is no backslash escape). fn oracle_quoted_token_end(ddl: &str, start: usize) -> usize { let bytes = ddl.as_bytes(); let quote = bytes[start]; let mut index = start + 1; while index < bytes.len() { if bytes[index] == quote { if bytes.get(index + 1) == Some("e) { index += 2; continue; } return index + 1; } index += 1; } bytes.len() } fn decode_oracle_quoted_identifier(identifier: &str) -> String { identifier.strip_prefix('"').and_then(|value| value.strip_suffix('"')).unwrap_or(identifier).replace("\"\"", "\"") } fn skip_ascii_whitespace(input: &str, mut index: usize) -> usize { while input.as_bytes().get(index).is_some_and(|byte| byte.is_ascii_whitespace()) { index += 1; } index } pub fn diff_columns(source: &[ColumnInfo], target: &[ColumnInfo]) -> Vec { diff_columns_with_options(source, target, false, false, false, 0.5) } /// Signature of a MySQL column type used to decide whether an integer display /// width difference is real or just MySQL echoing back its own default width. struct MysqlIntegerTypeSignature { /// Whole normalized type string, used to compare non-integer types as-is. normalized: String, /// `"{base} {suffix}"` (e.g. `"int unsigned"`) when `normalized` is a /// recognized integer type, regardless of whether a width is present. integer_key: Option, /// The explicit display width, when present on a recognized integer type. width: Option, } fn parse_mysql_integer_type(data_type: &str) -> MysqlIntegerTypeSignature { let normalized = data_type.split_whitespace().collect::>().join(" ").to_ascii_lowercase(); let is_integer_base = |base: &str| matches!(base, "tinyint" | "smallint" | "mediumint" | "int" | "integer" | "bigint" | "year"); let Some(open) = normalized.find('(') else { let base = normalized.split(' ').next().unwrap_or(&normalized); let integer_key = is_integer_base(base).then(|| normalized.clone()); return MysqlIntegerTypeSignature { normalized, integer_key, width: None }; }; let Some(close) = normalized[open + 1..].find(')').map(|index| open + 1 + index) else { return MysqlIntegerTypeSignature { normalized, integer_key: None, width: None }; }; let base = normalized[..open].trim(); let width_str = normalized[open + 1..close].trim(); if !is_integer_base(base) || width_str.is_empty() || !width_str.bytes().all(|byte| byte.is_ascii_digit()) { return MysqlIntegerTypeSignature { normalized, integer_key: None, width: None }; } let suffix = normalized[close + 1..].trim(); let integer_key = Some(if suffix.is_empty() { base.to_string() } else { format!("{base} {suffix}") }); let width = width_str.parse().ok(); MysqlIntegerTypeSignature { normalized, integer_key, width } } fn column_types_equal_for_dialects( source_type: &str, target_type: &str, source_dialect: Option, target_dialect: Option, ) -> bool { if source_type.eq_ignore_ascii_case(target_type) { return true; } if source_dialect != Some(DialectKind::Mysql) || target_dialect != Some(DialectKind::Mysql) { return false; } let source = parse_mysql_integer_type(source_type); let target = parse_mysql_integer_type(target_type); match (source.integer_key, target.integer_key) { // Same integer family (e.g. both "int unsigned"): a display width present on only one // side is MySQL filling in its own default and not a real difference, but two explicit, // differing widths (e.g. int(11) vs int(15)) are a genuine schema difference. (Some(source_key), Some(target_key)) => { source_key == target_key && match (source.width, target.width) { (Some(a), Some(b)) => a == b, _ => true, } } (None, None) => source.normalized == target.normalized, _ => false, } } 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 { diff_columns_with_dialect_options( source, target, ignore_comments, compare_column_order, detect_renames, rename_threshold, None, None, ) } #[allow(clippy::too_many_arguments)] fn diff_columns_with_dialect_options( source: &[ColumnInfo], target: &[ColumnInfo], ignore_comments: bool, compare_column_order: bool, detect_renames: bool, rename_threshold: f64, source_dialect: Option, target_dialect: Option, ) -> Vec { diff_columns_with_identifier_options( source, target, ignore_comments, compare_column_order, detect_renames, rename_threshold, source_dialect, target_dialect, false, ) } fn resolve_column_matches( source: &[ColumnInfo], target: &[ColumnInfo], ignore_column_name_case: bool, ) -> Vec> { let mut matches = vec![None; source.len()]; let mut used_targets = HashSet::new(); // Exact matches always win, even when a case-insensitive candidate is also available. for (source_index, source_column) in source.iter().enumerate() { if let Some(target_index) = target.iter().enumerate().find_map(|(target_index, target_column)| { (target_column.name == source_column.name && !used_targets.contains(&target_index)).then_some(target_index) }) { matches[source_index] = Some(target_index); used_targets.insert(target_index); } } if ignore_column_name_case { for (source_index, source_column) in source.iter().enumerate() { if matches[source_index].is_some() { continue; } let mut candidates = target.iter().enumerate().filter(|(target_index, target_column)| { !used_targets.contains(target_index) && target_column.name.eq_ignore_ascii_case(&source_column.name) }); let candidate = candidates.next().map(|(target_index, _)| target_index); if let Some(target_index) = candidate.filter(|_| candidates.next().is_none()) { matches[source_index] = Some(target_index); used_targets.insert(target_index); } } } matches } #[allow(clippy::too_many_arguments)] fn diff_columns_with_identifier_options( source: &[ColumnInfo], target: &[ColumnInfo], ignore_comments: bool, compare_column_order: bool, detect_renames: bool, rename_threshold: f64, source_dialect: Option, target_dialect: Option, ignore_column_name_case: bool, ) -> Vec { let mut diffs = Vec::new(); let column_matches = resolve_column_matches(source, target, ignore_column_name_case); let can_compare_order = compare_column_order && source.len() == target.len() && column_matches.iter().all(Option::is_some); for (source_index, source_column) in source.iter().enumerate() { if let Some(target_index) = column_matches[source_index] { let target_column = &target[target_index]; let mut changes = Vec::new(); // Compare the *declared* type, not the raw driver string: Oracle reports // `NUMBER` with the precision/scale in separate fields, so `NUMBER(10,2)` and // `NUMBER(12,2)` would otherwise look identical (#9261). let source_type = declared_column_type(source_column); let target_type = declared_column_type(target_column); if !column_types_equal_for_dialects(&source_type, &target_type, source_dialect, target_dialect) { changes.push(format!("type: {target_type} → {source_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 && 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, add_position: None, }); } } else { diffs.push(ColumnDiff { diff_type: "added".to_string(), name: source_column.name.clone(), source: Some(source_column.clone()), target: None, changes: Vec::new(), add_position: Some(column_add_position(source, source_index)), }); } } for (target_index, target_column) in target.iter().enumerate() { if !column_matches.iter().flatten().any(|matched_index| *matched_index == target_index) { diffs.push(ColumnDiff { diff_type: "removed".to_string(), name: target_column.name.clone(), source: None, target: Some(target_column.clone()), changes: Vec::new(), add_position: Some(column_add_position(target, target_index)), }); } } 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)], add_position: None, }; diffs[*ai] = ColumnDiff { diff_type: "_matched_rename".to_string(), name: String::new(), source: None, target: None, changes: Vec::new(), add_position: None, }; } diffs.retain(|d| d.diff_type != "_matched_rename"); } diffs } fn column_add_position(columns: &[ColumnInfo], index: usize) -> ColumnAddPosition { if index == 0 { ColumnAddPosition::First } else { ColumnAddPosition::After(columns[index - 1].name.clone()) } } pub fn diff_indexes(source: &[IndexInfo], target: &[IndexInfo]) -> Vec { diff_indexes_with_options(source, target, false) } fn index_columns_equal(source: &IndexInfo, target: &IndexInfo, ignore_column_name_case: bool) -> bool { source.columns.len() == target.columns.len() && source.columns.iter().enumerate().all(|(index, source_column)| { let target_column = &target.columns[index]; let source_is_expression = source.key_is_expression.get(index).copied().unwrap_or(false); let target_is_expression = target.key_is_expression.get(index).copied().unwrap_or(false); if source_is_expression || target_is_expression { source_column == target_column } else { identifiers_equal(source_column, target_column, ignore_column_name_case) } }) } fn identifier_lists_equal(left: &[String], right: &[String], ignore_column_name_case: bool) -> bool { left.len() == right.len() && left.iter().zip(right).all(|(left, right)| identifiers_equal(left, right, ignore_column_name_case)) } /// Names a server hands out on its own. Oracle names the index behind an *unnamed* /// PRIMARY KEY / UNIQUE constraint `SYS_C`, and that number is per-object, so two /// structurally identical tables never agree on it — while Dameng and SQLite do the same /// with `SYS_C…`/`sqlite_autoindex_…` shapes. Those names carry no user intent and must not /// be compared as if they did. fn index_name_is_server_generated(name: &str) -> bool { let upper = name.trim().to_ascii_uppercase(); if let Some(digits) = upper.strip_prefix("SYS_C") { return !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()); } upper.starts_with("SQLITE_AUTOINDEX_") } /// Everything that identifies an index apart from its name. Used to pair up two indexes /// whose names differ only because the server generated them. fn index_signatures_equal(source: &IndexInfo, target: &IndexInfo, ignore_column_name_case: bool) -> bool { source.is_unique == target.is_unique && source.index_type == target.index_type && source.filter == target.filter && source.column_opclasses == target.column_opclasses && source.key_options == target.key_options && index_columns_equal(source, target, ignore_column_name_case) && identifier_lists_equal( &source.included_columns.clone().unwrap_or_default(), &target.included_columns.clone().unwrap_or_default(), ignore_column_name_case, ) } /// Pairs source indexes with the target index that is *the same index under a /// server-generated name*, so comparing two structurally identical tables does not report /// a phantom "index added + index removed" pair (#9261). Returns the target position for /// every source position that was paired this way. fn match_server_generated_indexes( source: &[IndexInfo], target: &[IndexInfo], ignore_column_name_case: bool, ) -> Vec> { let source_names: HashSet<&str> = source.iter().map(|index| index.name.as_str()).collect(); let target_names: HashSet<&str> = target.iter().map(|index| index.name.as_str()).collect(); let mut matched_targets: HashSet = HashSet::new(); let mut pairs: Vec> = vec![None; source.len()]; for (source_position, source_index) in source.iter().enumerate() { if source_index.is_primary || target_names.contains(source_index.name.as_str()) { continue; } let Some(target_position) = target.iter().enumerate().find_map(|(position, target_index)| { if matched_targets.contains(&position) || target_index.is_primary || source_names.contains(target_index.name.as_str()) { return None; } // Only a server-generated name may stand in for a user-chosen one; two // deliberately different names stay a real difference. if !index_name_is_server_generated(&source_index.name) && !index_name_is_server_generated(&target_index.name) { return None; } index_signatures_equal(source_index, target_index, ignore_column_name_case).then_some(position) }) else { continue; }; matched_targets.insert(target_position); pairs[source_position] = Some(target_position); } pairs } fn diff_indexes_with_options( source: &[IndexInfo], target: &[IndexInfo], ignore_column_name_case: bool, ) -> 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(); let server_generated_pairs = match_server_generated_indexes(source, target, ignore_column_name_case); let paired_targets: HashSet = server_generated_pairs.iter().flatten().copied().collect(); for (source_position, source_index) in source.iter().enumerate() { if source_index.is_primary { continue; } let Some(target_index) = target_map.get(source_index.name.as_str()) else { if server_generated_pairs[source_position].is_some() { continue; } 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 !index_columns_equal(source_index, target_index, ignore_column_name_case) { 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 !identifier_lists_equal(&source_included, &target_included, ignore_column_name_case) { 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 source_index.column_opclasses != target_index.column_opclasses { let fmt_opclass = |o: &Option| -> String { o.as_deref().filter(|v| !v.is_empty()).unwrap_or("default").to_string() }; let src_str = source_index.column_opclasses.iter().map(fmt_opclass).collect::>().join(", "); let tgt_str = target_index.column_opclasses.iter().map(fmt_opclass).collect::>().join(", "); changes.push(format!("opclass: {} → {}", tgt_str, src_str)); } 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_position, target_index) in target.iter().enumerate() { if target_index.is_primary || paired_targets.contains(&target_position) { 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()) } fn normalize_self_referencing_fk( fk: &ForeignKeyInfo, own_table_names: &HashSet<&str>, ignore_table_name_case: bool, ) -> ForeignKeyInfo { let mut normalized = fk.clone(); if fk.ref_schema.is_some() && own_table_names.iter().any(|table_name| identifiers_equal(table_name, &fk.ref_table, ignore_table_name_case)) { normalized.ref_schema = None; } normalized } fn normalize_mapped_foreign_key( fk: &ForeignKeyInfo, source_table_names: &HashSet<&str>, target_table_names: &HashSet<&str>, table_pairs: &[(String, String)], mappings: &[SchemaDiffTableMapping], ignore_table_name_case: bool, ) -> ForeignKeyInfo { let mut normalized = normalize_self_referencing_fk(fk, source_table_names, ignore_table_name_case); let mapped_target = mappings .iter() .find(|mapping| { mapping.source_table == normalized.ref_table && target_table_names.contains(mapping.target_table.as_str()) }) .map(|mapping| mapping.target_table.as_str()) .or_else(|| { table_pairs .iter() .find(|(source_table, _)| { identifiers_equal(source_table, &normalized.ref_table, ignore_table_name_case) }) .map(|(_, target_table)| target_table.as_str()) }); if let Some(mapped_target) = mapped_target { normalized.ref_table = mapped_target.to_string(); normalized.ref_schema = None; } normalized } pub fn diff_foreign_keys(source: &[ForeignKeyInfo], target: &[ForeignKeyInfo]) -> Vec { diff_foreign_keys_with_options(source, target, false, false) } fn diff_foreign_keys_with_options( source: &[ForeignKeyInfo], target: &[ForeignKeyInfo], ignore_table_name_case: bool, ignore_column_name_case: bool, ) -> 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 !identifiers_equal(&source_fk.column, &target_fk.column, ignore_column_name_case) { changes.push(format!("column: {} → {}", target_fk.column, source_fk.column)); } if !identifiers_equal(&source_fk.ref_table, &target_fk.ref_table, ignore_table_name_case) { 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 !identifiers_equal(&source_fk.ref_column, &target_fk.ref_column, ignore_column_name_case) { 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 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) } /// The `NOT NULL` / `DEFAULT ...` tail of a column definition, in the order the target /// dialect accepts. Oracle's grammar is `datatype [DEFAULT expr] [NOT NULL]` and rejects /// the reverse order with `ORA-00907`, while MySQL/Postgres/SQLite write the constraint /// first, so the order is dialect data (see `column_default_precedes_not_null`). fn column_modifier_tail( profile: &DdlDialectProfile, col: &ColumnInfo, declared_type: &str, db_type: DatabaseType, source_dialect: Option, skip_default: bool, ) -> String { let not_null = if col.is_nullable { String::new() } else { " NOT NULL".to_string() }; let default = if skip_default { String::new() } else { col.column_default.as_ref().map_or_else(String::new, |value| { format!( " DEFAULT {}", default_literal( value, declared_type, effective_source_dialect(source_dialect, db_type), col.extra.as_deref() ) ) }) }; if profile.column_default_precedes_not_null { format!("{default}{not_null}") } else { format!("{not_null}{default}") } } fn column_def(col: &ColumnInfo, db_type: DatabaseType, source_dialect: Option) -> String { if db_type == DatabaseType::SqlServer { return sqlserver_column_definition(col, &col.data_type, source_dialect, None); } let profile = profile_for(db_type); let mut definition = format!("{} {}", quote_id(&col.name, db_type), col.data_type); definition.push_str(&column_modifier_tail(&profile, col, &col.data_type, db_type, source_dialect, false)); // Suffix-style auto-increment is only valid in MySQL-family ALTER clauses // (ADD/MODIFY/CHANGE). Other dialects' identity clauses are order-sensitive // inside ADD COLUMN, so keep omitting them outside the MySQL family. if column_is_auto_increment(col) && profile.alter_uses_modify_column { if let AutoIncSyntax::Suffix(suffix) = profile.auto_inc { definition.push_str(suffix); } } 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 { let schema = schema .map(str::trim) .filter(|schema| !schema.is_empty()) .or_else(|| (db_type == DatabaseType::SqlServer).then_some("dbo")); schema .map(|schema| format!("{}.{}", quote_id(schema, db_type), quote_id(name, db_type))) .unwrap_or_else(|| quote_id(name, db_type)) } /// `CREATE TABLE`-family header keywords that native source DDL may start /// with, longest-prefix-first so e.g. `CREATE FOREIGN TABLE` isn't shadowed /// by a naive `CREATE TABLE` match. const TABLE_DDL_HEADER_KEYWORDS: &[&str] = &["CREATE FOREIGN TABLE", "CREATE UNLOGGED TABLE", "CREATE TEMPORARY TABLE", "CREATE TEMP TABLE", "CREATE TABLE"]; /// `CREATE VIEW`-family header keywords, covering the `OR REPLACE` and /// `MATERIALIZED` variants emitted by the Postgres/MySQL view DDL builders. const VIEW_DDL_HEADER_KEYWORDS: &[&str] = &[ "CREATE MATERIALIZED VIEW", "CREATE OR REPLACE VIEW", // Oracle's `DBMS_METADATA` writes the `FORCE`/`EDITIONABLE` variants; without them an // Oracle view that exists only in the source kept the source schema in its header and // the sync script created it on the wrong schema (or failed on rights). "CREATE OR REPLACE FORCE EDITIONABLE VIEW", "CREATE OR REPLACE FORCE NONEDITIONABLE VIEW", "CREATE OR REPLACE EDITIONABLE VIEW", "CREATE OR REPLACE NONEDITIONABLE VIEW", "CREATE OR REPLACE FORCE VIEW", "CREATE OR REPLACE NOFORCE VIEW", "CREATE FORCE VIEW", "CREATE VIEW", ]; /// Case-insensitive ASCII prefix check that avoids allocating an uppercased /// copy of `haystack` (which can be a whole multi-KB `CREATE TABLE` body) just /// to compare its first few bytes against a short keyword. fn starts_with_ignore_ascii_case(haystack: &str, needle: &str) -> bool { haystack.get(..needle.len()).is_some_and(|prefix| prefix.eq_ignore_ascii_case(needle)) } /// Native DDL captured from the source connection is schema/database-qualified /// against the *source* schema (see `render_postgres_table_ddl_with_partition_info` /// and `build_view_ddl_sql`). When the diff engine reuses that DDL verbatim for a /// same-dialect sync script, running it against a different target schema fails /// outright — the statement still names the source schema, which may not even /// exist on the target connection. /// /// Rewrites the object name in a `CREATE ...` header to `qualified` when the /// captured name is already schema/database-qualified (contains a `.`). An /// unqualified name (e.g. MySQL DDL relying on the connection's current /// database) is left untouched, since it already resolves correctly wherever /// the sync script runs. fn rewrite_ddl_header_qualifier(ddl: &str, header_keywords: &[&str], schema: Option<&str>, qualified: &str) -> String { let leading_ws = ddl.len() - ddl.trim_start().len(); let body = &ddl[leading_ws..]; let Some(keyword) = header_keywords.iter().find(|keyword| starts_with_ignore_ascii_case(body, keyword)) else { return ddl.to_string(); }; let mut idx = skip_ascii_whitespace(ddl, leading_ws + keyword.len()); if starts_with_ignore_ascii_case(&ddl[idx..], "IF NOT EXISTS") { idx = skip_ascii_whitespace(ddl, idx + "IF NOT EXISTS".len()); } let ident_start = idx; let Some(parsed) = parse_qualified_identifier(ddl, idx) else { return ddl.to_string(); }; let Some((schema_start, schema_end)) = parsed.schema_span else { // Unqualified name (e.g. MySQL DDL relying on the connection's // current database) already resolves correctly wherever the sync // script runs — leave it untouched. return ddl.to_string(); }; let embedded_schema = strip_identifier_quotes(&ddl[schema_start..schema_end]); if schema.map(str::trim).is_some_and(|schema| schema == embedded_schema) { // Already qualified with the target schema — avoid needlessly // reformatting DDL that's already correct. return ddl.to_string(); } format!("{}{}{}", &ddl[..ident_start], qualified, &ddl[parsed.end..]) } struct ParsedDdlName { end: usize, schema_span: Option<(usize, usize)>, } /// Parses a (possibly dotted, possibly quoted) identifier starting at `idx`. /// Returns the byte offset just past it, plus the span of the schema/database /// segment if the name was qualified, or `None` if `idx` isn't the start of /// an identifier. Handles the quoting styles used across DDL dialects: double /// quotes, backticks, and SQL Server brackets. fn parse_qualified_identifier(ddl: &str, start: usize) -> Option { let mut idx = start; let mut schema_span = None; let mut segment_start = start; loop { let segment_end = parse_identifier_segment_end(ddl, idx)?; idx = segment_end; if ddl[idx..].starts_with('.') { schema_span = Some((segment_start, segment_end)); idx += 1; segment_start = idx; continue; } break; } Some(ParsedDdlName { end: idx, schema_span }) } /// Parses one identifier segment (quoted or bare) starting at `idx` and /// returns the byte offset just past it, or `None` if `idx` isn't the start /// of a segment. fn parse_identifier_segment_end(ddl: &str, mut idx: usize) -> Option { let start = idx; let ch = ddl[idx..].chars().next()?; let closing_quote = match ch { '"' => Some('"'), '`' => Some('`'), '[' => Some(']'), _ => None, }; if let Some(closing_quote) = closing_quote { idx += ch.len_utf8(); loop { let next = ddl[idx..].chars().next()?; idx += next.len_utf8(); if next == closing_quote { // SQL Server brackets escape a literal `]` the same way // double-quote/backtick identifiers escape their own quote // char: by doubling it (`[a]]b]` is the identifier `a]b`). if ddl[idx..].starts_with(closing_quote) { idx += closing_quote.len_utf8(); continue; } break; } } } else if ch.is_alphanumeric() || ch == '_' { while let Some(next) = ddl[idx..].chars().next() { if next.is_alphanumeric() || next == '_' { idx += next.len_utf8(); } else { break; } } } else { return None; } (idx != start).then_some(idx) } /// Strips a single matching pair of identifier-quoting characters (double /// quotes, backticks, or brackets) from `segment`, undoubling an escaped /// closing quote. Returns `segment` unchanged if it isn't quoted. fn strip_identifier_quotes(segment: &str) -> String { let mut chars = segment.chars(); let (Some(first), Some(last)) = (chars.next(), segment.chars().next_back()) else { return segment.to_string(); }; let matches = matches!((first, last), ('"', '"') | ('`', '`') | ('[', ']')); if !matches || segment.len() < 2 { return segment.to_string(); } let inner = &segment[first.len_utf8()..segment.len() - last.len_utf8()]; // Brackets escape their closing char the same way same-char quoting // does (`]]` -> `]`), just with a different open/close pair. inner.replace(&format!("{last}{last}"), &last.to_string()) } 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); if db_type == DatabaseType::SqlServer { let table_literal = table.replace('\'', "''"); let index_literal = index_name.replace('\'', "''"); let quoted_index = quote_id(index_name, db_type); // sys.indexes also exposes the backing indexes of PRIMARY KEY and UNIQUE // constraints. SQL Server rejects DROP INDEX for those and requires // ALTER TABLE ... DROP CONSTRAINT instead, so resolve the object kind at // execution time before choosing the documented drop form. let batch = format!( "DECLARE @dbx_constraint_name sysname, @dbx_drop_sql NVARCHAR(MAX); \ SELECT @dbx_constraint_name = kc.name \ FROM sys.key_constraints AS kc \ JOIN sys.indexes AS i ON i.object_id = kc.parent_object_id AND i.index_id = kc.unique_index_id \ WHERE kc.parent_object_id = OBJECT_ID(N'{table_literal}') AND i.name = N'{index_literal}'; \ IF @dbx_constraint_name IS NOT NULL BEGIN \ SET @dbx_drop_sql = N'ALTER TABLE {table_literal} DROP CONSTRAINT ' + QUOTENAME(@dbx_constraint_name); \ EXEC sys.sp_executesql @dbx_drop_sql; \ END ELSE IF EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'{table_literal}') AND name = N'{index_literal}') BEGIN \ DROP INDEX {quoted_index} ON {table}; \ END;" ); return sqlserver_single_statement_batch(&batch); } let index = qualified_name(index_name, db_type, schema); if matches!(db_type, DatabaseType::Oracle | DatabaseType::OceanbaseOracle) { // Oracle versions before 23c do not support `DROP INDEX IF EXISTS`. // Schema comparison already identified this index as present, so the // direct form is both valid and sufficient for the generated script. return format!("DROP INDEX {index};"); } 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 is_postgres_family_ddl(db_type: DatabaseType) -> bool { matches!( db_type, DatabaseType::Postgres | DatabaseType::Redshift | DatabaseType::Gaussdb | DatabaseType::Kingbase | DatabaseType::Highgo | DatabaseType::Vastbase | DatabaseType::OpenGauss | DatabaseType::Kwdb | DatabaseType::Firebird | DatabaseType::Vertica | DatabaseType::Exasol | DatabaseType::Uxdb ) } /// One PostgreSQL index key part for DDL: the key text plus its operator class /// and, for B-tree keys, the explicit ordering. /// /// Expression/functional index key parts (e.g. from pg_get_indexdef) arrive as raw /// expression text, not a plain column name; quoting the whole expression as an /// identifier turns it into a literal column reference that doesn't exist (#6295). /// /// `pg_index.indoption` carries bit 0 = DESC and bit 1 = NULLS FIRST per key. /// Dropping it silently rebuilt `col DESC NULLS LAST` as a plain ASC key (#8559 /// fixed exactly this for data transfer; the sync DDL kept losing it, #9988). /// Only B-tree keys carry meaningful flags, so other access methods keep the /// bare key text - same rule as the transfer path. fn postgres_index_column_sql( column: &str, is_expression: Option, opclass: Option<&str>, key_options: Option, db_type: DatabaseType, ) -> String { let trimmed = column.trim(); let base = if is_expression.unwrap_or(false) { trimmed.to_string() } else { quote_id(column, db_type) }; let with_opclass = match opclass.filter(|opclass| !opclass.is_empty()) { Some(opclass) => format!("{base} {opclass}"), None => base, }; match key_options { Some(options) => format!( "{with_opclass} {} NULLS {}", if options & 1 != 0 { "DESC" } else { "ASC" }, if options & 2 != 0 { "FIRST" } else { "LAST" } ), None => with_opclass, } } pub 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); if db_type == DatabaseType::SqlServer && index.columns.iter().enumerate().any(|(position, column)| { index.key_is_expression.get(position).copied().unwrap_or(false) || column.trim_start().starts_with('(') }) { return format!( "-- Skip index {} on {table}: SQL Server indexes require columns or pre-existing computed columns, not source-dialect expressions.", index.name ); } let mut columns = index .columns .iter() .enumerate() .map(|(i, column)| { if db_type == DatabaseType::Mysql { mysql_index_column_sql(column) } else if is_postgres_family_ddl(db_type) { let opclass = index.column_opclasses.get(i).and_then(|opclass| opclass.as_deref()); let key_options = index .index_type .as_deref() .filter(|index_type| index_type.trim().eq_ignore_ascii_case("btree")) .and_then(|_| index.key_options.get(i).copied()); postgres_index_column_sql( column, index.key_is_expression.get(i).copied(), opclass, key_options, db_type, ) } else { quote_id(column, db_type) } }) .collect::>() .join(", "); let source_index_type = index.index_type.as_deref().unwrap_or_default().trim(); let index_type = if db_type == DatabaseType::SqlServer { match source_index_type.to_ascii_uppercase().as_str() { "CLUSTERED" => "CLUSTERED".to_string(), "NONCLUSTERED" => "NONCLUSTERED".to_string(), "CLUSTERED COLUMNSTORE" => "CLUSTERED COLUMNSTORE".to_string(), "NONCLUSTERED COLUMNSTORE" | "COLUMNSTORE" => "NONCLUSTERED COLUMNSTORE".to_string(), // SQL Server rowstore indexes are B-trees by default. "" | "BTREE" => String::new(), _ => { return format!( "-- Skip index {} on {table}: index type '{}' has no direct SQL Server CREATE INDEX form.", index.name, source_index_type ); } } } else { source_index_type.to_string() }; if db_type == DatabaseType::SqlServer && index.is_unique && index_type.contains("COLUMNSTORE") { return format!("-- Skip index {} on {table}: SQL Server columnstore indexes cannot be UNIQUE.", index.name); } let unique = if index.is_unique && !index_type.contains("COLUMNSTORE") { "UNIQUE " } else { "" }; 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 && !(db_type == DatabaseType::SqlServer && index_type.contains("COLUMNSTORE")) { format!( " INCLUDE ({})", included_columns.iter().map(|column| quote_id(column, db_type)).collect::>().join(", ") ) } else { String::new() }; if db_type == DatabaseType::SqlServer && index_type == "NONCLUSTERED COLUMNSTORE" && columns.is_empty() && !included_columns.is_empty() { // sys.index_columns exposes columnstore members as included columns // because a columnstore index has no key columns. columns = included_columns.iter().map(|column| quote_id(column, db_type)).collect::>().join(", "); } 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() }; if db_type == DatabaseType::SqlServer && index_type == "CLUSTERED COLUMNSTORE" { return format!("CREATE {type_prefix}INDEX {} ON {table};", quote_id(&index.name, db_type)); } if columns.is_empty() { return format!("-- Skip index {} on {table}: no index columns were available.", index.name); } // 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 { add_foreign_key_sql_with_reference_separator(table_name, fk, db_type, schema, " ") } fn add_foreign_key_sql_with_reference_separator( table_name: &str, fk: &ForeignKeyInfo, db_type: DatabaseType, schema: Option<&str>, reference_separator: &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 action = |kind: &str, value: Option<&String>| -> String { let Some(value) = value else { return String::new(); }; let normalized = value.trim().to_ascii_uppercase(); if db_type != DatabaseType::SqlServer { return format!(" ON {kind} {value}"); } let sqlserver_action = match normalized.as_str() { "RESTRICT" | "NO ACTION" => "NO ACTION", "CASCADE" => "CASCADE", "SET NULL" => "SET NULL", "SET DEFAULT" => "SET DEFAULT", _ => return String::new(), }; format!(" ON {kind} {sqlserver_action}") }; let on_delete = action("DELETE", fk.on_delete.as_ref()); let on_update = action("UPDATE", fk.on_update.as_ref()); format!( "ALTER TABLE {table} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {ref_table}{reference_separator}({}){on_delete}{on_update};", quote_id(&fk.name, db_type), quote_id(&fk.column, db_type), quote_id(&fk.ref_column, db_type) ) } fn target_table_name(diff: &TableDiff) -> &str { diff.target_name.as_deref().unwrap_or(&diff.name) } fn ddl_column_name(column: &ColumnDiff) -> &str { if let (Some(source), Some(target)) = (&column.source, &column.target) { if source.name.eq_ignore_ascii_case(&target.name) { return &target.name; } } &column.name } 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" }; let name = qualified_name(target_table_name(diff), db_type, schema); if db_type == DatabaseType::SqlServer { let object_id_type = if object_type == "VIEW" { "V" } else { "U" }; return format!( "IF OBJECT_ID(N'{}', N'{object_id_type}') IS NOT NULL DROP {object_type} {name};", name.replace('\'', "''") ); } // Oracle only gained `IF EXISTS` in 23c, and Access/Firebird/Db2/Informix/HANA/Teradata // never had it; the comparison already knows the object exists on the target, so the // direct form is valid and sufficient — the same reasoning `drop_index_sql` uses. if !profile_for(db_type).drop_table_supports_if_exists { return format!("DROP {object_type} {name}{cascade};"); } format!("DROP {object_type} IF EXISTS {name}{cascade};") } fn comment_literal(comment: &str) -> String { format!("'{}'", comment.replace('\'', "''")) } /// Bare temporal keywords that are defaults in their own right and must not be quoted. const TEMPORAL_DEFAULT_KEYWORDS: [&str; 8] = ["current_timestamp", "current_date", "current_time", "now", "localtime", "localtimestamp", "getdate", "sysdate"]; /// Prefixes that introduce an already-quoted literal: SQL Server / Sybase `N'x'`, /// MySQL `b'1'` and `x'1f'`, Postgres `e'\n'`. const QUOTED_LITERAL_PREFIXES: [&str; 4] = ["n'", "b'", "x'", "e'"]; /// The dialect the column metadata came from. /// /// The caller does not always declare one. When it does not, the comparison is /// same-dialect, so the target database is also the source. fn effective_source_dialect(source_dialect: Option, db_type: DatabaseType) -> DialectKind { source_dialect.unwrap_or_else(|| DialectKind::from_database_type(db_type)) } /// Render a column default as a SQL literal. /// /// Drivers hand `column_default` back verbatim and they do not agree on its /// shape, so the rule has to be bound to the dialect the value came from rather /// than guessed from the value itself. The same bare token means different /// things in different databases: `CURRENT_USER` is an expression on Postgres /// and Oracle, while on MySQL a bare `CURRENT_USER` on a text column is the /// literal string. /// /// Only MySQL-family metadata strips the quotes from a string default, so it is /// the only source that needs any repair here. Everywhere else the value already /// arrives quoted, cast or wrapped, and is passed through untouched. /// /// `table_structure_sql::util::format_default_for_sql` and /// `transfer::format_mysql_default_literal` do the same job on their own paths; /// both are private to their modules. fn default_literal(default: &str, data_type: &str, source: DialectKind, extra: Option<&str>) -> String { let normalized = default.trim(); // Every dialect except MySQL returns a string default already quoted, cast // or wrapped, so a bare token there is an expression and rewriting it would // change its meaning: Postgres `text DEFAULT CURRENT_USER`, Oracle // `varchar2 DEFAULT USER`, SQL Server `('x')`. if source != DialectKind::Mysql { return normalized.to_string(); } // MySQL 8.0.13 and later flag an expression default in `EXTRA`. That marker // is authoritative, so consult it before looking at the value at all: a // default is a literal unless the server says it was generated. if extra.is_some_and(|value| value.to_ascii_uppercase().contains("DEFAULT_GENERATED")) { return normalized.to_string(); } // MySQL writes an expression default wrapped in parentheses, `DEFAULT // (uuid())`, and reports it that way. The wrapping is the syntax, so it is // a reliable marker even when `EXTRA` is not populated. Note this asks // whether the value *is* parenthesised, not whether it merely contains a // parenthesis: the string default `a(b)` is not wrapped and is still // quoted. if normalized.starts_with('(') && normalized.ends_with(')') { return normalized.to_string(); } // Already a literal: `'x'` or a prefixed form like `x'1f'`. let lowered = normalized.to_ascii_lowercase(); if normalized.starts_with('\'') || QUOTED_LITERAL_PREFIXES.iter().any(|prefix| lowered.starts_with(prefix)) { return normalized.to_string(); } let base_type = data_type.split('(').next().unwrap_or(data_type).trim().to_ascii_lowercase(); let takes_text_literal = ["char", "text", "string", "clob", "enum", "set"].iter().any(|kind| base_type.contains(kind)); let takes_binary_literal = ["binary", "blob", "bytea"].iter().any(|kind| base_type.contains(kind)); let takes_temporal_literal = base_type.contains("date") || base_type.contains("time"); // Before 8.0.13 there is no `EXTRA` marker and a temporal column was the // only place an expression default could appear. if takes_temporal_literal && is_temporal_keyword_default(normalized) { return normalized.to_string(); } // A binary default is commonly reported as a hex literal, which is already // valid unquoted; a bare string on the same column still needs quoting. if takes_binary_literal && is_hex_literal(normalized) { return normalized.to_string(); } if takes_text_literal || takes_binary_literal || takes_temporal_literal { // Deliberately no parenthesis check. MySQL reports the string default // `'a(b)'` as the bare value `a(b)`, and treating a parenthesis as proof // of a function call is what produced invalid `DEFAULT a(b)`. return format!("'{}'", default.replace('\'', "''")); } normalized.to_string() } fn consume_postgres_cast_name(value: &str, start: usize) -> Option<(usize, Option)> { let ch = value[start..].chars().next()?; if ch == '"' { let mut index = start + ch.len_utf8(); while index < value.len() { let next = value[index..].chars().next().expect("index is on a character boundary"); index += next.len_utf8(); if next == '"' { if value[index..].starts_with('"') { index += 1; } else { return Some((index, None)); } } } return None; } if ch.is_alphabetic() || ch == '_' { let mut index = start + ch.len_utf8(); while index < value.len() { let next = value[index..].chars().next().expect("index is on a character boundary"); if next.is_alphanumeric() || matches!(next, '_' | '$') { index += next.len_utf8(); } else { break; } } return Some((index, Some(value[start..index].to_ascii_lowercase()))); } None } fn postgres_cast_whitespace_end(value: &str, start: usize) -> usize { start + value[start..].chars().take_while(|ch| ch.is_ascii_whitespace()).map(char::len_utf8).sum::() } fn consume_postgres_cast_keyword(value: &str, start: usize, keyword: &str) -> Option { let token_start = postgres_cast_whitespace_end(value, start); if token_start == start { return None; } let (end, unquoted) = consume_postgres_cast_name(value, token_start)?; unquoted.as_deref().is_some_and(|token| token.eq_ignore_ascii_case(keyword)).then_some(end) } fn postgres_dollar_quote_delimiter_end(value: &str, start: usize) -> Option { if !value[start..].starts_with('$') { return None; } if value[..start].chars().next_back().is_some_and(|ch| ch.is_alphanumeric() || matches!(ch, '_' | '$')) { return None; } let relative_end = value[start + 1..].find('$')?; let delimiter_end = start + 1 + relative_end + 1; let tag = &value[start + 1..delimiter_end - 1]; let mut chars = tag.chars(); if let Some(first) = chars.next() { if !(first.is_alphabetic() || first == '_') || !chars.all(|ch| ch.is_alphanumeric() || ch == '_') { return None; } } Some(delimiter_end) } fn consume_postgres_dollar_quoted(value: &str, start: usize) -> Option { let delimiter_end = postgres_dollar_quote_delimiter_end(value, start)?; let delimiter = &value[start..delimiter_end]; value[delimiter_end..].find(delimiter).map(|offset| delimiter_end + offset + delimiter.len()) } fn postgres_escape_string_quote(value: &str, quote_index: usize) -> bool { let mut prefix = value[..quote_index].char_indices().rev(); let Some((e_index, 'e' | 'E')) = prefix.next() else { return false; }; value[..e_index].chars().next_back().is_none_or(|ch| !(ch.is_alphanumeric() || matches!(ch, '_' | '$'))) } fn consume_postgres_cast_group(value: &str, start: usize, open: char, close: char) -> Option { if value[start..].chars().next()? != open { return None; } let mut depth = 0usize; let mut index = start; let mut in_single_quote = false; let mut in_escape_single_quote = false; let mut in_double_quote = false; while index < value.len() { let ch = value[index..].chars().next().expect("index is on a character boundary"); let next_index = index + ch.len_utf8(); if in_single_quote && in_escape_single_quote && ch == '\\' { index = value[next_index..].chars().next().map_or(next_index, |escaped| next_index + escaped.len_utf8()); continue; } if ch == '\'' && !in_double_quote { if in_single_quote && value[next_index..].starts_with('\'') { index = next_index + 1; continue; } in_single_quote = !in_single_quote; if in_single_quote { in_escape_single_quote = postgres_escape_string_quote(value, index); } else { in_escape_single_quote = false; } } else if ch == '"' && !in_single_quote { if in_double_quote && value[next_index..].starts_with('"') { index = next_index + 1; continue; } in_double_quote = !in_double_quote; } else if !in_single_quote && !in_double_quote { if postgres_dollar_quote_delimiter_end(value, index).is_some() { index = consume_postgres_dollar_quoted(value, index)?; continue; } if ch == open { depth += 1; } else if ch == close { depth = depth.saturating_sub(1); if depth == 0 { return Some(next_index); } } } index = next_index; } None } fn consume_postgres_cast_type(value: &str, start: usize) -> usize { let mut index = postgres_cast_whitespace_end(value, start); let Some((name_end, first_name)) = consume_postgres_cast_name(value, index) else { return start; }; index = name_end; let mut qualified = false; // A type name may be schema-qualified, with whitespace around the dot. Do // not treat arbitrary whitespace-separated words as part of the type: that // would consume expression operators such as `AND`, `IS`, or `COLLATE`. loop { let dot = postgres_cast_whitespace_end(value, index); if !value[dot..].starts_with('.') { break; } let component_start = postgres_cast_whitespace_end(value, dot + 1); let Some((component_end, _)) = consume_postgres_cast_name(value, component_start) else { return start; }; qualified = true; index = component_end; } let base_name = (!qualified).then_some(first_name).flatten(); // PostgreSQL has a small, explicit set of whitespace-separated built-in // type names. Only those phrases may extend across whitespace; accepting an // arbitrary second identifier corrupts expressions such as `x::int IS NULL`. if let Some(base) = base_name.as_deref() { match base { "character" | "char" | "bit" => { if let Some(end) = consume_postgres_cast_keyword(value, index, "varying") { index = end; } } "double" => { if let Some(end) = consume_postgres_cast_keyword(value, index, "precision") { index = end; } } "national" => { if let Some(end) = consume_postgres_cast_keyword(value, index, "character") .or_else(|| consume_postgres_cast_keyword(value, index, "char")) { index = end; if let Some(end) = consume_postgres_cast_keyword(value, index, "varying") { index = end; } } } _ => {} } } let modifier_start = postgres_cast_whitespace_end(value, index); if value[modifier_start..].starts_with('(') { let Some(end) = consume_postgres_cast_group(value, modifier_start, '(', ')') else { return start; }; index = end; } if matches!(base_name.as_deref(), Some("time" | "timestamp")) { let suffix_start = index; if let Some(with_end) = consume_postgres_cast_keyword(value, suffix_start, "with") .or_else(|| consume_postgres_cast_keyword(value, suffix_start, "without")) { let Some(time_end) = consume_postgres_cast_keyword(value, with_end, "time") else { return start; }; let Some(zone_end) = consume_postgres_cast_keyword(value, time_end, "zone") else { return start; }; index = zone_end; } } if base_name.as_deref() == Some("interval") { const INTERVAL_FIELDS: &[&str] = &["year", "month", "day", "hour", "minute", "second"]; let field_start = index; let field = INTERVAL_FIELDS .iter() .find_map(|field| consume_postgres_cast_keyword(value, field_start, field).map(|end| (*field, end))); if let Some((first_field, first_end)) = field { index = first_end; let to_start = index; if let Some(to_end) = consume_postgres_cast_keyword(value, to_start, "to") { let valid_final_fields: &[&str] = match first_field { "year" => &["month"], "day" => &["hour", "minute", "second"], "hour" => &["minute", "second"], "minute" => &["second"], _ => &[], }; let Some(final_end) = valid_final_fields.iter().find_map(|field| consume_postgres_cast_keyword(value, to_end, field)) else { return start; }; index = final_end; } let precision_start = postgres_cast_whitespace_end(value, index); if value[precision_start..].starts_with('(') { let Some(end) = consume_postgres_cast_group(value, precision_start, '(', ')') else { return start; }; index = end; } } } loop { let array_start = postgres_cast_whitespace_end(value, index); if !value[array_start..].starts_with('[') { break; } let Some(end) = consume_postgres_cast_group(value, array_start, '[', ']') else { return start; }; let bounds = value[array_start + 1..end - 1].trim(); if !bounds.is_empty() && !bounds.chars().all(|ch| ch.is_ascii_digit()) { return start; } index = end; } if value[index..] .chars() .next() .is_some_and(|ch| ch.is_alphanumeric() || matches!(ch, '_' | '$' | '"' | '\'' | '(')) { return start; } index } fn strip_postgres_default_casts(value: &str) -> String { let mut result = String::with_capacity(value.len()); let mut segment_start = 0; let mut index = 0; let mut in_single_quote = false; let mut in_escape_single_quote = false; let mut in_double_quote = false; while index < value.len() { let ch = value[index..].chars().next().expect("index is on a character boundary"); if in_single_quote && in_escape_single_quote && ch == '\\' { let next_index = index + ch.len_utf8(); index = value[next_index..].chars().next().map_or(next_index, |escaped| next_index + escaped.len_utf8()); continue; } if ch == '\'' && !in_double_quote { let next_index = index + ch.len_utf8(); if in_single_quote && value[next_index..].starts_with('\'') { index = next_index + 1; continue; } in_single_quote = !in_single_quote; if in_single_quote { in_escape_single_quote = postgres_escape_string_quote(value, index); } else { in_escape_single_quote = false; } index = next_index; continue; } if ch == '"' && !in_single_quote { let next_index = index + ch.len_utf8(); if in_double_quote && value[next_index..].starts_with('"') { index = next_index + 1; continue; } in_double_quote = !in_double_quote; index = next_index; continue; } if !in_single_quote && !in_double_quote && postgres_dollar_quote_delimiter_end(value, index).is_some() { index = consume_postgres_dollar_quoted(value, index).unwrap_or(value.len()); continue; } if !in_single_quote && !in_double_quote && value[index..].starts_with("::") { let type_start = index + 2; let cast_end = consume_postgres_cast_type(value, type_start); if cast_end == type_start { // Cast removal is all-or-nothing. Continuing after a malformed // candidate could peel a nested `::` inside its unclosed // modifier or array suffix and corrupt the original default. return value.to_string(); } result.push_str(&value[segment_start..index]); index = cast_end; segment_start = index; continue; } index += ch.len_utf8(); } result.push_str(&value[segment_start..]); result } fn sqlserver_sequence_default(value: &str, target_schema: Option<&str>) -> Option { let lowered = value.trim().to_ascii_lowercase(); if !lowered.starts_with("nextval(") { return None; } let start = value.find('\'')? + 1; let end = value[start..].find('\'')? + start; let sequence = value[start..end].split('.').next_back()?.trim_matches('"'); (!sequence.is_empty()) .then(|| format!("NEXT VALUE FOR {}", qualified_name(sequence, DatabaseType::SqlServer, target_schema))) } fn trim_outer_parentheses_for_match(mut value: &str) -> &str { loop { let trimmed = value.trim(); if !trimmed.starts_with('(') || !trimmed.ends_with(')') { return trimmed; } let mut depth = 0usize; let mut in_single_quote = false; let mut closes_at_end = false; let mut chars = trimmed.char_indices().peekable(); while let Some((index, ch)) = chars.next() { if ch == '\'' { if in_single_quote && chars.peek().is_some_and(|(_, next)| *next == '\'') { chars.next(); continue; } in_single_quote = !in_single_quote; continue; } if in_single_quote { continue; } if ch == '(' { depth += 1; } else if ch == ')' { depth = depth.saturating_sub(1); if depth == 0 { closes_at_end = index + ch.len_utf8() == trimmed.len(); if !closes_at_end { break; } } } } if !closes_at_end { return trimmed; } value = &trimmed[1..trimmed.len() - 1]; } } fn sqlserver_hex_default(value: &str) -> Option { let trimmed = value.trim(); let digits = if trimmed.len() >= 3 && (trimmed.starts_with("x'") || trimmed.starts_with("X'")) && trimmed.ends_with('\'') { &trimmed[2..trimmed.len() - 1] } else if trimmed.starts_with("'\\x") && trimmed.ends_with('\'') { &trimmed[3..trimmed.len() - 1] } else { return None; }; (!digits.is_empty() && digits.chars().all(|ch| ch.is_ascii_hexdigit())).then(|| format!("0x{digits}")) } fn sqlserver_bit_string_default(value: &str) -> Option { let trimmed = value.trim(); if trimmed.len() < 4 || !(trimmed.starts_with("b'") || trimmed.starts_with("B'")) || !trimmed.ends_with('\'') { return None; } let bits = &trimmed[2..trimmed.len() - 1]; (!bits.is_empty() && bits.len() <= 64 && bits.bytes().all(|bit| matches!(bit, b'0' | b'1'))) .then(|| u64::from_str_radix(bits, 2).ok().map(|value| value.to_string())) .flatten() } #[cfg(test)] fn sqlserver_default_literal( default: &str, mapped_type: &str, source_dialect: Option, extra: Option<&str>, ) -> String { sqlserver_default_literal_for_schema(default, mapped_type, source_dialect, extra, None) } fn sqlserver_default_literal_for_schema( default: &str, mapped_type: &str, source_dialect: Option, extra: Option<&str>, target_schema: Option<&str>, ) -> String { let source = effective_source_dialect(source_dialect, DatabaseType::SqlServer); let mut value = default_literal(default, mapped_type, source, extra); if source == DialectKind::SqlServer { return value; } if source == DialectKind::Postgres { value = strip_postgres_default_casts(&value); } let normalized = value.trim(); let match_value = trim_outer_parentheses_for_match(normalized); let lowered = match_value.to_ascii_lowercase(); let target_upper = mapped_type.trim().to_ascii_uppercase(); let target_is_binary = target_upper.starts_with("BINARY") || target_upper.starts_with("VARBINARY"); let target_is_bit = target_upper == "BIT"; let rewritten = if let Some(hex) = target_is_binary.then(|| sqlserver_hex_default(match_value)).flatten() { hex } else if target_is_bit && matches!(lowered.as_str(), "true" | "'true'" | "'t'" | "b'1'") { "1".to_string() } else if target_is_bit && matches!(lowered.as_str(), "false" | "'false'" | "'f'" | "b'0'") { "0".to_string() } else if let Some(bits) = sqlserver_bit_string_default(match_value) { bits } else if lowered == "now()" || lowered.starts_with("now(") || lowered == "transaction_timestamp()" || lowered == "statement_timestamp()" || lowered == "sysdate()" || lowered == "localtimestamp" || lowered.starts_with("localtimestamp(") || (source == DialectKind::Mysql && (lowered == "localtime" || lowered.starts_with("localtime("))) || lowered == "current_timestamp" || lowered.starts_with("current_timestamp(") { // CURRENT_TIMESTAMP is SQL Server's GETDATE() synonym and therefore // returns `datetime`, even when assigned to a higher-precision target. // Use the native high-precision functions for datetime2/offset columns. if target_upper.starts_with("DATETIMEOFFSET") { "SYSDATETIMEOFFSET()".to_string() } else if target_upper.starts_with("DATETIME2") { "SYSDATETIME()".to_string() } else { "CURRENT_TIMESTAMP".to_string() } } else if matches!(lowered.as_str(), "uuid()" | "uuid_generate_v4()" | "gen_random_uuid()") { "NEWID()".to_string() } else if matches!(lowered.as_str(), "curdate()" | "current_date" | "current_date()") { "CONVERT(date, GETDATE())".to_string() } else if matches!(lowered.as_str(), "curtime()" | "current_time" | "current_time()" | "localtime") || lowered.starts_with("current_time(") || lowered.starts_with("localtime(") { "CONVERT(time, GETDATE())".to_string() } else if let Some(sequence) = sqlserver_sequence_default(match_value, target_schema) { sequence } else if lowered.starts_with("e'") { format!("N{}", &normalized[1..]) } else { normalized.to_string() }; let target_is_unicode = target_upper.starts_with("NVARCHAR") || target_upper.starts_with("NCHAR") || target_upper.starts_with("NTEXT"); if target_is_unicode { sqlserver_unicode_string_literal(&rewritten).unwrap_or(rewritten) } else { rewritten } } /// A bare temporal default, with or without a precision argument, so /// `CURRENT_TIMESTAMP(6)` and `LOCALTIME(3)` are recognised alongside the bare /// keywords. `transfer::is_mysql_function_default` already accepts the /// parenthesised forms and this mirrors it. /// /// Deliberately keyed on the known keywords rather than on the presence of a /// parenthesis, so a string default such as `a(b)` is still quoted. fn is_temporal_keyword_default(value: &str) -> bool { let upper = value.to_ascii_uppercase(); TEMPORAL_DEFAULT_KEYWORDS.iter().any(|keyword| { let keyword = keyword.to_ascii_uppercase(); upper == keyword || upper.strip_prefix(&keyword).is_some_and(|rest| rest.starts_with('(')) }) } /// `0x61`, the shape MySQL reports a binary default in. fn is_hex_literal(value: &str) -> bool { let Some(digits) = value.strip_prefix("0x").or_else(|| value.strip_prefix("0X")) else { return false; }; !digits.is_empty() && digits.chars().all(|c| c.is_ascii_hexdigit()) } fn sqlserver_identity_clause(col: &ColumnInfo) -> Option { let extra = col.extra.as_deref().unwrap_or_default().trim(); let lowered = extra.to_ascii_lowercase(); if let Some(identity_index) = lowered.find("identity") { let rest = extra[identity_index + "identity".len()..].trim_start(); if let Some(args) = rest.strip_prefix('(').and_then(|args| args.split_once(')').map(|pair| pair.0)) { let values = args.split(',').map(str::trim).collect::>(); if values.len() == 2 && values.iter().all(|value| value.parse::().is_ok()) { return Some(format!("IDENTITY({},{})", values[0], values[1])); } } return Some("IDENTITY(1,1)".to_string()); } if lowered.contains("auto_increment") || lowered.contains("serial") { return Some("IDENTITY(1,1)".to_string()); } let source_base = col.data_type.split('(').next().unwrap_or(&col.data_type).trim().to_ascii_lowercase(); if matches!(source_base.as_str(), "serial" | "smallserial" | "bigserial") { return Some("IDENTITY(1,1)".to_string()); } None } fn sqlserver_column_definition( col: &ColumnInfo, mapped_type: &str, source_dialect: Option, target_schema: Option<&str>, ) -> String { let mut definition = format!("{} {mapped_type}", quote_id(&col.name, DatabaseType::SqlServer)); let identity = sqlserver_identity_clause(col); if let Some(identity) = &identity { definition.push(' '); definition.push_str(identity); } // PRIMARY KEY columns must be NOT NULL in SQL Server even when a source // (notably SQLite metadata) reports the column as nullable. definition.push_str(if col.is_nullable && !col.is_primary_key { " NULL" } else { " NOT NULL" }); // SQL Server does not permit a DEFAULT constraint on an IDENTITY column. if identity.is_none() { if let Some(default) = col.column_default.as_deref().filter(|value| !value.trim().is_empty()) { definition.push_str(&format!( " DEFAULT {}", sqlserver_default_literal_for_schema( default, mapped_type, source_dialect, col.extra.as_deref(), target_schema, ) )); } } definition } fn sqlserver_has_default(col: &ColumnInfo) -> bool { col.column_default .as_deref() .is_some_and(|value| !value.trim().is_empty() && !value.trim().eq_ignore_ascii_case("null")) } /// Keep a DECLARE/SELECT/IF default-constraint operation together when the deploy /// path splits top-level SQL on semicolons. The inner batch is a Unicode literal, /// so it is submitted to SQL Server as one executable statement. fn sqlserver_single_statement_batch(batch: &str) -> String { format!("EXEC sys.sp_executesql N'{}';", batch.trim().trim_end_matches(';').replace('\'', "''")) } fn sqlserver_add_default_constraint_sql( table: &str, column_name: &str, source: &ColumnInfo, mapped_type: &str, source_dialect: Option, target_schema: Option<&str>, ) -> Option { let default = source .column_default .as_deref() .filter(|value| !value.trim().is_empty() && !value.trim().eq_ignore_ascii_case("null"))?; Some(format!( "ALTER TABLE {table} ADD DEFAULT {} FOR {};", sqlserver_default_literal_for_schema( default, mapped_type, source_dialect, source.extra.as_deref(), target_schema, ), quote_id(column_name, DatabaseType::SqlServer) )) } fn sqlserver_column_change_statements( table: &str, column_name: &str, source: &ColumnInfo, target: &ColumnInfo, mapped_type: &str, source_dialect: Option, target_schema: Option<&str>, ) -> Vec { let definition_changed = !source.data_type.trim().eq_ignore_ascii_case(target.data_type.trim()) || source.is_nullable != target.is_nullable; let default_changed = source.column_default.as_deref().map(str::trim) != target.column_default.as_deref().map(str::trim); let had_default = sqlserver_has_default(target); let mut statements = Vec::new(); if definition_changed { let mut alter_batch = Vec::new(); if had_default && !default_changed { alter_batch.push(build_sqlserver_alter_column_preserving_default_sql( table, column_name, mapped_type, source.is_nullable, )); } else { if had_default { alter_batch.push(build_sqlserver_drop_default_constraint_sql(table, column_name)); } let nullability = if source.is_nullable { "NULL" } else { "NOT NULL" }; alter_batch.push(format!( "ALTER TABLE {table} ALTER COLUMN {} {mapped_type} {nullability};", quote_id(column_name, DatabaseType::SqlServer) )); if default_changed { if let Some(statement) = sqlserver_add_default_constraint_sql( table, column_name, source, mapped_type, source_dialect, target_schema, ) { alter_batch.push(statement); } } } let batch = build_dependency_aware_alter_column_batch(table, column_name, &alter_batch.join("\n")); statements.push(sqlserver_single_statement_batch(&batch)); return statements; } if default_changed { if had_default { statements.push(sqlserver_single_statement_batch(&build_sqlserver_drop_default_constraint_sql( table, column_name, ))); } if let Some(statement) = sqlserver_add_default_constraint_sql(table, column_name, source, mapped_type, source_dialect, target_schema) { statements.push(statement); } } statements } fn column_comment_sql( table_name: &str, column_name: &str, comment: &str, db_type: DatabaseType, schema: Option<&str>, ) -> Vec { let table = qualified_name(table_name, db_type, schema); if db_type == DatabaseType::SqlServer { return build_sqlserver_column_comment_sql(&table, schema, table_name, column_name, comment); } let profile = profile_for(db_type); if profile.column_comment_via_modify_only { return vec![format!("-- Column comment for {column_name}: use ALTER TABLE ... MODIFY COLUMN to set comment")]; } vec![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>) -> Vec { let profile = profile_for(db_type); let table = qualified_name(table_name, db_type, schema); if db_type == DatabaseType::SqlServer { return build_sqlserver_table_comment_sql(&table, schema, table_name, comment); } if profile.table_comment_via_alter { vec![format!("ALTER TABLE {table} COMMENT = {};", comment_literal(comment))] } else { vec![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 is_sqlserver_native_trigger_definition(definition: &str) -> bool { Regex::new(r"(?i)^CREATE\s+(?:OR\s+ALTER\s+)?TRIGGER\b") .expect("static SQL Server trigger regex") .is_match(definition.trim()) } fn sqlserver_native_function_sql(definition: &str, qualified_name: &str, is_modified: bool) -> Option { let trimmed = definition.trim().trim_end_matches(';').trim_end(); let prefix = Regex::new(r"(?i)^(?:(?:CREATE\s+OR\s+ALTER)|CREATE|ALTER)\s+FUNCTION\b").ok()?.find(trimmed)?; let verb = if is_modified { "ALTER FUNCTION" } else { "CREATE FUNCTION" }; let definition_after_verb = trimmed[prefix.end()..].trim_start(); let arguments_start = definition_after_verb.find('(')?; Some(format!("{verb} {qualified_name}{};", &definition_after_verb[arguments_start..])) } /// PostgreSQL-family drivers read routines through `pg_get_functiondef`, which already /// returns a complete `CREATE OR REPLACE FUNCTION name(args) ...` statement. Keep that /// header verb and only swap in the target-qualified name. fn native_create_routine_sql(definition: &str, qualified_name: &str) -> Option { let trimmed = definition.trim().trim_end_matches(';').trim_end(); let prefix = Regex::new(r"(?i)^CREATE\s+(?:OR\s+REPLACE\s+)?(?:FUNCTION|PROCEDURE)\b").ok()?.find(trimmed)?; let definition_after_verb = trimmed[prefix.end()..].trim_start(); let arguments_start = definition_after_verb.find('(')?; Some(format!("{} {qualified_name}{};", prefix.as_str(), &definition_after_verb[arguments_start..])) } 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 = |col: &ColumnInfo| -> String { let source_type = declared_column_type(col); 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); if db_type == DatabaseType::SqlServer { col_defs.push(sqlserver_column_definition(col, &mapped_type, source_dialect, schema)); if col.is_primary_key { pk_cols.push(col_name); } continue; } 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 } => { // SQLite accepts AUTOINCREMENT only on an exact INTEGER // PRIMARY KEY, so integer aliases must be normalized here too. let effective_type = if db_type == DatabaseType::Sqlite && suffix.contains("AUTOINCREMENT") { "INTEGER" } else { mapped_type.as_str() }; let mut def = format!("{} {}", col_name, effective_type); def.push_str(&column_modifier_tail(&profile, col, &mapped_type, db_type, source_dialect, skip_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() { // SQLite requires AUTOINCREMENT to be part of an inline // INTEGER PRIMARY KEY declaration; it cannot be combined // with the table-level PRIMARY KEY clause below. if db_type == DatabaseType::Sqlite && col.is_primary_key && suffix.contains("AUTOINCREMENT") { def.push_str(" PRIMARY KEY"); } 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 && !(db_type == DatabaseType::Sqlite && suffix.contains("AUTOINCREMENT")) { pk_cols.push(quote_id(&col.name, db_type)); } } AutoIncColumnBuild::Normal { skip_default } => { let mut def = format!("{} {}", col_name, mapped_type); def.push_str(&column_modifier_tail(&profile, col, &mapped_type, db_type, source_dialect, skip_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; }; lines.push(add_foreign_key_sql_with_reference_separator(name, fk, db_type, schema, "")); } if !foreign_keys.is_empty() { lines.push(String::new()); } // Column comments for col_diff in columns { let Some(col) = &col_diff.source else { continue; }; if let Some(comment) = &col.comment { if !comment.is_empty() && !profile.inline_column_comment { lines.extend(column_comment_sql(name, &col.name, comment, db_type, schema)); } } } // Table comment if let Some(comment) = table_comment { if !comment.is_empty() { lines.extend(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 db_type == DatabaseType::SqlServer && source_dialect.is_some_and(|source| source != DialectKind::SqlServer) { missing.push(MissingRollbackObject { kind: "trigger".to_string(), name: trigger.name.clone(), table: Some(name.to_string()), reason: "source-dialect trigger bodies cannot be translated safely to T-SQL".to_string(), }); } else if let Some(stmt) = &trigger.statement { if !stmt.trim().is_empty() { let trimmed = stmt.trim().trim_end_matches(';'); if db_type == DatabaseType::SqlServer && is_sqlserver_native_trigger_definition(trimmed) { // OBJECT_DEFINITION already returns the complete native // CREATE TRIGGER statement. Wrapping it as a trigger body // produces an invalid nested CREATE TRIGGER batch. lines.push(sqlserver_single_statement_batch(&format!("{trimmed};"))); } else { let trigger_sql = create_trigger_sql(&profile, &trigger.name, timing, event_desc, &table, stmt); if db_type == DatabaseType::SqlServer { lines.push(sqlserver_single_statement_batch(&trigger_sql)); } else { lines.push(trigger_sql); } } } 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) } fn append_sequence_diff_sql( lines: &mut Vec, sequence_diffs: &[SequenceDiff], profile: DdlDialectProfile, db_type: DatabaseType, schema: Option<&str>, cascade: &str, should_render: impl Fn(&str) -> bool, ) { let mut matching = sequence_diffs.iter().filter(|diff| should_render(&diff.diff_type)).peekable(); if matching.peek().is_none() { return; } lines.push(String::new()); lines.push("-- Sequences".to_string()); for diff in matching { 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)); if db_type == DatabaseType::SqlServer && diff.target.as_ref().is_some_and(|target| { !target.data_type.trim().eq_ignore_ascii_case(source.data_type.trim()) }) { lines.push( "-- SQL Server ALTER SEQUENCE cannot change the data type; recreate the sequence manually if the type must change." .to_string(), ); } 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)); } } } _ => {} } } } #[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 } #[allow(clippy::too_many_arguments)] pub fn generate_schema_sync_sql_plan( 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], enable_rollback: bool, ) -> SchemaSyncSqlPlan { let (sync_sql, _) = generate_schema_sync_sql_inner( diffs, function_diffs, sequence_diffs, rule_diffs, owner_diffs, db_type, schema, cascade_delete, source_dialect, field_mappings, ); let (rollback_sync_sql, missing_rollback_objects) = if enable_rollback { let dependency_graph = DependencyGraph { nodes: HashMap::new(), topological_order: Vec::new() }; let rollback_graph = RollbackGraph::from_forward_diffs(diffs, &[], &dependency_graph); let (sql, missing) = generate_rollback_sync_sql_with_missing(&rollback_graph, db_type, schema, cascade_delete); (Some(sql), missing) } else { (None, Vec::new()) }; let rollback_completeness = if missing_rollback_objects.is_empty() { RollbackCompleteness::Complete } else { RollbackCompleteness::Incomplete }; SchemaSyncSqlPlan { sync_sql, rollback_sync_sql, rollback_completeness, missing_rollback_objects } } /// Names of the objects a diff's own statements reference. /// /// Foreign keys are read from the side that owns the definition: an `added` /// object only has the source side, a `removed` object only the target side. /// View definitions have no foreign keys, so their DDL is scanned for table /// references — the same fallback `DependencyGraph::build_*` uses. fn diff_referenced_objects(diff: &TableDiff, known: &HashSet<&str>) -> Vec { fn push_unique(names: &mut Vec, name: &str) { if !name.is_empty() && !names.iter().any(|existing| existing == name) { names.push(name.to_string()); } } let mut names: Vec = Vec::new(); for foreign_key in diff.foreign_keys.as_deref().unwrap_or_default() { let info = match diff.diff_type.as_str() { "removed" => foreign_key.target.as_ref().or(foreign_key.source.as_ref()), _ => foreign_key.source.as_ref().or(foreign_key.target.as_ref()), }; if let Some(info) = info { push_unique(&mut names, info.ref_table.as_str()); } } if diff.object_type.as_deref() == Some("view") { if let Some(ddl) = diff.ddl.as_deref().or(diff.target_ddl.as_deref()) { // Identifier quoting would hide the reference from the keyword scan. let unquoted: String = ddl.chars().filter(|ch| !matches!(ch, '`' | '"' | '[' | ']')).collect(); for name in extract_ddl_references(&unquoted, known) { push_unique(&mut names, name.as_str()); } } } names } /// Orders table diffs so the generated script runs top-to-bottom on the target. /// /// A `CREATE TABLE` fails when its foreign key points at a table that does not /// exist yet, and a `DROP TABLE` fails while another table still references it. /// Comparison order comes from the object list (alphabetical), so a child table /// can precede its parent: the batch then aborts halfway and the target ends up /// partially synced (#9761). Parents are therefore emitted before the tables /// that reference them, and dropped after them. /// /// Only diffs with a real dependency move; entries without one keep their /// relative order, so plans without dependencies are unchanged. fn order_diffs_for_execution(diffs: &[TableDiff]) -> Vec<&TableDiff> { let mut added: HashMap<&str, usize> = HashMap::new(); let mut removed: HashMap<&str, usize> = HashMap::new(); for (index, diff) in diffs.iter().enumerate() { match diff.diff_type.as_str() { "added" => { added.insert(diff.name.as_str(), index); } "removed" => { removed.insert(diff.name.as_str(), index); } _ => {} } } if added.is_empty() && removed.is_empty() { return diffs.iter().collect(); } let known: HashSet<&str> = diffs.iter().map(|diff| diff.name.as_str()).collect(); let references: Vec> = diffs.iter().map(|diff| diff_referenced_objects(diff, &known)).collect(); let mut successors: Vec> = vec![Vec::new(); diffs.len()]; let mut in_degree = vec![0usize; diffs.len()]; let mut seen_edges: HashSet<(usize, usize)> = HashSet::new(); let mut add_edge = |from: usize, to: usize, successors: &mut Vec>, in_degree: &mut Vec| { if from != to && seen_edges.insert((from, to)) { successors[from].push(to); in_degree[to] += 1; } }; for (index, diff) in diffs.iter().enumerate() { match diff.diff_type.as_str() { // Parents before children. "added" => { for dependency in &references[index] { if let Some(&parent) = added.get(dependency.as_str()) { add_edge(parent, index, &mut successors, &mut in_degree); } } } // Children before parents, so no table is dropped while still referenced. "removed" => { for dependency in &references[index] { if let Some(&parent) = removed.get(dependency.as_str()) { add_edge(index, parent, &mut successors, &mut in_degree); } } } // Modified tables emit `ALTER TABLE ... ADD/DROP FOREIGN KEY` inline, // so an FK added on a modified table still needs the referenced // table's CREATE first, and an FK dropped on a modified table must // run before the referenced table's DROP. _ => { for foreign_key in diff.foreign_keys.as_deref().unwrap_or_default() { let info = foreign_key.source.as_ref().or(foreign_key.target.as_ref()); let Some(info) = info else { continue }; match foreign_key.diff_type.as_str() { "added" => { if let Some(&parent) = added.get(info.ref_table.as_str()) { add_edge(parent, index, &mut successors, &mut in_degree); } } "removed" => { if let Some(&parent) = removed.get(info.ref_table.as_str()) { add_edge(index, parent, &mut successors, &mut in_degree); } } _ => {} } } } } } let mut ready: BTreeSet = (0..diffs.len()).filter(|index| in_degree[*index] == 0).collect(); let mut ordered: Vec = Vec::with_capacity(diffs.len()); let mut placed = vec![false; diffs.len()]; while let Some(&index) = ready.iter().next() { ready.remove(&index); ordered.push(index); placed[index] = true; for &next in &successors[index] { in_degree[next] -= 1; if in_degree[next] == 0 { ready.insert(next); } } } // A dependency cycle cannot be ordered; keep the caller's order for it. for (index, was_placed) in placed.iter().enumerate() { if !was_placed { ordered.push(index); } } ordered.into_iter().map(|index| &diffs[index]).collect() } 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); // SQL Server has no DROP ... CASCADE syntax (related constraints are handled explicitly // by the comparison plan), and Oracle spells the clause `CASCADE CONSTRAINTS` — which // `drop_table_supports_cascade` deliberately excludes — so an unknown dialect gets the // plain form instead of a clause its server would reject. let cascade = if cascade_delete && profile.drop_table_supports_cascade { " CASCADE" } else { "" }; let map_type = |col: &ColumnInfo| -> String { let tgt = DialectKind::from_database_type(db_type); let source_type = declared_column_type(col); 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) }; let is_same_dialect = source_dialect.map(|source| DialectKind::from_database_type(db_type) == source).unwrap_or(false); append_sequence_diff_sql(&mut lines, sequence_diffs, profile, db_type, schema, cascade, |diff_type| { diff_type == "added" }); for diff in order_diffs_for_execution(diffs) { let target_name = target_table_name(diff); let table = qualified_name(target_name, db_type, schema); if diff.diff_type == "added" && diff.object_type.as_deref() == Some("view") { if let Some(ddl) = &diff.ddl { if is_same_dialect || source_dialect.is_none() { let ddl = rewrite_ddl_header_qualifier(ddl, VIEW_DDL_HEADER_KEYWORDS, schema, &table); lines.push(format!("-- Create view: {}", diff.name)); lines.push(format!("{};", ddl.trim_end().trim_end_matches(';'))); lines.push(String::new()); continue; } } lines.push(format!("-- View exists only in source: {}", diff.name)); if diff.ddl.is_some() { lines.push("-- Source view definition cannot be reused across different SQL dialects.".to_string()); } else { lines.push("-- Source view definition is not available from this driver yet.".to_string()); } lines.push(String::new()); continue; } 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(); 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( target_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. // It's already qualified against the same target schema this // rollback re-runs against, so the rewrite below is normally a // no-op — kept for symmetry with the other native-DDL // passthrough sites in case that invariant ever changes. let ddl = rewrite_ddl_header_qualifier(ddl, TABLE_DDL_HEADER_KEYWORDS, schema, &table); 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 { let ddl = rewrite_ddl_header_qualifier(ddl, TABLE_DDL_HEADER_KEYWORDS, schema, &table); lines.push(format!("-- Create {}: {}", diff.object_type.as_deref().unwrap_or("table"), diff.name)); lines.push(format!("{};", ddl.trim_end().trim_end_matches(';'))); 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( target_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( target_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 == "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(target_name, &fk.name, db_type, schema)); } } } if db_type == DatabaseType::SqlServer { if let Some(indexes) = &diff.indexes { for index in indexes { if index.diff_type == "removed" || (index.diff_type == "modified" && index.source.is_some()) { // SQL Server will reject DROP/ALTER COLUMN while a changed // index still depends on it. Drop changed indexes before // applying column DDL, then recreate modified ones below. lines.push(drop_index_sql(target_name, &index.name, db_type, schema)); } } } } if let Some(columns) = &diff.columns { let convert_col = |col: &ColumnInfo| -> ColumnInfo { ColumnInfo { data_type: map_type(col), ..col.clone() } }; for column in columns { match column.diff_type.as_str() { "added" => { if let Some(source) = &column.source { if db_type == DatabaseType::SqlServer { let mapped = convert_col(source); standalone_statements.push(format!( "ALTER TABLE {table} ADD {};", sqlserver_column_definition(source, &mapped.data_type, source_dialect, schema) )); continue; } let position = if db_type == DatabaseType::Mysql { match &column.add_position { Some(ColumnAddPosition::First) => " FIRST".to_string(), Some(ColumnAddPosition::After(predecessor)) => { format!(" AFTER {}", quote_id(predecessor, db_type)) } None => String::new(), } } else { String::new() }; let definition = column_def(&convert_col(source), db_type, source_dialect); // Oracle has no `COLUMN` keyword here: it parenthesizes the whole // definition (`ADD (AMT NUMBER(12,2))`) instead. parts.push(if profile.add_column_uses_column_keyword { format!(" ADD COLUMN {definition}{position}") } else { format!(" ADD ({definition}){position}") }); } } "removed" => { if db_type == DatabaseType::SqlServer { if column.target.as_ref().is_some_and(sqlserver_has_default) { standalone_statements.push(sqlserver_single_statement_batch( &build_sqlserver_drop_default_constraint_sql(&table, &column.name), )); } standalone_statements .push(format!("ALTER TABLE {table} DROP COLUMN {};", quote_id(&column.name, db_type))); } else { parts.push(format!(" DROP COLUMN {}", quote_id(&column.name, db_type))); } } "modified" => { if let Some(source) = &column.source { let target_column_name = ddl_column_name(column); let mut mapped = convert_col(source); if mapped.name != target_column_name { mapped.name = target_column_name.to_string(); } if db_type == DatabaseType::SqlServer { if let Some(target_col) = &column.target { standalone_statements.extend(sqlserver_column_change_statements( &table, target_column_name, source, target_col, &mapped.data_type, source_dialect, schema, )); } } else if profile.parenthesized_alter_column_clause { // Oracle spells the whole column change as one // `MODIFY (col definition)` clause, which also carries the // `DEFAULT`/`NOT NULL` order the server accepts. if column.changes.iter().any(|change| !change.starts_with("order:")) { let modify_keyword = profile.alter_modify_keyword(); let mut definition = column_def(&mapped, db_type, source_dialect); // Re-stating a definition does *not* drop an existing // `NOT NULL` on Oracle (verified on 11g: the constraint // survives `MODIFY (c NUMBER(12,2))`), so relaxing a // column has to spell `NULL` out or the statement reports // success while the target keeps the old constraint. if source.is_nullable && column.changes.iter().any(|change| change.starts_with("nullable:")) { definition.push_str(" NULL"); } parts.push(format!(" {modify_keyword} ({definition})")); } } else if profile.alter_uses_modify_column { if column.changes.iter().any(|change| !change.starts_with("order:")) { let modify_keyword = profile.alter_modify_keyword(); parts.push(format!( " {modify_keyword} {}", column_def(&mapped, db_type, source_dialect) )); } } else { let name = quote_id(target_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_literal( default, &mapped.data_type, effective_source_dialect(source_dialect, db_type), source.extra.as_deref() ) ) } 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, source_dialect) )); } 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}")); // Follow-up clauses keep the renamed column's shape in // sync; their spelling is dialect data because `TYPE` is // Postgres/ANSI grammar that Oracle rejects. if source.data_type.to_lowercase() != target_col.data_type.to_lowercase() { parts.push(profile.alter_column_type_clause(&new_name, &mapped.data_type)); } if source.is_nullable != target_col.is_nullable { parts.push( profile.alter_column_nullability_clause(&new_name, source.is_nullable), ); } } 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(target_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('\'', "''") )); standalone_statements.extend(sqlserver_column_change_statements( &target_table, &column.name, source, target_col, &mapped.data_type, source_dialect, schema, )); } } } } _ => {} } } } 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.extend(column_comment_sql( target_name, ddl_column_name(column), source.comment.as_deref().unwrap_or_default(), db_type, schema, )); } if column.diff_type == "added" { if let Some(comment) = &source.comment { lines.extend(column_comment_sql(target_name, &column.name, comment, db_type, schema)); } } if column.diff_type == "renamed" { if let Some(comment) = &source.comment { lines.extend(column_comment_sql(target_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.extend(table_comment_sql(target_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(target_name, source, db_type, schema)); } } "removed" => { if db_type != DatabaseType::SqlServer { lines.push(drop_index_sql(target_name, &index.name, db_type, schema)); } } "modified" => { if let Some(source) = &index.source { if db_type != DatabaseType::SqlServer { lines.push(drop_index_sql(target_name, &index.name, db_type, schema)); } lines.push(create_index_sql(target_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(target_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 db_type == DatabaseType::SqlServer && source_dialect.is_some_and(|source| source != DialectKind::SqlServer) { lines.push(format!( "-- Skip function {}: source-dialect function definitions cannot be translated safely to T-SQL", diff.name )); continue; } 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)); if db_type == DatabaseType::SqlServer { let name = qualified_name(&diff.name, db_type, schema); if let Some(sql) = sqlserver_native_function_sql( &source.definition, &name, diff.diff_type == "modified", ) { lines.push(sqlserver_single_statement_batch(&sql)); continue; } } if db_type != DatabaseType::SqlServer { let name = qualified_name(&diff.name, db_type, schema); if let Some(sql) = native_create_routine_sql(&source.definition, &name) { lines.push(sql); continue; } } let create_kw = if db_type == DatabaseType::SqlServer && diff.diff_type == "modified" { "ALTER FUNCTION" } else if profile.create_function_or_replace { "CREATE OR REPLACE FUNCTION" } else { "CREATE FUNCTION" }; let name = qualified_name(&diff.name, db_type, schema); let function_sql = DdlDialectProfile::render_template( template, &[("create_kw", create_kw), ("name", &name), ("definition", &source.definition)], ); if db_type == DatabaseType::SqlServer { lines.push(sqlserver_single_statement_batch(&function_sql)); } else { lines.push(function_sql); } } 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)); } } _ => {} } } } append_sequence_diff_sql(&mut lines, sequence_diffs, profile, db_type, schema, cascade, |diff_type| { matches!(diff_type, "removed" | "modified") }); // 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, key_is_expression: overrides.key_is_expression, column_opclasses: overrides.column_opclasses, key_options: overrides.key_options, constraint_backed: false, } } 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(), resolved_schema: None, is_nullable: false, column_default: None, is_primary_key: false, is_unique: false, extra: None, comment: comment.map(str::to_string), numeric_precision: None, numeric_scale: None, character_maximum_length: None, metadata_capabilities: None, enum_values: None, character_set: None, collation: None, } } fn table_info(name: &str, table_type: &str) -> TableInfo { TableInfo { name: name.to_string(), table_type: table_type.to_string(), valid: None, comment: None, parent_schema: None, parent_name: None, } } fn schema_detail(name: &str, ddl: Option<&str>) -> TableSchemaDetail { TableSchemaDetail { name: name.to_string(), columns: vec![], indexes: vec![], foreign_keys: vec![], triggers: vec![], ddl: ddl.map(str::to_string), } } fn common_mysql_view_options(source_ddl: Option<&str>, target_ddl: Option<&str>) -> SchemaDiffPreparationOptions { SchemaDiffPreparationOptions { source_tables: vec![table_info("active_orders", "VIEW")], target_tables: vec![table_info("active_orders", "VIEW")], source_details: vec![schema_detail("active_orders", source_ddl)], target_details: vec![schema_detail("active_orders", target_ddl)], database_type: DatabaseType::Mysql, source_dialect: Some(DialectKind::Mysql), target_dialect: Some(DialectKind::Mysql), ..Default::default() } } #[test] fn oracle_schema_sync_drops_indexes_without_if_exists() { assert_eq!( drop_index_sql("orders", "idx_orders_status", DatabaseType::Oracle, Some("APP")), "DROP INDEX APP.IDX_ORDERS_STATUS;" ); assert_eq!( drop_index_sql("orders", "idx_orders_status", DatabaseType::OceanbaseOracle, Some("APP")), "DROP INDEX APP.IDX_ORDERS_STATUS;" ); } #[test] fn manual_table_mapping_compares_source_with_target_metadata_and_uses_target_ddl_name() { let result = prepare_schema_diff(SchemaDiffPreparationOptions { source_tables: vec![table_info("charge_records", "TABLE")], target_tables: vec![table_info("charge_record", "TABLE")], source_details: vec![TableSchemaDetail { name: "charge_records".to_string(), columns: vec![column("id", "int", None), column("amount", "decimal(12,2)", None)], indexes: Vec::new(), foreign_keys: Vec::new(), triggers: Vec::new(), ddl: Some("CREATE TABLE charge_records (id int, amount decimal(12,2));".to_string()), }], target_details: vec![TableSchemaDetail { name: "charge_record".to_string(), columns: vec![column("id", "int", None), column("amount", "decimal(10,2)", None)], indexes: Vec::new(), foreign_keys: Vec::new(), triggers: Vec::new(), ddl: Some("CREATE TABLE charge_record (id int, amount decimal(10,2));".to_string()), }], database_type: DatabaseType::Mysql, table_mappings: vec![SchemaDiffTableMapping { source_table: "charge_records".to_string(), target_table: "charge_record".to_string(), }], ..Default::default() }); assert_eq!(result.diffs.len(), 1); let table_diff = &result.diffs[0]; assert_eq!(table_diff.diff_type, "modified"); assert_eq!(table_diff.name, "charge_records"); assert_eq!(table_diff.target_name.as_deref(), Some("charge_record")); assert!(table_diff .columns .as_ref() .is_some_and(|columns| columns.iter().any(|column| column.name == "amount"))); assert!(result.sync_sql.contains("ALTER TABLE `charge_record`"), "sync SQL: {}", result.sync_sql); assert!(!result.sync_sql.contains("ALTER TABLE `charge_records`"), "sync SQL: {}", result.sync_sql); } #[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(), target_name: None, 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_add_columns_preserve_source_positions() { let diffs = make_col_diffs( &[("first", "int"), ("a", "int"), ("middle", "varchar(32)"), ("next", "int"), ("last", "int")], &[("a", "int"), ("last", "int")], false, ); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Mysql, None); assert!(sql.contains("ADD COLUMN `first` int NOT NULL FIRST"), "first position: {sql}"); assert!(sql.contains("ADD COLUMN `middle` varchar(32) NOT NULL AFTER `a`"), "middle position: {sql}"); assert!(sql.contains("ADD COLUMN `next` int NOT NULL AFTER `middle`"), "consecutive additions: {sql}"); } #[test] fn mysql_add_column_quotes_predecessor_and_keeps_trailing_position() { let diffs = make_col_diffs( &[("odd`name", "int"), ("middle", "int"), ("tail", "int"), ("new_tail", "int")], &[("odd`name", "int"), ("tail", "int")], false, ); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Mysql, None); assert!(sql.contains("ADD COLUMN `middle` int NOT NULL AFTER `odd``name`"), "quoted predecessor: {sql}"); assert!(sql.contains("ADD COLUMN `new_tail` int NOT NULL AFTER `tail`"), "trailing position: {sql}"); } #[test] fn non_mysql_add_columns_do_not_emit_mysql_position_clauses() { let diffs = make_col_diffs(&[("first", "int"), ("a", "int"), ("middle", "int")], &[("a", "int")], false); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Postgres, None); assert!(!sql.contains(" FIRST"), "PostgreSQL must not use FIRST: {sql}"); assert!(!sql.contains(" AFTER "), "PostgreSQL must not use AFTER: {sql}"); } #[test] fn mysql_add_after_renamed_predecessor_uses_final_source_name() { let diffs = make_col_diffs(&[("new_name", "varchar(32)"), ("inserted", "int")], &[("old_name", "varchar(32)")], true); let sql = gen_sql(wrap_table_diff("t", diffs), DatabaseType::Mysql, None); assert!(sql.contains("ADD COLUMN `inserted` int NOT NULL AFTER `new_name`"), "rename predecessor: {sql}"); assert!(sql.contains("CHANGE COLUMN `old_name` `new_name`"), "rename remains present: {sql}"); } #[test] fn mysql_manual_added_diff_without_position_keeps_legacy_sql() { let diff = ColumnDiff { diff_type: "added".into(), name: "legacy".into(), source: Some(column("legacy", "int", None)), target: None, changes: Vec::new(), add_position: None, }; let sql = gen_sql(wrap_table_diff("t", vec![diff]), DatabaseType::Mysql, None); assert!(sql.contains("ADD COLUMN `legacy` int NOT NULL"), "legacy add: {sql}"); assert!(!sql.contains(" FIRST"), "legacy diff has no position: {sql}"); assert!(!sql.contains(" AFTER "), "legacy diff has no position: {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}"); } #[test] fn sync_to_sqlite_keeps_plain_integer_pk_without_autoincrement() { let columns = vec![ColumnDiff { diff_type: "added".into(), name: "id".into(), source: Some(ColumnInfo { name: "id".into(), data_type: "int".into(), is_nullable: false, is_primary_key: true, ..Default::default() }), target: None, changes: vec![], add_position: None, }]; let (sql, missing) = generate_create_table_sql( "t", &columns, &[], &[], None, DatabaseType::Sqlite, None, Some(DialectKind::Mysql), &[], &[], ); assert!(missing.is_empty(), "{missing:?}"); assert!(!sql.contains("AUTOINCREMENT"), "plain integer PK must not gain AUTOINCREMENT: {sql}"); assert!(sql.contains("PRIMARY KEY"), "table-level PK must be preserved: {sql}"); } #[test] fn sync_to_sqlite_composite_integer_pk_stays_valid() { let columns = vec![ ColumnDiff { diff_type: "added".into(), name: "a".into(), source: Some(ColumnInfo { name: "a".into(), data_type: "int".into(), is_nullable: false, is_primary_key: true, ..Default::default() }), target: None, changes: vec![], add_position: None, }, ColumnDiff { diff_type: "added".into(), name: "b".into(), source: Some(ColumnInfo { name: "b".into(), data_type: "int".into(), is_nullable: false, is_primary_key: true, ..Default::default() }), target: None, changes: vec![], add_position: None, }, ]; let (sql, missing) = generate_create_table_sql( "t", &columns, &[], &[], None, DatabaseType::Sqlite, None, Some(DialectKind::Mysql), &[], &[], ); assert!(missing.is_empty(), "{missing:?}"); assert!(!sql.contains("AUTOINCREMENT"), "composite PK must not gain AUTOINCREMENT: {sql}"); assert!(sql.matches("PRIMARY KEY").count() == 1, "single table-level PK expected: {sql}"); } #[test] fn sync_to_sqlite_explicit_autoincrement_preserved_and_normalized() { let columns = vec![ColumnDiff { diff_type: "added".into(), name: "id".into(), source: Some(ColumnInfo { name: "id".into(), data_type: "bigint".into(), is_nullable: false, is_primary_key: true, extra: Some("auto_increment".to_string()), ..Default::default() }), target: None, changes: vec![], add_position: None, }]; let (sql, missing) = generate_create_table_sql( "t", &columns, &[], &[], None, DatabaseType::Sqlite, None, Some(DialectKind::Mysql), &[], &[], ); assert!(missing.is_empty(), "{missing:?}"); assert!(sql.contains("PRIMARY KEY AUTOINCREMENT"), "explicit auto-increment preserved: {sql}"); assert!(sql.contains("INTEGER"), "bigint must normalize to exact INTEGER: {sql}"); assert!(!sql.contains("BIGINT"), "bigint must normalize to exact INTEGER: {sql}"); assert!(!sql.contains("PRIMARY KEY (\"id\")"), "no duplicate table-level PK: {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("[dbo].[orders]"), "sp_rename table path: {sql}"); assert!(!sql.contains("ALTER TABLE [dbo].[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 sqlserver_schema_diff_adds_columns_and_unicode_comments_with_tsql() { let source = vec![ColumnInfo { is_nullable: true, comment: Some("厂家追溯码's".to_string()), ..column("manufacture_trace_code", "varchar(200)", None) }]; let diffs = diff_columns_with_options(&source, &[], false, false, false, 0.5); let sql = generate_schema_sync_sql( &[wrap_table_diff("inter_putaway_sub", diffs)], &[], &[], &[], &[], DatabaseType::SqlServer, Some("dbo"), false, Some(DialectKind::SqlServer), &[], ); assert!( sql.contains("ALTER TABLE [dbo].[inter_putaway_sub] ADD [manufacture_trace_code] varchar(200) NULL;"), "SQL Server ADD syntax: {sql}" ); assert!(sql.contains("sys.sp_addextendedproperty"), "SQL Server comment add: {sql}"); assert!(sql.contains("@value=N'厂家追溯码''s'"), "Unicode comment escaping: {sql}"); assert!(!sql.contains("ADD COLUMN"), "SQL Server must not emit ADD COLUMN: {sql}"); assert!(!sql.contains("COMMENT ON"), "SQL Server must not emit COMMENT ON: {sql}"); } #[test] fn sqlserver_schema_diff_renders_default_constraint_transitions_as_single_batches() { let transitions = [(None, Some("((0))"), true), (Some("((0))"), Some("((1))"), true), (Some("((0))"), None, false)]; for (old_default, new_default, expects_add) in transitions { let source = vec![ColumnInfo { column_default: new_default.map(str::to_string), ..column("frozen_status", "int", None) }]; let target = vec![ColumnInfo { column_default: old_default.map(str::to_string), ..column("frozen_status", "int", None) }]; let diffs = diff_columns_with_options(&source, &target, false, false, false, 0.5); let sql = generate_schema_sync_sql( &[wrap_table_diff("wbs_flat_package_data_sub", diffs)], &[], &[], &[], &[], DatabaseType::SqlServer, Some("dbo"), false, Some(DialectKind::SqlServer), &[], ); assert!(!sql.contains(" SET DEFAULT "), "SQL Server must not emit SET DEFAULT: {sql}"); assert!(!sql.contains(" DROP DEFAULT"), "SQL Server must not emit DROP DEFAULT: {sql}"); if old_default.is_some() { assert!(sql.contains("sys.default_constraints"), "default lookup: {sql}"); assert!(sql.contains("DROP CONSTRAINT"), "default drop: {sql}"); assert!(sql.contains("EXEC sys.sp_executesql N'"), "drop batch wrapper: {sql}"); } if expects_add { assert!( sql.contains(&format!("ADD DEFAULT {} FOR [frozen_status]", new_default.unwrap())), "default add: {sql}" ); } else { assert!(!sql.contains("ADD DEFAULT"), "default removal must not re-add: {sql}"); } let statements = crate::sql::split_sql_statements_for_database(&sql, DatabaseType::SqlServer); assert!( statements.iter().all(|statement| !statement.trim_start().starts_with("DECLARE ")), "SQL Server splitter must keep helper batches together: {statements:?}" ); } } #[test] fn sqlserver_schema_diff_alters_full_column_definition_and_preserves_default() { let source = vec![ColumnInfo { is_nullable: false, column_default: Some("((0))".to_string()), ..column("amount", "bigint", None) }]; let target = vec![ColumnInfo { is_nullable: true, column_default: Some("((0))".to_string()), ..column("amount", "int", None) }]; let diffs = diff_columns_with_options(&source, &target, false, false, false, 0.5); let sql = generate_schema_sync_sql( &[wrap_table_diff("payments", diffs)], &[], &[], &[], &[], DatabaseType::SqlServer, Some("billing"), false, Some(DialectKind::SqlServer), &[], ); assert!( sql.contains("ALTER TABLE [billing].[payments] ALTER COLUMN [amount] bigint NOT NULL"), "full SQL Server column definition: {sql}" ); assert!(sql.contains("dc.definition"), "existing default definition must be captured: {sql}"); assert!(sql.contains("ADD CONSTRAINT"), "existing default constraint must be restored: {sql}"); assert!(!sql.contains(" ALTER COLUMN [amount] TYPE "), "no PostgreSQL TYPE syntax: {sql}"); assert!(!sql.contains(" SET NOT NULL"), "no PostgreSQL nullability syntax: {sql}"); let statements = crate::sql::split_sql_statements_for_database(&sql, DatabaseType::SqlServer); assert_eq!(statements.len(), 1, "preserve-default batch must stay atomic: {statements:?}"); assert!(statements[0].trim_start().starts_with("-- Alter table: payments\nEXEC sys.sp_executesql N'")); } #[test] fn sqlserver_schema_diff_upserts_and_drops_escaped_column_comments() { let cases = [ (None, Some("owner's 新值"), true, true, false), (Some("旧值"), Some("owner's 新值"), true, true, false), (Some("旧值"), None, false, false, true), ]; for (old_comment, new_comment, expects_add, expects_update, expects_drop) in cases { let source = vec![column("display'name", "nvarchar(80)", new_comment)]; let target = vec![column("display'name", "nvarchar(80)", old_comment)]; let diffs = diff_columns_with_options(&source, &target, false, false, false, 0.5); let sql = generate_schema_sync_sql( &[wrap_table_diff("user's", diffs)], &[], &[], &[], &[], DatabaseType::SqlServer, Some("app's"), false, Some(DialectKind::SqlServer), &[], ); assert_eq!(sql.contains("sys.sp_addextendedproperty"), expects_add, "comment add: {sql}"); assert_eq!(sql.contains("sys.sp_updateextendedproperty"), expects_update, "comment update: {sql}"); assert_eq!(sql.contains("sys.sp_dropextendedproperty"), expects_drop, "comment drop: {sql}"); assert!(sql.contains("N'app''s'"), "schema escaping: {sql}"); assert!(sql.contains("N'user''s'"), "table escaping: {sql}"); assert!(sql.contains("N'display''name'"), "column escaping: {sql}"); if new_comment.is_some() { assert!(sql.contains("N'owner''s 新值'"), "comment escaping: {sql}"); } assert!(!sql.contains("COMMENT ON"), "SQL Server must not emit COMMENT ON: {sql}"); let statements = crate::sql::split_sql_statements_for_database(&sql, DatabaseType::SqlServer); assert_eq!(statements.len(), 1, "comment transition must be one T-SQL statement: {statements:?}"); } } #[test] fn sqlserver_schema_diff_rollback_uses_the_same_tsql_strategy() { let source = vec![ColumnInfo { column_default: Some("((1))".to_string()), comment: Some("new".to_string()), ..column("status", "int", None) }]; let target = vec![ColumnInfo { column_default: Some("((0))".to_string()), comment: Some("old".to_string()), ..column("status", "int", None) }]; let diffs = diff_columns_with_options(&source, &target, false, false, false, 0.5); let plan = generate_schema_sync_sql_plan( &[wrap_table_diff("orders", diffs)], &[], &[], &[], &[], DatabaseType::SqlServer, Some("dbo"), false, Some(DialectKind::SqlServer), &[], true, ); let rollback = plan.rollback_sync_sql.expect("rollback SQL"); for (direction, sql) in [("forward", &plan.sync_sql), ("rollback", &rollback)] { assert!(sql.contains("sys.default_constraints"), "{direction} default constraint strategy: {sql}"); assert!(sql.contains("sys.sp_updateextendedproperty"), "{direction} comment strategy: {sql}"); assert!(!sql.contains(" SET DEFAULT "), "{direction} no PostgreSQL default syntax: {sql}"); assert!(!sql.contains("COMMENT ON"), "{direction} no PostgreSQL comment syntax: {sql}"); } assert!(plan.sync_sql.contains("ADD DEFAULT ((1)) FOR [status]"), "forward: {}", plan.sync_sql); assert!(rollback.contains("ADD DEFAULT ((0)) FOR [status]"), "rollback: {rollback}"); } #[test] fn sqlserver_column_changes_use_tsql_syntax() { let added = ColumnInfo { column_default: Some("(0)".into()), ..column("status", "int", None) }; let source_amount = ColumnInfo { is_nullable: false, column_default: Some("((1))".into()), ..column("amount", "decimal(18,2)", None) }; let target_amount = ColumnInfo { is_nullable: true, column_default: Some("((0))".into()), ..column("amount", "decimal(10,2)", None) }; let diff = wrap_table_diff( "orders", vec![ ColumnDiff { diff_type: "added".into(), name: "status".into(), source: Some(added), target: None, changes: Vec::new(), add_position: None, }, ColumnDiff { diff_type: "modified".into(), name: "amount".into(), source: Some(source_amount), target: Some(target_amount), changes: vec![ "type: decimal(10,2) → decimal(18,2)".into(), "nullable: YES → NO".into(), "default: ((0)) → ((1))".into(), ], add_position: None, }, ], ); let sql = generate_schema_sync_sql( &[diff], &[], &[], &[], &[], DatabaseType::SqlServer, Some("sales"), false, Some(DialectKind::SqlServer), &[], ); assert!( sql.contains("ALTER TABLE [sales].[orders] ADD [status] int NOT NULL DEFAULT (0);"), "ADD must use SQL Server column syntax: {sql}" ); assert!( sql.contains("ALTER TABLE [sales].[orders] ALTER COLUMN [amount] decimal(18,2) NOT NULL;"), "ALTER COLUMN must repeat type and nullability: {sql}" ); assert!(sql.contains("sys.default_constraints"), "old default constraint must be discovered: {sql}"); assert!( sql.contains("ALTER TABLE [sales].[orders] ADD DEFAULT ((1)) FOR [amount];"), "new default constraint: {sql}" ); assert!(!sql.contains("ADD COLUMN"), "SQL Server must not emit ADD COLUMN: {sql}"); assert!(!sql.contains(" TYPE "), "SQL Server must not emit PostgreSQL TYPE syntax: {sql}"); assert!(!sql.contains("SET NOT NULL"), "SQL Server must not emit SET NOT NULL: {sql}"); assert!(!sql.contains("SET DEFAULT"), "SQL Server must not emit SET DEFAULT: {sql}"); let parsed = crate::sql::split_sql_statements_for_database(&sql, DatabaseType::SqlServer); assert!( parsed.iter().any(|statement| statement.contains("EXEC sys.sp_executesql N'SET ANSI_NULLS ON;")), "{parsed:?}" ); assert!(!parsed.iter().any(|statement| statement.trim_start().starts_with("DECLARE ")), "{parsed:?}"); } #[test] fn sqlserver_type_change_preserves_unchanged_default_constraint() { let source = ColumnInfo { column_default: Some("((0))".into()), ..column("count", "bigint", None) }; let target = ColumnInfo { column_default: Some("((0))".into()), ..column("count", "int", None) }; let diff = wrap_table_diff( "metrics", vec![ColumnDiff { diff_type: "modified".into(), name: "count".into(), source: Some(source), target: Some(target), changes: vec!["type: int → bigint".into()], add_position: None, }], ); let sql = gen_sql(diff, DatabaseType::SqlServer, Some(DialectKind::SqlServer)); assert!(sql.contains("EXEC sys.sp_executesql N'SET ANSI_NULLS ON;"), "single executable batch: {sql}"); assert!(sql.contains("dc.definition"), "default expression must be preserved: {sql}"); assert!(sql.contains("DROP CONSTRAINT"), "old default must be removed before ALTER COLUMN: {sql}"); assert!( sql.contains("ALTER TABLE [dbo].[metrics] ALTER COLUMN [count] bigint NOT NULL"), "valid ALTER COLUMN: {sql}" ); assert!(sql.contains("ADD CONSTRAINT"), "original default constraint name must be restored: {sql}"); } #[test] fn sqlserver_schema_diff_rollback_preserves_default_and_column_comment_changes() { let source = vec![ColumnInfo { column_default: Some("((1))".to_string()), comment: Some("new owner's state".to_string()), ..column("status", "int", None) }]; let target = vec![ColumnInfo { column_default: Some("((0))".to_string()), comment: Some("旧状态".to_string()), ..column("status", "int", None) }]; let diffs = diff_columns_with_options(&source, &target, false, false, false, 0.5); let plan = generate_schema_sync_sql_plan( &[wrap_table_diff("orders", diffs)], &[], &[], &[], &[], DatabaseType::SqlServer, Some("dbo"), false, Some(DialectKind::SqlServer), &[], true, ); let rollback = plan.rollback_sync_sql.expect("rollback SQL"); for (direction, sql) in [("forward", &plan.sync_sql), ("rollback", &rollback)] { assert!(sql.contains("sys.default_constraints"), "{direction} default constraint strategy: {sql}"); assert!(sql.contains("sys.sp_updateextendedproperty"), "{direction} comment update strategy: {sql}"); assert!(sql.contains("sys.sp_addextendedproperty"), "{direction} comment add fallback: {sql}"); assert!(!sql.contains(" SET DEFAULT "), "{direction} no PostgreSQL default syntax: {sql}"); assert!(!sql.contains("COMMENT ON"), "{direction} no PostgreSQL comment syntax: {sql}"); } assert!(plan.sync_sql.contains("ADD DEFAULT ((1)) FOR [status]"), "forward: {}", plan.sync_sql); assert!(plan.sync_sql.contains("N'new owner''s state'"), "forward comment: {}", plan.sync_sql); assert!(rollback.contains("ADD DEFAULT ((0)) FOR [status]"), "rollback: {rollback}"); assert!(rollback.contains("N'旧状态'"), "rollback comment: {rollback}"); } #[test] fn sqlserver_index_comments_and_drop_object_use_tsql() { let comment_source = column("payload", "nvarchar(max)", Some("new description")); let comment_target = column("payload", "nvarchar(max)", Some("old description")); let modified = TableDiff { diff_type: "modified".into(), object_type: Some("table".into()), name: "events".into(), target_name: None, columns: Some(vec![ColumnDiff { diff_type: "modified".into(), name: "payload".into(), source: Some(comment_source), target: Some(comment_target), changes: vec!["comment: old description → new description".into()], add_position: None, }]), indexes: Some(vec![IndexDiff { diff_type: "removed".into(), name: "idx_events_payload".into(), source: None, target: None, changes: Vec::new(), }]), foreign_keys: None, triggers: None, ddl: None, target_ddl: None, source_table_comment: Some(Some("new table description".into())), target_table_comment: Some(Some("old table description".into())), sync_sql: None, }; let sql = generate_schema_sync_sql( &[modified], &[], &[], &[], &[], DatabaseType::SqlServer, Some("audit"), false, Some(DialectKind::SqlServer), &[], ); assert!(sql.contains("DROP INDEX [idx_events_payload] ON [audit].[events];"), "DROP INDEX: {sql}"); assert!(sql.contains("sys.sp_updateextendedproperty"), "update existing MS_Description: {sql}"); assert!(sql.contains("sys.sp_addextendedproperty"), "add missing MS_Description: {sql}"); assert!(!sql.contains("sys.sp_dropextendedproperty"), "non-empty comments are not dropped first: {sql}"); assert!(!sql.contains("COMMENT ON"), "SQL Server has no COMMENT ON: {sql}"); let removed = TableDiff { diff_type: "removed".into(), object_type: Some("table".into()), name: "events".into(), target_name: None, ..Default::default() }; let drop_sql = generate_schema_sync_sql( &[removed], &[], &[], &[], &[], DatabaseType::SqlServer, Some("audit"), true, Some(DialectKind::SqlServer), &[], ); assert!( drop_sql.contains("IF OBJECT_ID(N'[audit].[events]', N'U') IS NOT NULL DROP TABLE [audit].[events];"), "version-compatible conditional DROP: {drop_sql}" ); assert!(!drop_sql.contains("CASCADE"), "SQL Server has no DROP CASCADE: {drop_sql}"); } #[test] fn sqlserver_structured_create_preserves_only_explicit_identity() { let identity = ColumnInfo { is_primary_key: true, extra: Some("identity(10,5)".into()), ..column("id", "int", None) }; let ordinary_pk = ColumnInfo { is_primary_key: true, is_nullable: true, ..column("tenant_id", "int", None) }; let added = TableDiff { diff_type: "added".into(), object_type: Some("table".into()), name: "accounts".into(), target_name: None, columns: Some(vec![ ColumnDiff { diff_type: "added".into(), name: "id".into(), source: Some(identity), target: None, changes: Vec::new(), add_position: None, }, ColumnDiff { diff_type: "added".into(), name: "tenant_id".into(), source: Some(ordinary_pk), target: None, changes: Vec::new(), add_position: None, }, ]), ..Default::default() }; let sql = generate_schema_sync_sql( &[added], &[], &[], &[], &[], DatabaseType::SqlServer, Some("dbo"), false, Some(DialectKind::SqlServer), &[], ); assert!(sql.contains("[id] int IDENTITY(10,5) NOT NULL"), "identity order and seed: {sql}"); assert!(sql.contains("[tenant_id] int NOT NULL"), "SQL Server PK columns cannot be nullable: {sql}"); assert_eq!(sql.matches("IDENTITY(").count(), 1, "integer PKs must not become identity implicitly: {sql}"); assert!(sql.contains("PRIMARY KEY ([id], [tenant_id])"), "primary key: {sql}"); } #[test] fn sqlserver_function_and_sequence_changes_use_tsql_verbs() { let function = FunctionDiff { diff_type: "modified".into(), name: "next_value".into(), source: Some(FunctionInfo { name: "next_value".into(), function_type: "scalar".into(), data_type: "int".into(), definition: "() RETURNS int AS BEGIN RETURN 1 END".into(), arguments: String::new(), }), target: None, changes: Vec::new(), }; let removed_function = FunctionDiff { diff_type: "removed".into(), name: "old_value".into(), source: None, target: None, changes: Vec::new(), }; let sequence = SequenceDiff { diff_type: "modified".into(), name: "event_seq".into(), source: Some(SequenceInfo { name: "event_seq".into(), data_type: "bigint".into(), start_value: "10".into(), min_value: "1".into(), max_value: "9223372036854775807".into(), increment: "5".into(), cycle: false, last_value: None, }), target: Some(SequenceInfo { name: "event_seq".into(), data_type: "int".into(), start_value: "1".into(), min_value: "1".into(), max_value: "2147483647".into(), increment: "1".into(), cycle: false, last_value: None, }), changes: Vec::new(), }; let sql = generate_schema_sync_sql( &[], &[function, removed_function], &[sequence], &[], &[], DatabaseType::SqlServer, Some("dbo"), false, Some(DialectKind::SqlServer), &[], ); assert!(sql.contains("ALTER FUNCTION [dbo].[next_value]"), "modified function: {sql}"); assert!(sql.contains("DROP FUNCTION [dbo].[old_value];"), "removed function: {sql}"); assert!(!sql.contains("DROP FUNCTION IF EXISTS"), "legacy-compatible function drop: {sql}"); assert!( sql.contains( "ALTER SEQUENCE [dbo].[event_seq] RESTART WITH 10 INCREMENT BY 5 MINVALUE 1 MAXVALUE 9223372036854775807 NO CYCLE;" ), "modified sequence: {sql}" ); assert!(!sql.contains("ALTER SEQUENCE [dbo].[event_seq] AS"), "ALTER SEQUENCE cannot change type: {sql}"); assert!(!sql.contains("ALTER SEQUENCE [dbo].[event_seq] START WITH"), "use RESTART WITH: {sql}"); assert!(sql.contains("cannot change the data type"), "manual type-change diagnostic: {sql}"); } #[test] fn sqlserver_drops_changed_indexes_and_defaults_before_columns() { let target_column = ColumnInfo { column_default: Some("((0))".into()), ..column("legacy_status", "int", None) }; let target_index = IndexInfo { name: "idx_events_legacy_status".into(), columns: vec!["legacy_status".into()], is_unique: false, is_primary: false, filter: None, index_type: Some("NONCLUSTERED".into()), included_columns: None, comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, }; let diff = TableDiff { diff_type: "modified".into(), object_type: Some("table".into()), name: "events".into(), target_name: None, columns: Some(vec![ColumnDiff { diff_type: "removed".into(), name: "legacy_status".into(), source: None, target: Some(target_column), changes: Vec::new(), add_position: None, }]), indexes: Some(vec![IndexDiff { diff_type: "removed".into(), name: "idx_events_legacy_status".into(), source: None, target: Some(target_index), changes: Vec::new(), }]), ..Default::default() }; let sql = generate_schema_sync_sql( &[diff], &[], &[], &[], &[], DatabaseType::SqlServer, Some("dbo"), false, Some(DialectKind::SqlServer), &[], ); let index_drop = sql.find("DROP INDEX [idx_events_legacy_status] ON [dbo].[events]").expect("index drop"); let default_drop = sql.find("sys.default_constraints").expect("default constraint lookup"); let column_drop = sql.find("ALTER TABLE [dbo].[events] DROP COLUMN [legacy_status]").expect("column drop"); assert!(index_drop < default_drop && default_drop < column_drop, "dependency order: {sql}"); assert!(sql.contains("sys.key_constraints"), "constraint-backed index handling: {sql}"); assert_eq!( sql.matches("DROP INDEX [idx_events_legacy_status] ON [dbo].[events]").count(), 1, "drop once: {sql}" ); } #[test] fn sqlserver_column_changes_capture_dependencies_omitted_from_the_diff() { let source_column = column("amount", "bigint", None); let target_column = column("amount", "int", None); let unchanged_index = IndexInfo { name: "idx_events_amount".into(), columns: vec!["amount".into()], is_unique: false, is_primary: false, filter: None, index_type: Some("NONCLUSTERED".into()), included_columns: None, comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, }; let unchanged_fk = ForeignKeyInfo { name: "fk_events_parent".into(), column: "amount".into(), ref_schema: Some("dbo".into()), ref_table: "parents".into(), ref_column: "amount".into(), on_update: None, on_delete: None, }; let detail = |column: ColumnInfo| TableSchemaDetail { name: "events".into(), columns: vec![column], indexes: vec![unchanged_index.clone()], foreign_keys: vec![unchanged_fk.clone()], triggers: Vec::new(), ddl: None, }; let prepared = prepare_schema_diff(SchemaDiffPreparationOptions { source_tables: vec![table_info("events", "BASE TABLE")], target_tables: vec![table_info("events", "BASE TABLE")], source_details: vec![detail(source_column)], target_details: vec![detail(target_column)], database_type: DatabaseType::SqlServer, target_schema: Some("dbo".into()), source_dialect: Some(DialectKind::SqlServer), target_dialect: Some(DialectKind::SqlServer), ..Default::default() }); assert_eq!(prepared.diffs.len(), 1); assert!(prepared.diffs[0].indexes.is_none(), "unchanged index is intentionally absent from the diff"); assert!(prepared.diffs[0].foreign_keys.is_none(), "unchanged FK is intentionally absent from the diff"); let sql = &prepared.sync_sql; for catalog in [ "sys.indexes AS idx", "sys.key_constraints AS key_constraint", "sys.check_constraints AS cc", "sys.foreign_keys AS fk", "sys.stats_columns AS sc", ] { assert!(sql.contains(catalog), "runtime dependency catalog {catalog}: {sql}"); } assert!(sql.contains("fkc.referenced_object_id = @dbx_object_id"), "inbound FK predicate: {sql}"); assert!(sql.contains("THEN N''PRIMARY KEY '' ELSE N''UNIQUE '' END"), "preserve key object type: {sql}"); assert!(sql.contains("ALTER COLUMN [amount] bigint NOT NULL"), "column alter: {sql}"); assert!(!sql.contains("idx_events_amount"), "dependency names must come from the live target catalog: {sql}"); assert!(!sql.contains("fk_events_parent"), "dependency names must come from the live target catalog: {sql}"); let executable = crate::sql::split_sql_statements_for_database(sql, DatabaseType::SqlServer); assert_eq!(executable.len(), 1, "the catalog snapshot/drop/alter/recreate flow must stay in one batch: {sql}"); assert!(executable[0].contains("EXEC sys.sp_executesql N'"), "single executable batch: {sql}"); } #[test] fn sqlserver_index_and_foreign_key_forms_follow_tsql() { let btree = IndexInfo { name: "idx_events_status".into(), columns: vec!["status".into()], is_unique: true, is_primary: false, filter: Some("[status] IS NOT NULL".into()), index_type: Some("BTREE".into()), included_columns: Some(vec!["payload".into()]), comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, }; let btree_sql = create_index_sql("events", &btree, DatabaseType::SqlServer, Some("dbo")); assert_eq!( btree_sql, "CREATE UNIQUE INDEX [idx_events_status] ON [dbo].[events] ([status]) INCLUDE ([payload]) WHERE [status] IS NOT NULL;" ); assert!(!btree_sql.contains("BTREE")); let columnstore = IndexInfo { name: "ix_events_analytics".into(), columns: Vec::new(), is_unique: false, is_primary: false, filter: None, index_type: Some("NONCLUSTERED COLUMNSTORE".into()), included_columns: Some(vec!["status".into(), "payload".into()]), comment: None, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, }; assert_eq!( create_index_sql("events", &columnstore, DatabaseType::SqlServer, Some("dbo")), "CREATE NONCLUSTERED COLUMNSTORE INDEX [ix_events_analytics] ON [dbo].[events] ([status], [payload]);" ); let fk = ForeignKeyInfo { name: "fk_events_user".into(), column: "user_id".into(), ref_schema: Some("crm".into()), ref_table: "users".into(), ref_column: "id".into(), on_update: Some("RESTRICT".into()), on_delete: Some("SET NULL".into()), }; let fk_sql = add_foreign_key_sql("events", &fk, DatabaseType::SqlServer, Some("dbo")); assert!(fk_sql.contains("ON DELETE SET NULL ON UPDATE NO ACTION"), "FK actions: {fk_sql}"); assert!(!fk_sql.contains("RESTRICT"), "SQL Server does not accept RESTRICT: {fk_sql}"); } #[test] fn sqlserver_uses_native_trigger_and_function_definitions_only_for_tsql_sources() { let columns = vec![ColumnDiff { diff_type: "added".into(), name: "id".into(), source: Some(column("id", "int", None)), target: None, changes: Vec::new(), add_position: None, }]; let trigger = TriggerInfo { name: "trg_events_insert".into(), event: "INSERT".into(), timing: "AFTER".into(), level: None, condition: None, language: None, enabled: Some(true), valid: None, comment: None, created_at: None, statement: Some( "CREATE TRIGGER [dbo].[trg_events_insert] ON [dbo].[events] AFTER INSERT AS BEGIN SELECT 1; END".into(), ), }; let (native_sql, native_missing) = generate_create_table_sql( "events", &columns, &[], &[], None, DatabaseType::SqlServer, Some("dbo"), Some(DialectKind::SqlServer), &[], std::slice::from_ref(&trigger), ); assert!(native_missing.is_empty(), "native trigger is reconstructible"); assert_eq!(native_sql.matches("CREATE TRIGGER").count(), 1, "do not nest native DDL: {native_sql}"); assert!(!native_sql.contains("AS BEGIN CREATE TRIGGER"), "native trigger body: {native_sql}"); let native_trigger_statements = crate::sql::split_sql_statements_for_database(&native_sql, DatabaseType::SqlServer); let trigger_batches = native_trigger_statements .iter() .filter(|statement| statement.contains("CREATE TRIGGER")) .collect::>(); assert_eq!( trigger_batches.len(), 1, "trigger body must remain one executable batch: {native_trigger_statements:?}" ); let trigger_executable = trigger_batches[0] .lines() .find(|line| !line.trim().is_empty() && !line.trim_start().starts_with("--")) .expect("trigger executable line"); assert!(trigger_executable.starts_with("EXEC sys.sp_executesql N'"), "trigger batch: {trigger_batches:?}"); let (_, cross_missing) = generate_create_table_sql( "events", &columns, &[], &[], None, DatabaseType::SqlServer, Some("dbo"), Some(DialectKind::Mysql), &[], &[trigger], ); assert_eq!(cross_missing.len(), 1, "foreign trigger must require manual translation"); let native_function = FunctionDiff { diff_type: "modified".into(), name: "next_value".into(), source: Some(FunctionInfo { name: "next_value".into(), function_type: "scalar".into(), data_type: "int".into(), definition: "CREATE FUNCTION [source].[next_value]() RETURNS int AS BEGIN RETURN 2 END".into(), arguments: String::new(), }), target: None, changes: Vec::new(), }; let native_function_sql = generate_schema_sync_sql( &[], &[native_function], &[], &[], &[], DatabaseType::SqlServer, Some("dbo"), false, Some(DialectKind::SqlServer), &[], ); assert!( native_function_sql.contains("ALTER FUNCTION [dbo].[next_value]() RETURNS int"), "native function verb and schema: {native_function_sql}" ); assert_eq!(native_function_sql.matches("FUNCTION").count(), 1, "do not nest native function DDL"); let native_function_statements = crate::sql::split_sql_statements_for_database(&native_function_sql, DatabaseType::SqlServer); let function_batches = native_function_statements .iter() .filter(|statement| statement.contains("ALTER FUNCTION")) .collect::>(); assert_eq!( function_batches.len(), 1, "function body must remain one executable batch: {native_function_statements:?}" ); let function_executable = function_batches[0] .lines() .find(|line| !line.trim().is_empty() && !line.trim_start().starts_with("--")) .expect("function executable line"); assert!(function_executable.starts_with("EXEC sys.sp_executesql N'"), "function batch: {function_batches:?}"); let foreign_function = FunctionDiff { diff_type: "added".into(), name: "pg_only".into(), source: Some(FunctionInfo { name: "pg_only".into(), function_type: "FUNCTION".into(), data_type: "integer".into(), definition: "CREATE FUNCTION pg_only() RETURNS integer LANGUAGE sql AS $$ SELECT 1 $$".into(), arguments: String::new(), }), target: None, changes: Vec::new(), }; let foreign_function_sql = generate_schema_sync_sql( &[], &[foreign_function], &[], &[], &[], DatabaseType::SqlServer, Some("dbo"), false, Some(DialectKind::Postgres), &[], ); assert!(foreign_function_sql.contains("cannot be translated safely to T-SQL")); assert!( !foreign_function_sql.contains("CREATE FUNCTION"), "do not emit PostgreSQL DDL: {foreign_function_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::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}"); } } #[test] fn mysql_to_sqlserver_uses_native_target_types() { let diffs = make_col_diffs( &[ ("flag", "tinyint(1)"), ("payload", "json"), ("body", "longtext"), ("raw", "blob"), ("created_at", "datetime(6)"), ("ratio", "double"), ], &[], false, ); let sql = gen_sql(wrap_table_diff("events", diffs), DatabaseType::SqlServer, Some(DialectKind::Mysql)); assert!(sql.contains("[flag] BIT"), "tinyint(1) → BIT: {sql}"); assert!(sql.contains("[payload] NVARCHAR(MAX)"), "json → NVARCHAR(MAX): {sql}"); assert!(sql.contains("[body] NVARCHAR(MAX)"), "longtext → NVARCHAR(MAX): {sql}"); assert!(sql.contains("[raw] VARBINARY(MAX)"), "blob → VARBINARY(MAX): {sql}"); assert!(sql.contains("[created_at] DATETIME2(6)"), "datetime → DATETIME2: {sql}"); assert!(sql.contains("[ratio] FLOAT"), "double → FLOAT: {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); // Oracle's ADD clause is parenthesized and has no `COLUMN` keyword. assert!(sql.contains("ADD (NAME VARCHAR2(100) NOT NULL)"), "Oracle add: {sql}"); assert!(!sql.contains("ADD COLUMN"), "Oracle add must omit COLUMN: {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); if db == DatabaseType::Oracle { assert_eq!(sql.matches("ADD (").count(), 2, "Oracle two adds: {sql}"); assert!(!sql.contains("ADD COLUMN"), "Oracle add must omit COLUMN: {sql}"); } else { 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}"); } } #[test] fn dameng_modify_column_uses_modify_without_column_keyword() { let diffs = make_col_diffs(&[("name", "varchar(64)")], &[("name", "varchar(32)")], false); let sql = gen_sql(wrap_table_diff("tbxx", diffs), DatabaseType::Dameng, None); assert!(sql.contains("MODIFY NAME varchar(64)"), "Dameng modify: {sql}"); assert!(!sql.contains("MODIFY COLUMN"), "Dameng must omit COLUMN: {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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, })], &[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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, })], ); 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_opclass_change_in_postgres_index() { let source_index = index(IndexInfo { name: "idx_name_trgm".to_string(), columns: vec!["name".to_string()], is_unique: false, is_primary: false, filter: None, index_type: Some("gin".to_string()), included_columns: None, comment: None, key_is_expression: vec![false], column_opclasses: vec![Some("gin_trgm_ops".to_string())], key_options: Vec::new(), constraint_backed: false, }); let target_index = index(IndexInfo { name: "idx_name_trgm".to_string(), columns: vec!["name".to_string()], is_unique: false, is_primary: false, filter: None, index_type: Some("gin".to_string()), included_columns: None, comment: None, key_is_expression: vec![false], column_opclasses: vec![None], key_options: Vec::new(), constraint_backed: false, }); let diffs = diff_indexes(&[source_index], &[target_index]); assert_eq!(diffs.len(), 1); assert_eq!(diffs[0].diff_type, "modified"); assert!( diffs[0].changes.iter().any(|c| c.contains("opclass")), "Expected opclass change line, got: {:?}", diffs[0].changes ); } #[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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, }); 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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, }); 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(), target_name: None, 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 preserves_bare_expression_in_postgres_family_unique_index_ddl() { // Regression for #6295: highgo/postgres-family expression index key parts (e.g. from // pg_get_indexdef) arrived as raw expression text in IndexInfo.columns; quoting the // whole expression as an identifier turned it into a literal (and nonexistent) column. let expression_key_part = "COALESCE(height, '-1'::integer::double precision)"; let new_index = index(IndexInfo { name: "uq_tankong_sta_type_time".to_string(), columns: vec![ "sta_id".to_string(), "data_type".to_string(), "data_time".to_string(), expression_key_part.to_string(), ], is_unique: true, is_primary: false, filter: None, index_type: None, included_columns: None, comment: None, key_is_expression: vec![false, false, false, true], column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, }); let sql = generate_schema_sync_sql( &[TableDiff { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "tankong_data".to_string(), target_name: None, columns: None, indexes: Some(vec![IndexDiff { diff_type: "added".to_string(), name: new_index.name.clone(), source: Some(new_index), 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, }], &[], &[], &[], &[], DatabaseType::Highgo, Some("public"), false, None, &[], ); assert!(sql.contains(&format!( "CREATE UNIQUE INDEX \"uq_tankong_sta_type_time\" ON \"public\".\"tankong_data\" (\"sta_id\", \"data_type\", \"data_time\", {expression_key_part})" ))); assert!(!sql.contains(&format!("\"{expression_key_part}\""))); } #[test] fn postgres_sync_index_ddl_keeps_key_order_opclass_and_full_expression() { // Regression for #9988: `pg_index.indoption` (bit 0 = DESC, bit 1 = NULLS FIRST) // and `pg_index.indclass` were dropped by the sync DDL, so a published index came // back as plain ASC keys with default null ordering. The expression key part is // also asserted verbatim - the driver used to return it truncated to 63 bytes // because `COALESCE(name, text)` resolves to `name` (see postgres.rs). let expression_key_part = "(((category)::text = ANY ((ARRAY['industrial'::character varying, 'macro'::character varying])::text[])))"; let new_index = index(IndexInfo { name: "index_repro_rank".to_string(), columns: vec![ "((has_current_analysis AND (NOT dirty) AND researchable))".to_string(), "researchable".to_string(), expression_key_part.to_string(), "((new_events_24h > 0))".to_string(), "evidence_count".to_string(), "material_at".to_string(), "topic_id".to_string(), ], is_unique: false, is_primary: false, filter: Some("(NOT suppressed)".to_string()), index_type: Some("btree".to_string()), included_columns: None, comment: None, key_is_expression: vec![true, false, true, true, false, false, false], column_opclasses: vec![None, None, None, None, None, None, Some("pg_catalog.text_pattern_ops".to_string())], key_options: vec![3, 3, 3, 3, 3, 3, 3], constraint_backed: false, }); let sql = generate_schema_sync_sql( &[TableDiff { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "index_repro".to_string(), target_name: None, columns: None, indexes: Some(vec![IndexDiff { diff_type: "added".to_string(), name: new_index.name.clone(), source: Some(new_index), 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, }], &[], &[], &[], &[], DatabaseType::Postgres, Some("public"), false, None, &[], ); assert!( sql.contains( "CREATE INDEX \"index_repro_rank\" ON \"public\".\"index_repro\" USING btree (((has_current_analysis AND (NOT dirty) AND researchable)) DESC NULLS FIRST, \"researchable\" DESC NULLS FIRST" ), "{sql}" ); assert!(sql.contains(&format!("{expression_key_part} DESC NULLS FIRST")), "{sql}"); assert!(sql.contains("\"topic_id\" pg_catalog.text_pattern_ops DESC NULLS FIRST"), "{sql}"); assert!(sql.contains("WHERE (NOT suppressed);"), "{sql}"); } #[test] fn postgres_sync_index_ddl_keeps_non_btree_keys_bare() { // `indoption` is only meaningful for B-tree keys, so a GIN index must not gain // `ASC NULLS LAST` suffixes even when the introspection reports the flags. let new_index = index(IndexInfo { name: "idx_payload_gin".to_string(), columns: vec!["payload".to_string()], is_unique: false, is_primary: false, filter: None, index_type: Some("gin".to_string()), included_columns: None, comment: None, key_is_expression: vec![false], column_opclasses: vec![Some("public.gin_trgm_ops".to_string())], key_options: vec![0], constraint_backed: false, }); let sql = generate_schema_sync_sql( &[TableDiff { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "events".to_string(), target_name: None, columns: None, indexes: Some(vec![IndexDiff { diff_type: "added".to_string(), name: new_index.name.clone(), source: Some(new_index), 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, }], &[], &[], &[], &[], DatabaseType::Postgres, Some("public"), false, None, &[], ); assert!(sql.contains("USING gin (\"payload\" public.gin_trgm_ops)"), "{sql}"); assert!(!sql.contains("NULLS LAST"), "{sql}"); assert!(!sql.contains("NULLS FIRST"), "{sql}"); } #[test] fn quotes_real_columns_whose_names_contain_expression_like_characters() { // PR #6312 review: a quoted column identifier can legitimately contain whitespace, // `(`, or `::` (e.g. PostgreSQL metadata returning the ordinary column name // `order item` through a.attname). The old character-based heuristic mistook such // columns for expressions and left them bare, generating an invalid // `CREATE INDEX ... (order item)` instead of `CREATE INDEX ... ("order item")`. // With real per-key provenance (`key_is_expression`), only genuine expression key // parts from pg_get_indexdef are left unquoted. let expression_key_part = "COALESCE(height, '-1'::integer::double precision)"; let new_index = index(IndexInfo { name: "uq_weird_columns".to_string(), columns: vec![ "order item".to_string(), "a(b)".to_string(), "a::b".to_string(), expression_key_part.to_string(), ], key_is_expression: vec![false, false, false, true], column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, is_unique: true, is_primary: false, filter: None, index_type: None, included_columns: None, comment: None, }); let sql = generate_schema_sync_sql( &[TableDiff { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "tankong_data".to_string(), target_name: None, columns: None, indexes: Some(vec![IndexDiff { diff_type: "added".to_string(), name: new_index.name.clone(), source: Some(new_index), 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, }], &[], &[], &[], &[], DatabaseType::Highgo, Some("public"), false, None, &[], ); assert!(sql.contains(&format!( "CREATE UNIQUE INDEX \"uq_weird_columns\" ON \"public\".\"tankong_data\" (\"order item\", \"a(b)\", \"a::b\", {expression_key_part})" ))); assert!(!sql.contains(&format!("\"{expression_key_part}\""))); // Real PostgreSQL-family validation: parse the generated DDL with the PostgreSQL // dialect so this also proves the statement is syntactically valid, not just that the // expected substring is present. use sqlparser::dialect::PostgreSqlDialect; use sqlparser::parser::Parser; let create_index_sql = sql .lines() .find(|line| line.starts_with("CREATE UNIQUE INDEX \"uq_weird_columns\"")) .expect("generated DDL should include the CREATE INDEX statement"); let statements = Parser::parse_sql(&PostgreSqlDialect {}, create_index_sql) .unwrap_or_else(|error| panic!("generated DDL must be valid PostgreSQL: {error}\nSQL: {create_index_sql}")); assert_eq!(statements.len(), 1); } #[test] fn quotes_expression_like_column_names_without_agent_provenance() { for db_type in [DatabaseType::Kingbase, DatabaseType::Vastbase] { let new_index = index(IndexInfo { name: "idx_weird_columns".to_string(), columns: vec!["order item".to_string(), "a(b)".to_string(), "a::b".to_string()], key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, is_unique: false, is_primary: false, filter: None, index_type: None, included_columns: None, comment: None, }); let sql = generate_schema_sync_sql( &[TableDiff { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "tankong_data".to_string(), target_name: None, columns: None, indexes: Some(vec![IndexDiff { diff_type: "added".to_string(), name: new_index.name.clone(), source: Some(new_index), 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, }], &[], &[], &[], &[], db_type, Some("public"), false, None, &[], ); assert!(sql.contains("(\"order item\", \"a(b)\", \"a::b\")"), "{db_type:?}: {sql}"); } } #[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(), target_name: None, 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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, })), 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(), target_name: None, 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()], add_position: 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("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(), target_name: None, 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()], add_position: None, }]), 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(), target_name: None, 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()], add_position: None, }]), 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(), valid: None, comment: Some("用户表".to_string()), parent_schema: None, parent_name: None, }], target_tables: vec![TableInfo { name: "users".to_string(), table_type: "BASE TABLE".to_string(), valid: None, 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(), valid: None, comment: None, parent_schema: None, parent_name: None, }], target_tables: vec![TableInfo { name: "users".to_string(), table_type: "BASE TABLE".to_string(), valid: None, 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 schema_sync_plan_builds_matching_forward_and_rollback_sql_for_selected_children() { let selected_diff = TableDiff { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "users".to_string(), target_name: None, columns: Some(vec![ColumnDiff { diff_type: "added".to_string(), name: "nickname".to_string(), source: Some(column("nickname", "varchar(64)", None)), target: None, changes: Vec::new(), add_position: None, }]), ..Default::default() }; let plan = generate_schema_sync_sql_plan( &[selected_diff], &[], &[], &[], &[], DatabaseType::Mysql, Some("shop"), false, None, &[], true, ); assert!(plan.sync_sql.contains("ADD COLUMN `nickname`"), "{}", plan.sync_sql); let rollback = plan.rollback_sync_sql.expect("rollback SQL"); assert!(rollback.contains("DROP COLUMN `nickname`"), "{rollback}"); assert_eq!(plan.rollback_completeness, RollbackCompleteness::Complete); } #[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(), target_name: None, 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(), resolved_schema: None, is_nullable: true, column_default: None, is_primary_key: false, is_unique: false, extra: None, comment: None, numeric_precision: None, numeric_scale: None, character_maximum_length: None, metadata_capabilities: None, enum_values: None, character_set: None, collation: None, }), target: None, changes: Vec::new(), add_position: None, }]), 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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, })), 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") ); } #[test] fn added_table_native_ddl_rewrites_source_schema_to_target_schema() { // Issue #7249: comparing two Postgres schemas emitted the *source* // schema-qualified CREATE TABLE verbatim, so the generated sync SQL // referenced a schema that may not even exist on the target and // failed 100% of the time. let diffs = vec![TableDiff { diff_type: "added".to_string(), object_type: Some("table".to_string()), name: "orders".to_string(), ddl: Some("CREATE TABLE \"source_schema\".\"orders\" (\n \"id\" integer\n)".to_string()), ..TableDiff::default() }]; let sql = generate_schema_sync_sql( &diffs, &[], &[], &[], &[], DatabaseType::Postgres, Some("target_schema"), false, Some(DialectKind::Postgres), &[], ); assert!(sql.contains("CREATE TABLE \"target_schema\".\"orders\""), "{sql}"); assert!(!sql.contains("source_schema"), "{sql}"); } #[test] fn added_view_native_ddl_rewrites_source_schema_to_target_schema() { let diffs = vec![TableDiff { diff_type: "added".to_string(), object_type: Some("view".to_string()), name: "active_orders".to_string(), ddl: Some("CREATE OR REPLACE VIEW \"source_schema\".\"active_orders\" AS\nSELECT 1".to_string()), ..TableDiff::default() }]; let sql = generate_schema_sync_sql( &diffs, &[], &[], &[], &[], DatabaseType::Postgres, Some("target_schema"), false, Some(DialectKind::Postgres), &[], ); assert!(sql.contains("CREATE OR REPLACE VIEW \"target_schema\".\"active_orders\""), "{sql}"); assert!(!sql.contains("source_schema"), "{sql}"); } #[test] fn added_table_unqualified_native_ddl_is_left_unchanged() { // MySQL-family DDL that relies on the connection's current database // (no schema/database qualifier) already resolves correctly wherever // the sync script runs, so it must not be rewritten. let diffs = vec![TableDiff { diff_type: "added".to_string(), object_type: Some("table".to_string()), name: "orders".to_string(), ddl: Some("CREATE TABLE `orders` (\n `id` int\n)".to_string()), ..TableDiff::default() }]; let sql = generate_schema_sync_sql( &diffs, &[], &[], &[], &[], DatabaseType::Mysql, Some("shop"), false, Some(DialectKind::Mysql), &[], ); assert!(sql.contains("CREATE TABLE `orders`"), "{sql}"); } #[test] fn added_table_native_ddl_rewrites_bracket_quoted_schema_with_escaped_bracket() { // SQL Server escapes a literal `]` inside a bracketed identifier by // doubling it (`[a]]b]` is the identifier `a]b`) — the schema segment // here must still be recognized as "source_schema]" rather than the // parser stopping at the first `]`. let diffs = vec![TableDiff { diff_type: "added".to_string(), object_type: Some("table".to_string()), name: "orders".to_string(), ddl: Some("CREATE TABLE [source_schema]]].[orders] (\n [id] int\n)".to_string()), ..TableDiff::default() }]; let sql = generate_schema_sync_sql( &diffs, &[], &[], &[], &[], DatabaseType::SqlServer, Some("target_schema"), false, Some(DialectKind::SqlServer), &[], ); assert!(sql.contains("[target_schema].[orders]"), "{sql}"); assert!(!sql.contains("source_schema"), "{sql}"); } // ======================================================================== // 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(), valid: None, comment: None, parent_schema: None, parent_name: None, }, TableInfo { name: "users".to_string(), table_type: "BASE TABLE".to_string(), valid: None, 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(), valid: None, comment: None, parent_schema: None, parent_name: None, }, TableInfo { name: "orders".to_string(), table_type: "BASE TABLE".to_string(), valid: None, comment: None, parent_schema: None, parent_name: None, }, TableInfo { name: "users".to_string(), table_type: "BASE TABLE".to_string(), valid: None, 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(), target_name: None, 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(), valid: None, 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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, })], 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()), key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, })], 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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, })], 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(), level: None, condition: None, language: None, enabled: None, valid: None, comment: None, created_at: None, 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(), target_name: None, 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()], add_position: None, }]), 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)"); assert_eq!(rb_cols[0].changes, vec!["type: varchar(100) → varchar(50)"]); } #[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\"")); } #[test] fn generate_permission_sql_sqlserver_uses_securable_syntax() { let diffs = vec![ PermissionDiff { diff_type: "added".into(), grantee: "app]user".into(), object_name: "orders".into(), privilege: "SELECT".into(), source: Some(PermissionInfo { grantee: "app]user".into(), object_type: "TABLE".into(), object_name: "orders".into(), privilege: "SELECT".into(), is_grantable: true, }), target: None, }, PermissionDiff { diff_type: "removed".into(), grantee: "old_user".into(), object_name: "orders".into(), privilege: "UPDATE".into(), source: None, target: Some(PermissionInfo { grantee: "old_user".into(), object_type: "TABLE".into(), object_name: "orders".into(), privilege: "UPDATE".into(), is_grantable: false, }), }, ]; let sql = generate_permission_sync_sql(&diffs, DatabaseType::SqlServer, Some("sales")); assert!( sql.contains("GRANT SELECT ON OBJECT::[sales].[orders] TO [app]]user] WITH GRANT OPTION;"), "GRANT: {sql}" ); assert!(sql.contains("REVOKE UPDATE ON OBJECT::[sales].[orders] FROM [old_user];"), "REVOKE: {sql}"); assert!(!sql.contains(" ON TABLE "), "PostgreSQL object syntax must not leak into T-SQL: {sql}"); let qualified_diff = PermissionDiff { diff_type: "added".into(), grantee: "reporter".into(), object_name: "[audit].[ledger]".into(), privilege: "SELECT".into(), source: Some(PermissionInfo { grantee: "reporter".into(), object_type: "TABLE".into(), object_name: "[audit].[ledger]".into(), privilege: "SELECT".into(), is_grantable: false, }), target: None, }; let qualified_sql = generate_permission_sync_sql(&[qualified_diff], DatabaseType::SqlServer, None); assert!(qualified_sql.contains("OBJECT::[audit].[ledger]"), "qualified object: {qualified_sql}"); assert!(!qualified_sql.contains("[dbo].[[audit]]"), "do not quote a qualified name as one identifier"); } // ======================================================================== // 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(), valid: None, comment: None, parent_schema: None, parent_name: None, }], target_tables: vec![TableInfo { name: "users_new".to_string(), table_type: "BASE TABLE".to_string(), valid: None, 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(), valid: None, 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); } #[test] fn mysql_same_dialect_ignores_only_integer_display_widths() { let source = vec![ column("id", "int(11) unsigned", None), column("status", "tinyint(4)", None), column("amount", "decimal(10,2)", None), column("name", "varchar(128)", None), ]; let target = vec![ column("id", "int unsigned", None), column("status", "tinyint", None), column("amount", "decimal(12,2)", None), column("name", "varchar(64)", None), ]; let diffs = diff_columns_with_dialect_options( &source, &target, false, false, false, 0.5, Some(DialectKind::Mysql), Some(DialectKind::Mysql), ); assert_eq!(diffs.iter().map(|diff| diff.name.as_str()).collect::>(), vec!["amount", "name"]); assert!(diffs.iter().all(|diff| diff.changes.iter().any(|change| change.starts_with("type:")))); } // Regression for #7615: comparing two MySQL databases where an integer column has // different EXPLICIT display widths on both sides (e.g. `int(11)` vs `int(15)`) must // still be reported as a type difference. `data_type` values below are exactly what // `information_schema.COLUMNS.COLUMN_TYPE` returns on a real MySQL 5.7 server for the // DDL in the issue (MySQL 8.0.19+ drops these widths entirely, so this only reproduces // on pre-8.0.19 servers, which is why the issue's own repro needed a specific version). #[test] fn mysql_same_dialect_detects_explicit_integer_display_width_mismatch() { let source = vec![column("id", "int(11)", Some("ID")), column("name", "varchar(20)", Some("名称"))]; let target = vec![column("id", "int(15)", Some("ID")), column("name", "varchar(20)", Some("名称"))]; let diffs = diff_columns_with_dialect_options( &source, &target, false, false, false, 0.5, Some(DialectKind::Mysql), Some(DialectKind::Mysql), ); assert_eq!(diffs.iter().map(|diff| diff.name.as_str()).collect::>(), vec!["id"]); assert!(diffs[0].changes.iter().any(|change| change.starts_with("type:")), "{:?}", diffs[0].changes); // Same scenario the maintainer's own regression test already covers must keep passing: // tinyint(1) (a common MySQL boolean idiom) vs tinyint(4) (the server's own default // display width for tinyint) is likewise a real, explicit difference, not noise. let source = vec![column("flag", "tinyint(1)", None)]; let target = vec![column("flag", "tinyint(4)", None)]; let diffs = diff_columns_with_dialect_options( &source, &target, false, false, false, 0.5, Some(DialectKind::Mysql), Some(DialectKind::Mysql), ); assert_eq!(diffs.iter().map(|diff| diff.name.as_str()).collect::>(), vec!["flag"]); } #[test] fn mysql_modify_column_preserves_explicit_auto_increment() { let mut source = column("id", "int", Some("new comment")); source.is_primary_key = true; source.extra = Some("auto_increment".to_string()); let mut target = source.clone(); target.comment = Some("old comment".to_string()); let diff = ColumnDiff { diff_type: "modified".to_string(), name: "id".to_string(), source: Some(source), target: Some(target), changes: vec!["comment: old comment → new comment".to_string()], add_position: None, }; let sql = gen_sql(wrap_table_diff("users", vec![diff]), DatabaseType::Mysql, Some(DialectKind::Mysql)); assert!( sql.contains("MODIFY COLUMN `id` int NOT NULL AUTO_INCREMENT COMMENT 'new comment'"), "MySQL MODIFY must preserve AUTO_INCREMENT: {sql}" ); } #[test] fn mysql_add_column_keeps_auto_increment_suffix() { let mut source = column("seq", "int", None); source.extra = Some("auto_increment".to_string()); let diff = ColumnDiff { diff_type: "added".to_string(), name: "seq".to_string(), source: Some(source), target: None, changes: vec![], add_position: None, }; let sql = gen_sql(wrap_table_diff("users", vec![diff]), DatabaseType::Mysql, Some(DialectKind::Mysql)); assert!(sql.contains("AUTO_INCREMENT"), "MySQL ADD COLUMN must keep AUTO_INCREMENT: {sql}"); } #[test] fn sqlserver_add_column_places_identity_after_type() { let mut source = column("seq", "int", None); source.extra = Some("auto_increment".to_string()); let diff = ColumnDiff { diff_type: "added".to_string(), name: "seq".to_string(), source: Some(source), target: None, changes: vec![], add_position: None, }; let sql = gen_sql(wrap_table_diff("users", vec![diff]), DatabaseType::SqlServer, Some(DialectKind::Mysql)); assert!( sql.contains("[seq] INT IDENTITY(1,1) NOT NULL"), "SQL Server ADD COLUMN must place IDENTITY directly after the type: {sql}" ); assert!( !sql.to_uppercase().contains("NOT NULL IDENTITY"), "SQL Server ADD COLUMN must not trail IDENTITY after constraints: {sql}" ); } // -- 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}"); } #[test] fn added_varchar_column_quotes_a_bare_default_mysql() { // MySQL's information_schema returns a string default unquoted, so the // generated DDL read `DEFAULT THE_VALUE` and the deploy failed. let source = vec![ColumnInfo { column_default: Some("THE_VALUE".into()), ..column("menu_type", "varchar(64)", None) }]; let target: Vec = vec![]; 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("DEFAULT 'THE_VALUE'"), "bare default must be quoted: {sql}"); } #[test] fn default_literal_only_quotes_bare_values_that_need_it() { use DialectKind::Mysql; // MySQL strips the quotes from a string default, which is the whole // reason this function exists. assert_eq!(default_literal("THE_VALUE", "varchar(64)", Mysql, None), "'THE_VALUE'"); assert_eq!(default_literal("it's", "text", Mysql, None), "'it''s'"); assert_eq!(default_literal("2024-01-01", "date", Mysql, None), "'2024-01-01'"); // `DEFAULT ''` previously emitted a bare `DEFAULT `. assert_eq!(default_literal("", "varchar(20)", Mysql, None), "''"); // Untouched on MySQL: already quoted and numeric values. assert_eq!(default_literal("'guest'", "varchar(50)", Mysql, None), "'guest'"); assert_eq!(default_literal("0", "bigint", Mysql, None), "0"); assert_eq!(default_literal("NULL", "varchar(10)", Mysql, None), "'NULL'"); assert_eq!(default_literal("null", "text", Mysql, None), "'null'"); assert_eq!(default_literal(" spaced ", "varchar(32)", Mysql, None), "' spaced '"); } #[test] fn default_literal_uses_mysql_extra_to_tell_an_expression_from_a_string() { use DialectKind::Mysql; // 8.0.13+ marks an expression default in EXTRA, and that marker decides // it. Without the marker the value is a string, parentheses and all, // so a column declared `DEFAULT 'a(b)'` stops emitting invalid // `DEFAULT a(b)`. assert_eq!(default_literal("a(b)", "varchar(32)", Mysql, None), "'a(b)'"); assert_eq!(default_literal("uuid()", "varchar(36)", Mysql, Some("DEFAULT_GENERATED")), "uuid()"); // MySQL wraps an expression default in parentheses and reports it that // way, so the wrapping identifies it even when EXTRA is missing. That is // a different question from whether the value contains a parenthesis, // which is what `a(b)` above turns on. assert_eq!(default_literal("(uuid())", "varchar(36)", Mysql, None), "(uuid())"); assert_eq!(default_literal("(now())", "datetime", Mysql, None), "(now())"); assert_eq!( default_literal( "CURRENT_TIMESTAMP", "datetime", Mysql, Some("DEFAULT_GENERATED on update CURRENT_TIMESTAMP") ), "CURRENT_TIMESTAMP" ); // Before 8.0.13 there is no marker, and a temporal column was the only // place an expression default could appear. assert_eq!(default_literal("CURRENT_TIMESTAMP", "datetime", Mysql, None), "CURRENT_TIMESTAMP"); } #[test] fn default_literal_keeps_temporal_defaults_that_carry_a_precision() { use DialectKind::Mysql; // `TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6)` is valid MySQL. On a server // older than 8.0.13 it arrives with no EXTRA marker, so the fallback has // to accept the precision argument or the column is deployed with a // quoted string where an expression belongs. assert_eq!(default_literal("CURRENT_TIMESTAMP(6)", "timestamp(6)", Mysql, None), "CURRENT_TIMESTAMP(6)"); assert_eq!(default_literal("NOW()", "datetime", Mysql, None), "NOW()"); assert_eq!(default_literal("LOCALTIME(3)", "datetime(3)", Mysql, None), "LOCALTIME(3)"); assert_eq!(default_literal("LOCALTIMESTAMP(3)", "timestamp(3)", Mysql, None), "LOCALTIMESTAMP(3)"); assert_eq!(default_literal("current_timestamp(6)", "timestamp(6)", Mysql, None), "current_timestamp(6)"); // The precision form must not become a general "contains a parenthesis" // rule again: a string default keeps its quotes. assert_eq!(default_literal("a(b)", "varchar(32)", Mysql, None), "'a(b)'"); assert_eq!(default_literal("CURRENT_TIMESTAMPX(6)", "varchar(64)", Mysql, None), "'CURRENT_TIMESTAMPX(6)'"); } #[test] fn default_literal_leaves_non_mysql_expressions_alone() { use DialectKind::{Oracle, Postgres, SqlServer}; // These dialects return a string default already quoted, so a bare token // is an expression. Quoting it would silently turn a per-row value into // a fixed string. assert_eq!(default_literal("CURRENT_USER", "text", Postgres, None), "CURRENT_USER"); assert_eq!(default_literal("USER", "varchar2(30)", Oracle, None), "USER"); assert_eq!(default_literal("'new'::text", "text", Postgres, None), "'new'::text"); assert_eq!(default_literal("nextval('s'::regclass)", "integer", Postgres, None), "nextval('s'::regclass)"); assert_eq!(default_literal("N'guest'", "nvarchar(50)", SqlServer, None), "N'guest'"); assert_eq!(default_literal("('x')", "varchar(10)", SqlServer, None), "('x')"); assert_eq!(default_literal("NULL", "text", Postgres, None), "NULL"); // The same bare token on MySQL is a string, which is why the rule has to // follow the source dialect rather than the value. assert_eq!(default_literal("CURRENT_USER", "text", DialectKind::Mysql, None), "'CURRENT_USER'"); } #[test] fn sqlserver_default_literal_rewrites_cross_dialect_expressions() { assert_eq!( sqlserver_default_literal("CURRENT_TIMESTAMP(6)", "DATETIME2(6)", Some(DialectKind::Mysql), None), "SYSDATETIME()" ); assert_eq!( sqlserver_default_literal("CURRENT_TIMESTAMP", "DATETIME2(6)", Some(DialectKind::Mysql), None), "SYSDATETIME()" ); assert_eq!( sqlserver_default_literal("now()", "DATETIMEOFFSET(6)", Some(DialectKind::Postgres), None), "SYSDATETIMEOFFSET()" ); assert_eq!( sqlserver_default_literal("(uuid())", "UNIQUEIDENTIFIER", Some(DialectKind::Mysql), None), "NEWID()" ); assert_eq!(sqlserver_default_literal("b'1'", "BIT", Some(DialectKind::Mysql), None), "1"); assert_eq!(sqlserver_default_literal("b'1010'", "BIGINT", Some(DialectKind::Mysql), None), "10"); assert_eq!(sqlserver_default_literal("x'DEAD'", "VARBINARY(2)", Some(DialectKind::Mysql), None), "0xDEAD"); assert_eq!(sqlserver_default_literal("true", "BIT", Some(DialectKind::Postgres), None), "1"); assert_eq!(sqlserver_default_literal("'false'::boolean", "BIT", Some(DialectKind::Postgres), None), "0"); assert_eq!( sqlserver_default_literal("'true'::text", "NVARCHAR(16)", Some(DialectKind::Postgres), None), "N'true'" ); assert_eq!( sqlserver_default_literal("'\\xCAFE'::bytea", "VARBINARY(MAX)", Some(DialectKind::Postgres), None), "0xCAFE" ); assert_eq!( sqlserver_default_literal("'guest'::character varying", "NVARCHAR(64)", Some(DialectKind::Postgres), None), "N'guest'" ); assert_eq!( sqlserver_default_literal("'0.00'::numeric(10,2)", "DECIMAL(10,2)", Some(DialectKind::Postgres), None), "'0.00'" ); assert_eq!( sqlserver_default_literal( "'guest'::character varying(20)", "NVARCHAR(20)", Some(DialectKind::Postgres), None ), "N'guest'" ); assert_eq!( sqlserver_default_literal("'{a,b}'::text[]", "NVARCHAR(32)", Some(DialectKind::Postgres), None), "N'{a,b}'" ); assert_eq!( sqlserver_default_literal( "'guest'::\"public\".\"character varying\"(20)", "NVARCHAR(20)", Some(DialectKind::Postgres), None ), "N'guest'" ); assert_eq!( sqlserver_default_literal("('中文')::text", "NVARCHAR(64)", Some(DialectKind::Postgres), None), "(N'中文')" ); assert_eq!( sqlserver_default_literal( "nextval('sales.order_seq'::regclass)", "BIGINT", Some(DialectKind::Postgres), None ), "NEXT VALUE FOR [dbo].[order_seq]" ); assert_eq!( sqlserver_default_literal_for_schema( "nextval('public.order_seq'::regclass)", "BIGINT", Some(DialectKind::Postgres), None, Some("sales") ), "NEXT VALUE FOR [sales].[order_seq]" ); assert_eq!( sqlserver_default_literal("CURRENT_DATE()", "DATE", Some(DialectKind::Mysql), None), "CONVERT(date, GETDATE())" ); assert_eq!( sqlserver_default_literal("LOCALTIME(3)", "TIME(3)", Some(DialectKind::Postgres), None), "CONVERT(time, GETDATE())" ); assert_eq!( sqlserver_default_literal("((getdate()))", "DATETIME2", Some(DialectKind::SqlServer), None), "((getdate()))" ); } #[test] fn strip_postgres_default_casts_consumes_complete_type_syntax() { assert_eq!(strip_postgres_default_casts("'value'::numeric(10,2)"), "'value'"); assert_eq!(strip_postgres_default_casts("'value'::character varying(20)"), "'value'"); assert_eq!(strip_postgres_default_casts("'value'::public.\"custom_type\"(10,2)[10][]"), "'value'"); assert_eq!(strip_postgres_default_casts("'value'::\"sch\"\"ema\".\"ty\"\"pe\"(4)[]"), "'value'"); assert_eq!(strip_postgres_default_casts("'value'::timestamp(3) without time zone"), "'value'"); assert_eq!(strip_postgres_default_casts("'value'::double precision"), "'value'"); assert_eq!(strip_postgres_default_casts("'value'::interval day to second(6)"), "'value'"); assert_eq!(strip_postgres_default_casts("nextval('orders.id'::regclass)"), "nextval('orders.id')"); assert_eq!(strip_postgres_default_casts("'1'::text::integer"), "'1'"); } #[test] fn strip_postgres_default_casts_preserves_expression_boundaries() { assert_eq!(strip_postgres_default_casts("(true::boolean AND false)"), "(true AND false)"); assert_eq!(strip_postgres_default_casts("1::int IS DISTINCT FROM 2"), "1 IS DISTINCT FROM 2"); assert_eq!( strip_postgres_default_casts("CURRENT_TIMESTAMP::timestamp AT TIME ZONE 'UTC'"), "CURRENT_TIMESTAMP AT TIME ZONE 'UTC'" ); assert_eq!(strip_postgres_default_casts("'x'::text COLLATE \"C\""), "'x' COLLATE \"C\""); assert_eq!(strip_postgres_default_casts("1::integer IN (1,2)"), "1 IN (1,2)"); assert_eq!(strip_postgres_default_casts("\"fn::name\"()::integer"), "\"fn::name\"()"); assert_eq!(strip_postgres_default_casts("$$a::int$$::text"), "$$a::int$$"); assert_eq!(strip_postgres_default_casts("$tag$a::int$tag$::text"), "$tag$a::int$tag$"); assert_eq!(strip_postgres_default_casts(r"E'a\'::int'::text"), r"E'a\'::int'"); } #[test] fn strip_postgres_default_casts_leaves_malformed_types_unchanged() { for malformed in [ "x::", "x::numeric(10", "x::numeric(10::int", "x::numeric(10,2)garbage", "x::numeric(10)(20)", "x::\"unterminated", "x::\"foo\"bar", "x::schema.", "x::text[bad]", "x::text[bad::int]", "x::text[++]]", "x::text[1+2]", "x::text[-1]", "x::text[]garbage", ] { assert_eq!(strip_postgres_default_casts(malformed), malformed); } } #[test] fn default_literal_handles_set_and_binary_boundaries() { use DialectKind::Mysql; // A SET default is a bare comma-separated string. assert_eq!(default_literal("a,b", "set('a','b')", Mysql, None), "'a,b'"); assert_eq!(default_literal("", "set('a','b')", Mysql, None), "''"); // Binary defaults arrive as a hex literal, which is already valid // unquoted; a bare string on the same column still needs quoting. assert_eq!(default_literal("0x61", "varbinary(16)", Mysql, None), "0x61"); assert_eq!(default_literal("abc", "binary(3)", Mysql, None), "'abc'"); assert_eq!(default_literal("x'1f'", "blob", Mysql, None), "x'1f'"); // Not hex, so not a hex literal. assert_eq!(default_literal("0xzz", "varbinary(8)", Mysql, None), "'0xzz'"); } // -- 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(), valid: None, comment: None, parent_schema: None, parent_name: None, }], target_tables: vec![TableInfo { name: "users".into(), table_type: "BASE TABLE".into(), valid: None, 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(), valid: None, comment: None, parent_schema: None, parent_name: None, }], target_tables: vec![TableInfo { name: "t".into(), table_type: "BASE TABLE".into(), valid: None, 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(), target_name: None, 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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, })), 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(), target_name: None, 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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, })), 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(), target_name: None, 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()], add_position: None, }]), 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(), target_name: None, columns: Some(vec![ColumnDiff { diff_type: "added".to_string(), name: "id".to_string(), source: Some(column("id", "int(11)", None)), target: None, changes: vec![], add_position: None, }]), 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(), target_name: None, 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(), target_name: None, 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(), valid: None, comment: Some("new".into()), parent_schema: None, parent_name: None, }], target_tables: vec![TableInfo { name: "t".into(), table_type: "BASE TABLE".into(), valid: None, 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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, })], &[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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, })], ); 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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, })], &[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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, })], ); 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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, })], &[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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, })], ); 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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, })], &[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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, })], ); 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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, })], &[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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, })], ); 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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, })], &[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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, })], ); 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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, }), 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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, }), ], &[ 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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, }), 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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, }), ], ); 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(), target_name: None, 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}"); } // -- Regression: issue #7287 -- // MySQL's information_schema always fills REFERENCED_TABLE_SCHEMA with the literal // database name, even for a foreign key that just self-references a table in its own // database. Comparing two differently-named databases (e.g. a dev copy vs prod) made // every such self-referencing FK look "changed" purely because the database names // differ, and the deploy script it generated pointed the target's FK at the *source* // database instead of leaving it self-referencing within the target. fn self_referencing_fk_options( source_ref_schema: &str, target_ref_schema: &str, source_on_delete: &str, target_on_delete: &str, ) -> SchemaDiffPreparationOptions { let table_infos = vec![ TableInfo { name: "sys_organization".into(), table_type: "BASE TABLE".into(), valid: None, comment: None, parent_schema: None, parent_name: None, }, TableInfo { name: "sys_user".into(), table_type: "BASE TABLE".into(), valid: None, comment: None, parent_schema: None, parent_name: None, }, ]; let cols = vec![column("id", "int(11)", None), column("leader_id", "int(11)", None)]; let fk = |ref_schema: &str, on_delete: &str| ForeignKeyInfo { name: "sys_organization_ibfk_1".into(), column: "leader_id".into(), ref_schema: Some(ref_schema.to_string()), ref_table: "sys_user".into(), ref_column: "user_id".into(), on_update: Some("RESTRICT".into()), on_delete: Some(on_delete.to_string()), }; SchemaDiffPreparationOptions { source_tables: table_infos.clone(), target_tables: table_infos, source_details: vec![ TableSchemaDetail { name: "sys_organization".into(), columns: cols.clone(), indexes: vec![], foreign_keys: vec![fk(source_ref_schema, source_on_delete)], triggers: vec![], ddl: None, }, TableSchemaDetail { name: "sys_user".into(), columns: vec![column("user_id", "int(11)", None)], indexes: vec![], foreign_keys: vec![], triggers: vec![], ddl: None, }, ], target_details: vec![ TableSchemaDetail { name: "sys_organization".into(), columns: cols.clone(), indexes: vec![], foreign_keys: vec![fk(target_ref_schema, target_on_delete)], triggers: vec![], ddl: None, }, TableSchemaDetail { name: "sys_user".into(), columns: vec![column("user_id", "int(11)", None)], indexes: vec![], foreign_keys: vec![], triggers: vec![], ddl: None, }, ], database_type: DatabaseType::Mysql, target_schema: Some("jinxinnuo_agent_db".into()), 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() } } #[test] fn self_referencing_fk_across_differently_named_databases_is_not_a_diff() { let options = self_referencing_fk_options("jinxinnuo_agent_db_test", "jinxinnuo_agent_db", "SET NULL", "SET NULL"); let result = prepare_schema_diff(options); assert!( !result.sync_sql.contains("sys_organization_ibfk_1"), "same-database self-reference must not be resynced just because the two \ database names differ: {}", result.sync_sql ); } #[test] fn modified_self_referencing_fk_regenerates_against_target_database() { // A genuine change (ON DELETE) forces the FK to be resynced; the regenerated // REFERENCES clause must still point at the target's own database, not the source's. let options = self_referencing_fk_options("jinxinnuo_agent_db_test", "jinxinnuo_agent_db", "SET NULL", "CASCADE"); let result = prepare_schema_diff(options); assert!( !result.sync_sql.contains("jinxinnuo_agent_db_test"), "must not reference the source database: {}", result.sync_sql ); assert!( result.sync_sql.contains("REFERENCES `jinxinnuo_agent_db`.`sys_user`") || result.sync_sql.contains("REFERENCES `sys_user`"), "must reference the target database (or be left unqualified): {}", result.sync_sql ); } #[test] fn genuine_cross_database_fk_reference_change_is_still_detected() { // `external_lookup` is not one of the tables being compared, so a differing // ref_schema here is a real cross-database reference change, not a same-database // self-reference — it must still be surfaced and regenerated with the source's value. let table_infos = vec![TableInfo { name: "orders".into(), table_type: "BASE TABLE".into(), valid: None, comment: None, parent_schema: None, parent_name: None, }]; let cols = vec![column("id", "int(11)", None), column("region_id", "int(11)", None)]; let make_detail = |ref_schema: &str| TableSchemaDetail { name: "orders".into(), columns: cols.clone(), indexes: vec![], foreign_keys: vec![ForeignKeyInfo { name: "orders_region_fk".into(), column: "region_id".into(), ref_schema: Some(ref_schema.to_string()), ref_table: "external_lookup".into(), ref_column: "id".into(), on_update: Some("RESTRICT".into()), on_delete: Some("RESTRICT".into()), }], triggers: vec![], ddl: None, }; let options = SchemaDiffPreparationOptions { source_tables: table_infos.clone(), target_tables: table_infos, source_details: vec![make_detail("shared_lookup_db")], target_details: vec![make_detail("stale_lookup_db")], database_type: DatabaseType::Mysql, target_schema: Some("jinxinnuo_agent_db".into()), 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.sync_sql.contains("REFERENCES `shared_lookup_db`.`external_lookup`"), "genuine cross-database reference change must still be resynced to the source's \ external database: {}", result.sync_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(), target_name: None, 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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, })), 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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, })), 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(), resolved_schema: None, is_nullable: true, column_default: Some("'default'".into()), comment: Some("new".into()), is_primary_key: false, is_unique: false, extra: None, numeric_precision: None, numeric_scale: None, character_maximum_length: None, metadata_capabilities: None, enum_values: None, character_set: None, collation: None, }]; let t = vec![ColumnInfo { name: "c".into(), data_type: "varchar(50)".into(), resolved_schema: None, is_nullable: false, column_default: None, comment: Some("old".into()), is_primary_key: false, is_unique: false, extra: None, numeric_precision: None, numeric_scale: None, character_maximum_length: None, metadata_capabilities: 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(), valid: None, comment: Some("new_comment".into()), parent_schema: None, parent_name: None, }], target_tables: vec![TableInfo { name: "t".into(), table_type: "BASE TABLE".into(), valid: None, 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(), valid: None, 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, ignore_table_name_case: false, ignore_column_name_case: 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![], table_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, is_unique: false, extra: Some("auto_increment".into()), ..Default::default() }), target: None, changes: vec![], add_position: None, }, 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![], add_position: None, }, 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![], add_position: None, }, 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![], add_position: None, }, ]; 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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, }), 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(ColumnInfo { extra: Some("auto_increment".into()), ..col_pk("id", "int") }), target: None, changes: vec![], add_position: None, }, 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![], add_position: None, }, ] } 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(), target_name: None, 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(ColumnInfo { extra: Some("auto_increment".into()), ..col_pk("id", "int") }), target: None, changes: vec![], add_position: None, }, 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![], add_position: None, }, 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![], add_position: None, }, 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![], add_position: None, }, 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![], add_position: None, }, 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![], add_position: None, }, ]; 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, key_is_expression: Vec::new(), column_opclasses: vec![], key_options: Vec::new(), constraint_backed: false, }), 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(), target_name: None, 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![], add_position: None, }, 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![], add_position: None, }, ]; 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(), target_name: None, 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(), valid: None, 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(), target_name: None, 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![], add_position: None, }, 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![], add_position: None, }, ]; let td = TableDiff { diff_type: "added".into(), object_type: Some("table".into()), name: "t".into(), target_name: None, 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![], add_position: None, }, 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![], add_position: None, }, ]; let td = TableDiff { diff_type: "added".into(), object_type: Some("table".into()), name: "t".into(), target_name: None, 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![], add_position: None, }, 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![], add_position: None, }, ]; let td = TableDiff { diff_type: "added".into(), object_type: Some("table".into()), name: "t".into(), target_name: None, 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![], add_position: None, }, 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![], add_position: None, }, ]; let table_diff = TableDiff { diff_type: "added".into(), object_type: Some("table".into()), name: "t".into(), target_name: None, 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 field_mapping_matches_character_varying_alias() { // Kingbase/Postgres report a varchar column's base type as // `character varying` (via format_type()), not `varchar`. A user who // configures a mapping using the shorter, more common `varchar` // spelling must still match it (issue #8011) — previously an exact // string comparison meant such a mapping silently never fired // against the real `character varying` column, dropping the user's // chosen param strategy. let mappings = vec![FieldMapping { source_type: "varchar".into(), target_type: "varchar".into(), param_strategy: ParamStrategy::Custom, custom_params: Some("255".to_string()), }]; let result = FieldMapping::apply_with_params(&mappings, "character varying", DialectKind::Mysql); assert_eq!(result, Some("varchar(255)".to_string())); let result = FieldMapping::apply_with_params(&mappings, "character varying(50)", DialectKind::Mysql); assert_eq!(result, Some("varchar(255)".to_string())); assert_eq!(FieldMapping::apply(&mappings, "character varying"), Some("varchar")); } #[test] fn field_mapping_exact_match_is_not_shadowed_by_an_alias() { // char/character/varchar/character varying commonly coexist as // separate auto-generated rows with independently chosen targets. // An exact match must win over an alias match picked up from an // earlier, unrelated row that merely shares the same canonical name // (issue #8011 review: aliasing broke this without a two-pass find). let mappings = vec![ FieldMapping { source_type: "char".into(), target_type: "binary".into(), param_strategy: ParamStrategy::Preserve, custom_params: None, }, FieldMapping { source_type: "character".into(), target_type: "text".into(), param_strategy: ParamStrategy::Preserve, custom_params: None, }, ]; assert_eq!(FieldMapping::apply(&mappings, "character"), Some("text"), "exact row must win over the char alias"); assert_eq!( FieldMapping::apply(&mappings, "char"), Some("binary"), "exact row must win over the character alias" ); } #[test] fn with_known_length_splices_reported_length_into_bare_type() { assert_eq!(with_known_length("varchar", Some(1000)), "varchar(1000)"); assert_eq!(with_known_length("character varying", None), "character varying"); assert_eq!(with_known_length("varchar(50)", Some(1000)), "varchar(50)", "explicit params are never overridden"); assert_eq!(with_known_length("varchar", Some(0)), "varchar", "non-positive length is ignored"); assert_eq!(with_known_length("varchar", Some(-1)), "varchar", "negative length (unbounded marker) is ignored"); } #[test] fn with_known_length_ignores_non_length_bearing_types() { // `character_maximum_length` is populated by several drivers for // columns where it does not mean "declared length here" — MySQL's // information_schema fills it in for TEXT/BLOB (byte capacity, e.g. // TEXT -> 65535), and Oracle's DATA_LENGTH is filled in for every // column, DATE and NUMBER included (issue #8011 review round 2). // Splicing those in would silently reinterpret the type (MySQL turns // `TEXT(65535)` into MEDIUMTEXT) or produce invalid DDL (`DATE(7)`). assert_eq!(with_known_length("text", Some(65535)), "text"); assert_eq!(with_known_length("date", Some(7)), "date"); assert_eq!(with_known_length("number", Some(22)), "number"); } #[test] fn with_known_length_restores_a_real_char_length() { // Unlike type_rewrite's *default*-to-255 list (which must exclude // CHAR — a bare CHAR is already valid, meaning CHAR(1)), this // function *restores* a length the driver already knows: MySQL's // information_schema reports a CHAR(10) column as DATA_TYPE="char" // with CHARACTER_MAXIMUM_LENGTH=10 (real DB verified). Using // length 1 here would make this assertion pass even with CHAR // wrongly excluded — CHAR(1) and bare CHAR mean the same thing — so // this deliberately uses a length where truncation would show up // (issue #8011 review round 3). assert_eq!(with_known_length("char", Some(10)), "char(10)"); assert_eq!(with_known_length("character", Some(10)), "character(10)"); assert_eq!(with_known_length("char", None), "char", "no known length means no invented one either"); } #[test] fn mysql_same_dialect_add_column_keeps_text_type_when_length_metadata_is_present() { // MySQL's own information_schema reports a real character_maximum_length // for TEXT (65535) even though COLUMN_TYPE never carries it in // parentheses. Splicing it in verbatim would have this same-dialect // ADD COLUMN silently reinterpreted as MEDIUMTEXT by the server (real // MySQL 8.4.6 verified) instead of staying TEXT. let mut source_col = column("notes", "text", None); source_col.character_maximum_length = Some(65535); let diff = ColumnDiff { diff_type: "added".to_string(), name: "notes".to_string(), source: Some(source_col), target: None, changes: vec![], add_position: None, }; let sql = gen_sql(wrap_table_diff("t", vec![diff]), DatabaseType::Mysql, Some(DialectKind::Mysql)); assert!(sql.contains("text"), "expected the column to stay TEXT: {sql}"); assert!(!sql.contains("(65535)"), "must not splice TEXT's byte capacity in as a length: {sql}"); } #[test] fn oracle_to_mysql_date_column_does_not_gain_an_invalid_length() { // Oracle's DATA_LENGTH is populated for every column, DATE included // (byte length, e.g. 7) — not just character types. Splicing it in // would send `DATE(7)` to MySQL, which is a syntax error there (real // MySQL 8.4.6 verified); the pre-fix behavior of passing `DATE` // through untouched was already correct. let mut source_col = column("created_on", "DATE", None); source_col.character_maximum_length = Some(7); let diff = ColumnDiff { diff_type: "added".to_string(), name: "created_on".to_string(), source: Some(source_col), target: None, changes: vec![], add_position: None, }; let sql = gen_sql(wrap_table_diff("t", vec![diff]), DatabaseType::Mysql, Some(DialectKind::Oracle)); assert!(!sql.contains("DATE(7)"), "must not turn a valid DATE column into invalid DDL: {sql}"); } #[test] fn cross_dialect_add_column_uses_known_character_maximum_length_not_a_generic_default() { // Kingbase's MySQL-compatible introspection can report a bare // `varchar`/`character varying` (no length in the type string) // while still exposing the real length via `character_maximum_length` // on the same ColumnInfo. Silently defaulting to 255 in that case // would trade a loud syntax error for a quiet wrong-schema bug // (issue #8011 review). let mut source_col = column("bio", "character varying", None); source_col.character_maximum_length = Some(1000); let diff = ColumnDiff { diff_type: "added".to_string(), name: "bio".to_string(), source: Some(source_col), target: None, changes: vec![], add_position: None, }; let sql = gen_sql(wrap_table_diff("t", vec![diff]), DatabaseType::Mysql, Some(DialectKind::Postgres)); assert!(sql.contains("(1000)"), "expected the real reported length to be preserved: {sql}"); assert!( !sql.contains("(255)"), "must not silently fall back to the generic default when the real length is known: {sql}" ); } #[test] fn postgres_creates_sequences_before_tables_that_reference_them() { let table_diff = TableDiff { diff_type: "added".into(), object_type: Some("table".into()), name: "events".into(), target_name: None, ddl: Some( "CREATE TABLE public.events (id bigint NOT NULL DEFAULT nextval('public.events_id_seq'::regclass))" .into(), ), ..TableDiff::default() }; let sequence_diff = SequenceDiff { diff_type: "added".into(), name: "events_id_seq".into(), source: Some(SequenceInfo { name: "events_id_seq".into(), data_type: "bigint".into(), start_value: "1".into(), min_value: "1".into(), max_value: "9223372036854775807".into(), increment: "1".into(), cycle: false, last_value: None, }), target: None, changes: vec![], }; let sql = generate_schema_sync_sql( &[table_diff], &[], &[sequence_diff], &[], &[], DatabaseType::Postgres, Some("public"), false, Some(DialectKind::Postgres), &[], ); let sequence_position = sql.find("CREATE SEQUENCE").expect("sequence DDL"); let table_position = sql.find("CREATE TABLE public.events").expect("table DDL"); assert!(sequence_position < table_position, "{sql}"); } #[test] fn postgres_drops_sequences_after_dependent_tables() { let table_diff = TableDiff { diff_type: "removed".into(), object_type: Some("table".into()), name: "events".into(), target_name: None, ..TableDiff::default() }; let sequence_diff = SequenceDiff { diff_type: "removed".into(), name: "events_id_seq".into(), source: None, target: None, changes: vec![], }; let sql = generate_schema_sync_sql( &[table_diff], &[], &[sequence_diff], &[], &[], DatabaseType::Postgres, Some("public"), false, Some(DialectKind::Postgres), &[], ); let table_position = sql.find("DROP TABLE").expect("table DDL"); let sequence_position = sql.find("DROP SEQUENCE").expect("sequence DDL"); assert!(table_position < sequence_position, "{sql}"); } #[test] fn postgres_sync_sql_preserves_zero_timestamp_precision() { let table_diff = TableDiff { diff_type: "modified".into(), object_type: Some("table".into()), name: "events".into(), target_name: None, columns: Some(vec![ColumnDiff { diff_type: "modified".into(), name: "created_at".into(), source: Some(column("created_at", "timestamp(0) without time zone", None)), target: Some(column("created_at", "timestamp without time zone", None)), changes: vec!["type: timestamp without time zone → timestamp(0) without time zone".into()], add_position: None, }]), ..TableDiff::default() }; let sql = generate_schema_sync_sql( &[table_diff], &[], &[], &[], &[], DatabaseType::Postgres, Some("public"), false, Some(DialectKind::Postgres), &[], ); assert!(sql.contains("ALTER COLUMN \"created_at\" TYPE timestamp(0)"), "{sql}"); } #[test] fn detects_changed_common_mysql_view_definitions() { let options = common_mysql_view_options( Some("CREATE ALGORITHM=UNDEFINED DEFINER=`viewer_a`@`%` SQL SECURITY DEFINER VIEW `source_db`.`active_orders` AS select `source_db`.`orders`.`id` AS `id` from `source_db`.`orders` where (`source_db`.`orders`.`active` = 1)"), Some("CREATE ALGORITHM=UNDEFINED DEFINER=`viewer_b`@`%` SQL SECURITY DEFINER VIEW `target_db`.`active_orders` AS select `target_db`.`orders`.`id` AS `id` from `target_db`.`orders` where (`target_db`.`orders`.`active` = 0)"), ); let result = prepare_schema_diff(options); assert_eq!(result.diffs.len(), 1); let diff = &result.diffs[0]; assert_eq!(diff.diff_type, "modified"); assert_eq!(diff.object_type.as_deref(), Some("view")); assert!(diff.ddl.as_deref().is_some_and(|ddl| ddl.contains("active` = 1"))); assert!(diff.target_ddl.as_deref().is_some_and(|ddl| ddl.contains("active` = 0"))); assert!(diff.sync_sql.is_none()); assert!(result.sync_sql.is_empty()); } #[test] fn ignores_mysql_view_environment_and_formatting_differences() { let result = prepare_schema_diff(common_mysql_view_options( Some("CREATE ALGORITHM = UNDEFINED DEFINER = `viewer_a` @ `%` SQL SECURITY DEFINER VIEW `source_db` . `active_orders` AS select `source_db` . `orders` . `id` from `source_db` . `orders` where ( `source_db` . `orders` . `active` = 1 )"), Some("CREATE ALGORITHM=UNDEFINED DEFINER=`viewer_b`@`localhost` SQL SECURITY DEFINER VIEW `target_db`.`active_orders` AS select `target_db`.`orders`.`id` from `target_db`.`orders` where(`target_db`.`orders`.`active`=1)"), )); assert!(result.diffs.is_empty()); assert!(result.sync_sql.is_empty()); } #[test] fn preserves_mysql_view_literal_contents_during_comparison() { let common = "CREATE VIEW `source_db`.`active_orders` AS SELECT 'source_db.orders', 'a b' FROM `source_db`.`orders`"; let changed_schema_literal = "CREATE VIEW `target_db`.`active_orders` AS SELECT 'target_db.orders', 'a b' FROM `target_db`.`orders`"; let changed_literal_whitespace = "CREATE VIEW `target_db`.`active_orders` AS SELECT 'source_db.orders', 'a b' FROM `target_db`.`orders`"; assert!(view_definitions_differ( common, changed_schema_literal, Some(DialectKind::Mysql), Some(DialectKind::Mysql) )); assert!(view_definitions_differ( common, changed_literal_whitespace, Some(DialectKind::Mysql), Some(DialectKind::Mysql) )); } #[test] fn preserves_mysql_view_compound_operator_semantics() { let compact = "CREATE VIEW `app`.`active_orders` AS SELECT 1 <=> 1, 1 <= 2, 1 != 2"; let split = "CREATE VIEW `app`.`active_orders` AS SELECT 1 < = > 1, 1 < = 2, 1 ! = 2"; assert!(view_definitions_differ(compact, split, Some(DialectKind::Mysql), Some(DialectKind::Mysql))); } #[test] fn preserves_mysql_view_options_and_identifier_case() { let ddl = "CREATE ALGORITHM=MERGE SQL SECURITY INVOKER VIEW `app`.`active_orders` AS SELECT `OrderId` FROM `app`.`orders` WITH CASCADED CHECK OPTION"; for changed in [ ddl.replace("ALGORITHM=MERGE", "ALGORITHM=TEMPTABLE"), ddl.replace("SECURITY INVOKER", "SECURITY DEFINER"), ddl.replace("`OrderId`", "`orderid`"), ddl.replace("CASCADED", "LOCAL"), ] { assert!(view_definitions_differ(ddl, &changed, Some(DialectKind::Mysql), Some(DialectKind::Mysql))); } } #[test] fn common_view_comparison_requires_two_ddls_and_matching_mysql_dialects() { for (source, target) in [(None, Some("CREATE VIEW v AS SELECT 1")), (Some("CREATE VIEW v AS SELECT 1"), None)] { assert!(prepare_schema_diff(common_mysql_view_options(source, target)).diffs.is_empty()); } let mut cross_dialect = common_mysql_view_options( Some("CREATE VIEW `app`.`active_orders` AS SELECT 1"), Some("CREATE VIEW active_orders AS SELECT 2"), ); cross_dialect.target_dialect = Some(DialectKind::Postgres); assert!(prepare_schema_diff(cross_dialect).diffs.is_empty()); } #[test] fn sharded_diff_keeps_common_mysql_view_comparison() { let source_ddl = "CREATE VIEW `source_db`.`active_orders` AS SELECT 1"; let target_ddl = "CREATE VIEW `target_db`.`active_orders` AS SELECT 2"; let mut options = common_mysql_view_options(Some(source_ddl), Some(target_ddl)); options.source_tables.push(table_info("other_view", "VIEW")); options.target_tables.push(table_info("other_view", "VIEW")); options.source_details.push(schema_detail("other_view", Some("CREATE VIEW other_view AS SELECT 1"))); options.target_details.push(schema_detail("other_view", Some("CREATE VIEW other_view AS SELECT 1"))); options.shard_strategy = Some(ShardStrategy { shard_count: 2, shard_by: ShardBy::RoundRobin }); let result = prepare_schema_diff(options); assert_eq!(result.diffs.len(), 1); assert_eq!(result.diffs[0].name, "active_orders"); assert_eq!(result.diffs[0].diff_type, "modified"); } #[test] fn keeps_added_removed_views_and_table_view_name_boundaries() { let result = prepare_schema_diff(SchemaDiffPreparationOptions { source_tables: vec![table_info("source_view", "VIEW"), table_info("same_name", "BASE TABLE")], target_tables: vec![table_info("target_view", "VIEW"), table_info("same_name", "VIEW")], source_details: vec![ schema_detail("source_view", Some("CREATE VIEW source_view AS SELECT 1")), schema_detail("same_name", Some("CREATE TABLE same_name (id int)")), ], target_details: vec![ schema_detail("target_view", Some("CREATE VIEW target_view AS SELECT 1")), schema_detail("same_name", Some("CREATE VIEW same_name AS SELECT 1")), ], database_type: DatabaseType::Mysql, source_dialect: Some(DialectKind::Mysql), target_dialect: Some(DialectKind::Mysql), ..Default::default() }); assert!(result.diffs.iter().any(|diff| diff.name == "source_view" && diff.diff_type == "added" && diff.object_type.as_deref() == Some("view"))); assert!(result.diffs.iter().any(|diff| diff.name == "target_view" && diff.diff_type == "removed" && diff.object_type.as_deref() == Some("view"))); assert!(result.diffs.iter().any(|diff| diff.name == "same_name" && diff.diff_type == "added" && diff.object_type.as_deref() == Some("table"))); assert!(result.diffs.iter().any(|diff| diff.name == "same_name" && diff.diff_type == "removed" && diff.object_type.as_deref() == Some("view"))); } #[test] fn mysql_sync_sql_uses_same_dialect_view_ddl() { let view_diff = TableDiff { diff_type: "added".into(), object_type: Some("view".into()), name: "active_users".into(), target_name: None, ddl: Some("CREATE VIEW `active_users` AS SELECT `id` FROM `users` WHERE `active` = 1;".into()), ..TableDiff::default() }; let sql = generate_schema_sync_sql( &[view_diff], &[], &[], &[], &[], DatabaseType::Mysql, Some("app"), false, Some(DialectKind::Mysql), &[], ); assert!(sql.contains("CREATE VIEW `active_users`"), "{sql}"); assert!(!sql.contains("Source view definition is not available"), "{sql}"); assert!(!sql.contains(";;"), "{sql}"); } #[test] fn cross_dialect_sync_sql_does_not_reuse_view_ddl() { let view_diff = TableDiff { diff_type: "added".into(), object_type: Some("view".into()), name: "active_users".into(), target_name: None, ddl: Some("CREATE VIEW `active_users` AS SELECT `id` FROM `users`".into()), ..TableDiff::default() }; let sql = generate_schema_sync_sql( &[view_diff], &[], &[], &[], &[], DatabaseType::Postgres, Some("public"), false, Some(DialectKind::Mysql), &[], ); assert!(!sql.contains("CREATE VIEW `active_users`"), "{sql}"); assert!(sql.contains("Source view definition cannot be reused across different SQL dialects"), "{sql}"); } #[test] fn same_dialect_sync_sql_keeps_diagnostic_without_view_ddl() { let view_diff = TableDiff { diff_type: "added".into(), object_type: Some("view".into()), name: "active_users".into(), target_name: None, ..TableDiff::default() }; let sql = generate_schema_sync_sql( &[view_diff], &[], &[], &[], &[], DatabaseType::Mysql, Some("app"), false, Some(DialectKind::Mysql), &[], ); assert!(sql.contains("Source view definition is not available from this driver yet"), "{sql}"); } #[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 postgres_function_sync_reuses_native_functiondef_header_once() { let function = |arguments: &str, definition: &str| FunctionDiff { diff_type: "added".into(), name: "armor".into(), source: Some(FunctionInfo { name: "armor".into(), function_type: "FUNCTION".into(), data_type: "text".into(), definition: definition.into(), arguments: arguments.into(), }), target: None, changes: vec![], }; let diffs = [ function( "bytea", "CREATE OR REPLACE FUNCTION armor(bytea)\n RETURNS text\n LANGUAGE c\n IMMUTABLE PARALLEL SAFE STRICT\nAS '$libdir/pgcrypto', $function$pg_armor$function$\n", ), function( "bytea, text[], text[]", "CREATE OR REPLACE FUNCTION armor(bytea, text[], text[])\n RETURNS text\n LANGUAGE c\nAS '$libdir/pgcrypto', $function$pg_armor$function$\n", ), ]; let sql = generate_schema_sync_sql( &[], &diffs, &[], &[], &[], DatabaseType::Postgres, Some("public"), false, None, &[], ); assert_eq!(sql.matches("CREATE OR REPLACE FUNCTION").count(), 2, "{sql}"); assert!(sql.contains("CREATE OR REPLACE FUNCTION \"public\".\"armor\"(bytea)\n RETURNS text"), "{sql}"); assert!(sql.contains("CREATE OR REPLACE FUNCTION \"public\".\"armor\"(bytea, text[], text[])\n"), "{sql}"); assert!(sql.contains("$function$pg_armor$function$;"), "{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}"); } fn added_table_diff(name: &str) -> TableDiff { TableDiff { diff_type: "added".into(), object_type: Some("table".into()), name: name.into(), ddl: Some(format!("CREATE TABLE `{name}` (\n `id` int NOT NULL\n)")), ..Default::default() } } fn added_table_diff_referencing(name: &str, parent: &str) -> TableDiff { TableDiff { foreign_keys: Some(vec![ForeignKeyDiff { diff_type: "added".into(), name: format!("fk_{name}"), source: Some(ForeignKeyInfo { name: format!("fk_{name}"), column: "parent_id".into(), ref_schema: None, ref_table: parent.into(), ref_column: "id".into(), on_update: None, on_delete: None, }), target: None, changes: Vec::new(), }]), ..added_table_diff(name) } } fn removed_table_diff_referencing(name: &str, parent: &str) -> TableDiff { TableDiff { diff_type: "removed".into(), object_type: Some("table".into()), name: name.into(), foreign_keys: Some(vec![ForeignKeyDiff { diff_type: "removed".into(), name: format!("fk_{name}"), source: None, target: Some(ForeignKeyInfo { name: format!("fk_{name}"), column: "parent_id".into(), ref_schema: None, ref_table: parent.into(), ref_column: "id".into(), on_update: None, on_delete: None, }), changes: Vec::new(), }]), target_ddl: Some(format!("CREATE TABLE `{name}` (`id` int NOT NULL, `parent_id` int)")), ..Default::default() } } fn statement_position(sql: &str, marker: &str) -> usize { sql.find(marker).unwrap_or_else(|| panic!("missing {marker} in:\n{sql}")) } #[test] fn added_foreign_key_child_is_deployed_after_its_parent() { let diffs = vec![added_table_diff_referencing("child9761", "parent9761"), added_table_diff("parent9761")]; let sql = generate_schema_sync_sql(&diffs, &[], &[], &[], &[], DatabaseType::Mysql, None, false, None, &[]); assert!( statement_position(&sql, "-- Create table: parent9761") < statement_position(&sql, "-- Create table: child9761"), "parents must be created before the tables that reference them:\n{sql}" ); } #[test] fn removed_foreign_key_child_is_dropped_before_its_parent() { let diffs = vec![ removed_table_diff_referencing("zzz_child9761", "aaa_parent9761"), TableDiff { diff_type: "removed".into(), object_type: Some("table".into()), name: "aaa_parent9761".into(), target_ddl: Some("CREATE TABLE `aaa_parent9761` (`id` int NOT NULL)".into()), ..Default::default() }, ]; let sql = generate_schema_sync_sql(&diffs, &[], &[], &[], &[], DatabaseType::Mysql, None, true, None, &[]); assert!( statement_position(&sql, "-- Drop table: zzz_child9761") < statement_position(&sql, "-- Drop table: aaa_parent9761"), "referencing tables must be dropped first:\n{sql}" ); } #[test] fn added_view_is_created_after_the_table_it_reads() { let view = TableDiff { diff_type: "added".into(), object_type: Some("view".into()), name: "aaa_view9761".into(), ddl: Some("CREATE VIEW `aaa_view9761` AS SELECT `id` FROM `zzz_table9761`".into()), ..Default::default() }; let diffs = vec![view, added_table_diff("zzz_table9761")]; let sql = generate_schema_sync_sql(&diffs, &[], &[], &[], &[], DatabaseType::Mysql, None, false, None, &[]); assert!( statement_position(&sql, "-- Create table: zzz_table9761") < statement_position(&sql, "-- Create view: aaa_view9761"), "views must be created after the tables they read:\n{sql}" ); } #[test] fn independent_diffs_keep_their_original_statement_order() { let diffs = vec![added_table_diff("bbb9761"), added_table_diff("aaa9761"), added_table_diff("ccc9761")]; let sql = generate_schema_sync_sql(&diffs, &[], &[], &[], &[], DatabaseType::Mysql, None, false, None, &[]); assert!( statement_position(&sql, "-- Create table: bbb9761") < statement_position(&sql, "-- Create table: aaa9761") && statement_position(&sql, "-- Create table: aaa9761") < statement_position(&sql, "-- Create table: ccc9761"), "plans without dependencies must keep the caller's order:\n{sql}" ); } #[test] fn modified_table_fk_added_to_new_table_waits_for_its_create() { let modified = TableDiff { diff_type: "modified".into(), object_type: Some("table".into()), name: "aaa_child_mod9761".into(), foreign_keys: Some(vec![ForeignKeyDiff { diff_type: "added".into(), name: "fk_aaa_child_mod9761".into(), source: Some(ForeignKeyInfo { name: "fk_aaa_child_mod9761".into(), column: "parent_id".into(), ref_schema: None, ref_table: "zzz_parent9761".into(), ref_column: "id".into(), on_update: None, on_delete: None, }), target: None, changes: Vec::new(), }]), ..Default::default() }; let diffs = vec![modified, added_table_diff("zzz_parent9761")]; let sql = generate_schema_sync_sql(&diffs, &[], &[], &[], &[], DatabaseType::Mysql, None, false, None, &[]); assert!( statement_position(&sql, "-- Create table: zzz_parent9761") < statement_position(&sql, "ALTER TABLE `aaa_child_mod9761` ADD CONSTRAINT"), "an ALTER on a modified table adding an FK to a new table must follow that table's CREATE:\n{sql}" ); } #[test] fn modified_table_fk_dropped_runs_before_referenced_table_drop() { let modified = TableDiff { diff_type: "modified".into(), object_type: Some("table".into()), name: "aaa_child_mod9761".into(), foreign_keys: Some(vec![ForeignKeyDiff { diff_type: "removed".into(), name: "fk_aaa_child_mod9761".into(), source: None, target: Some(ForeignKeyInfo { name: "fk_aaa_child_mod9761".into(), column: "parent_id".into(), ref_schema: None, ref_table: "zzz_parent9761".into(), ref_column: "id".into(), on_update: None, on_delete: None, }), changes: Vec::new(), }]), ..Default::default() }; let removed_parent = TableDiff { diff_type: "removed".into(), object_type: Some("table".into()), name: "zzz_parent9761".into(), ..Default::default() }; let diffs = vec![modified, removed_parent]; let sql = generate_schema_sync_sql(&diffs, &[], &[], &[], &[], DatabaseType::Mysql, None, false, None, &[]); assert!( statement_position(&sql, "ALTER TABLE `aaa_child_mod9761` DROP FOREIGN KEY") < statement_position(&sql, "-- Drop table: zzz_parent9761"), "an ALTER dropping an FK must run before the referenced table's DROP:\n{sql}" ); } #[test] fn circular_foreign_keys_still_emit_every_create_statement() { let diffs = vec![ added_table_diff_referencing("loop_a9761", "loop_b9761"), added_table_diff_referencing("loop_b9761", "loop_a9761"), ]; let sql = generate_schema_sync_sql(&diffs, &[], &[], &[], &[], DatabaseType::Mysql, None, false, None, &[]); assert!(sql.contains("-- Create table: loop_a9761"), "{sql}"); assert!(sql.contains("-- Create table: loop_b9761"), "{sql}"); } fn typed_column( name: &str, data_type: &str, numeric_precision: Option, numeric_scale: Option, character_maximum_length: Option, ) -> ColumnInfo { ColumnInfo { is_nullable: true, numeric_precision, numeric_scale, character_maximum_length, ..column(name, data_type, None) } } fn named_index(name: &str, columns: &[&str], is_unique: bool) -> IndexInfo { index(IndexInfo { name: name.to_string(), columns: columns.iter().map(|column| (*column).to_string()).collect(), is_unique, is_primary: false, filter: None, index_type: Some("NORMAL".to_string()), included_columns: None, comment: None, key_is_expression: Vec::new(), column_opclasses: Vec::new(), key_options: Vec::new(), constraint_backed: false, }) } fn oracle_table_pair( columns: Vec, source_indexes: Vec, target_indexes: Vec, ) -> SchemaDiffPreparationOptions { SchemaDiffPreparationOptions { source_tables: vec![table_info("cmp_autoname", "TABLE")], target_tables: vec![table_info("cmp_autoname", "TABLE")], source_details: vec![TableSchemaDetail { name: "cmp_autoname".to_string(), columns: columns.clone(), indexes: source_indexes, foreign_keys: Vec::new(), triggers: Vec::new(), ddl: None, }], target_details: vec![TableSchemaDetail { name: "cmp_autoname".to_string(), columns, indexes: target_indexes, foreign_keys: Vec::new(), triggers: Vec::new(), ddl: None, }], database_type: DatabaseType::Oracle, ..Default::default() } } fn single_column_table_pair(source_column: ColumnInfo, target_column: ColumnInfo) -> SchemaDiffPreparationOptions { SchemaDiffPreparationOptions { source_tables: vec![table_info("cmp_autoname", "TABLE")], target_tables: vec![table_info("cmp_autoname", "TABLE")], source_details: vec![TableSchemaDetail { name: "cmp_autoname".to_string(), columns: vec![source_column], indexes: Vec::new(), foreign_keys: Vec::new(), triggers: Vec::new(), ddl: None, }], target_details: vec![TableSchemaDetail { name: "cmp_autoname".to_string(), columns: vec![target_column], indexes: Vec::new(), foreign_keys: Vec::new(), triggers: Vec::new(), ddl: None, }], database_type: DatabaseType::Oracle, ..Default::default() } } /// #9261:Oracle 给未命名的 PRIMARY KEY/UNIQUE 约束自动取的索引名(`SYS_C<编号>`) /// 是对象级的,两张结构完全相同的表也必然不同。按签名配对后不能再报出这对凭空 /// 多出/消失的索引。 #[test] fn server_generated_index_names_do_not_report_index_churn() { let result = prepare_schema_diff(oracle_table_pair( vec![ typed_column("id", "NUMBER", Some(10), Some(0), Some(0)), typed_column("code", "VARCHAR2", None, None, Some(20)), ], vec![named_index("SYS_C008146", &["code"], true)], vec![named_index("SYS_C008149", &["code"], true)], )); assert!(result.diffs.is_empty(), "{:?}", result.diffs); } /// 兜底只覆盖「服务器自己命名」的索引:用户命名的索引改名仍然要报出来。 #[test] fn differently_named_user_indexes_still_report_index_churn() { let result = prepare_schema_diff(oracle_table_pair( vec![typed_column("code", "VARCHAR2", None, None, Some(20))], vec![named_index("IDX_CODE", &["code"], false)], vec![named_index("IDX_CODE_OLD", &["code"], false)], )); assert_eq!(result.diffs.len(), 1, "{:?}", result.diffs); let indexes = result.diffs[0].indexes.as_ref().expect("index diff"); assert_eq!(indexes.len(), 2, "{indexes:?}"); assert!(indexes.iter().any(|diff| diff.diff_type == "added" && diff.name == "IDX_CODE")); assert!(indexes.iter().any(|diff| diff.diff_type == "removed" && diff.name == "IDX_CODE_OLD")); } /// 名字是服务器生成的、但定义不同(列不一样),必须仍然报差异。 #[test] fn server_generated_index_with_a_different_definition_is_still_a_difference() { let result = prepare_schema_diff(oracle_table_pair( vec![ typed_column("code", "VARCHAR2", None, None, Some(20)), typed_column("other_code", "VARCHAR2", None, None, Some(20)), ], vec![named_index("SYS_C008146", &["code"], true)], vec![named_index("SYS_C008149", &["other_code"], true)], )); assert_eq!(result.diffs.len(), 1, "{:?}", result.diffs); assert!(result.diffs[0].indexes.as_ref().is_some_and(|indexes| indexes.len() == 2)); } /// #9261:Oracle 报的 `NUMBER` 不带精度,`NUMBER(10,2)` 与 `NUMBER(12,2)` 的类型串 /// 一模一样,差别只在 data_precision/data_scale 里 —— 必须能比较出来。 #[test] fn reported_numeric_precision_changes_are_detected() { let result = prepare_schema_diff(single_column_table_pair( typed_column("amt", "NUMBER", Some(10), Some(2), Some(0)), typed_column("amt", "NUMBER", Some(12), Some(2), Some(0)), )); assert_eq!(result.diffs.len(), 1, "{:?}", result.diffs); let columns = result.diffs[0].columns.as_ref().expect("column diff"); assert_eq!(columns.len(), 1); assert_eq!(columns[0].changes, vec!["type: NUMBER(12,2) → NUMBER(10,2)".to_string()]); } /// 字符长度同理:驱动把它放在 character_maximum_length 里。 #[test] fn reported_character_length_changes_are_detected() { let result = prepare_schema_diff(single_column_table_pair( typed_column("name", "VARCHAR2", None, None, Some(40)), typed_column("name", "VARCHAR2", None, None, Some(20)), )); assert_eq!(result.diffs.len(), 1, "{:?}", result.diffs); let columns = result.diffs[0].columns.as_ref().expect("column diff"); assert_eq!(columns[0].changes, vec!["type: VARCHAR2(20) → VARCHAR2(40)".to_string()]); } /// 驱动根本没报精度/长度时不能臆造差异(两侧都为空 => 相等)。 #[test] fn columns_without_reported_parameters_do_not_invent_a_difference() { let result = prepare_schema_diff(single_column_table_pair( typed_column("amt", "NUMBER", None, None, None), typed_column("amt", "NUMBER", None, None, None), )); assert!(result.diffs.is_empty(), "{:?}", result.diffs); } /// MySQL 的 `int` 也会带 numeric_precision,但 `int(10)` 不是它的声明类型(跨方言会 /// 变成非法 DDL),所以整数族不在拼接白名单里。 #[test] fn integer_precision_metadata_is_not_spliced_into_the_type() { let result = prepare_schema_diff(SchemaDiffPreparationOptions { source_tables: vec![table_info("t", "TABLE")], target_tables: vec![table_info("t", "TABLE")], source_details: vec![TableSchemaDetail { name: "t".to_string(), columns: vec![typed_column("id", "int", Some(10), Some(0), None)], indexes: Vec::new(), foreign_keys: Vec::new(), triggers: Vec::new(), ddl: None, }], target_details: vec![TableSchemaDetail { name: "t".to_string(), columns: vec![typed_column("id", "int", Some(10), Some(0), None)], indexes: Vec::new(), foreign_keys: Vec::new(), triggers: Vec::new(), ddl: None, }], database_type: DatabaseType::Mysql, ..Default::default() }); assert!(result.diffs.is_empty(), "{:?}", result.diffs); assert_eq!(declared_column_type(&typed_column("id", "int", Some(10), Some(0), None)), "int"); } fn oracle_column( name: &str, data_type: &str, numeric_precision: Option, numeric_scale: Option, character_maximum_length: Option, is_nullable: bool, column_default: Option<&str>, ) -> ColumnInfo { ColumnInfo { is_nullable, column_default: column_default.map(str::to_string), ..typed_column(name, data_type, numeric_precision, numeric_scale, character_maximum_length) } } fn oracle_column_pair(source: Vec, target: Vec) -> SchemaDiffPreparationOptions { SchemaDiffPreparationOptions { source_tables: vec![table_info("cmp_grammar", "TABLE")], target_tables: vec![table_info("cmp_grammar", "TABLE")], source_details: vec![TableSchemaDetail { name: "cmp_grammar".to_string(), columns: source, indexes: Vec::new(), foreign_keys: Vec::new(), triggers: Vec::new(), ddl: None, }], target_details: vec![TableSchemaDetail { name: "cmp_grammar".to_string(), columns: target, indexes: Vec::new(), foreign_keys: Vec::new(), triggers: Vec::new(), ddl: None, }], database_type: DatabaseType::Oracle, source_dialect: Some(DialectKind::Oracle), target_dialect: Some(DialectKind::Oracle), ..Default::default() } } /// Oracle 没有 `ADD COLUMN`/`MODIFY COLUMN`,列变更子句要写成 `ADD (定义)` / /// `MODIFY (定义)`;定义内部 `DEFAULT` 又必须排在 `NOT NULL` 前面,否则 11g 直接 /// 报 ORA-00907(都是真机实测过的语法)。 #[test] fn oracle_column_clauses_follow_the_oracle_grammar() { let result = prepare_schema_diff(oracle_column_pair( vec![ oracle_column("amt", "NUMBER", Some(12), Some(2), Some(0), false, None), oracle_column("label", "VARCHAR2", None, None, Some(20), false, Some("'x'")), ], vec![oracle_column("amt", "NUMBER", Some(10), Some(2), Some(0), false, None)], )); let sql = result.sync_sql; assert!(sql.contains("ADD (LABEL VARCHAR2(20) DEFAULT 'x' NOT NULL)"), "{sql}"); assert!(sql.contains("MODIFY (AMT NUMBER(12,2) NOT NULL)"), "{sql}"); assert!(!sql.contains("ADD COLUMN"), "{sql}"); assert!(!sql.contains("MODIFY COLUMN"), "{sql}"); assert!(!sql.contains("ALTER COLUMN"), "{sql}"); let add = sql.lines().find(|line| line.contains("ADD (")).expect("ADD clause"); let default_at = add.find("DEFAULT").expect("default literal"); let not_null_at = add.find("NOT NULL").expect("not null"); assert!(default_at < not_null_at, "DEFAULT must precede NOT NULL: {sql}"); } /// 把列改成可空时,Oracle 重述定义的 `MODIFY (列 定义)` **不会**去掉已有的 /// `NOT NULL`(11g 实测),所以生成的语句必须显式写 `NULL`,否则脚本报成功但约束还在。 #[test] fn oracle_modify_spells_null_when_dropping_not_null() { let with_type_change = prepare_schema_diff(oracle_column_pair( vec![oracle_column("amt", "NUMBER", Some(12), Some(2), Some(0), true, None)], vec![oracle_column("amt", "NUMBER", Some(10), Some(2), Some(0), false, None)], )); let sql = with_type_change.sync_sql; assert!(sql.contains("MODIFY (AMT NUMBER(12,2) NULL)"), "{sql}"); assert!(!sql.contains("NOT NULL"), "{sql}"); let nullability_only = prepare_schema_diff(oracle_column_pair( vec![oracle_column("amt", "NUMBER", Some(10), Some(2), Some(0), true, None)], vec![oracle_column("amt", "NUMBER", Some(10), Some(2), Some(0), false, None)], )); let sql = nullability_only.sync_sql; assert!(sql.contains("MODIFY (AMT NUMBER(10,2) NULL)"), "{sql}"); } fn oracle_view_options(source_ddl: Option<&str>, target_ddl: Option<&str>) -> SchemaDiffPreparationOptions { SchemaDiffPreparationOptions { source_tables: vec![table_info("v_change", "VIEW")], target_tables: vec![table_info("v_change", "VIEW")], source_details: vec![schema_detail("v_change", source_ddl)], target_details: vec![schema_detail("v_change", target_ddl)], database_type: DatabaseType::Oracle, source_dialect: Some(DialectKind::Oracle), target_dialect: Some(DialectKind::Oracle), ..Default::default() } } /// #9261:`DBMS_METADATA` 抓到的 Oracle 视图文本里 owner 出现在头部 /// (`CREATE OR REPLACE FORCE VIEW "DBX_TEST"."V_SAME"`)和每一处限定名上,两张不同 /// schema 的**同一张**视图必然处处不同 —— 归一化 owner 之后不能再报差异。 #[test] fn oracle_views_with_different_owners_compare_equal() { let result = prepare_schema_diff(oracle_view_options( Some( "CREATE OR REPLACE FORCE VIEW \"DBX_TEST\".\"V_SAME\" (\"ID\", \"NAME\", \"AMT\") AS\n SELECT ID, NAME, AMT FROM DBX_TEST.CMP_SAME;", ), Some( "CREATE OR REPLACE FORCE VIEW \"DBX_TGT\".\"V_SAME\" (\"ID\",\"NAME\",\"AMT\") AS\n SELECT ID,\n NAME,\n AMT\n FROM DBX_TGT.CMP_SAME", ), )); assert!(result.diffs.is_empty(), "{:?}", result.diffs); } /// 真正不同(列少了、条件不同)的视图仍然要报出来。 #[test] fn oracle_views_with_a_different_definition_are_reported() { let result = prepare_schema_diff(oracle_view_options( Some( "CREATE OR REPLACE FORCE VIEW \"DBX_TEST\".\"V_CHANGE\" (\"ID\", \"NAME\", \"AMT\") AS\n SELECT ID, NAME, AMT FROM DBX_TEST.CMP_SAME;", ), Some( "CREATE OR REPLACE FORCE VIEW \"DBX_TGT\".\"V_CHANGE\" (\"ID\", \"NAME\") AS\n SELECT ID, NAME FROM DBX_TGT.CMP_SAME;", ), )); assert_eq!(result.diffs.len(), 1, "{:?}", result.diffs); assert_eq!(result.diffs[0].object_type.as_deref(), Some("view")); assert_eq!(result.diffs[0].diff_type, "modified"); } /// 字符串字面量里的 `'DBX_TEST.CMP_SAME'` 不是限定名:归一化只改 owner 的限定引用, /// 字面量原样保留 —— 所以两侧字面量不同时,视图仍然算不同。 #[test] fn oracle_view_owner_normalization_leaves_literals_alone() { let source = "CREATE OR REPLACE FORCE VIEW \"DBX_TEST\".\"V_LIT\" AS SELECT 'DBX_TEST.CMP_SAME' AS TAG, ID FROM DBX_TEST.CMP_SAME"; let target = "CREATE OR REPLACE FORCE VIEW \"DBX_TGT\".\"V_LIT\" AS SELECT 'DBX_TEST.CMP_SAME' AS TAG, ID FROM DBX_TGT.CMP_SAME"; let normalized = normalize_oracle_view_ddl(source); // 只有头部 owner 和 `FROM` 后面的限定名被替换,字面量原样保留。 assert!(normalized.contains("'DBX_TEST.CMP_SAME' AS TAG"), "{normalized}"); assert_eq!(normalized.matches("__dbx_schema__").count(), 2, "{normalized}"); assert_eq!(normalized, normalize_oracle_view_ddl(target), "{normalized}"); // 字面量真的不一样时仍然是差异(说明字面量没有被一起改写掉)。 let changed_literal = target.replace("'DBX_TEST.CMP_SAME' AS TAG", "'DBX_TGT.CMP_SAME' AS TAG"); assert_ne!(normalized, normalize_oracle_view_ddl(&changed_literal)); } /// dbx 自己生成的脚本只改写视图头,落库后目标视图的正文可能仍限定源 schema;这时两侧 /// 必须判为相同,否则脚本执行完差异也不会消失。只有第三个 schema 的限定名才算差异。 #[test] fn oracle_views_whose_body_keeps_the_other_schema_compare_equal() { let source = "CREATE OR REPLACE FORCE VIEW \"DBX_TEST\".\"V_NEW\" (\"ID\") AS \n select id from DBX_TEST.CMP_OK;"; let target = "CREATE OR REPLACE FORCE VIEW \"DBX_TGT\".\"V_NEW\" (\"ID\") AS \n select id from DBX_TEST.CMP_OK;"; let oracle = (Some(DialectKind::Oracle), Some(DialectKind::Oracle)); assert!(!view_definitions_differ(source, target, oracle.0, oracle.1)); // 目标侧按自己的 schema 写正文,同样不能算差异。 let target_self = target.replace("from DBX_TEST.CMP_OK", "from DBX_TGT.CMP_OK"); assert!(!view_definitions_differ(source, &target_self, oracle.0, oracle.1)); // 第三个 schema 的限定名不在归一化范围内:正文确实不同时仍然报差异。 let other_schema = target.replace("from DBX_TEST.CMP_OK", "from DBX_OTHER.CMP_OK"); assert!(view_definitions_differ(source, &other_schema, oracle.0, oracle.1)); let changed_body = target.replace("select id from", "select id, name from"); assert!(view_definitions_differ(source, &changed_body, oracle.0, oracle.1)); } /// 跨方言的视图文本本来就不是同一种 SQL,不参与比较。 #[test] fn oracle_view_text_is_not_compared_across_dialects() { assert!(!view_definitions_differ( "CREATE OR REPLACE FORCE VIEW \"DBX_TEST\".\"V_SAME\" AS SELECT 1 FROM DUAL", "CREATE ALGORITHM=UNDEFINED VIEW `v_same` AS select 1", Some(DialectKind::Mysql), Some(DialectKind::Oracle), )); } /// Oracle 的视图头带 `FORCE`,生成的脚本要把它改写成目标 schema,否则会在源 schema /// 上建视图(或因为权限直接失败)。 #[test] fn added_oracle_view_native_ddl_rewrites_source_schema_to_target_schema() { let diffs = vec![TableDiff { diff_type: "added".to_string(), object_type: Some("view".to_string()), name: "V_NEW".to_string(), ddl: Some( "CREATE OR REPLACE FORCE VIEW \"DBX_TEST\".\"V_NEW\" (\"ID\") AS\n SELECT ID FROM DBX_TEST.CMP_SAME" .to_string(), ), ..TableDiff::default() }]; let sql = generate_schema_sync_sql( &diffs, &[], &[], &[], &[], DatabaseType::Oracle, Some("DBX_TGT"), false, Some(DialectKind::Oracle), &[], ); assert!(sql.contains("CREATE OR REPLACE FORCE VIEW DBX_TGT.V_NEW"), "{sql}"); assert!(!sql.contains("DBX_TEST.V_NEW"), "{sql}"); } }