fix(sqlserver): complete schema diff DDL support
This commit is contained in:
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, ¤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('\'', "''");
|
||||
|
||||
@@ -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)", "中文"), "'中文'");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user