mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
fix(oracle): extract trigger body from driver source when cloning
This commit is contained in:
@@ -1438,6 +1438,74 @@ fn oracle_create_table_distinguishes_new_and_referenced_foreign_key_identifiers(
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_create_table_extracts_single_line_trigger_source_into_the_body() {
|
||||
let mut id = column("id");
|
||||
id.data_type = "NUMBER".to_string();
|
||||
// Oracle returns ALL_SOURCE verbatim when a trigger is written on a single line, so the
|
||||
// declaration must be stripped before it is appended after `FOR EACH ROW`. t8y2/dbx#9731.
|
||||
let cloned_trigger = trigger(
|
||||
"DBX_V1_TRG_COPY_TRG1",
|
||||
"BEFORE",
|
||||
"INSERT",
|
||||
"TRIGGER dbx_v1_trg_bi BEFORE INSERT ON dbx_v1_trg FOR EACH ROW BEGIN NULL; END;",
|
||||
);
|
||||
|
||||
let result = build_create_table_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Oracle),
|
||||
driver_profile: None,
|
||||
schema: Some("APP".to_string()),
|
||||
table_name: "dbx_copy".to_string(),
|
||||
columns: vec![id],
|
||||
indexes: Vec::new(),
|
||||
foreign_keys: Vec::new(),
|
||||
triggers: vec![cloned_trigger],
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
mysql_engine: None,
|
||||
partitioned: false,
|
||||
is_gaussdb_m_mode: false,
|
||||
table_collation: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec![
|
||||
"CREATE TABLE \"APP\".dbx_copy (\n \"id\" NUMBER\n);",
|
||||
"CREATE OR REPLACE TRIGGER \"APP\".DBX_V1_TRG_COPY_TRG1 BEFORE INSERT ON \"APP\".dbx_copy\nFOR EACH ROW\nBEGIN NULL; END;",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_create_table_warns_instead_of_emitting_an_unparsed_trigger_declaration() {
|
||||
let mut id = column("id");
|
||||
id.data_type = "NUMBER".to_string();
|
||||
let unsplittable = trigger("odd_trg", "BEFORE", "INSERT", "TRIGGER odd_trg COMPOUND TRIGGER");
|
||||
|
||||
let result = build_create_table_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::Oracle),
|
||||
driver_profile: None,
|
||||
schema: Some("APP".to_string()),
|
||||
table_name: "dbx_copy".to_string(),
|
||||
columns: vec![id],
|
||||
indexes: Vec::new(),
|
||||
foreign_keys: Vec::new(),
|
||||
triggers: vec![unsplittable],
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
mysql_engine: None,
|
||||
partitioned: false,
|
||||
is_gaussdb_m_mode: false,
|
||||
table_collation: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.statements, vec!["CREATE TABLE \"APP\".dbx_copy (\n \"id\" NUMBER\n);"]);
|
||||
assert_eq!(result.warnings.len(), 1);
|
||||
assert!(result.warnings[0].contains("odd_trg"), "unexpected warning: {:?}", result.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_existing_quoted_identifiers_keep_exact_spelling() {
|
||||
let mut column = column("CamelCase");
|
||||
|
||||
@@ -306,13 +306,143 @@ fn create_oracle_trigger_sql(
|
||||
trigger_identifier
|
||||
};
|
||||
let row_clause = if row_level { "\nFOR EACH ROW" } else { "" };
|
||||
let statement = oracle_trigger_body(statement);
|
||||
if starts_with_trigger_declaration(statement) {
|
||||
// Oracle triggers are edited through their complete source definition, so a declaration we
|
||||
// could not strip means dumping the raw statement after our own `FOR EACH ROW` would send
|
||||
// invalid DDL to the server (ORA-04079) and abort the clone half way.
|
||||
warnings.push(format!(
|
||||
"Trigger \"{name}\" could not be cloned automatically; its stored source is not a plain PL/SQL body. Recreate it manually on {table}."
|
||||
));
|
||||
return None;
|
||||
}
|
||||
let statement = statement.trim_end().trim_end_matches('/').trim_end().trim_end_matches(';').trim_end();
|
||||
if statement.is_empty() {
|
||||
warnings.push(format!("Trigger \"{name}\" has an empty body and was skipped."));
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(format!(
|
||||
"CREATE OR REPLACE TRIGGER {trigger_name} {timing_clause} {event} ON {table}{row_clause}\n{statement};"
|
||||
))
|
||||
}
|
||||
|
||||
/// Oracle triggers come back from the driver as `ALL_SOURCE` text. When the dictionary declaration
|
||||
/// and the stored source cannot be aligned line by line — a trigger written on a single line — the
|
||||
/// raw source is returned instead of the body, and appending it after our own `FOR EACH ROW`
|
||||
/// produces invalid DDL (`ORA-04079`). Strip the declaration the same way `sqlserver_trigger_body`
|
||||
/// strips `CREATE TRIGGER ... AS`.
|
||||
fn oracle_trigger_body(statement: &str) -> &str {
|
||||
let statement = statement.trim();
|
||||
if !starts_with_trigger_declaration(statement) {
|
||||
return statement;
|
||||
}
|
||||
match oracle_trigger_body_start(statement) {
|
||||
Some(index) => statement[index..].trim_end(),
|
||||
None => statement,
|
||||
}
|
||||
}
|
||||
|
||||
fn starts_with_trigger_declaration(statement: &str) -> bool {
|
||||
let keywords = leading_keywords(statement, 4);
|
||||
match keywords.first().map(String::as_str) {
|
||||
Some("TRIGGER") => true,
|
||||
Some("CREATE") => keywords.iter().any(|keyword| keyword == "TRIGGER"),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn leading_keywords(statement: &str, limit: usize) -> Vec<String> {
|
||||
let mut keywords = Vec::new();
|
||||
let mut current = String::new();
|
||||
for ch in statement.chars() {
|
||||
if ch.is_ascii_alphanumeric() || ch == '_' {
|
||||
current.push(ch.to_ascii_uppercase());
|
||||
continue;
|
||||
}
|
||||
if !current.is_empty() {
|
||||
keywords.push(std::mem::take(&mut current));
|
||||
if keywords.len() == limit {
|
||||
return keywords;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !current.is_empty() && keywords.len() < limit {
|
||||
keywords.push(current);
|
||||
}
|
||||
keywords
|
||||
}
|
||||
|
||||
/// Returns the offset where the PL/SQL block of an Oracle trigger declaration starts, skipping
|
||||
/// string literals, quoted identifiers, and comments so a `WHEN` condition cannot be mistaken for
|
||||
/// the body. The declaration always closes with the `ON <table>` clause before the body begins.
|
||||
fn oracle_trigger_body_start(statement: &str) -> Option<usize> {
|
||||
let bytes = statement.as_bytes();
|
||||
let mut index = 0;
|
||||
let mut seen_on = false;
|
||||
let mut word_start: Option<usize> = None;
|
||||
while index < bytes.len() {
|
||||
let ch = bytes[index] as char;
|
||||
if ch == '\'' || ch == '"' {
|
||||
index += 1;
|
||||
while index < bytes.len() {
|
||||
if bytes[index] as char == ch {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
word_start = None;
|
||||
continue;
|
||||
}
|
||||
if ch == '-' && bytes.get(index + 1) == Some(&b'-') {
|
||||
while index < bytes.len() && bytes[index] != b'\n' {
|
||||
index += 1;
|
||||
}
|
||||
word_start = None;
|
||||
continue;
|
||||
}
|
||||
if ch == '/' && bytes.get(index + 1) == Some(&b'*') {
|
||||
index += 2;
|
||||
while index + 1 < bytes.len() && !(bytes[index] == b'*' && bytes[index + 1] == b'/') {
|
||||
index += 1;
|
||||
}
|
||||
index = (index + 2).min(bytes.len());
|
||||
word_start = None;
|
||||
continue;
|
||||
}
|
||||
if ch.is_ascii_alphanumeric() || ch == '_' {
|
||||
word_start.get_or_insert(index);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if let Some(start) = word_start.take() {
|
||||
let word = &statement[start..index];
|
||||
if is_oracle_trigger_body_keyword(word, seen_on) {
|
||||
return Some(start);
|
||||
}
|
||||
if word.eq_ignore_ascii_case("ON") {
|
||||
seen_on = true;
|
||||
}
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
if let Some(start) = word_start {
|
||||
let word = &statement[start..];
|
||||
if is_oracle_trigger_body_keyword(word, seen_on) {
|
||||
return Some(start);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_oracle_trigger_body_keyword(word: &str, seen_on: bool) -> bool {
|
||||
seen_on
|
||||
&& (word.eq_ignore_ascii_case("DECLARE")
|
||||
|| word.eq_ignore_ascii_case("BEGIN")
|
||||
|| word.eq_ignore_ascii_case("CALL"))
|
||||
}
|
||||
|
||||
fn oracle_trigger_timing(timing: &str) -> Option<(&'static str, bool)> {
|
||||
match timing {
|
||||
"BEFORE" | "BEFORE EACH ROW" => Some(("BEFORE", true)),
|
||||
@@ -360,4 +490,36 @@ mod tests {
|
||||
let leading_as = "AS SELECT 2";
|
||||
assert_eq!(sqlserver_trigger_body(leading_as), "SELECT 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_trigger_body_strips_single_line_dictionary_source() {
|
||||
// Oracle stores a trigger written on one line with its declaration and body on the same
|
||||
// line, so the driver cannot align ALL_SOURCE with the dictionary DESCRIPTION and returns
|
||||
// the whole statement. t8y2/dbx#9731.
|
||||
let source = "trigger dbx_v1_trg_bi before insert on dbx_v1_trg for each row begin null; end;";
|
||||
assert_eq!(oracle_trigger_body(source), "begin null; end;");
|
||||
|
||||
let with_when = "TRIGGER audit_trg BEFORE INSERT ON \"HR\".\"ORDERS\" FOR EACH ROW WHEN (NEW.STATUS <> 'BEGIN') BEGIN NULL; END;";
|
||||
assert_eq!(oracle_trigger_body(with_when), "BEGIN NULL; END;");
|
||||
|
||||
let full_ddl = "CREATE OR REPLACE TRIGGER \"HR\".\"AUDIT_TRG\" BEFORE INSERT ON \"HR\".\"ORDERS\" FOR EACH ROW\nDECLARE\n v NUMBER;\nBEGIN\n NULL;\nEND;";
|
||||
assert_eq!(oracle_trigger_body(full_ddl), "DECLARE\n v NUMBER;\nBEGIN\n NULL;\nEND;");
|
||||
|
||||
let call_trigger = "TRIGGER call_trg BEFORE INSERT ON HR.ORDERS FOR EACH ROW CALL log_insert()";
|
||||
assert_eq!(oracle_trigger_body(call_trigger), "CALL log_insert()");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_trigger_body_keeps_plain_bodies_untouched() {
|
||||
let body = "DECLARE\n v NUMBER;\nBEGIN\n v := 1; -- ON BEGIN\nEND;";
|
||||
assert_eq!(oracle_trigger_body(body), body);
|
||||
|
||||
// A body that merely mentions the keywords must not be truncated.
|
||||
let literal = "BEGIN\n INSERT INTO log_v2 VALUES ('on', 'begin');\nEND;";
|
||||
assert_eq!(oracle_trigger_body(literal), literal);
|
||||
|
||||
// Without the ON clause the declaration cannot be split safely, so the source is kept.
|
||||
let unsplittable = "TRIGGER odd_trg COMPOUND TRIGGER";
|
||||
assert_eq!(oracle_trigger_body(unsplittable), unsplittable);
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 151 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 154 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
Reference in New Issue
Block a user