fix(transfer): retarget SQL Server DDL schemas and comment owners

This commit is contained in:
dienaso
2026-10-11 21:32:30 +08:00
committed by GitHub
parent 169fc05087
commit 29cab5c0b8
+283
View File
@@ -5267,11 +5267,143 @@ fn rewrite_transfer_source_table_ddl(
// and string literals remain untouched; table casing is validated as
// Preserve for structure transfers.
Some(rewrite_double_quoted_schema_qualifier(sql, source_schema, target_schema))
} else if matches!((source_db_type, target_db_type), (DatabaseType::SqlServer, DatabaseType::SqlServer)) {
// SQL Server's reused DDL brackets the schema qualifier in the CREATE
// TABLE head and in FOREIGN KEY ... REFERENCES; reusing it verbatim
// recreated the table in the source schema (or failed outright when
// the target had no schema of that name). Rewrite only the bracketed
// source qualifier — cross-schema references to third-party tables
// keep their own qualifier, exactly like the Oracle-family rewrite.
Some(rewrite_sqlserver_bracketed_schema_qualifier(sql, source_schema, target_schema))
} else {
Some(sql.to_string())
}
}
fn rewrite_sqlserver_bracketed_schema_qualifier(ddl: &str, source_schema: &str, target_schema: &str) -> String {
if source_schema == target_schema || source_schema.is_empty() {
return ddl.to_string();
}
let source_identifier = format!("[{}]", source_schema.replace(']', "]]"));
let target_identifier = format!("[{}]", target_schema.replace(']', "]]"));
let source_literal = format!("N'{}'", source_schema.replace('\'', "''"));
let target_literal = format!("N'{}'", target_schema.replace('\'', "''"));
let tokens = sqlserver_ddl_tokens(ddl);
let token_text = |token: &(usize, usize)| &ddl[token.0..token.1];
let mut output = String::with_capacity(ddl.len());
let mut previous = 0;
for statement in tokens.split(|token| token_text(token) == ";") {
let has_argument = |name: &str, value: &str| {
statement.windows(3).any(|argument| {
token_text(&argument[0]).eq_ignore_ascii_case(name)
&& token_text(&argument[1]) == "="
&& token_text(&argument[2]).eq_ignore_ascii_case(value)
})
};
let generated_comment = statement.len() >= 4
&& matches!(token_text(&statement[0]).to_ascii_uppercase().as_str(), "EXEC" | "EXECUTE")
&& token_text(&statement[1]).eq_ignore_ascii_case("sys")
&& token_text(&statement[2]) == "."
&& token_text(&statement[3]).eq_ignore_ascii_case("sp_addextendedproperty")
&& has_argument("@name", "N'MS_Description'")
&& has_argument("@level0type", "N'SCHEMA'")
&& has_argument("@level1type", "N'TABLE'");
for (index, token) in statement.iter().enumerate() {
let replacement = if token_text(token) == source_identifier
&& statement.get(index + 1).is_some_and(|next| token_text(next) == ".")
{
&target_identifier
} else if generated_comment
&& token_text(token) == source_literal
&& index >= 2
&& token_text(&statement[index - 2]).eq_ignore_ascii_case("@level0name")
&& token_text(&statement[index - 1]) == "="
{
&target_literal
} else {
continue;
};
output.push_str(&ddl[previous..token.0]);
output.push_str(replacement);
previous = token.1;
}
}
output.push_str(&ddl[previous..]);
output
}
fn sqlserver_ddl_tokens(ddl: &str) -> Vec<(usize, usize)> {
let bytes = ddl.as_bytes();
let mut tokens = Vec::new();
let mut position = 0;
while position < bytes.len() {
if bytes[position].is_ascii_whitespace() {
position += 1;
continue;
}
if bytes[position..].starts_with(b"--") {
while position < bytes.len() && !matches!(bytes[position], b'\n' | b'\r') {
position += 1;
}
continue;
}
if bytes[position..].starts_with(b"/*") {
let mut depth = 1;
position += 2;
while position < bytes.len() && depth > 0 {
if bytes[position..].starts_with(b"/*") {
depth += 1;
position += 2;
} else if bytes[position..].starts_with(b"*/") {
depth -= 1;
position += 2;
} else {
position += 1;
}
}
continue;
}
let start = position;
if matches!(bytes[position], b'N' | b'n') && bytes.get(position + 1) == Some(&b'\'') {
position += 1;
}
if matches!(bytes[position], b'\'' | b'"' | b'[') {
let delimiter = if bytes[position] == b'[' { b']' } else { bytes[position] };
position += 1;
while position < bytes.len() {
if bytes[position] == delimiter {
position += 1;
if bytes.get(position) == Some(&delimiter) {
position += 1;
} else {
break;
}
} else {
position += 1;
}
}
} else if bytes[position].is_ascii_alphanumeric()
|| matches!(bytes[position], b'_' | b'@' | b'#' | b'$')
|| !bytes[position].is_ascii()
{
while position < bytes.len()
&& (bytes[position].is_ascii_alphanumeric()
|| matches!(bytes[position], b'_' | b'@' | b'#' | b'$')
|| !bytes[position].is_ascii())
{
position += 1;
}
} else {
position += 1;
}
tokens.push((start, position));
}
tokens
}
/// Rewrites the table identifier of the leading CREATE TABLE statement of a reused
/// MySQL-family DDL. Only the last (table) segment of a qualified name is replaced.
/// Returns None when the statement head does not look like `CREATE [TEMPORARY] TABLE
@@ -16905,6 +17037,157 @@ PARTITION p_old VALUES LESS THAN (TO_DAYS('2026-01-01')))";
assert!(!rewritten.contains("`orders_plain`"));
}
#[test]
fn sqlserver_reused_ddl_rewrites_schema_qualifier_in_head_and_foreign_keys() {
// Issue #11533: the reused DDL kept `[src].` so the CREATE TABLE ran
// against the source schema — same-database transfers failed with 2714
// and cross-database ones either errored (2760) or silently created
// the table in a stray source-named schema.
let ddl = "CREATE TABLE [src].[DBXE_PARENT] (\n [ID] int NOT NULL,\n [NAME] nvarchar(50) NOT NULL,\n CONSTRAINT [PK_DBXE_PARENT] PRIMARY KEY ([ID])\n);\nCREATE TABLE [src].[DBXE_CHILD] (\n [ID] int NOT NULL,\n [PARENT_ID] int NOT NULL,\n CONSTRAINT [FK_DBXE_CHILD_PARENT] FOREIGN KEY ([PARENT_ID]) REFERENCES [src].[DBXE_PARENT]([ID])\n);";
let rewritten = rewrite_transfer_source_table_ddl(
ddl,
"src",
"dst",
&DatabaseType::SqlServer,
&DatabaseType::SqlServer,
"DBXE_PARENT",
"DBXE_PARENT",
)
.unwrap();
assert!(rewritten.contains("CREATE TABLE [dst].[DBXE_PARENT] ("), "head: {rewritten}");
assert!(rewritten.contains("CREATE TABLE [dst].[DBXE_CHILD] ("), "child head: {rewritten}");
assert!(rewritten.contains("REFERENCES [dst].[DBXE_PARENT]([ID])"), "fk: {rewritten}");
assert!(!rewritten.contains("[src]."), "no source qualifier may remain: {rewritten}");
}
#[test]
fn sqlserver_reused_ddl_keeps_cross_schema_references_and_literals() {
let ddl = "CREATE TABLE [src].[child] (\n [note] nvarchar(50) NOT NULL DEFAULT 'mirrors [src].[keep]',\n [ref_id] int NOT NULL,\n CONSTRAINT [fk_other] FOREIGN KEY ([ref_id]) REFERENCES [other].[target]([id])\n);";
let rewritten = rewrite_transfer_source_table_ddl(
ddl,
"src",
"dst",
&DatabaseType::SqlServer,
&DatabaseType::SqlServer,
"child",
"child",
)
.unwrap();
assert!(rewritten.contains("CREATE TABLE [dst].[child] ("), "head: {rewritten}");
// A third-party table referenced from another schema must not be
// repointed at the target schema, where it does not exist.
assert!(rewritten.contains("REFERENCES [other].[target]([id])"), "cross-schema ref: {rewritten}");
// String literals keep their original text.
assert!(rewritten.contains("'mirrors [src].[keep]'"), "literal: {rewritten}");
}
#[test]
fn sqlserver_reused_ddl_preserves_identity_and_escaped_brackets() {
// Same schema on both sides: the DDL is reused verbatim.
assert_eq!(
rewrite_transfer_source_table_ddl(
"CREATE TABLE [dbo].[t] ([id] int);",
"dbo",
"dbo",
&DatabaseType::SqlServer,
&DatabaseType::SqlServer,
"t",
"t"
),
Some("CREATE TABLE [dbo].[t] ([id] int);".to_string())
);
// A schema name containing `]` appears in the DDL with its escaped
// form (`]` doubled inside brackets) and is rewritten as a whole.
let rewritten = rewrite_transfer_source_table_ddl(
"CREATE TABLE [we]]ird].[t] ([id] int);",
"we]ird",
"plain",
&DatabaseType::SqlServer,
&DatabaseType::SqlServer,
"t",
"t",
)
.unwrap();
assert_eq!(rewritten, "CREATE TABLE [plain].[t] ([id] int);");
}
#[test]
fn sqlserver_reused_ddl_retargets_generated_table_and_column_comments() {
let columns = vec![db::ColumnInfo {
name: "src".into(),
data_type: "nvarchar(50)".into(),
comment: Some("column src [src]. C:\\".into()),
..Default::default()
}];
let ddl =
crate::schema::render_sqlserver_table_ddl("src", "src", &columns, &[], &[], Some("table's src [src]."));
let rewritten = rewrite_sqlserver_bracketed_schema_qualifier(&ddl, "src", "dst'雪]");
assert!(rewritten.contains("CREATE TABLE [dst'雪]]].[src]"), "{rewritten}");
assert_eq!(rewritten.matches("@level0name=N'dst''雪]'").count(), 2, "{rewritten}");
assert!(rewritten.contains("@value=N'table''s src [src].'"), "{rewritten}");
assert!(rewritten.contains("@value=N'column src [src]. C:\\'"), "{rewritten}");
assert_eq!(rewritten.matches("@level1name=N'src'").count(), 2, "{rewritten}");
assert!(rewritten.contains("@level2name=N'src'"), "{rewritten}");
}
#[test]
fn sqlserver_reused_ddl_keeps_windows_defaults_and_rewrites_later_qualifiers() {
let ddl = "CREATE TABLE [src].[child] (\n [path] nvarchar(50) DEFAULT ('C:\\'),\n [note] nvarchar(50) DEFAULT (N'it''s [src]. C:\\'),\n [id] int,\n FOREIGN KEY ([id]) REFERENCES [src].[parent]([id])\n);\nCREATE INDEX [ix] ON [src].[child] ([id]);";
let expected = ddl
.replace("CREATE TABLE [src].", "CREATE TABLE [dst].")
.replace("REFERENCES [src].", "REFERENCES [dst].")
.replace("ON [src].", "ON [dst].");
assert_eq!(rewrite_sqlserver_bracketed_schema_qualifier(ddl, "src", "dst"), expected);
}
#[test]
fn sqlserver_reused_ddl_handles_schema_token_characters_and_comment_owners() {
for source_schema in ["sales#old", "o'connor", "we]ird", "other[src", "we--ird", "we/*ird", "雪#'[]"] {
let source_identifier = format!("[{}]", source_schema.replace(']', "]]"));
let source_literal = format!("N'{}'", source_schema.replace('\'', "''"));
let ddl = format!("CREATE TABLE {source_identifier}.[t] ([id] int);\nEXEC sys.sp_addextendedproperty @name=N'MS_Description', @value={source_literal}, @level0type=N'SCHEMA', @level0name={source_literal}, @level1type=N'TABLE', @level1name=N't';");
let rewritten = rewrite_sqlserver_bracketed_schema_qualifier(&ddl, source_schema, "dst");
let expected = ddl
.replace(&format!("CREATE TABLE {source_identifier}."), "CREATE TABLE [dst].")
.replace(&format!("@level0name={source_literal}"), "@level0name=N'dst'");
assert_eq!(rewritten, expected, "source schema: {source_schema}");
}
}
#[test]
fn sqlserver_reused_ddl_preserves_nested_comments_and_whole_third_party_identifiers() {
let ddl = "/* outer /* inner */ [src].[comment] ' # */\n-- [src].[line]\rCREATE TABLE [src] /* [src]. */ . [child] (\n [name#'/*[src]]] int,\n [other] int,\n [note] nvarchar(50) DEFAULT '/* [src]. */',\n FOREIGN KEY ([other]) REFERENCES [other[src].[parent]([id]),\n FOREIGN KEY ([name#'/*[src]]]) REFERENCES [other]]src].[parent]([id])\n);";
let expected = ddl.replace("CREATE TABLE [src]", "CREATE TABLE [dst]");
assert_eq!(rewrite_sqlserver_bracketed_schema_qualifier(ddl, "src", "dst"), expected);
}
#[test]
fn sqlserver_reused_ddl_rewrites_only_generated_extended_property_schema_arguments() {
let generated = "EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'src', @level0type=N'SCHEMA', @level0name=N'src', @level1type=N'TABLE', @level1name=N'src';";
for unrelated in [
generated.replace("sys.sp_addextendedproperty", "dbo.sp_addextendedproperty"),
generated.replace("sp_addextendedproperty", "sp_updateextendedproperty"),
generated.replace("N'MS_Description'", "N'OtherProperty'"),
generated.replace("N'SCHEMA'", "N'USER'"),
generated.replace("N'TABLE'", "N'VIEW'"),
format!("/* {generated} */"),
format!("SELECT N'{}';", generated.replace('\'', "''")),
] {
assert_eq!(rewrite_sqlserver_bracketed_schema_qualifier(&unrelated, "src", "dst"), unrelated);
}
assert_eq!(
rewrite_sqlserver_bracketed_schema_qualifier(generated, "src", "dst"),
generated.replace("@level0name=N'src'", "@level0name=N'dst'")
);
assert_eq!(rewrite_sqlserver_bracketed_schema_qualifier(generated, "src", "src"), generated);
assert_eq!(rewrite_sqlserver_bracketed_schema_qualifier(generated, "", "dst"), generated);
}
#[test]
fn mysql_reused_ddl_keeps_identity_when_target_name_matches() {
let ddl = "CREATE TABLE `orders` (`id` int)";