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
@@ -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 &[
|
||||
|
||||
-1
@@ -202,7 +202,6 @@ expression: all
|
||||
"drop_sequence",
|
||||
"alter_owner",
|
||||
"grant_revoke",
|
||||
"if_not_exists",
|
||||
"temporary_table",
|
||||
"transactional_ddl",
|
||||
"identity_columns"
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ expression: desc
|
||||
---
|
||||
{
|
||||
"dialect": "sql_server",
|
||||
"flags": 3086983151,
|
||||
"flags": 3019874287,
|
||||
"max_identifier_length": 128,
|
||||
"supports_schemas": true,
|
||||
"supports_catalogs": true,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user