fix(sqlserver): complete schema diff DDL support

This commit is contained in:
KuriyamaMirai authored and GitHub committed 2026-08-26 13:21:50 +08:00
1 parent 2b7d353313
commit a4bf8442d7
13 files changed
+3301 -282

No files matched your search

@@ -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, &current_name, column));
statements.push(build_sqlserver_alter_column_preserving_default_sql(
table,
&current_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, &current_name));
@@ -760,15 +765,10 @@ pub(super) fn build_sqlserver_existing_column_sql(
statements.push(build_sqlserver_drop_default_constraint_sql(table, &current_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, &current_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('\'', "''");
@@ -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<String> {
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<String> {
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<String> {
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<String> {
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}");
}
}
@@ -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::<String>::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");
@@ -62,6 +62,26 @@ fn is_sql_string_literal(value: &str) -> bool {
true
}
pub(crate) fn sqlserver_unicode_string_literal(value: &str) -> Option<String> {
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)", "中文"), "'中文'");
}
}