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

+11 -6
View File
@@ -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).
@@ -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 &[
@@ -202,7 +202,6 @@ expression: all
"drop_sequence",
"alter_owner",
"grant_revoke",
"if_not_exists",
"temporary_table",
"transactional_ddl",
"identity_columns"
@@ -5,7 +5,7 @@ expression: desc
---
{
"dialect": "sql_server",
"flags": 3086983151,
"flags": 3019874287,
"max_identifier_length": 128,
"supports_schemas": true,
"supports_catalogs": true,
+222 -1
View File
@@ -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::<Vec<_>>();
let Ok(precision) = values.first().copied().unwrap_or_default().parse::<u32>() 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::<u32>() 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::<u32>() {
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::<u32>().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<String> {
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::<Vec<_>>()
.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<DialectKind>) -> 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);