diff --git a/crates/dbx-core/src/schema_diff.rs b/crates/dbx-core/src/schema_diff.rs index 4d4f15aa6e..5ea849ab67 100644 --- a/crates/dbx-core/src/schema_diff.rs +++ b/crates/dbx-core/src/schema_diff.rs @@ -6,19 +6,25 @@ use regex::Regex; use serde::{Deserialize, Serialize}; use crate::models::connection::DatabaseType; -use crate::sql_dialect::ddl_profile::{ - profile_for, AutoIncSyntax, ColumnDdlSyntax, CommentDdlSyntax, DdlDialectProfile, -}; +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 { @@ -1510,6 +1516,35 @@ pub fn diff_permissions(source: &[PermissionInfo], target: &[PermissionInfo]) -> 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); @@ -1518,7 +1553,13 @@ pub fn generate_permission_sync_sql(diffs: &[PermissionDiff], db_type: DatabaseT match diff.diff_type.as_str() { "added" => { if let Some(source) = &diff.source { - if profile.grant_uses_mysql_user_syntax { + 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 { @@ -1548,7 +1589,11 @@ pub fn generate_permission_sync_sql(diffs: &[PermissionDiff], db_type: DatabaseT } "removed" => { if let Some(target) = &diff.target { - if profile.grant_uses_mysql_user_syntax { + 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 { @@ -3187,6 +3232,9 @@ fn quote_id(name: &str, db_type: DatabaseType) -> String { } 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); if !col.is_nullable { @@ -3220,9 +3268,11 @@ fn column_def(col: &ColumnInfo, db_type: DatabaseType, source_dialect: Option) -> String { - schema + 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)) } @@ -3230,6 +3280,29 @@ fn qualified_name(name: &str, db_type: DatabaseType, schema: Option<&str>) -> St 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 profile.drop_index_uses_on_table { format!("DROP INDEX {} ON {table};", quote_id(index_name, db_type)) @@ -3283,7 +3356,17 @@ fn create_index_sql(table_name: &str, index: &IndexInfo, db_type: DatabaseType, use crate::sql_dialect::ddl_profile::IndexTypePlacement; let profile = profile_for(db_type); let table = qualified_name(table_name, db_type, schema); - let columns = index + 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() @@ -3298,8 +3381,29 @@ fn create_index_sql(table_name: &str, index: &IndexInfo, db_type: DatabaseType, }) .collect::>() .join(", "); - let unique = if index.is_unique { "UNIQUE " } else { "" }; - let index_type = index.index_type.as_deref().unwrap_or_default(); + 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 { @@ -3311,7 +3415,10 @@ fn create_index_sql(table_name: &str, index: &IndexInfo, db_type: DatabaseType, } }; let included_columns = index.included_columns.clone().unwrap_or_default(); - let include_clause = if !included_columns.is_empty() && profile.index_supports_include { + 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(", ") @@ -3319,6 +3426,15 @@ fn create_index_sql(table_name: &str, index: &IndexInfo, db_type: DatabaseType, } 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(""); @@ -3327,6 +3443,12 @@ fn create_index_sql(table_name: &str, index: &IndexInfo, db_type: DatabaseType, } 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!( @@ -3353,12 +3475,39 @@ fn drop_foreign_key_sql(table_name: &str, fk_name: &str, db_type: DatabaseType, } 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 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(); + 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} ({}){on_delete}{on_update};", + "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) @@ -3367,7 +3516,15 @@ fn add_foreign_key_sql(table_name: &str, fk: &ForeignKeyInfo, db_type: DatabaseT fn drop_object_sql(diff: &TableDiff, db_type: DatabaseType, schema: Option<&str>, cascade: &str) -> String { let object_type = if diff.object_type.as_deref() == Some("view") { "VIEW" } else { "TABLE" }; - format!("DROP {object_type} IF EXISTS {}{cascade};", qualified_name(&diff.name, db_type, schema)) + let name = qualified_name(&diff.name, 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('\'', "''") + ); + } + format!("DROP {object_type} IF EXISTS {name}{cascade};") } fn comment_literal(comment: &str) -> String { @@ -3465,6 +3622,502 @@ fn default_literal(default: &str, data_type: &str, source: DialectKind, extra: O 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 @@ -3488,133 +4141,199 @@ fn is_hex_literal(value: &str) -> bool { !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>, -) -> String { +) -> 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 format!("-- Column comment for {column_name}: use ALTER TABLE ... MODIFY COLUMN to set comment"); + return vec![format!("-- Column comment for {column_name}: use ALTER TABLE ... MODIFY COLUMN to set comment")]; } - if profile.comment_ddl == CommentDdlSyntax::SqlServerExtendedProperty { - return sqlserver_column_comment_sql(table_name, column_name, comment, db_type, schema); - } - let table = qualified_name(table_name, db_type, schema); - format!("COMMENT ON COLUMN {table}.{} IS {};", quote_id(column_name, db_type), comment_literal(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>) -> String { +fn table_comment_sql(table_name: &str, comment: &str, db_type: DatabaseType, schema: Option<&str>) -> Vec { let profile = profile_for(db_type); - if profile.comment_ddl == CommentDdlSyntax::SqlServerExtendedProperty { - return sqlserver_table_comment_sql(table_name, comment, db_type, schema); - } 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 { - format!("ALTER TABLE {table} COMMENT = {};", comment_literal(comment)) + vec![format!("ALTER TABLE {table} COMMENT = {};", comment_literal(comment))] } else { - format!("COMMENT ON TABLE {table} IS {};", comment_literal(comment)) + vec![format!("COMMENT ON TABLE {table} IS {};", comment_literal(comment))] } } -fn sqlserver_schema_name(schema: Option<&str>) -> &str { - schema.map(str::trim).filter(|name| !name.is_empty()).unwrap_or("dbo") -} - -fn sqlserver_column_comment_sql( - table_name: &str, - column_name: &str, - comment: &str, - db_type: DatabaseType, - schema: Option<&str>, -) -> String { - let table = qualified_name(table_name, db_type, schema); - let object_id = table.replace('\'', "''"); - let schema = sqlserver_schema_name(schema).replace('\'', "''"); - let table_name = table_name.replace('\'', "''"); - let column_name = column_name.replace('\'', "''"); - let exists = format!( - "EXISTS (SELECT 1 FROM sys.extended_properties WHERE major_id = OBJECT_ID(N'{object_id}') AND minor_id = COLUMNPROPERTY(OBJECT_ID(N'{object_id}'), N'{column_name}', 'ColumnId') AND name = N'MS_Description')" - ); - let levels = format!( - "@level0type=N'SCHEMA', @level0name=N'{schema}', @level1type=N'TABLE', @level1name=N'{table_name}', @level2type=N'COLUMN', @level2name=N'{column_name}'" - ); - - if comment.is_empty() { - format!("IF {exists} EXEC sys.sp_dropextendedproperty @name=N'MS_Description', {levels};") - } else { - let comment = comment.replace('\'', "''"); - format!( - "IF {exists} EXEC sys.sp_updateextendedproperty @name=N'MS_Description', @value=N'{comment}', {levels} ELSE EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'{comment}', {levels};" - ) - } -} - -fn sqlserver_table_comment_sql(table_name: &str, comment: &str, db_type: DatabaseType, schema: Option<&str>) -> String { - let table = qualified_name(table_name, db_type, schema); - let object_id = table.replace('\'', "''"); - let schema = sqlserver_schema_name(schema).replace('\'', "''"); - let table_name = table_name.replace('\'', "''"); - let exists = format!( - "EXISTS (SELECT 1 FROM sys.extended_properties WHERE major_id = OBJECT_ID(N'{object_id}') AND minor_id = 0 AND name = N'MS_Description')" - ); - let levels = - format!("@level0type=N'SCHEMA', @level0name=N'{schema}', @level1type=N'TABLE', @level1name=N'{table_name}'"); - - if comment.is_empty() { - format!("IF {exists} EXEC sys.sp_dropextendedproperty @name=N'MS_Description', {levels};") - } else { - let comment = comment.replace('\'', "''"); - format!( - "IF {exists} EXEC sys.sp_updateextendedproperty @name=N'MS_Description', @value=N'{comment}', {levels} ELSE EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'{comment}', {levels};" - ) - } -} - -fn sqlserver_exec_batch(sql: &str) -> String { - format!("EXEC sys.sp_executesql N'{}';", sql.replace('\'', "''")) -} - -fn sqlserver_has_default(column: Option<&ColumnInfo>) -> bool { - column.and_then(|column| column.column_default.as_deref()).is_some_and(|default| { - let default = default.trim(); - !default.is_empty() && !default.eq_ignore_ascii_case("null") - }) -} - -fn sqlserver_drop_default_constraint_sql(table: &str, column_name: &str) -> String { - let table_literal = table.replace('\'', "''"); - let column_literal = column_name.replace('\'', "''"); - sqlserver_exec_batch(&format!( - "DECLARE @dbx_default_sql NVARCHAR(MAX); \ - SELECT TOP (1) @dbx_default_sql = N'ALTER TABLE {table_literal} DROP CONSTRAINT ' + QUOTENAME(dc.name) \ - FROM sys.default_constraints AS dc \ - WHERE dc.parent_object_id = OBJECT_ID(N'{table_literal}') AND dc.parent_column_id = COLUMNPROPERTY(OBJECT_ID(N'{table_literal}'), N'{column_literal}', 'ColumnId'); \ - IF @dbx_default_sql IS NOT NULL EXEC sys.sp_executesql @dbx_default_sql;" - )) -} - -fn sqlserver_alter_column_preserving_default_sql(table: &str, column_name: &str, column: &ColumnInfo) -> String { - let table_literal = table.replace('\'', "''"); - let column_literal = column_name.replace('\'', "''"); - let quoted_column = quote_id(column_name, DatabaseType::SqlServer); - let quoted_column_literal = quoted_column.replace('\'', "''"); - let nullability = if column.is_nullable { "NULL" } else { "NOT NULL" }; - - sqlserver_exec_batch(&format!( - "DECLARE @dbx_default_sql NVARCHAR(MAX), @dbx_default_name sysname, @dbx_default_definition NVARCHAR(MAX); \ - SELECT TOP (1) @dbx_default_name = dc.name, @dbx_default_definition = dc.definition \ - FROM sys.default_constraints AS dc \ - WHERE dc.parent_object_id = OBJECT_ID(N'{table_literal}') AND dc.parent_column_id = COLUMNPROPERTY(OBJECT_ID(N'{table_literal}'), N'{column_literal}', 'ColumnId'); \ - IF @dbx_default_name IS NOT NULL BEGIN SET @dbx_default_sql = N'ALTER TABLE {table_literal} DROP CONSTRAINT ' + QUOTENAME(@dbx_default_name); EXEC sys.sp_executesql @dbx_default_sql; END; \ - ALTER TABLE {table} ALTER COLUMN {quoted_column} {data_type} {nullability}; \ - IF @dbx_default_name IS NOT NULL BEGIN SET @dbx_default_sql = N'ALTER TABLE {table_literal} ADD CONSTRAINT ' + QUOTENAME(@dbx_default_name) + N' DEFAULT ' + @dbx_default_definition + N' FOR {quoted_column_literal}'; EXEC sys.sp_executesql @dbx_default_sql; END;", - data_type = column.data_type, - )) -} - fn create_trigger_sql( profile: &crate::sql_dialect::ddl_profile::DdlDialectProfile, name: &str, @@ -3644,6 +4363,21 @@ fn create_trigger_sql( } } +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..])) +} + fn generate_create_table_sql( name: &str, columns: &[ColumnDiff], @@ -3681,6 +4415,13 @@ fn generate_create_table_sql( }; let col_name = quote_id(&col.name, db_type); let mapped_type = map_type(&col.data_type); + 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); @@ -3828,33 +4569,20 @@ fn generate_create_table_sql( let Some(fk) = &fk_diff.source else { continue; }; - let fk_name = quote_id(&fk.name, db_type); - let fk_col = quote_id(&fk.column, db_type); - let ref_table = qualified_name(&fk.ref_table, db_type, fk.ref_schema.as_deref().or(schema)); - let ref_col = quote_id(&fk.ref_column, db_type); - let on_delete = fk.on_delete.as_ref().map(|a| format!(" ON DELETE {}", a)).unwrap_or_default(); - let on_update = fk.on_update.as_ref().map(|a| format!(" ON UPDATE {}", a)).unwrap_or_default(); - lines.push(format!( - "ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {}({}){}{};", - table, fk_name, fk_col, ref_table, ref_col, on_delete, on_update - )); + 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 (ANSI COMMENT ON … when profile does not use inline COMMENT) + // 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() { - let col_name = quote_id(&col.name, db_type); - let esc_comment = comment.replace('\'', "''"); - if !profile.inline_column_comment { - lines.push(format!("COMMENT ON COLUMN {}.{} IS '{}';", table, col_name, esc_comment)); - } + if !comment.is_empty() && !profile.inline_column_comment { + lines.extend(column_comment_sql(name, &col.name, comment, db_type, schema)); } } } @@ -3862,7 +4590,7 @@ fn generate_create_table_sql( // Table comment if let Some(comment) = table_comment { if !comment.is_empty() { - lines.push(table_comment_sql(name, comment, db_type, schema)); + lines.extend(table_comment_sql(name, comment, db_type, schema)); } } @@ -3882,9 +4610,31 @@ fn generate_create_table_sql( }; let timing = &trigger.timing; - if let Some(stmt) = &trigger.statement { + 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() { - lines.push(create_trigger_sql(&profile, &trigger.name, timing, event_desc, &table, stmt)); + 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(), @@ -3975,6 +4725,16 @@ fn append_sequence_diff_sql( 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( @@ -4086,7 +4846,9 @@ fn generate_schema_sync_sql_inner( let mut lines = Vec::new(); let mut missing_objects: Vec = Vec::new(); let profile = profile_for(db_type); - let cascade = if cascade_delete { " CASCADE" } else { "" }; + // SQL Server has no DROP ... CASCADE syntax. Related constraints are handled + // explicitly by the comparison plan instead of appending an invalid clause. + let cascade = if cascade_delete && db_type != DatabaseType::SqlServer { " CASCADE" } else { "" }; let map_type = |source_type: &str| -> String { let tgt = DialectKind::from_database_type(db_type); @@ -4242,6 +5004,18 @@ fn generate_schema_sync_sql_inner( } } } + 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(&diff.name, &index.name, db_type, schema)); + } + } + } + } if let Some(columns) = &diff.columns { let convert_col = @@ -4250,6 +5024,14 @@ fn generate_schema_sync_sql_inner( 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(), @@ -4261,64 +5043,40 @@ fn generate_schema_sync_sql_inner( } else { String::new() }; - let add_column = - if profile.column_ddl == ColumnDdlSyntax::SqlServer { "ADD" } else { "ADD COLUMN" }; parts.push(format!( - " {add_column} {}{}", + " ADD COLUMN {}{}", column_def(&convert_col(source), db_type, source_dialect), position )); } } "removed" => { - parts.push(format!(" DROP COLUMN {}", quote_id(&column.name, db_type))); + 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 mapped = convert_col(source); - if profile.column_ddl == ColumnDdlSyntax::SqlServer { - let name = quote_id(&column.name, db_type); - let definition_changed = column - .changes - .iter() - .any(|change| change.starts_with("type:") || change.starts_with("nullable:")); - let default_changed = - column.changes.iter().any(|change| change.starts_with("default:")); - let had_default = sqlserver_has_default(column.target.as_ref()); - - if definition_changed && had_default && !default_changed { - standalone_statements.push(sqlserver_alter_column_preserving_default_sql( + if db_type == DatabaseType::SqlServer { + if let Some(target_col) = &column.target { + standalone_statements.extend(sqlserver_column_change_statements( &table, &column.name, - &mapped, + source, + target_col, + &mapped.data_type, + source_dialect, + schema, )); - } else { - if definition_changed && had_default { - standalone_statements - .push(sqlserver_drop_default_constraint_sql(&table, &column.name)); - } - if definition_changed { - let nullability = if source.is_nullable { "NULL" } else { "NOT NULL" }; - parts.push(format!(" ALTER COLUMN {name} {} {nullability}", mapped.data_type)); - } - } - - if default_changed { - if had_default && !definition_changed { - standalone_statements - .push(sqlserver_drop_default_constraint_sql(&table, &column.name)); - } - if let Some(default) = &source.column_default { - parts.push(format!( - " ADD DEFAULT {} FOR {name}", - default_literal( - default, - &mapped.data_type, - effective_source_dialect(source_dialect, db_type), - source.extra.as_deref() - ) - )); - } } } else if profile.alter_uses_modify_column { if column.changes.iter().any(|change| !change.starts_with("order:")) { @@ -4402,6 +5160,15 @@ fn generate_schema_sync_sql_inner( 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, + )); } } } @@ -4432,7 +5199,7 @@ fn generate_schema_sync_sql_inner( for column in columns { if let Some(source) = &column.source { if column.changes.iter().any(|change| change.starts_with("comment:")) { - lines.push(column_comment_sql( + lines.extend(column_comment_sql( &diff.name, &column.name, source.comment.as_deref().unwrap_or_default(), @@ -4442,12 +5209,12 @@ fn generate_schema_sync_sql_inner( } if column.diff_type == "added" { if let Some(comment) = &source.comment { - lines.push(column_comment_sql(&diff.name, &column.name, comment, db_type, schema)); + lines.extend(column_comment_sql(&diff.name, &column.name, comment, db_type, schema)); } } if column.diff_type == "renamed" { if let Some(comment) = &source.comment { - lines.push(column_comment_sql(&diff.name, &column.name, comment, db_type, schema)); + lines.extend(column_comment_sql(&diff.name, &column.name, comment, db_type, schema)); } } } @@ -4457,7 +5224,7 @@ fn generate_schema_sync_sql_inner( if diff.source_table_comment.is_some() && diff.source_table_comment != diff.target_table_comment { let comment = diff.source_table_comment.as_ref().and_then(|comment| comment.as_deref()).unwrap_or_default(); - lines.push(table_comment_sql(&diff.name, comment, db_type, schema)); + lines.extend(table_comment_sql(&diff.name, comment, db_type, schema)); } if let Some(indexes) = &diff.indexes { @@ -4468,10 +5235,16 @@ fn generate_schema_sync_sql_inner( lines.push(create_index_sql(&diff.name, source, db_type, schema)); } } - "removed" => lines.push(drop_index_sql(&diff.name, &index.name, db_type, schema)), + "removed" => { + if db_type != DatabaseType::SqlServer { + lines.push(drop_index_sql(&diff.name, &index.name, db_type, schema)); + } + } "modified" => { if let Some(source) = &index.source { - lines.push(drop_index_sql(&diff.name, &index.name, db_type, schema)); + if db_type != DatabaseType::SqlServer { + lines.push(drop_index_sql(&diff.name, &index.name, db_type, schema)); + } lines.push(create_index_sql(&diff.name, source, db_type, schema)); } } @@ -4522,19 +5295,46 @@ fn generate_schema_sync_sql_inner( 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)); - let create_kw = if profile.create_function_or_replace { + 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; + } + } + 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); - lines.push(DdlDialectProfile::render_template( + 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", @@ -5045,8 +5845,8 @@ mod tests { ); let sql = gen_sql(wrap_table_diff("orders", diffs), DatabaseType::SqlServer, None); assert!(sql.contains("sp_rename"), "SQL Server uses sp_rename: {sql}"); - assert!(sql.contains("\"orders\""), "sp_rename table path: {sql}"); - assert!(!sql.contains("ALTER TABLE \"orders\" EXEC sp_rename"), "sp_rename must be standalone: {sql}"); + assert!(sql.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}"); } @@ -5073,7 +5873,7 @@ mod tests { ); assert!( - sql.contains("ALTER TABLE \"dbo\".\"inter_putaway_sub\" ADD \"manufacture_trace_code\" varchar(200);"), + 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}"); @@ -5119,7 +5919,7 @@ mod tests { } if expects_add { assert!( - sql.contains(&format!("ADD DEFAULT {} FOR \"frozen_status\"", new_default.unwrap())), + sql.contains(&format!("ADD DEFAULT {} FOR [frozen_status]", new_default.unwrap())), "default add: {sql}" ); } else { @@ -5161,12 +5961,12 @@ mod tests { ); assert!( - sql.contains("ALTER TABLE \"billing\".\"payments\" ALTER COLUMN \"amount\" bigint NOT NULL"), + 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(" 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); @@ -5249,8 +6049,951 @@ mod tests { 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}"); + 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(), + 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(), + ..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(), + 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(), + }; + let diff = TableDiff { + diff_type: "modified".into(), + object_type: Some("table".into()), + name: "events".into(), + 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(), + }; + 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}"); + } + + #[tokio::test] + #[ignore = "requires DBX_LIVE_SQLSERVER_HOST/PORT/USER/PASSWORD pointing at a writable SQL Server database"] + async fn live_sqlserver_alter_column_preserves_unchanged_runtime_dependencies() { + let database = std::env::var("DBX_LIVE_SQLSERVER_DATABASE").unwrap_or_else(|_| "tempdb".to_string()); + let host = std::env::var("DBX_LIVE_SQLSERVER_HOST").expect("DBX_LIVE_SQLSERVER_HOST"); + let port = std::env::var("DBX_LIVE_SQLSERVER_PORT") + .expect("DBX_LIVE_SQLSERVER_PORT") + .parse() + .expect("valid DBX_LIVE_SQLSERVER_PORT"); + let user = std::env::var("DBX_LIVE_SQLSERVER_USER").expect("DBX_LIVE_SQLSERVER_USER"); + let password = std::env::var("DBX_LIVE_SQLSERVER_PASSWORD").expect("DBX_LIVE_SQLSERVER_PASSWORD"); + let mut client = crate::db::sqlserver::connect( + &host, + port, + &user, + &password, + Some(&database), + None, + std::time::Duration::from_secs(10), + ) + .await + .expect("connect SQL Server"); + + let suffix = uuid::Uuid::new_v4().simple().to_string(); + let lookup_table = format!("dbx_dep_lookup_{suffix}"); + let parent_table = format!("dbx_dep_parent_{suffix}"); + let child_table = format!("dbx_dep_child_{suffix}"); + let primary_key = format!("PK_dbx_dep_parent_{suffix}"); + let unique_constraint = format!("UQ_dbx_dep_parent_code_{suffix}"); + let lookup_unique_constraint = format!("UQ_dbx_dep_lookup_code_{suffix}"); + let check_constraint = format!("CK_dbx_dep_parent_values_{suffix}"); + let outbound_foreign_key = format!("FK_dbx_dep_parent_lookup_{suffix}"); + let inbound_foreign_key = format!("FK_dbx_dep_child_parent_{suffix}"); + let ordinary_index = format!("IX_dbx_dep_parent_code_{suffix}"); + let cleanup = format!( + "DROP TABLE IF EXISTS [dbo].[{child_table}];\ + DROP TABLE IF EXISTS [dbo].[{parent_table}];\ + DROP TABLE IF EXISTS [dbo].[{lookup_table}];" + ); + let _ = crate::db::sqlserver::execute_batch(&mut client, &cleanup).await; + + let exercise = async { + let setup = format!( + "SET ANSI_NULLS ON;\ + SET QUOTED_IDENTIFIER ON;\ + SET ANSI_PADDING ON;\ + SET ANSI_WARNINGS ON;\ + SET CONCAT_NULL_YIELDS_NULL ON;\ + SET ARITHABORT ON;\ + SET NUMERIC_ROUNDABORT OFF;\ + CREATE TABLE [dbo].[{lookup_table}] (\ + [code] INT NOT NULL,\ + CONSTRAINT [{lookup_unique_constraint}] UNIQUE NONCLUSTERED ([code] ASC)\ + );\ + CREATE TABLE [dbo].[{parent_table}] (\ + [id] INT NOT NULL,\ + [code] INT NOT NULL,\ + [payload] NVARCHAR(64) NULL,\ + CONSTRAINT [{primary_key}] PRIMARY KEY CLUSTERED ([id] ASC),\ + CONSTRAINT [{unique_constraint}] UNIQUE NONCLUSTERED ([code] ASC),\ + CONSTRAINT [{check_constraint}] CHECK ([id] > 0 AND [code] >= 0),\ + CONSTRAINT [{outbound_foreign_key}] FOREIGN KEY ([code])\ + REFERENCES [dbo].[{lookup_table}] ([code])\ + );\ + CREATE TABLE [dbo].[{child_table}] (\ + [id] INT NOT NULL,\ + [parent_code] INT NULL,\ + CONSTRAINT [{inbound_foreign_key}] FOREIGN KEY ([parent_code])\ + REFERENCES [dbo].[{parent_table}] ([code])\ + ON UPDATE CASCADE ON DELETE SET NULL NOT FOR REPLICATION\ + );\ + CREATE NONCLUSTERED INDEX [{ordinary_index}]\ + ON [dbo].[{parent_table}] ([code] DESC, [id] ASC)\ + INCLUDE ([payload])\ + WHERE [code] IS NOT NULL\ + WITH (PAD_INDEX = ON, FILLFACTOR = 80, STATISTICS_NORECOMPUTE = ON,\ + ALLOW_ROW_LOCKS = OFF, ALLOW_PAGE_LOCKS = ON)\ + ON [PRIMARY];" + ); + crate::db::sqlserver::execute_batch(&mut client, &setup).await?; + + let target_detail = TableSchemaDetail { + name: parent_table.clone(), + columns: crate::db::sqlserver::get_columns(&mut client, "dbo", &parent_table).await?, + indexes: crate::db::sqlserver::list_indexes(&mut client, "dbo", &parent_table).await?, + foreign_keys: crate::db::sqlserver::list_foreign_keys(&mut client, "dbo", &parent_table).await?, + triggers: Vec::new(), + ddl: None, + }; + let mut source_detail = target_detail.clone(); + let source_id = source_detail + .columns + .iter_mut() + .find(|column| column.name == "id") + .ok_or_else(|| "live target metadata did not contain id".to_string())?; + source_id.data_type = "bigint".to_string(); + source_id.numeric_precision = Some(19); + let source_code = source_detail + .columns + .iter_mut() + .find(|column| column.name == "code") + .ok_or_else(|| "live target metadata did not contain code".to_string())?; + source_code.is_nullable = true; + + let prepared = prepare_schema_diff(SchemaDiffPreparationOptions { + source_tables: vec![table_info(&parent_table, "BASE TABLE")], + target_tables: vec![table_info(&parent_table, "BASE TABLE")], + source_details: vec![source_detail], + target_details: vec![target_detail], + database_type: DatabaseType::SqlServer, + target_schema: Some("dbo".to_string()), + source_dialect: Some(DialectKind::SqlServer), + target_dialect: Some(DialectKind::SqlServer), + ..Default::default() + }); + + crate::db::sqlserver::execute_batch(&mut client, "SET QUOTED_IDENTIFIER OFF;").await?; + crate::db::sqlserver::execute_batch(&mut client, &prepared.sync_sql).await?; + let verification_sql = format!( + "DECLARE @parent_id int = OBJECT_ID(N'[dbo].[{parent_table}]');\ + SELECT\ + CONVERT(int, CASE WHEN EXISTS (\ + SELECT 1\ + FROM sys.indexes AS idx\ + JOIN sys.stats AS stats\ + ON stats.object_id = idx.object_id AND stats.stats_id = idx.index_id\ + JOIN sys.data_spaces AS data_space ON data_space.data_space_id = idx.data_space_id\ + JOIN sys.index_columns AS key_ic\ + ON key_ic.object_id = idx.object_id AND key_ic.index_id = idx.index_id\ + JOIN sys.columns AS key_column\ + ON key_column.object_id = key_ic.object_id AND key_column.column_id = key_ic.column_id\ + WHERE idx.object_id = @parent_id\ + AND idx.name = N'{ordinary_index}'\ + AND idx.type_desc = N'NONCLUSTERED'\ + AND idx.is_primary_key = 0 AND idx.is_unique_constraint = 0\ + AND idx.is_disabled = 0 AND idx.has_filter = 1\ + AND idx.is_padded = 1 AND idx.fill_factor = 80\ + AND idx.allow_row_locks = 0 AND idx.allow_page_locks = 1\ + AND stats.no_recompute = 1 AND data_space.name = N'PRIMARY'\ + AND key_column.name = N'code' AND key_ic.key_ordinal = 1\ + AND key_ic.is_descending_key = 1\ + AND CHARINDEX(N'[code]', idx.filter_definition) > 0\ + AND EXISTS (\ + SELECT 1\ + FROM sys.index_columns AS include_ic\ + JOIN sys.columns AS include_column\ + ON include_column.object_id = include_ic.object_id\ + AND include_column.column_id = include_ic.column_id\ + WHERE include_ic.object_id = idx.object_id\ + AND include_ic.index_id = idx.index_id\ + AND include_ic.is_included_column = 1\ + AND include_column.name = N'payload'\ + )\ + ) THEN 1 ELSE 0 END) AS ordinary_index_ok,\ + CONVERT(int, CASE WHEN EXISTS (\ + SELECT 1\ + FROM sys.key_constraints AS key_constraint\ + JOIN sys.indexes AS idx\ + ON idx.object_id = key_constraint.parent_object_id\ + AND idx.index_id = key_constraint.unique_index_id\ + JOIN sys.index_columns AS ic\ + ON ic.object_id = idx.object_id AND ic.index_id = idx.index_id\ + JOIN sys.columns AS column_info\ + ON column_info.object_id = ic.object_id AND column_info.column_id = ic.column_id\ + JOIN sys.types AS column_type ON column_type.user_type_id = column_info.user_type_id\ + WHERE key_constraint.parent_object_id = @parent_id\ + AND key_constraint.name = N'{primary_key}'\ + AND key_constraint.type = N'PK'\ + AND idx.is_primary_key = 1 AND idx.type_desc = N'CLUSTERED'\ + AND ic.key_ordinal = 1 AND column_info.name = N'id'\ + AND column_type.name = N'bigint' AND column_info.is_nullable = 0\ + ) THEN 1 ELSE 0 END) AS primary_key_ok,\ + CONVERT(int, CASE WHEN EXISTS (\ + SELECT 1\ + FROM sys.check_constraints AS check_constraint\ + WHERE check_constraint.parent_object_id = @parent_id\ + AND check_constraint.name = N'{check_constraint}'\ + AND check_constraint.is_disabled = 0\ + AND check_constraint.is_not_trusted = 0\ + AND CHARINDEX(N'[id]', check_constraint.definition) > 0\ + AND CHARINDEX(N'[code]', check_constraint.definition) > 0\ + ) THEN 1 ELSE 0 END) AS check_constraint_ok,\ + CONVERT(int, CASE WHEN EXISTS (\ + SELECT 1\ + FROM sys.foreign_keys AS foreign_key\ + JOIN sys.foreign_key_columns AS fkc\ + ON fkc.constraint_object_id = foreign_key.object_id\ + JOIN sys.columns AS child_column\ + ON child_column.object_id = fkc.parent_object_id\ + AND child_column.column_id = fkc.parent_column_id\ + JOIN sys.columns AS parent_column\ + ON parent_column.object_id = fkc.referenced_object_id\ + AND parent_column.column_id = fkc.referenced_column_id\ + WHERE foreign_key.name = N'{inbound_foreign_key}'\ + AND foreign_key.parent_object_id = OBJECT_ID(N'[dbo].[{child_table}]')\ + AND foreign_key.referenced_object_id = @parent_id\ + AND foreign_key.type = N'F'\ + AND foreign_key.is_disabled = 0 AND foreign_key.is_not_trusted = 1\ + AND foreign_key.is_not_for_replication = 1\ + AND foreign_key.update_referential_action_desc = N'CASCADE'\ + AND foreign_key.delete_referential_action_desc = N'SET_NULL'\ + AND child_column.name = N'parent_code' AND parent_column.name = N'code'\ + AND EXISTS (\ + SELECT 1 FROM sys.key_constraints AS uq\ + WHERE uq.parent_object_id = @parent_id\ + AND uq.name = N'{unique_constraint}' AND uq.type = N'UQ'\ + )\ + ) THEN 1 ELSE 0 END) AS inbound_foreign_key_ok,\ + CONVERT(int, CASE WHEN\ + EXISTS (\ + SELECT 1 FROM sys.columns AS column_info\ + JOIN sys.types AS column_type ON column_type.user_type_id = column_info.user_type_id\ + WHERE column_info.object_id = @parent_id AND column_info.name = N'id'\ + AND column_type.name = N'bigint' AND column_info.is_nullable = 0\ + )\ + AND EXISTS (\ + SELECT 1 FROM sys.columns AS column_info\ + JOIN sys.types AS column_type ON column_type.user_type_id = column_info.user_type_id\ + WHERE column_info.object_id = @parent_id AND column_info.name = N'code'\ + AND column_type.name = N'int' AND column_info.is_nullable = 1\ + )\ + THEN 1 ELSE 0 END) AS altered_columns_ok;" + ); + let verification = crate::db::sqlserver::execute_query(&mut client, &verification_sql).await?; + Ok::<_, String>((prepared, verification)) + } + .await; + + let cleanup_result = crate::db::sqlserver::execute_batch(&mut client, &cleanup).await; + cleanup_result.expect("drop live SQL Server dependency test tables"); + let (prepared, verification) = exercise.expect("exercise live SQL Server dependency-aware ALTER COLUMN"); + + assert_eq!(prepared.diffs.len(), 1, "diffs={:?}", prepared.diffs); + let table_diff = &prepared.diffs[0]; + assert_eq!(table_diff.diff_type, "modified"); + assert_eq!(table_diff.columns.as_ref().map(Vec::len), Some(2)); + assert!(table_diff.indexes.is_none(), "unchanged indexes must be absent: {table_diff:?}"); + assert!(table_diff.foreign_keys.is_none(), "unchanged foreign keys must be absent: {table_diff:?}"); + assert!( + !prepared.sync_sql.contains(&ordinary_index), + "index must be discovered at runtime: {}", + prepared.sync_sql + ); + assert!( + !prepared.sync_sql.contains(&outbound_foreign_key), + "foreign keys must be discovered at runtime: {}", + prepared.sync_sql + ); + assert!( + !prepared.sync_sql.contains(&inbound_foreign_key), + "inbound foreign keys are not represented by the table diff: {}", + prepared.sync_sql + ); + + assert_eq!(verification.rows.len(), 1, "verification={verification:?}"); + assert_eq!( + verification.rows[0], + vec![ + serde_json::json!(1), + serde_json::json!(1), + serde_json::json!(1), + serde_json::json!(1), + serde_json::json!(1), + ], + "columns={:?}, sync_sql={} ", + verification.columns, + prepared.sync_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(), + }; + 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(), + }; + 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] @@ -5313,7 +7056,6 @@ mod tests { #[test] fn mysql_to_unsupported_dialect_types_pass_through() { let targets = [ - DatabaseType::SqlServer, DatabaseType::ClickHouse, DatabaseType::Oracle, DatabaseType::DuckDb, @@ -5335,6 +7077,30 @@ mod tests { } } + #[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() { @@ -7388,6 +9154,7 @@ mod tests { 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] @@ -7468,6 +9235,66 @@ mod tests { 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 // ======================================================================== @@ -7721,7 +9548,7 @@ mod tests { } #[test] - fn sqlserver_add_column_omits_identity_suffix() { + 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 { @@ -7736,8 +9563,12 @@ mod tests { let sql = gen_sql(wrap_table_diff("users", vec![diff]), DatabaseType::SqlServer, Some(DialectKind::Mysql)); assert!( - !sql.to_uppercase().contains("IDENTITY("), - "SQL Server ADD COLUMN must not append an order-sensitive IDENTITY clause: {sql}" + 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}" ); } @@ -7930,6 +9761,155 @@ mod tests { 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; @@ -9439,7 +11419,7 @@ mod tests { ColumnDiff { diff_type: "added".into(), name: "id".into(), - source: Some(col_pk("id", "int")), + source: Some(ColumnInfo { extra: Some("auto_increment".into()), ..col_pk("id", "int") }), target: None, changes: vec![], add_position: None, @@ -9513,7 +11493,7 @@ mod tests { ColumnDiff { diff_type: "added".into(), name: "id".into(), - source: Some(col_pk("id", "int")), + source: Some(ColumnInfo { extra: Some("auto_increment".into()), ..col_pk("id", "int") }), target: None, changes: vec![], add_position: None, diff --git a/crates/dbx-core/src/schema_diff/sqlserver_dependencies.rs b/crates/dbx-core/src/schema_diff/sqlserver_dependencies.rs new file mode 100644 index 0000000000..9b888ab5ba --- /dev/null +++ b/crates/dbx-core/src/schema_diff/sqlserver_dependencies.rs @@ -0,0 +1,547 @@ +//! Runtime scripting for SQL Server column dependencies. +//! +//! SQL Server refuses `ALTER COLUMN` while a number of catalog objects depend +//! on the column. The schema diff model intentionally omits unchanged +//! objects, so the only complete source for those dependencies is the target +//! database itself. This helper snapshots the supported definitions before +//! dropping anything, performs the caller-provided alteration, and restores +//! the objects in dependency order. + +const DEPENDENCY_CAPTURE_AND_DROP_SQL: &str = r#" +DECLARE @dbx_qualified_table nvarchar(517) = + QUOTENAME(OBJECT_SCHEMA_NAME(@dbx_object_id)) + N'.' + QUOTENAME(OBJECT_NAME(@dbx_object_id)); + +DECLARE @dbx_unsupported_index sysname; +SELECT TOP (1) @dbx_unsupported_index = idx.name +FROM sys.indexes AS idx +WHERE idx.object_id = @dbx_object_id + AND idx.index_id > 0 + AND idx.name IS NOT NULL + AND idx.is_hypothetical = 0 + AND idx.type NOT IN (1, 2) + AND EXISTS ( + SELECT 1 + FROM sys.index_columns AS ic + WHERE ic.object_id = idx.object_id + AND ic.index_id = idx.index_id + AND ic.column_id = @dbx_column_id + ); +IF @dbx_unsupported_index IS NOT NULL +BEGIN + DECLARE @dbx_unsupported_index_message nvarchar(2048) = + N'DBX cannot safely preserve affected SQL Server index ' + QUOTENAME(@dbx_unsupported_index) + + N' because its index type is not a supported rowstore type.'; + ;THROW 50001, @dbx_unsupported_index_message, 1; +END; + +IF EXISTS ( + SELECT 1 + FROM sys.indexes AS idx + JOIN sys.data_spaces AS data_space ON data_space.data_space_id = idx.data_space_id + WHERE idx.object_id = @dbx_object_id + AND idx.type IN (1, 2) + AND idx.is_hypothetical = 0 + AND data_space.type = N'PS' + AND EXISTS ( + SELECT 1 + FROM sys.index_columns AS affected_ic + WHERE affected_ic.object_id = idx.object_id + AND affected_ic.index_id = idx.index_id + AND affected_ic.column_id = @dbx_column_id + ) + AND NOT EXISTS ( + SELECT 1 + FROM sys.index_columns AS partition_ic + WHERE partition_ic.object_id = idx.object_id + AND partition_ic.index_id = idx.index_id + AND partition_ic.partition_ordinal > 0 + ) +) +BEGIN + ;THROW 50002, 'DBX could not resolve the partition column for an affected SQL Server index.', 1; +END; + +DECLARE @dbx_started_transaction bit = 0; +DECLARE @dbx_has_savepoint bit = 0; + +BEGIN TRY + IF @@TRANCOUNT = 0 + BEGIN + BEGIN TRANSACTION; + SET @dbx_started_transaction = 1; + END + ELSE + BEGIN + SAVE TRANSACTION dbx_alter_column_dependencies; + SET @dbx_has_savepoint = 1; + END; + + DECLARE @dbx_dependencies TABLE ( + dependency_id int IDENTITY(1, 1) NOT NULL, + drop_order tinyint NOT NULL, + create_order tinyint NOT NULL, + drop_sql nvarchar(max) NOT NULL, + create_sql nvarchar(max) NOT NULL + ); + + -- Foreign keys are captured first and dropped before key constraints. The + -- predicate covers outgoing, inbound, composite, and self-referencing FKs. + INSERT INTO @dbx_dependencies (drop_order, create_order, drop_sql, create_sql) + SELECT + 10, + 50, + N'ALTER TABLE ' + QUOTENAME(OBJECT_SCHEMA_NAME(fk.parent_object_id)) + N'.' + + QUOTENAME(OBJECT_NAME(fk.parent_object_id)) + N' DROP CONSTRAINT ' + QUOTENAME(fk.name) + N';', + N'ALTER TABLE ' + QUOTENAME(OBJECT_SCHEMA_NAME(fk.parent_object_id)) + N'.' + + QUOTENAME(OBJECT_NAME(fk.parent_object_id)) + + CASE WHEN fk.is_not_trusted = 1 THEN N' WITH NOCHECK' ELSE N' WITH CHECK' END + + N' ADD CONSTRAINT ' + QUOTENAME(fk.name) + N' FOREIGN KEY (' + + STUFF(( + SELECT N', ' + QUOTENAME(parent_column.name) + FROM sys.foreign_key_columns AS fkc + JOIN sys.columns AS parent_column + ON parent_column.object_id = fkc.parent_object_id + AND parent_column.column_id = fkc.parent_column_id + WHERE fkc.constraint_object_id = fk.object_id + ORDER BY fkc.constraint_column_id + FOR XML PATH(''), TYPE + ).value('.', 'nvarchar(max)'), 1, 2, N'') + N') REFERENCES ' + + QUOTENAME(OBJECT_SCHEMA_NAME(fk.referenced_object_id)) + N'.' + + QUOTENAME(OBJECT_NAME(fk.referenced_object_id)) + N' (' + + STUFF(( + SELECT N', ' + QUOTENAME(referenced_column.name) + FROM sys.foreign_key_columns AS fkc + JOIN sys.columns AS referenced_column + ON referenced_column.object_id = fkc.referenced_object_id + AND referenced_column.column_id = fkc.referenced_column_id + WHERE fkc.constraint_object_id = fk.object_id + ORDER BY fkc.constraint_column_id + FOR XML PATH(''), TYPE + ).value('.', 'nvarchar(max)'), 1, 2, N'') + N')' + + CASE WHEN fk.delete_referential_action_desc = N'NO_ACTION' THEN N'' + ELSE N' ON DELETE ' + REPLACE(fk.delete_referential_action_desc, N'_', N' ') END + + CASE WHEN fk.update_referential_action_desc = N'NO_ACTION' THEN N'' + ELSE N' ON UPDATE ' + REPLACE(fk.update_referential_action_desc, N'_', N' ') END + + CASE WHEN fk.is_not_for_replication = 1 THEN N' NOT FOR REPLICATION' ELSE N'' END + N'; ' + + N'ALTER TABLE ' + QUOTENAME(OBJECT_SCHEMA_NAME(fk.parent_object_id)) + N'.' + + QUOTENAME(OBJECT_NAME(fk.parent_object_id)) + + CASE WHEN fk.is_disabled = 1 THEN N' NOCHECK' ELSE N' CHECK' END + + N' CONSTRAINT ' + QUOTENAME(fk.name) + N';' + FROM sys.foreign_keys AS fk + WHERE EXISTS ( + SELECT 1 + FROM sys.foreign_key_columns AS fkc + WHERE fkc.constraint_object_id = fk.object_id + AND ((fkc.parent_object_id = @dbx_object_id AND fkc.parent_column_id = @dbx_column_id) + OR (fkc.referenced_object_id = @dbx_object_id AND fkc.referenced_column_id = @dbx_column_id)) + ); + + -- parent_column_id identifies column-level checks. Table-level checks use + -- expression dependencies to avoid dropping unrelated constraints. + INSERT INTO @dbx_dependencies (drop_order, create_order, drop_sql, create_sql) + SELECT + 20, + 40, + N'ALTER TABLE ' + @dbx_qualified_table + N' DROP CONSTRAINT ' + QUOTENAME(cc.name) + N';', + N'ALTER TABLE ' + @dbx_qualified_table + + CASE WHEN cc.is_not_trusted = 1 THEN N' WITH NOCHECK' ELSE N' WITH CHECK' END + + N' ADD CONSTRAINT ' + QUOTENAME(cc.name) + N' CHECK ' + + CASE WHEN cc.is_not_for_replication = 1 THEN N'NOT FOR REPLICATION ' ELSE N'' END + + cc.definition + N'; ALTER TABLE ' + @dbx_qualified_table + + CASE WHEN cc.is_disabled = 1 THEN N' NOCHECK' ELSE N' CHECK' END + + N' CONSTRAINT ' + QUOTENAME(cc.name) + N';' + FROM sys.check_constraints AS cc + WHERE cc.parent_object_id = @dbx_object_id + AND (cc.parent_column_id = @dbx_column_id OR EXISTS ( + SELECT 1 + FROM sys.sql_expression_dependencies AS sed + WHERE sed.referencing_id = cc.object_id + AND sed.referenced_id = @dbx_object_id + AND sed.referenced_minor_id = @dbx_column_id + )); + + -- Index-owned statistics disappear with their index. Only independent, + -- user-created statistics need an explicit drop and recreation. + INSERT INTO @dbx_dependencies (drop_order, create_order, drop_sql, create_sql) + SELECT + 30, + 30, + N'DROP STATISTICS ' + @dbx_qualified_table + N'.' + QUOTENAME(stats.name) + N';', + N'CREATE STATISTICS ' + QUOTENAME(stats.name) + N' ON ' + @dbx_qualified_table + N' (' + + STUFF(( + SELECT N', ' + QUOTENAME(stats_column.name) + FROM sys.stats_columns AS sc + JOIN sys.columns AS stats_column + ON stats_column.object_id = sc.object_id + AND stats_column.column_id = sc.column_id + WHERE sc.object_id = stats.object_id + AND sc.stats_id = stats.stats_id + ORDER BY sc.stats_column_id + FOR XML PATH(''), TYPE + ).value('.', 'nvarchar(max)'), 1, 2, N'') + N')' + + CASE WHEN stats.has_filter = 1 THEN N' WHERE ' + stats.filter_definition ELSE N'' END + + CASE WHEN stats.no_recompute = 1 THEN N' WITH NORECOMPUTE' ELSE N'' END + N';' + FROM sys.stats AS stats + WHERE stats.object_id = @dbx_object_id + AND stats.user_created = 1 + AND EXISTS ( + SELECT 1 + FROM sys.stats_columns AS sc + WHERE sc.object_id = stats.object_id + AND sc.stats_id = stats.stats_id + AND sc.column_id = @dbx_column_id + ) + AND NOT EXISTS ( + SELECT 1 + FROM sys.indexes AS matching_index + WHERE matching_index.object_id = stats.object_id + AND matching_index.index_id = stats.stats_id + ); + + -- Ordinary clustered/nonclustered rowstore indexes. Explicit key order, + -- INCLUDE columns, filters, durable catalog options, data space, and the + -- disabled state are reconstructed from the live target catalog. + INSERT INTO @dbx_dependencies (drop_order, create_order, drop_sql, create_sql) + SELECT + 40, + 20, + N'DROP INDEX ' + QUOTENAME(idx.name) + N' ON ' + @dbx_qualified_table + N';', + N'CREATE ' + CASE WHEN idx.is_unique = 1 THEN N'UNIQUE ' ELSE N'' END + idx.type_desc + + N' INDEX ' + QUOTENAME(idx.name) + N' ON ' + @dbx_qualified_table + N' (' + + STUFF(( + SELECT N', ' + QUOTENAME(index_column.name) + + CASE WHEN ic.is_descending_key = 1 THEN N' DESC' ELSE N' ASC' END + FROM sys.index_columns AS ic + JOIN sys.columns AS index_column + ON index_column.object_id = ic.object_id + AND index_column.column_id = ic.column_id + WHERE ic.object_id = idx.object_id + AND ic.index_id = idx.index_id + AND ic.key_ordinal > 0 + ORDER BY ic.key_ordinal + FOR XML PATH(''), TYPE + ).value('.', 'nvarchar(max)'), 1, 2, N'') + N')' + + CASE WHEN EXISTS ( + SELECT 1 + FROM sys.index_columns AS include_check + WHERE include_check.object_id = idx.object_id + AND include_check.index_id = idx.index_id + AND include_check.is_included_column = 1 + ) THEN N' INCLUDE (' + STUFF(( + SELECT N', ' + QUOTENAME(include_column.name) + FROM sys.index_columns AS include_ic + JOIN sys.columns AS include_column + ON include_column.object_id = include_ic.object_id + AND include_column.column_id = include_ic.column_id + WHERE include_ic.object_id = idx.object_id + AND include_ic.index_id = idx.index_id + AND include_ic.is_included_column = 1 + ORDER BY include_ic.index_column_id + FOR XML PATH(''), TYPE + ).value('.', 'nvarchar(max)'), 1, 2, N'') + N')' ELSE N'' END + + CASE WHEN idx.has_filter = 1 THEN N' WHERE ' + idx.filter_definition ELSE N'' END + + N' WITH (PAD_INDEX = ' + CASE WHEN idx.is_padded = 1 THEN N'ON' ELSE N'OFF' END + + CASE WHEN idx.fill_factor > 0 + THEN N', FILLFACTOR = ' + CONVERT(nvarchar(3), idx.fill_factor) + ELSE N'' END + + N', IGNORE_DUP_KEY = ' + CASE WHEN idx.ignore_dup_key = 1 THEN N'ON' ELSE N'OFF' END + + N', STATISTICS_NORECOMPUTE = ' + CASE WHEN index_stats.no_recompute = 1 THEN N'ON' ELSE N'OFF' END + + N', ALLOW_ROW_LOCKS = ' + CASE WHEN idx.allow_row_locks = 1 THEN N'ON' ELSE N'OFF' END + + N', ALLOW_PAGE_LOCKS = ' + CASE WHEN idx.allow_page_locks = 1 THEN N'ON' ELSE N'OFF' END + N')' + + CASE WHEN data_space.type = N'FG' THEN N' ON ' + QUOTENAME(data_space.name) + WHEN data_space.type = N'PS' THEN N' ON ' + QUOTENAME(data_space.name) + N' (' + + QUOTENAME(( + SELECT TOP (1) partition_column.name + FROM sys.index_columns AS partition_ic + JOIN sys.columns AS partition_column + ON partition_column.object_id = partition_ic.object_id + AND partition_column.column_id = partition_ic.column_id + WHERE partition_ic.object_id = idx.object_id + AND partition_ic.index_id = idx.index_id + AND partition_ic.partition_ordinal > 0 + ORDER BY partition_ic.partition_ordinal + )) + N')' + ELSE N'' END + N';' + + CASE WHEN idx.is_disabled = 1 THEN N' ALTER INDEX ' + QUOTENAME(idx.name) + + N' ON ' + @dbx_qualified_table + N' DISABLE;' ELSE N'' END + FROM sys.indexes AS idx + LEFT JOIN sys.stats AS index_stats + ON index_stats.object_id = idx.object_id + AND index_stats.stats_id = idx.index_id + LEFT JOIN sys.data_spaces AS data_space ON data_space.data_space_id = idx.data_space_id + WHERE idx.object_id = @dbx_object_id + AND idx.index_id > 0 + AND idx.name IS NOT NULL + AND idx.type IN (1, 2) + AND idx.is_hypothetical = 0 + AND idx.is_primary_key = 0 + AND idx.is_unique_constraint = 0 + AND EXISTS ( + SELECT 1 + FROM sys.index_columns AS affected_ic + WHERE affected_ic.object_id = idx.object_id + AND affected_ic.index_id = idx.index_id + AND affected_ic.column_id = @dbx_column_id + ); + + -- PRIMARY KEY and UNIQUE constraints must be dropped/created as constraints, + -- never as ordinary unique indexes. + INSERT INTO @dbx_dependencies (drop_order, create_order, drop_sql, create_sql) + SELECT + 50, + 10, + N'ALTER TABLE ' + @dbx_qualified_table + N' DROP CONSTRAINT ' + QUOTENAME(key_constraint.name) + N';', + N'ALTER TABLE ' + @dbx_qualified_table + N' ADD CONSTRAINT ' + QUOTENAME(key_constraint.name) + N' ' + + CASE WHEN key_constraint.type = N'PK' THEN N'PRIMARY KEY ' ELSE N'UNIQUE ' END + + idx.type_desc + N' (' + + STUFF(( + SELECT N', ' + QUOTENAME(key_column.name) + + CASE WHEN ic.is_descending_key = 1 THEN N' DESC' ELSE N' ASC' END + FROM sys.index_columns AS ic + JOIN sys.columns AS key_column + ON key_column.object_id = ic.object_id + AND key_column.column_id = ic.column_id + WHERE ic.object_id = idx.object_id + AND ic.index_id = idx.index_id + AND ic.key_ordinal > 0 + ORDER BY ic.key_ordinal + FOR XML PATH(''), TYPE + ).value('.', 'nvarchar(max)'), 1, 2, N'') + N')' + + N' WITH (PAD_INDEX = ' + CASE WHEN idx.is_padded = 1 THEN N'ON' ELSE N'OFF' END + + CASE WHEN idx.fill_factor > 0 + THEN N', FILLFACTOR = ' + CONVERT(nvarchar(3), idx.fill_factor) + ELSE N'' END + + N', IGNORE_DUP_KEY = ' + CASE WHEN idx.ignore_dup_key = 1 THEN N'ON' ELSE N'OFF' END + + N', STATISTICS_NORECOMPUTE = ' + CASE WHEN key_stats.no_recompute = 1 THEN N'ON' ELSE N'OFF' END + + N', ALLOW_ROW_LOCKS = ' + CASE WHEN idx.allow_row_locks = 1 THEN N'ON' ELSE N'OFF' END + + N', ALLOW_PAGE_LOCKS = ' + CASE WHEN idx.allow_page_locks = 1 THEN N'ON' ELSE N'OFF' END + N')' + + CASE WHEN data_space.type = N'FG' THEN N' ON ' + QUOTENAME(data_space.name) + WHEN data_space.type = N'PS' THEN N' ON ' + QUOTENAME(data_space.name) + N' (' + + QUOTENAME(( + SELECT TOP (1) partition_column.name + FROM sys.index_columns AS partition_ic + JOIN sys.columns AS partition_column + ON partition_column.object_id = partition_ic.object_id + AND partition_column.column_id = partition_ic.column_id + WHERE partition_ic.object_id = idx.object_id + AND partition_ic.index_id = idx.index_id + AND partition_ic.partition_ordinal > 0 + ORDER BY partition_ic.partition_ordinal + )) + N')' + ELSE N'' END + N';' + + CASE WHEN idx.is_disabled = 1 THEN N' ALTER INDEX ' + QUOTENAME(idx.name) + + N' ON ' + @dbx_qualified_table + N' DISABLE;' ELSE N'' END + FROM sys.key_constraints AS key_constraint + JOIN sys.indexes AS idx + ON idx.object_id = key_constraint.parent_object_id + AND idx.index_id = key_constraint.unique_index_id + LEFT JOIN sys.stats AS key_stats + ON key_stats.object_id = idx.object_id + AND key_stats.stats_id = idx.index_id + LEFT JOIN sys.data_spaces AS data_space ON data_space.data_space_id = idx.data_space_id + WHERE key_constraint.parent_object_id = @dbx_object_id + AND EXISTS ( + SELECT 1 + FROM sys.index_columns AS affected_ic + WHERE affected_ic.object_id = idx.object_id + AND affected_ic.index_id = idx.index_id + AND affected_ic.column_id = @dbx_column_id + ); + + DECLARE @dbx_dependency_sql nvarchar(max); + DECLARE dbx_dependency_drop_cursor CURSOR LOCAL FAST_FORWARD FOR + SELECT drop_sql + FROM @dbx_dependencies + ORDER BY drop_order, dependency_id; + OPEN dbx_dependency_drop_cursor; + FETCH NEXT FROM dbx_dependency_drop_cursor INTO @dbx_dependency_sql; + WHILE @@FETCH_STATUS = 0 + BEGIN + EXEC sys.sp_executesql @dbx_dependency_sql; + FETCH NEXT FROM dbx_dependency_drop_cursor INTO @dbx_dependency_sql; + END; + CLOSE dbx_dependency_drop_cursor; + DEALLOCATE dbx_dependency_drop_cursor; +"#; + +const DEPENDENCY_RECREATE_AND_FINISH_SQL: &str = r#" + DECLARE dbx_dependency_create_cursor CURSOR LOCAL FAST_FORWARD FOR + SELECT create_sql + FROM @dbx_dependencies + ORDER BY create_order, dependency_id; + OPEN dbx_dependency_create_cursor; + FETCH NEXT FROM dbx_dependency_create_cursor INTO @dbx_dependency_sql; + WHILE @@FETCH_STATUS = 0 + BEGIN + EXEC sys.sp_executesql @dbx_dependency_sql; + FETCH NEXT FROM dbx_dependency_create_cursor INTO @dbx_dependency_sql; + END; + CLOSE dbx_dependency_create_cursor; + DEALLOCATE dbx_dependency_create_cursor; + + IF @dbx_started_transaction = 1 + COMMIT TRANSACTION; +END TRY +BEGIN CATCH + IF CURSOR_STATUS('local', 'dbx_dependency_drop_cursor') >= 0 + CLOSE dbx_dependency_drop_cursor; + IF CURSOR_STATUS('local', 'dbx_dependency_drop_cursor') >= -1 + DEALLOCATE dbx_dependency_drop_cursor; + IF CURSOR_STATUS('local', 'dbx_dependency_create_cursor') >= 0 + CLOSE dbx_dependency_create_cursor; + IF CURSOR_STATUS('local', 'dbx_dependency_create_cursor') >= -1 + DEALLOCATE dbx_dependency_create_cursor; + + IF @dbx_started_transaction = 1 AND XACT_STATE() <> 0 + ROLLBACK TRANSACTION; + ELSE IF @dbx_has_savepoint = 1 AND XACT_STATE() = 1 + ROLLBACK TRANSACTION dbx_alter_column_dependencies; + + THROW; +END CATCH; +"#; + +fn escape_tsql_string_literal(value: &str) -> String { + value.replace('\'', "''") +} + +/// Build one executable T-SQL batch that preserves live dependencies around an +/// `ALTER COLUMN` operation. +/// +/// `table` is the already-quoted target table name used by generated DDL (for +/// example `[dbo].[orders]`). `column_name` is the unquoted catalog name. +/// `alter_batch` may contain more than one statement, such as the existing +/// default-constraint preservation batch followed by `ALTER COLUMN`. +pub(super) fn build_dependency_aware_alter_column_batch(table: &str, column_name: &str, alter_batch: &str) -> String { + let table_literal = escape_tsql_string_literal(table); + let column_literal = escape_tsql_string_literal(column_name); + let alter_batch = alter_batch.trim(); + + let mut batch = String::with_capacity( + DEPENDENCY_CAPTURE_AND_DROP_SQL.len() + DEPENDENCY_RECREATE_AND_FINISH_SQL.len() + alter_batch.len() + 512, + ); + batch.push_str( + "SET ANSI_NULLS ON;\n\ + SET QUOTED_IDENTIFIER ON;\n\ + SET ANSI_PADDING ON;\n\ + SET ANSI_WARNINGS ON;\n\ + SET CONCAT_NULL_YIELDS_NULL ON;\n\ + SET ARITHABORT ON;\n\ + SET NUMERIC_ROUNDABORT OFF;\n\ + DECLARE @dbx_object_id int = OBJECT_ID(N'", + ); + batch.push_str(&table_literal); + batch.push_str("');\nDECLARE @dbx_column_id int = COLUMNPROPERTY(@dbx_object_id, N'"); + batch.push_str(&column_literal); + batch.push_str("', 'ColumnId');\nIF @dbx_object_id IS NULL OR @dbx_column_id IS NULL\n"); + batch.push_str(" THROW 50000, 'DBX could not resolve the SQL Server column before ALTER COLUMN.', 1;\n"); + batch.push_str(DEPENDENCY_CAPTURE_AND_DROP_SQL); + batch.push('\n'); + batch.push_str(" "); + batch.push_str(alter_batch); + if !alter_batch.ends_with(';') { + batch.push(';'); + } + batch.push('\n'); + batch.push_str(DEPENDENCY_RECREATE_AND_FINISH_SQL); + batch +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dependency_batch_escapes_catalog_literals_and_keeps_alter_sql() { + let sql = build_dependency_aware_alter_column_batch( + "[odd].[O'Brien]", + "amount's value", + "ALTER TABLE [odd].[O'Brien] ALTER COLUMN [amount's value] bigint NULL;", + ); + + assert!(sql.contains("OBJECT_ID(N'[odd].[O''Brien]')"), "{sql}"); + assert!(sql.contains("COLUMNPROPERTY(@dbx_object_id, N'amount''s value', 'ColumnId')"), "{sql}"); + assert!(sql.contains("ALTER TABLE [odd].[O'Brien] ALTER COLUMN [amount's value] bigint NULL;"), "{sql}"); + } + + #[test] + fn dependency_batch_covers_every_supported_dependency_kind() { + let sql = build_dependency_aware_alter_column_batch( + "[dbo].[orders]", + "amount", + "ALTER TABLE [dbo].[orders] ALTER COLUMN [amount] bigint NOT NULL;", + ); + + for catalog in [ + "sys.foreign_keys AS fk", + "sys.foreign_key_columns AS fkc", + "sys.check_constraints AS cc", + "sys.sql_expression_dependencies AS sed", + "sys.stats_columns AS sc", + "sys.indexes AS idx", + "sys.key_constraints AS key_constraint", + ] { + assert!(sql.contains(catalog), "missing {catalog}: {sql}"); + } + + assert!(sql.contains("fkc.referenced_object_id = @dbx_object_id"), "inbound FK predicate: {sql}"); + assert!(sql.contains("ORDER BY fkc.constraint_column_id"), "composite FK order: {sql}"); + assert!(sql.contains("fk.delete_referential_action_desc"), "delete action: {sql}"); + assert!(sql.contains("fk.update_referential_action_desc"), "update action: {sql}"); + assert!(sql.contains("fk.is_not_for_replication"), "NFR state: {sql}"); + assert!(sql.contains("fk.is_not_trusted"), "trust state: {sql}"); + assert!(sql.contains("fk.is_disabled"), "enabled state: {sql}"); + assert!(sql.contains("stats.user_created = 1"), "independent statistics: {sql}"); + assert!(sql.contains("stats.no_recompute"), "statistics options: {sql}"); + assert!(sql.contains("ic.is_descending_key"), "index key order: {sql}"); + assert!(sql.contains("include_ic.is_included_column = 1"), "included columns: {sql}"); + assert!(sql.contains("idx.filter_definition"), "filtered index: {sql}"); + assert!(sql.contains("idx.allow_row_locks"), "index options: {sql}"); + assert!(sql.contains("data_space.type = N'PS'"), "partition data space: {sql}"); + assert!(sql.contains("idx.is_disabled = 1"), "disabled index state: {sql}"); + assert!(sql.contains("key_constraint.type = N'PK'"), "primary key form: {sql}"); + assert!(sql.contains("THEN N'PRIMARY KEY ' ELSE N'UNIQUE ' END"), "PK/UQ distinction: {sql}"); + } + + #[test] + fn dependency_batch_rejects_unsupported_indexes_before_dropping_anything() { + let sql = build_dependency_aware_alter_column_batch( + "[dbo].[events]", + "payload", + "ALTER TABLE [dbo].[events] ALTER COLUMN [payload] varbinary(max) NULL;", + ); + + let guard = sql.find("idx.type NOT IN (1, 2)").expect("unsupported-index guard"); + let rejection = sql.find("THROW 50001").expect("unsupported-index THROW"); + let drop_cursor = sql.find("dbx_dependency_drop_cursor CURSOR").expect("drop cursor"); + assert!(guard < rejection && rejection < drop_cursor, "guard must run before every dependency drop: {sql}"); + } + + #[test] + fn dependency_batch_is_atomic_and_recreates_after_the_alter() { + let alter = "ALTER TABLE [dbo].[metrics] ALTER COLUMN [value] decimal(18,2) NOT NULL;"; + let sql = build_dependency_aware_alter_column_batch("[dbo].[metrics]", "value", alter); + + let drop_cursor = sql.find("dbx_dependency_drop_cursor CURSOR").expect("drop cursor"); + let alter_position = sql.find(alter).expect("alter statement"); + let create_cursor = sql.find("dbx_dependency_create_cursor CURSOR").expect("create cursor"); + assert!(drop_cursor < alter_position && alter_position < create_cursor, "drop/alter/create order: {sql}"); + assert!(sql.contains("BEGIN TRY"), "{sql}"); + assert!(sql.starts_with("SET ANSI_NULLS ON;\nSET QUOTED_IDENTIFIER ON;"), "{sql}"); + assert!(sql.contains("SAVE TRANSACTION dbx_alter_column_dependencies"), "{sql}"); + assert!(sql.contains("ROLLBACK TRANSACTION dbx_alter_column_dependencies"), "{sql}"); + assert!(sql.contains("IF @dbx_started_transaction = 1\n COMMIT TRANSACTION"), "{sql}"); + assert!(sql.contains("THROW;\nEND CATCH"), "{sql}"); + } + + #[test] + fn dependency_batch_terminates_an_unterminated_alter_batch_once() { + let sql = build_dependency_aware_alter_column_batch( + "[dbo].[metrics]", + "value", + "ALTER TABLE [dbo].[metrics] ALTER COLUMN [value] bigint NULL", + ); + assert!(sql.contains("ALTER TABLE [dbo].[metrics] ALTER COLUMN [value] bigint NULL;\n"), "{sql}"); + assert!(!sql.contains("bigint NULL;;"), "{sql}"); + } +} diff --git a/crates/dbx-core/src/script_generator.rs b/crates/dbx-core/src/script_generator.rs index 850073a56d..5154f173ec 100644 --- a/crates/dbx-core/src/script_generator.rs +++ b/crates/dbx-core/src/script_generator.rs @@ -381,19 +381,45 @@ fn upper_first_word(s: &str) -> &str { trimmed.split_whitespace().next().unwrap_or("") } +fn sqlserver_single_statement_batch(batch: &str) -> String { + format!("EXEC sys.sp_executesql N'{}';", batch.trim().trim_end_matches(';').replace('\'', "''")) +} + +/// `CREATE TABLE` variants that still create a table object and must keep +/// idempotent wrapping: SQLite `CREATE TEMP TABLE`, PostgreSQL +/// `GLOBAL/LOCAL TEMPORARY` and `UNLOGGED` forms, MySQL/MariaDB +/// `TEMPORARY`. None of the entries is a prefix of another. +const CREATE_TABLE_VARIANTS: &[&str] = &[ + "CREATE GLOBAL TEMPORARY TABLE ", + "CREATE LOCAL TEMPORARY TABLE ", + "CREATE TEMPORARY TABLE ", + "CREATE TEMP TABLE ", + "CREATE UNLOGGED TABLE ", + "CREATE TABLE ", +]; + +fn create_table_variant_prefix(upper: &str) -> Option<&'static str> { + CREATE_TABLE_VARIANTS.iter().copied().find(|prefix| upper.starts_with(prefix)) +} + fn wrap_if_not_exists(sql: &str, db_type: DatabaseType) -> String { use crate::sql_dialect::ddl_profile::profile_for; let profile = profile_for(db_type); let upper = sql.trim_start().to_uppercase(); - if upper.starts_with("CREATE TABLE") { + if let Some(variant) = create_table_variant_prefix(&upper) { if !profile.create_table_if_not_exists { return sql.to_string(); } if upper.contains("IF NOT EXISTS") { return sql.to_string(); } - let idx = sql.find("CREATE TABLE").unwrap_or(0) + "CREATE TABLE".len(); + let keyword = variant.trim_end(); + let Some(idx) = sql.find(keyword).map(|pos| pos + keyword.len()) else { + // Case-mismatched generated SQL: leave it unwrapped rather than + // emitting IF NOT EXISTS at an invalid position. + return sql.to_string(); + }; let (prefix, suffix) = sql.split_at(idx); format!("{prefix} IF NOT EXISTS{suffix}") } else if upper.starts_with("CREATE UNIQUE INDEX") || upper.starts_with("CREATE INDEX") { @@ -474,7 +500,7 @@ fn wrap_conditional_check(sql: &str, db_type: DatabaseType) -> String { let upper = trimmed.to_uppercase(); let first_word = upper_first_word(trimmed); - if first_word == "CREATE" && upper.contains("TABLE") { + if first_word == "CREATE" && create_table_variant_prefix(&upper).is_some() { // Prefer native IF NOT EXISTS — never append bare DDL after a SELECT CASE // (SELECT does not execute the string payload). if profile.create_table_if_not_exists { @@ -513,12 +539,25 @@ fn wrap_conditional_check(sql: &str, db_type: DatabaseType) -> String { "BEGIN\n EXECUTE IMMEDIATE '{ddl_literal}';\nEXCEPTION\n WHEN OTHERS THEN\n IF SQLCODE != -955 THEN RAISE; END IF;\nEND;" ) } + DatabaseType::SqlServer => sqlserver_single_statement_batch(&format!( + "IF OBJECT_ID(N'{}', N'U') IS NULL BEGIN {trimmed}; END;", + table_identifier.replace('\'', "''") + )), _ => { format!("-- ConditionalCheck: run only if table {table_name} does not exist\n{trimmed};") } }; } + if db_type == DatabaseType::SqlServer && first_word == "CREATE" && upper.contains(" INDEX ") { + let index_name = unquote_sqlserver_identifier(extract_object_identifier(trimmed, "INDEX")).replace('\'', "''"); + let table_identifier = extract_object_identifier(trimmed, "ON"); + let table_literal = table_identifier.replace('\'', "''"); + return sqlserver_single_statement_batch(&format!( + "IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'{table_literal}') AND name = N'{index_name}') BEGIN {trimmed}; END;" + )); + } + if first_word == "DROP" { let rebuilt = format!("{trimmed};"); let guarded = wrap_if_not_exists(&rebuilt, db_type); @@ -552,11 +591,30 @@ fn extract_object_identifier<'a>(sql: &'a str, keyword: &str) -> &'a str { let mut quote = None; let mut end = after.len(); - for (index, ch) in after.char_indices() { + let mut chars = after.char_indices().peekable(); + while let Some((index, ch)) = chars.next() { match quote { - Some('"') if ch == '"' => quote = None, - Some('`') if ch == '`' => quote = None, - Some(']') if ch == ']' => quote = None, + Some('"') if ch == '"' => { + if chars.peek().is_some_and(|(_, next)| *next == '"') { + chars.next(); + } else { + quote = None; + } + } + Some('`') if ch == '`' => { + if chars.peek().is_some_and(|(_, next)| *next == '`') { + chars.next(); + } else { + quote = None; + } + } + Some(']') if ch == ']' => { + if chars.peek().is_some_and(|(_, next)| *next == ']') { + chars.next(); + } else { + quote = None; + } + } Some(_) => {} None if ch == '"' || ch == '`' => quote = Some(ch), None if ch == '[' => quote = Some(']'), @@ -577,6 +635,17 @@ fn extract_table_name<'a>(sql: &'a str, keyword: &str) -> &'a str { extract_object_identifier(sql, keyword).trim_matches('"').trim_matches('`').trim_matches('[').trim_matches(']') } +fn unquote_sqlserver_identifier(identifier: &str) -> String { + let trimmed = identifier.trim(); + if let Some(inner) = trimmed.strip_prefix('[').and_then(|value| value.strip_suffix(']')) { + inner.replace("]]", "]") + } else if let Some(inner) = trimmed.strip_prefix('"').and_then(|value| value.strip_suffix('"')) { + inner.replace("\"\"", "\"") + } else { + trimmed.to_string() + } +} + fn postgres_table_identity(identifier: &str) -> (Option, String) { let mut quote = false; let mut separator = None; @@ -1476,6 +1545,15 @@ mod tests { assert_eq!(strategy, IdempotentStrategy::CreateOrReplace); } + #[test] + fn sqlserver_selects_catalog_based_conditional_strategy() { + let descriptor = DialectCapabilityDescriptor::for_dialect(DialectKind::SqlServer); + assert_eq!( + select_strategy(Some(DialectKind::SqlServer), Some(&descriptor)), + IdempotentStrategy::ConditionalCheck + ); + } + #[test] fn select_strategy_conditional() { let desc = DialectCapabilityDescriptor { flags: 0, ..Default::default() }; @@ -1497,6 +1575,21 @@ mod tests { assert!(result.contains("CREATE TABLE IF NOT EXISTS"), "Got: {result}"); } + #[test] + fn idempotent_create_temp_table_if_not_exists_sqlite() { + let sql = "CREATE TEMP TABLE sessions (token TEXT);"; + let result = apply_idempotent_strategy(sql, DatabaseType::Sqlite, IdempotentStrategy::IfNotExists); + assert!(result.contains("CREATE TEMP TABLE IF NOT EXISTS"), "Got: {result}"); + } + + #[test] + fn conditional_check_wraps_global_temporary_table_postgres() { + let sql = "CREATE GLOBAL TEMPORARY TABLE staging (id INT);"; + let result = apply_idempotent_strategy(sql, DatabaseType::Postgres, IdempotentStrategy::ConditionalCheck); + assert!(result.contains("DO $dbx_idempotent$"), "missing DO wrapper: {result}"); + assert!(result.contains("CREATE GLOBAL TEMPORARY TABLE staging"), "DDL not embedded: {result}"); + } + #[test] fn idempotent_create_table_if_not_exists_clickhouse() { let sql = "CREATE TABLE events (ts DateTime);"; @@ -1534,6 +1627,38 @@ mod tests { assert!(!result.contains("CREATE IF NOT EXISTS"), "wrong IF NOT EXISTS placement: {result}"); } + #[test] + fn sqlserver_never_emits_create_index_if_not_exists() { + let sql = "CREATE UNIQUE NONCLUSTERED INDEX [idx_email] ON [dbo].[users] ([email]);"; + let direct = apply_idempotent_strategy(sql, DatabaseType::SqlServer, IdempotentStrategy::IfNotExists); + assert_eq!(direct, sql); + assert!(!direct.contains("INDEX IF NOT EXISTS")); + + let conditional = apply_idempotent_strategy(sql, DatabaseType::SqlServer, IdempotentStrategy::ConditionalCheck); + assert!(conditional.starts_with("EXEC sys.sp_executesql N'IF NOT EXISTS"), "Got: {conditional}"); + assert!(conditional.contains("FROM sys.indexes"), "Got: {conditional}"); + assert!(conditional.contains("OBJECT_ID(N''[dbo].[users]'')"), "Got: {conditional}"); + assert!(conditional.contains("name = N''idx_email''"), "Got: {conditional}"); + assert!(!conditional.contains("INDEX IF NOT EXISTS"), "Got: {conditional}"); + + let escaped_name = apply_idempotent_strategy( + "CREATE INDEX [ix_owner]] name] ON [dbo].[user]] data] ([id]);", + DatabaseType::SqlServer, + IdempotentStrategy::ConditionalCheck, + ); + assert!(escaped_name.contains("OBJECT_ID(N''[dbo].[user]] data]'')"), "Got: {escaped_name}"); + assert!(escaped_name.contains("name = N''ix_owner] name''"), "Got: {escaped_name}"); + } + + #[test] + fn sqlserver_create_table_conditional_uses_object_id() { + let sql = "CREATE TABLE [dbo].[users] ([id] INT NOT NULL);"; + let result = apply_idempotent_strategy(sql, DatabaseType::SqlServer, IdempotentStrategy::ConditionalCheck); + assert!(result.starts_with("EXEC sys.sp_executesql N'IF OBJECT_ID"), "Got: {result}"); + assert!(result.contains("N''[dbo].[users]'', N''U''"), "Got: {result}"); + assert!(!result.contains("CREATE TABLE IF NOT EXISTS"), "Got: {result}"); + } + #[test] fn idempotent_drop_sequence_if_exists() { let sql = "DROP SEQUENCE user_seq;"; diff --git a/crates/dbx-core/src/sql_dialect/ddl_profile.rs b/crates/dbx-core/src/sql_dialect/ddl_profile.rs index 94e928ed8e..6f2718e45a 100644 --- a/crates/dbx-core/src/sql_dialect/ddl_profile.rs +++ b/crates/dbx-core/src/sql_dialect/ddl_profile.rs @@ -212,6 +212,9 @@ const SEQ_CREATE: &str = const SEQ_ALTER: &str = "ALTER SEQUENCE {name} AS {data_type} START WITH {start_value} INCREMENT BY {increment} MINVALUE {min_value} MAXVALUE {max_value} {cycle};"; const SEQ_DROP: &str = "DROP SEQUENCE {name}{cascade};"; +const SQLSERVER_FN_DROP: &str = "DROP FUNCTION {name};"; +const SQLSERVER_SEQ_ALTER: &str = + "ALTER SEQUENCE {name} RESTART WITH {start_value} INCREMENT BY {increment} MINVALUE {min_value} MAXVALUE {max_value} {cycle};"; const RULE_DROP: &str = "DROP RULE IF EXISTS {rule_name} ON {table_name}{cascade};"; const OWNER_ALTER: &str = "ALTER {object_type} {name} OWNER TO {owner};"; @@ -444,7 +447,7 @@ fn oracle_family(db: DatabaseType) -> DdlDialectProfile { fn sqlserver_family(db: DatabaseType) -> DdlDialectProfile { DdlDialectProfile { database_type: db, - quote: QuoteStyle::DoubleQuote, + quote: QuoteStyle::Brackets, auto_inc: AutoIncSyntax::Suffix(" IDENTITY(1,1)"), supports_display_width: false, max_varchar_len: None, @@ -468,16 +471,16 @@ fn sqlserver_family(db: DatabaseType) -> DdlDialectProfile { alter_uses_modify_column: false, alter_batches_clauses: false, create_table_if_not_exists: false, - create_index_if_not_exists: true, + create_index_if_not_exists: false, create_function_or_replace: false, supports_function_ddl: true, supports_sequence_ddl: true, supports_rule_ddl: false, supports_owner_ddl: false, function_create_template: Some(FN_CREATE), - function_drop_template: Some(FN_DROP), + function_drop_template: Some(SQLSERVER_FN_DROP), sequence_create_template: Some(SEQ_CREATE), - sequence_alter_template: Some(SEQ_ALTER), + sequence_alter_template: Some(SQLSERVER_SEQ_ALTER), sequence_drop_template: Some(SEQ_DROP), rule_drop_template: None, owner_alter_template: None, @@ -727,11 +730,12 @@ mod tests { assert!(matches!(mssql.auto_inc, AutoIncSyntax::Suffix(s) if s.contains("IDENTITY"))); assert!(access.lookup_type("VARCHAR").is_some()); assert!(mssql.type_map.is_empty()); - // Access must not inherit SQL Server lock-timeout / idempotent flags. + // Access must not inherit SQL Server lock-timeout behavior. Neither + // database supports CREATE INDEX IF NOT EXISTS syntax. assert_eq!(access.lock_timeout_sql, None); assert!(mssql.lock_timeout_sql.is_some()); assert!(!access.create_index_if_not_exists); - assert!(mssql.create_index_if_not_exists); + assert!(!mssql.create_index_if_not_exists); } #[test] @@ -748,6 +752,7 @@ mod tests { fn quote_styles() { assert_eq!(profile_for(DatabaseType::Mysql).quote_ident("a`b"), "`a``b`"); assert_eq!(profile_for(DatabaseType::Access).quote_ident(r#"a"b"#), "\"a\"\"b\""); + assert_eq!(profile_for(DatabaseType::SqlServer).quote_ident("a]b"), "[a]]b]"); assert_eq!(profile_for(DatabaseType::Oracle).quote_ident("emp"), "EMP"); assert_eq!(profile_for(DatabaseType::Oracle).quote_ident("EMP"), "EMP"); // Mixed-case must preserve spelling via quotes (not EMPMIXED). diff --git a/crates/dbx-core/src/sql_dialect/descriptor.rs b/crates/dbx-core/src/sql_dialect/descriptor.rs index c3c3407ee2..e4be68bd4e 100644 --- a/crates/dbx-core/src/sql_dialect/descriptor.rs +++ b/crates/dbx-core/src/sql_dialect/descriptor.rs @@ -68,7 +68,7 @@ impl DialectKind { // DuckDB "duckdb" => Some(DialectKind::DuckDb), // SQL Server family - "sqlserver" | "mssql" | "sql server" | "access" => Some(DialectKind::SqlServer), + "sqlserver" | "sql_server" | "mssql" | "sql server" | "access" => Some(DialectKind::SqlServer), // Oracle family "oracle" | "dameng" | "oceanbaseoracle" | "oceanbase" | "iris" | "yashandb" | "xugu" => { Some(DialectKind::Oracle) @@ -402,7 +402,6 @@ impl DialectCapabilityDescriptor { | CAP_DROP_SEQUENCE | CAP_ALTER_OWNER | CAP_GRANT_REVOKE - | CAP_IF_NOT_EXISTS | CAP_TEMPORARY_TABLE | CAP_TRANSACTIONAL_DDL | CAP_IDENTITY_COLUMNS, @@ -983,6 +982,11 @@ mod tests { } } + #[test] + fn dialect_kind_accepts_serde_sql_server_label() { + assert_eq!(DialectKind::from_label("sql_server"), Some(DialectKind::SqlServer)); + } + #[test] fn dialect_kind_to_database_type_roundtrip() { for kind in &[ diff --git a/crates/dbx-core/src/sql_dialect/snapshots/dbx_core__sql_dialect__descriptor_snapshots__all_dialects_info.snap b/crates/dbx-core/src/sql_dialect/snapshots/dbx_core__sql_dialect__descriptor_snapshots__all_dialects_info.snap index b40733d8a9..533535480c 100644 --- a/crates/dbx-core/src/sql_dialect/snapshots/dbx_core__sql_dialect__descriptor_snapshots__all_dialects_info.snap +++ b/crates/dbx-core/src/sql_dialect/snapshots/dbx_core__sql_dialect__descriptor_snapshots__all_dialects_info.snap @@ -202,7 +202,6 @@ expression: all "drop_sequence", "alter_owner", "grant_revoke", - "if_not_exists", "temporary_table", "transactional_ddl", "identity_columns" diff --git a/crates/dbx-core/src/sql_dialect/snapshots/dbx_core__sql_dialect__descriptor_snapshots__sqlserver_descriptor.snap b/crates/dbx-core/src/sql_dialect/snapshots/dbx_core__sql_dialect__descriptor_snapshots__sqlserver_descriptor.snap index 519f0d0af3..8d4a2ab447 100644 --- a/crates/dbx-core/src/sql_dialect/snapshots/dbx_core__sql_dialect__descriptor_snapshots__sqlserver_descriptor.snap +++ b/crates/dbx-core/src/sql_dialect/snapshots/dbx_core__sql_dialect__descriptor_snapshots__sqlserver_descriptor.snap @@ -5,7 +5,7 @@ expression: desc --- { "dialect": "sql_server", - "flags": 3086983151, + "flags": 3019874287, "max_identifier_length": 128, "supports_schemas": true, "supports_catalogs": true, diff --git a/crates/dbx-core/src/sql_dialect/type_rewrite.rs b/crates/dbx-core/src/sql_dialect/type_rewrite.rs index 86f24b6233..68d11a1adf 100644 --- a/crates/dbx-core/src/sql_dialect/type_rewrite.rs +++ b/crates/dbx-core/src/sql_dialect/type_rewrite.rs @@ -153,11 +153,179 @@ fn tinyint1_as_boolean_key(base: &str, params: Option<&str>) -> Option<&'static } } +fn sqlserver_decimal_type(params: Option<&str>) -> String { + let Some(params) = params else { + return "DECIMAL".to_string(); + }; + let values = params.split(',').map(str::trim).collect::>(); + let Ok(precision) = values.first().copied().unwrap_or_default().parse::() else { + return "DECIMAL".to_string(); + }; + let precision = precision.clamp(1, 38); + if values.len() == 1 { + return format!("DECIMAL({precision})"); + } + let Ok(scale) = values[1].parse::() else { + return format!("DECIMAL({precision})"); + }; + format!("DECIMAL({precision},{})", scale.min(precision)) +} + +fn sqlserver_length_type(target_type: &str, params: Option<&str>, max_length: u32) -> String { + let Some(param) = params.map(str::trim).filter(|param| !param.is_empty()) else { + return if matches!(target_type, "VARCHAR" | "NVARCHAR" | "VARBINARY") { + format!("{target_type}(MAX)") + } else { + target_type.to_string() + }; + }; + if param.eq_ignore_ascii_case("MAX") { + return match target_type { + "NCHAR" => "NVARCHAR(MAX)".to_string(), + "CHAR" => "VARCHAR(MAX)".to_string(), + "BINARY" => "VARBINARY(MAX)".to_string(), + _ => format!("{target_type}(MAX)"), + }; + } + match param.parse::() { + Ok(length) if length > max_length => match target_type { + "NCHAR" | "NVARCHAR" => "NVARCHAR(MAX)".to_string(), + "BINARY" | "VARBINARY" => "VARBINARY(MAX)".to_string(), + _ => "VARCHAR(MAX)".to_string(), + }, + Ok(length) => format!("{target_type}({})", length.max(1)), + Err(_) => format!("{target_type}(MAX)"), + } +} + +fn sqlserver_temporal_type(target_type: &str, params: Option<&str>) -> String { + match params.and_then(|param| param.trim().parse::().ok()) { + Some(precision) => format!("{target_type}({})", precision.min(7)), + None => target_type.to_string(), + } +} + +/// Convert common source types to native SQL Server types before the generic +/// mapping matrix runs. This is source-dialect aware because names such as +/// `timestamp` mean a date/time in MySQL/PostgreSQL but `rowversion` in SQL Server. +fn rewrite_cross_dialect_to_sqlserver(source_type: &str, source: DialectKind) -> Option { + if source == DialectKind::SqlServer { + return None; + } + + let source_upper = source_type.trim().to_ascii_uppercase(); + // MySQL places UNSIGNED/ZEROFILL after an optional parameter list, so it + // cannot be recovered from `raw_base` alone (`INT(11) UNSIGNED`). ZEROFILL + // also implies UNSIGNED in MySQL. + let mysql_unsigned = source_upper.split_whitespace().any(|part| matches!(part, "UNSIGNED" | "ZEROFILL")); + let (raw_base, params) = split_type_base_params(source_type); + let base = raw_base + .split_whitespace() + .filter(|part| !part.eq_ignore_ascii_case("UNSIGNED") && !part.eq_ignore_ascii_case("ZEROFILL")) + .collect::>() + .join(" "); + let params = params.as_deref(); + + let mapped = match source { + DialectKind::Mysql => match base.as_str() { + "BOOL" | "BOOLEAN" => "BIT".to_string(), + "TINYINT" if params == Some("1") && !mysql_unsigned => "BIT".to_string(), + // Preserve the complete MySQL value range. SQL Server TINYINT is + // unsigned, while MySQL TINYINT is signed unless explicitly marked. + "TINYINT" if mysql_unsigned => "TINYINT".to_string(), + "TINYINT" => "SMALLINT".to_string(), + "SMALLINT" if mysql_unsigned => "INT".to_string(), + "SMALLINT" => "SMALLINT".to_string(), + "MEDIUMINT" => "INT".to_string(), + "INT" | "INTEGER" if mysql_unsigned => "BIGINT".to_string(), + "INT" | "INTEGER" => "INT".to_string(), + "BIGINT" if mysql_unsigned => "DECIMAL(20,0)".to_string(), + "BIGINT" => "BIGINT".to_string(), + "DECIMAL" | "NUMERIC" => sqlserver_decimal_type(params), + "FLOAT" | "DOUBLE" | "DOUBLE PRECISION" | "REAL" => "FLOAT".to_string(), + "CHAR" | "VARCHAR" => sqlserver_length_type(&base, params, 8000), + "NCHAR" | "NVARCHAR" => sqlserver_length_type(&base, params, 4000), + "TINYTEXT" | "TEXT" | "MEDIUMTEXT" | "LONGTEXT" | "JSON" | "ENUM" | "SET" => "NVARCHAR(MAX)".to_string(), + "BINARY" | "VARBINARY" => sqlserver_length_type(&base, params, 8000), + "TINYBLOB" | "BLOB" | "MEDIUMBLOB" | "LONGBLOB" => "VARBINARY(MAX)".to_string(), + "DATE" => "DATE".to_string(), + "TIME" => sqlserver_temporal_type("TIME", params), + "DATETIME" | "TIMESTAMP" => sqlserver_temporal_type("DATETIME2", params), + "YEAR" => "SMALLINT".to_string(), + "GEOMETRY" => "GEOMETRY".to_string(), + _ => return None, + }, + DialectKind::Postgres => match base.as_str() { + "BOOL" | "BOOLEAN" => "BIT".to_string(), + "SMALLINT" | "INT2" | "SMALLSERIAL" => "SMALLINT".to_string(), + "INTEGER" | "INT" | "INT4" | "SERIAL" => "INT".to_string(), + "BIGINT" | "INT8" | "BIGSERIAL" => "BIGINT".to_string(), + "NUMERIC" | "DECIMAL" => sqlserver_decimal_type(params), + "REAL" | "FLOAT4" => "REAL".to_string(), + "DOUBLE PRECISION" | "FLOAT8" => "FLOAT".to_string(), + "CHAR" | "CHARACTER" | "VARCHAR" | "CHARACTER VARYING" => { + sqlserver_length_type("NVARCHAR", params.or(Some("MAX")), 4000) + } + "TEXT" | "JSON" | "JSONB" | "ARRAY" => "NVARCHAR(MAX)".to_string(), + "BIT VARYING" | "VARBIT" => "VARBINARY(MAX)".to_string(), + "BYTEA" => "VARBINARY(MAX)".to_string(), + "UUID" => "UNIQUEIDENTIFIER".to_string(), + "DATE" => "DATE".to_string(), + "TIME" if source_upper.contains("WITH TIME ZONE") && !source_upper.contains("WITHOUT TIME ZONE") => { + sqlserver_temporal_type("DATETIMEOFFSET", params) + } + "TIME" | "TIME WITHOUT TIME ZONE" => sqlserver_temporal_type("TIME", params), + "TIMETZ" | "TIME WITH TIME ZONE" => sqlserver_temporal_type("DATETIMEOFFSET", params), + "TIMESTAMP" if source_upper.contains("WITH TIME ZONE") && !source_upper.contains("WITHOUT TIME ZONE") => { + sqlserver_temporal_type("DATETIMEOFFSET", params) + } + "TIMESTAMP" | "TIMESTAMP WITHOUT TIME ZONE" => sqlserver_temporal_type("DATETIME2", params), + "TIMESTAMPTZ" | "TIMESTAMP WITH TIME ZONE" => sqlserver_temporal_type("DATETIMEOFFSET", params), + "XML" => "XML".to_string(), + "MONEY" => "MONEY".to_string(), + "INET" | "CIDR" | "MACADDR" | "MACADDR8" | "INTERVAL" => "NVARCHAR(100)".to_string(), + _ if base.ends_with("[]") => "NVARCHAR(MAX)".to_string(), + _ => return None, + }, + DialectKind::Sqlite | DialectKind::DuckDb => match base.as_str() { + "BOOL" | "BOOLEAN" => "BIT".to_string(), + "TINYINT" => "TINYINT".to_string(), + "SMALLINT" => "SMALLINT".to_string(), + "INT" | "INTEGER" => "BIGINT".to_string(), + "REAL" | "DOUBLE" | "DOUBLE PRECISION" | "FLOAT" => "FLOAT".to_string(), + "DECIMAL" | "NUMERIC" => sqlserver_decimal_type(params), + "CHAR" | "VARCHAR" => sqlserver_length_type("NVARCHAR", params.or(Some("MAX")), 4000), + "TEXT" | "JSON" => "NVARCHAR(MAX)".to_string(), + "BLOB" | "BYTEA" => "VARBINARY(MAX)".to_string(), + "DATE" => "DATE".to_string(), + "TIME" => sqlserver_temporal_type("TIME", params), + "DATETIME" | "TIMESTAMP" => sqlserver_temporal_type("DATETIME2", params), + "UUID" => "UNIQUEIDENTIFIER".to_string(), + _ => return None, + }, + _ => return None, + }; + Some(mapped) +} + /// Rewrite a source column type for `target` database. /// /// User field mappings must be applied by the caller *before* this function. pub fn rewrite_column_type(source_type: &str, target: DatabaseType, source_dialect: Option) -> String { let profile = profile_for(target); + if target == DatabaseType::SqlServer { + let source = source_dialect.unwrap_or(DialectKind::SqlServer); + if source == DialectKind::SqlServer { + // Same-dialect metadata already contains a valid native type. The + // generic display-width normalizer would otherwise truncate valid + // SQL Server parameters such as VARBINARY(MAX), DATETIME2(7), and + // DATETIMEOFFSET(7). + return source_type.trim().to_string(); + } + if let Some(mapped) = rewrite_cross_dialect_to_sqlserver(source_type, source) { + return mapped; + } + } let (base, params) = split_type_base_params(source_type); // Profile type_map (includes synthetic BOOL for TINYINT(1) when mapped) @@ -277,11 +445,64 @@ mod tests { profile.auto_inc, crate::sql_dialect::ddl_profile::AutoIncSyntax::Suffix(s) if s.contains("IDENTITY") )); - // No Access type map: display width stripped + // MySQL display width is not part of the SQL Server type. let t = rewrite_column_type("int(11)", DatabaseType::SqlServer, Some(DialectKind::Mysql)); assert_eq!(t, "INT"); } + #[test] + fn sqlserver_rewrites_common_mysql_types() { + let target = DatabaseType::SqlServer; + let source = Some(DialectKind::Mysql); + let cases = [ + ("tinyint(1)", "BIT"), + ("tinyint", "SMALLINT"), + ("tinyint unsigned", "TINYINT"), + ("tinyint(1) unsigned", "TINYINT"), + ("tinyint(1) zerofill", "TINYINT"), + ("smallint unsigned", "INT"), + ("mediumint unsigned", "INT"), + ("int(11) unsigned", "BIGINT"), + ("integer zerofill", "BIGINT"), + ("bigint unsigned", "DECIMAL(20,0)"), + ("double", "FLOAT"), + ("decimal(65,30)", "DECIMAL(38,30)"), + ("varchar(9000)", "VARCHAR(MAX)"), + ("nvarchar(5000)", "NVARCHAR(MAX)"), + ("longtext", "NVARCHAR(MAX)"), + ("json", "NVARCHAR(MAX)"), + ("longblob", "VARBINARY(MAX)"), + ("datetime(9)", "DATETIME2(7)"), + ]; + for (input, expected) in cases { + assert_eq!(rewrite_column_type(input, target, source), expected, "{input}"); + } + } + + #[test] + fn sqlserver_rewrites_common_postgres_types_without_rewriting_its_own_timestamp() { + let target = DatabaseType::SqlServer; + let postgres = Some(DialectKind::Postgres); + let cases = [ + ("serial", "INT"), + ("boolean", "BIT"), + ("character varying(64)", "NVARCHAR(64)"), + ("text", "NVARCHAR(MAX)"), + ("bytea", "VARBINARY(MAX)"), + ("uuid", "UNIQUEIDENTIFIER"), + ("time(4) with time zone", "DATETIMEOFFSET(4)"), + ("timestamp(6) without time zone", "DATETIME2(6)"), + ("timestamp(6) with time zone", "DATETIMEOFFSET(6)"), + ("jsonb", "NVARCHAR(MAX)"), + ]; + for (input, expected) in cases { + assert_eq!(rewrite_column_type(input, target, postgres), expected, "{input}"); + } + assert_eq!(rewrite_column_type("timestamp", target, Some(DialectKind::SqlServer)), "timestamp"); + assert_eq!(rewrite_column_type("varbinary(max)", target, None), "varbinary(max)"); + assert_eq!(rewrite_column_type("datetime2(7)", target, Some(DialectKind::SqlServer)), "datetime2(7)"); + } + #[test] fn mysql_keeps_display_width() { let t = rewrite_column_type("int(11)", DatabaseType::Mysql, None); diff --git a/crates/dbx-core/src/table_structure_sql.rs b/crates/dbx-core/src/table_structure_sql.rs index 3fb381af4d..7fd6fa1925 100644 --- a/crates/dbx-core/src/table_structure_sql.rs +++ b/crates/dbx-core/src/table_structure_sql.rs @@ -23,6 +23,12 @@ pub use owner::build_table_owner_change_sql; pub use sqlite_rebuild::{apply_sqlite_table_structure_change, preview_sqlite_table_structure_change}; pub use types::*; +pub(crate) use column_alter::{ + build_sqlserver_alter_column_preserving_default_sql, build_sqlserver_drop_default_constraint_sql, +}; +pub(crate) use comments::{build_sqlserver_column_comment_sql, build_sqlserver_table_comment_sql}; +pub(crate) use util::sqlserver_unicode_string_literal; + use crate::models::connection::DatabaseType; use columns::{build_column_sql, validate_primary_key_change_scope}; diff --git a/crates/dbx-core/src/table_structure_sql/column_alter.rs b/crates/dbx-core/src/table_structure_sql/column_alter.rs index e400210c12..66fb133101 100644 --- a/crates/dbx-core/src/table_structure_sql/column_alter.rs +++ b/crates/dbx-core/src/table_structure_sql/column_alter.rs @@ -739,7 +739,12 @@ pub(super) fn build_sqlserver_existing_column_sql( // SQL Server default constraints are separate objects that can block ALTER COLUMN. // Preserve the exact constraint name and expression when the default itself is unchanged. if has_column_definition_change && has_old_default && !has_default_change { - statements.push(build_sqlserver_alter_column_preserving_default_sql(table, ¤t_name, column)); + statements.push(build_sqlserver_alter_column_preserving_default_sql( + table, + ¤t_name, + &column_data_type(dialect, column), + column.is_nullable, + )); } else { if has_column_definition_change && has_old_default { statements.push(build_sqlserver_drop_default_constraint_sql(table, ¤t_name)); @@ -760,15 +765,10 @@ pub(super) fn build_sqlserver_existing_column_sql( statements.push(build_sqlserver_drop_default_constraint_sql(table, ¤t_name)); } if !default_value.is_empty() { - let short_table = - table.split('.').next_back().unwrap_or(table).trim_matches(|c: char| c == '[' || c == ']'); - let constraint_name = format!( - "DF_{short_table}_{col_name}", - short_table = short_table, - col_name = current_name.trim_matches(|c: char| c == '[' || c == ']') - ); + let constraint_name = format!("DF_{}_{}", table_name.trim(), current_name.trim()); statements.push(format!( - "ALTER TABLE {table} ADD CONSTRAINT [{constraint_name}] DEFAULT {} FOR {};", + "ALTER TABLE {table} ADD CONSTRAINT {} DEFAULT {} FOR {};", + quote_ident(dialect, &constraint_name), format_default_for_sql(StructureDialect::SqlServer, &column.data_type, &default_value), quote_ident(dialect, ¤t_name) )); @@ -789,10 +789,11 @@ pub(super) fn build_sqlserver_existing_column_sql( statements } -fn build_sqlserver_alter_column_preserving_default_sql( +pub(crate) fn build_sqlserver_alter_column_preserving_default_sql( table: &str, column_name: &str, - column: &EditableStructureColumn, + data_type: &str, + is_nullable: bool, ) -> String { let dialect = StructureDialect::SqlServer; let sql_var = sqlserver_default_constraint_sql_var(table, column_name); @@ -802,7 +803,7 @@ fn build_sqlserver_alter_column_preserving_default_sql( let column_literal = column_name.replace('\'', "''"); let quoted_column = quote_ident(dialect, column_name); let quoted_column_literal = quoted_column.replace('\'', "''"); - let null_clause = if column.is_nullable { "NULL" } else { "NOT NULL" }; + let null_clause = if is_nullable { "NULL" } else { "NOT NULL" }; format!( "DECLARE {sql_var} NVARCHAR(MAX), {name_var} sysname, {definition_var} NVARCHAR(MAX); \ @@ -812,11 +813,11 @@ fn build_sqlserver_alter_column_preserving_default_sql( IF {name_var} IS NOT NULL BEGIN SET {sql_var} = N'ALTER TABLE {table_literal} DROP CONSTRAINT ' + QUOTENAME({name_var}); EXEC sp_executesql {sql_var}; END; \ ALTER TABLE {table} ALTER COLUMN {quoted_column} {data_type} {null_clause}; \ IF {name_var} IS NOT NULL BEGIN SET {sql_var} = N'ALTER TABLE {table_literal} ADD CONSTRAINT ' + QUOTENAME({name_var}) + N' DEFAULT ' + {definition_var} + N' FOR {quoted_column_literal}'; EXEC sp_executesql {sql_var}; END;", - data_type = column_data_type(dialect, column), + data_type = data_type, ) } -fn build_sqlserver_drop_default_constraint_sql(table: &str, column_name: &str) -> String { +pub(crate) fn build_sqlserver_drop_default_constraint_sql(table: &str, column_name: &str) -> String { let sql_var = sqlserver_default_constraint_sql_var(table, column_name); let table_literal = table.replace('\'', "''"); let column_literal = column_name.replace('\'', "''"); diff --git a/crates/dbx-core/src/table_structure_sql/comments.rs b/crates/dbx-core/src/table_structure_sql/comments.rs index 394699f1f3..70b70c3146 100644 --- a/crates/dbx-core/src/table_structure_sql/comments.rs +++ b/crates/dbx-core/src/table_structure_sql/comments.rs @@ -50,30 +50,36 @@ pub(super) fn sqlserver_schema_name(schema: Option<&str>) -> String { schema.filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_string()).unwrap_or_else(|| "dbo".to_string()) } -pub(super) fn build_sqlserver_table_comment_sql( +fn build_sqlserver_extended_property_comment_sql(exists: &str, levels: &str, new_comment: &str) -> Vec { + let new_comment = clean(new_comment); + if new_comment.is_empty() { + return vec![format!("IF {exists} EXEC sys.sp_dropextendedproperty @name=N'MS_Description', {levels};")]; + } + + let escaped_comment = new_comment.replace('\'', "''"); + vec![format!( + "IF {exists} EXEC sys.sp_updateextendedproperty @name=N'MS_Description', @value=N'{escaped_comment}', {levels} ELSE EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'{escaped_comment}', {levels};" + )] +} + +pub(crate) fn build_sqlserver_table_comment_sql( qualified_table: &str, schema: Option<&str>, table_name: &str, new_comment: &str, ) -> Vec { - let mut statements = Vec::new(); let schema_name = sqlserver_schema_name(schema); let escaped_qualified = qualified_table.replace('\'', "''"); let escaped_schema = schema_name.replace('\'', "''"); let escaped_table = table_name.replace('\'', "''"); + let exists = format!( + "EXISTS (SELECT 1 FROM sys.extended_properties AS ep WHERE ep.class = 1 AND ep.major_id = OBJECT_ID(N'{escaped_qualified}') AND ep.minor_id = 0 AND ep.name = N'MS_Description')" + ); + let levels = format!( + "@level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}'" + ); - statements.push(format!( - "IF EXISTS (SELECT 1 FROM sys.extended_properties WHERE major_id = OBJECT_ID(N'{escaped_qualified}') AND minor_id = 0 AND name = N'MS_Description') EXEC sys.sp_dropextendedproperty @name=N'MS_Description', @level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}';" - )); - - if !clean(new_comment).is_empty() { - let quoted_comment = clean(new_comment).replace('\'', "''"); - statements.push(format!( - "EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'{quoted_comment}', @level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}';" - )); - } - - statements + build_sqlserver_extended_property_comment_sql(&exists, &levels, new_comment) } pub(super) fn build_sqlserver_index_comment_sql( @@ -83,51 +89,97 @@ pub(super) fn build_sqlserver_index_comment_sql( index_name: &str, new_comment: &str, ) -> Vec { - let mut statements = Vec::new(); let schema_name = sqlserver_schema_name(schema); let escaped_qualified = qualified_table.replace('\'', "''"); let escaped_schema = schema_name.replace('\'', "''"); let escaped_table = table_name.replace('\'', "''"); let escaped_idx = index_name.replace('\'', "''"); + let exists = format!( + "EXISTS (SELECT 1 FROM sys.extended_properties AS ep INNER JOIN sys.indexes AS i ON i.object_id = ep.major_id AND i.index_id = ep.minor_id WHERE ep.class = 7 AND ep.major_id = OBJECT_ID(N'{escaped_qualified}') AND i.name = N'{escaped_idx}' AND ep.name = N'MS_Description')" + ); + let levels = format!( + "@level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'INDEX', @level2name=N'{escaped_idx}'" + ); - statements.push(format!( - "IF EXISTS (SELECT 1 FROM sys.extended_properties WHERE major_id = OBJECT_ID(N'{escaped_qualified}') AND minor_id = 0 AND name = N'MS_Description' AND class_desc = 'INDEX') EXEC sys.sp_dropextendedproperty @name=N'MS_Description', @level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'INDEX', @level2name=N'{escaped_idx}';" - )); - - if !clean(new_comment).is_empty() { - let quoted_comment = clean(new_comment).replace('\'', "''"); - statements.push(format!( - "EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'{quoted_comment}', @level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'INDEX', @level2name=N'{escaped_idx}';" - )); - } - - statements + build_sqlserver_extended_property_comment_sql(&exists, &levels, new_comment) } -pub(super) fn build_sqlserver_column_comment_sql( +pub(crate) fn build_sqlserver_column_comment_sql( qualified_table: &str, schema: Option<&str>, table_name: &str, column_name: &str, new_comment: &str, ) -> Vec { - let mut statements = Vec::new(); let schema_name = sqlserver_schema_name(schema); let escaped_qualified = qualified_table.replace('\'', "''"); let escaped_schema = schema_name.replace('\'', "''"); let escaped_table = table_name.replace('\'', "''"); let escaped_col = column_name.replace('\'', "''"); + let exists = format!( + "EXISTS (SELECT 1 FROM sys.extended_properties AS ep WHERE ep.class = 1 AND ep.major_id = OBJECT_ID(N'{escaped_qualified}') AND ep.minor_id = COLUMNPROPERTY(OBJECT_ID(N'{escaped_qualified}'), N'{escaped_col}', 'ColumnId') AND ep.name = N'MS_Description')" + ); + let levels = format!( + "@level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'COLUMN', @level2name=N'{escaped_col}'" + ); - statements.push(format!( - "IF EXISTS (SELECT 1 FROM sys.extended_properties WHERE major_id = OBJECT_ID(N'{escaped_qualified}') AND minor_id = COLUMNPROPERTY(OBJECT_ID(N'{escaped_qualified}'), N'{escaped_col}', 'ColumnId') AND name = N'MS_Description') EXEC sys.sp_dropextendedproperty @name=N'MS_Description', @level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'COLUMN', @level2name=N'{escaped_col}';" - )); + build_sqlserver_extended_property_comment_sql(&exists, &levels, new_comment) +} - if !clean(new_comment).is_empty() { - let quoted_comment = clean(new_comment).replace('\'', "''"); - statements.push(format!( - "EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'{quoted_comment}', @level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'COLUMN', @level2name=N'{escaped_col}';" - )); +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sqlserver_table_comment_updates_or_adds_without_dropping() { + let statements = build_sqlserver_table_comment_sql("[app's].[user's]", Some("app's"), "user's", "owner's 新值"); + + assert_eq!(statements.len(), 1); + let sql = &statements[0]; + assert!(sql.contains("ep.class = 1"), "table extended-property class: {sql}"); + assert!(sql.contains("sys.sp_updateextendedproperty"), "update existing comment: {sql}"); + assert!(sql.contains("ELSE EXEC sys.sp_addextendedproperty"), "add missing comment: {sql}"); + assert!(!sql.contains("sys.sp_dropextendedproperty"), "non-empty comments are not dropped first: {sql}"); + assert!(sql.contains("OBJECT_ID(N'[app''s].[user''s]')"), "object name escaping: {sql}"); + assert!(sql.contains("@value=N'owner''s 新值'"), "Unicode comment escaping: {sql}"); + assert_eq!( + crate::sql::split_sql_statements_for_database(sql, crate::models::connection::DatabaseType::SqlServer) + .len(), + 1, + "IF/ELSE comment upsert must remain one executable statement: {sql}" + ); } - statements + #[test] + fn sqlserver_empty_column_comment_drops_only_when_present() { + let statements = build_sqlserver_column_comment_sql("[dbo].[orders]", None, "orders", "owner'id", " "); + + assert_eq!(statements.len(), 1); + let sql = &statements[0]; + assert!(sql.contains("COLUMNPROPERTY(OBJECT_ID(N'[dbo].[orders]'), N'owner''id', 'ColumnId')")); + assert!(sql.contains("sys.sp_dropextendedproperty"), "drop existing comment: {sql}"); + assert!(!sql.contains("sys.sp_updateextendedproperty"), "empty comment must not update: {sql}"); + assert!(!sql.contains("sys.sp_addextendedproperty"), "empty comment must not add: {sql}"); + assert!(sql.contains("@level0name=N'dbo'"), "default schema: {sql}"); + assert_eq!( + crate::sql::split_sql_statements_for_database(sql, crate::models::connection::DatabaseType::SqlServer) + .len(), + 1, + "conditional comment drop must remain one executable statement: {sql}" + ); + } + + #[test] + fn sqlserver_index_comment_uses_index_extended_property_identity() { + let statements = + build_sqlserver_index_comment_sql("[dbo].[orders]", None, "orders", "ix_owner's", "index comment"); + + assert_eq!(statements.len(), 1); + let sql = &statements[0]; + assert!(sql.contains("INNER JOIN sys.indexes AS i"), "index lookup: {sql}"); + assert!(sql.contains("ep.class = 7"), "index extended-property class: {sql}"); + assert!(sql.contains("i.name = N'ix_owner''s'"), "index name escaping: {sql}"); + assert!(sql.contains("sys.sp_updateextendedproperty"), "update existing comment: {sql}"); + assert!(sql.contains("ELSE EXEC sys.sp_addextendedproperty"), "add missing comment: {sql}"); + } } diff --git a/crates/dbx-core/src/table_structure_sql/tests.rs b/crates/dbx-core/src/table_structure_sql/tests.rs index 3bb0adb665..abd96de79d 100644 --- a/crates/dbx-core/src/table_structure_sql/tests.rs +++ b/crates/dbx-core/src/table_structure_sql/tests.rs @@ -2525,7 +2525,7 @@ fn sqlserver_default_changes_drop_old_constraints_with_isolated_batches() { assert_eq!( result.statements[1], - "ALTER TABLE [core].[products] ADD CONSTRAINT [DF_products_sku] DEFAULT 'new sku' FOR [sku];" + "ALTER TABLE [core].[products] ADD CONSTRAINT [DF_products_sku] DEFAULT N'new sku' FOR [sku];" ); assert_eq!( result.statements[3], @@ -2599,6 +2599,35 @@ fn sqlserver_type_and_default_change_drops_before_alter_and_adds_new_default() { ); } +#[test] +fn sqlserver_generated_default_constraint_escapes_identifiers_and_unicode_values() { + let mut owner = column("owner]id"); + owner.data_type = "nvarchar(40)".to_string(); + owner.default_value = "中文'值".to_string(); + owner.original = Some(ColumnInfo { + name: "owner]id".to_string(), + data_type: "nvarchar(40)".to_string(), + is_nullable: true, + column_default: Some("N'旧值'".to_string()), + ..Default::default() + }); + + let result = build_single_column_alter_sql(SingleColumnAlterSqlOptions { + database_type: Some(DatabaseType::SqlServer), + schema: Some("dbo".to_string()), + table_name: "order]s".to_string(), + column: owner, + }); + + assert_eq!(result.warnings, Vec::::new()); + assert_eq!(result.statements.len(), 2); + assert!(result.statements[0].contains("OBJECT_ID(N'[dbo].[order]]s]')")); + assert_eq!( + result.statements[1], + "ALTER TABLE [dbo].[order]]s] ADD CONSTRAINT [DF_order]]s_owner]]id] DEFAULT N'中文''值' FOR [owner]]id];" + ); +} + #[test] fn sqlserver_rename_and_nullability_change_restores_default_on_new_column_name() { let mut renamed = column("is_enabled"); diff --git a/crates/dbx-core/src/table_structure_sql/util.rs b/crates/dbx-core/src/table_structure_sql/util.rs index 03403869c0..7a986892ee 100644 --- a/crates/dbx-core/src/table_structure_sql/util.rs +++ b/crates/dbx-core/src/table_structure_sql/util.rs @@ -62,6 +62,26 @@ fn is_sql_string_literal(value: &str) -> bool { true } +pub(crate) fn sqlserver_unicode_string_literal(value: &str) -> Option { + let mut candidate = value.trim(); + let mut parenthesis_depth = 0usize; + + loop { + if let Some(literal) = candidate.strip_prefix('N').or_else(|| candidate.strip_prefix('n')) { + if is_sql_string_literal(literal) { + return Some(format!("{}N{}{}", "(".repeat(parenthesis_depth), literal, ")".repeat(parenthesis_depth))); + } + } + if is_sql_string_literal(candidate) { + return Some(format!("{}N{}{}", "(".repeat(parenthesis_depth), candidate, ")".repeat(parenthesis_depth))); + } + + let inner = candidate.strip_prefix('(')?.strip_suffix(')')?; + parenthesis_depth += 1; + candidate = inner.trim(); + } +} + fn postgres_string_default_literal(value: &str) -> Option<&str> { let trimmed = value.trim(); let inner = trimmed.strip_prefix('\'')?; @@ -284,6 +304,17 @@ pub(super) fn format_default_for_sql(dialect: StructureDialect, data_type: &str, return quote_string(default_value); } if is_string_type_for_default(dialect, base_type) { + if dialect == StructureDialect::SqlServer + && matches!(base_type.to_ascii_lowercase().as_str(), "nchar" | "nvarchar" | "ntext" | "sysname") + { + if let Some(literal) = sqlserver_unicode_string_literal(default_value) { + return literal; + } + if default_value.contains('(') || default_value.contains(')') { + return default_value.to_string(); + } + return format!("N{}", quote_string(default_value)); + } // Only skip quoting for function-call expressions like `gen_random_uuid()`. // Simple identifiers like `CURRENT_TIMESTAMP` are not valid defaults for string columns. if is_sql_string_literal(default_value) @@ -316,3 +347,22 @@ pub(super) fn original_default(column: &EditableStructureColumn) -> String { pub(super) fn original_comment(column: &EditableStructureColumn) -> String { clean(column.original.as_ref().and_then(|original| original.comment.as_deref()).unwrap_or("")) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sqlserver_unicode_defaults_use_unicode_literals_without_double_quoting() { + assert_eq!(format_default_for_sql(StructureDialect::SqlServer, "nvarchar(50)", "中文"), "N'中文'"); + assert_eq!(format_default_for_sql(StructureDialect::SqlServer, "nchar(5)", "'中文'"), "N'中文'"); + assert_eq!(format_default_for_sql(StructureDialect::SqlServer, "sysname", "n'guest'"), "N'guest'"); + assert_eq!(format_default_for_sql(StructureDialect::SqlServer, "nvarchar(50)", "('中文')"), "(N'中文')"); + assert_eq!(format_default_for_sql(StructureDialect::SqlServer, "nvarchar(50)", "((N'中文'))"), "((N'中文'))"); + assert_eq!( + format_default_for_sql(StructureDialect::SqlServer, "nvarchar(max)", "CONCAT(N'a', N'b')"), + "CONCAT(N'a', N'b')" + ); + assert_eq!(format_default_for_sql(StructureDialect::SqlServer, "varchar(50)", "中文"), "'中文'"); + } +}