mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 06:00:39 +00:00
fix(transfer): retarget SQL Server DDL schemas and comment owners
This commit is contained in:
@@ -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)";
|
||||
|
||||
Reference in New Issue
Block a user