fix(transfer): split reused Oracle table DDL and transferred trigger DDL

This commit is contained in:
Jeff
2026-10-12 18:28:02 +08:00
committed by GitHub
parent 97e3045a7c
commit cdb4ab5c80
3 changed files with 416 additions and 3 deletions
+162 -2
View File
@@ -115,6 +115,11 @@ static INLINE_FOREIGN_KEY_CONSTRAINT_LINE_RE: std::sync::LazyLock<Regex> = std::
.expect("valid inline foreign key constraint line regex")
});
static ORACLE_TRIGGER_STATEMENT_RE: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r"(?i)^\s*(?:CREATE\s+(?:OR\s+REPLACE\s+)?(?:(?:NON)?EDITIONABLE\s+)?|ALTER\s+)TRIGGER\s")
.expect("valid Oracle trigger statement regex")
});
// Upper bound for a single generated INSERT/upsert statement. Raised from
// 512 KiB so one `batchSize` page normally becomes one multi-row INSERT.
// MySQL-family targets additionally cap batches by the live
@@ -7458,6 +7463,15 @@ fn transfer_ddl_statements(sql: &str, db_type: &DatabaseType) -> Vec<String> {
} else {
statements
}
} else if matches!(db_type, DatabaseType::Oracle) {
// The Oracle agent appends index, trigger and comment DDL to the table
// DDL, which the target rejects as one statement with `ORA-03405`.
let statements = split_sql_statements_for_database(sql, *db_type);
if statements.is_empty() {
vec![sql.trim().to_string()]
} else {
statements.into_iter().filter(|statement| !is_oracle_trigger_statement(statement)).collect()
}
} else {
vec![sql.to_string()]
}
@@ -7517,6 +7531,15 @@ fn is_postgres_post_table_index_statement(statement: &str) -> bool {
|| normalized.starts_with("COMMENT ON INDEX ")
}
/// Trigger DDL is dropped from reused Oracle table DDL. Created together with the
/// table, a trigger would fire while transfer copies the rows, and one that reads
/// a sequence is still invalid then because sequences are transferred after
/// tables, so every insert fails with `ORA-04098`. Selected triggers are created
/// by the schema object phase instead, which runs after the data and the sequences.
fn is_oracle_trigger_statement(statement: &str) -> bool {
ORACLE_TRIGGER_STATEMENT_RE.is_match(statement)
}
/// Standalone `ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY` statements are
/// dropped from reused PostgreSQL-dialect DDL, mirroring how inline FK lines are
/// stripped from `CREATE TABLE`: openGauss's `pg_get_tabledef()` emits one ALTER
@@ -8668,6 +8691,7 @@ where
transfer_oracle_schema_objects(
state,
&filtered_request,
&target_db_type,
source_pool_key,
target_pool_key,
progress_callback,
@@ -8763,6 +8787,7 @@ fn resolve_oracle_schema(schema: &str, database: &str) -> String {
async fn transfer_oracle_schema_objects<F>(
state: &AppState,
request: &TransferRequest,
target_db_type: &DatabaseType,
source_pool_key: &str,
target_pool_key: &str,
mut progress_callback: F,
@@ -8816,8 +8841,8 @@ where
.ok_or_else(|| format!("No DDL returned for Oracle {:?} {name}", kind))?
.to_string();
let ddl = rewrite_oracle_schema_qualifier(&ddl, &source_schema, &target_schema);
match execute_on_pool(state, target_pool_key, &ddl).await {
Ok(_) => {
match execute_oracle_object_ddl_on_pool(state, target_pool_key, &ddl, &kind, target_db_type).await {
Ok(()) => {
outcome.transferred.push(format!("{kind:?}:{name}"));
progress(&mut outcome, TransferStatus::Running, None);
}
@@ -8831,6 +8856,38 @@ where
Ok(outcome)
}
/// `DBMS_METADATA.GET_DDL('TRIGGER', ...)` returns the trigger body followed by an
/// `ALTER TRIGGER ... ENABLE` statement. Sent as one statement, Oracle compiles the
/// `ALTER` as part of the PL/SQL body and the trigger is created invalid with
/// `PLS-00103`, so Oracle targets run each statement separately.
async fn execute_oracle_object_ddl_on_pool(
state: &AppState,
pool_key: &str,
ddl: &str,
kind: &TransferObjectKind,
target_db_type: &DatabaseType,
) -> Result<(), String> {
for statement in oracle_object_ddl_statements(ddl, kind, target_db_type) {
execute_on_pool(state, pool_key, &statement).await?;
}
Ok(())
}
fn oracle_object_ddl_statements(ddl: &str, kind: &TransferObjectKind, target_db_type: &DatabaseType) -> Vec<String> {
// Only trigger DDL carries a trailing statement. Every other object kind is a
// single CREATE statement and keeps being sent as returned, so a routine body or
// a `WITH FUNCTION` view is never cut at its inner semicolons.
if !matches!(target_db_type, DatabaseType::Oracle) || !matches!(kind, TransferObjectKind::Trigger) {
return vec![ddl.to_string()];
}
let statements = split_sql_statements_for_database(ddl, *target_db_type);
if statements.is_empty() {
vec![ddl.trim().to_string()]
} else {
statements
}
}
async fn transfer_sqlserver_schema_objects<F>(
state: &AppState,
request: &TransferRequest,
@@ -16622,6 +16679,109 @@ CREATE TABLE "Other"."prefix""Source"."NAME" ("ID" INT);"#;
);
}
#[test]
fn oracle_transfer_ddl_splits_reused_table_ddl_and_drops_triggers() {
let ddl = "\n CREATE TABLE \"APP\".\"ORDERS\" (\"ID\" NUMBER, \"NOTE\" VARCHAR2(50));\n\n\
CREATE INDEX \"APP\".\"ORDERS_NOTE_IX\" ON \"APP\".\"ORDERS\" (\"NOTE\");\n\n\
CREATE OR REPLACE EDITIONABLE TRIGGER \"APP\".\"ORDERS_TRG\" BEFORE INSERT ON \"APP\".\"ORDERS\" FOR EACH ROW\n\
BEGIN\n IF :NEW.ID IS NULL THEN SELECT \"APP\".\"ORDERS_SEQ\".NEXTVAL INTO :NEW.ID FROM DUAL; END IF;\nEND;\n\
ALTER TRIGGER \"APP\".\"ORDERS_TRG\" ENABLE;\n\n\
COMMENT ON TABLE \"APP\".\"ORDERS\" IS 'trigger; orders';";
let statements = transfer_ddl_statements(ddl, &DatabaseType::Oracle);
assert_eq!(
statements,
vec![
"CREATE TABLE \"APP\".\"ORDERS\" (\"ID\" NUMBER, \"NOTE\" VARCHAR2(50))".to_string(),
"CREATE INDEX \"APP\".\"ORDERS_NOTE_IX\" ON \"APP\".\"ORDERS\" (\"NOTE\")".to_string(),
"COMMENT ON TABLE \"APP\".\"ORDERS\" IS 'trigger; orders'".to_string(),
]
);
}
#[test]
fn oracle_trigger_statement_detection_ignores_other_ddl() {
assert!(is_oracle_trigger_statement("CREATE TRIGGER t BEFORE INSERT ON x FOR EACH ROW BEGIN NULL; END;"));
assert!(is_oracle_trigger_statement("create or replace noneditionable trigger \"T\" ..."));
assert!(is_oracle_trigger_statement("\n ALTER TRIGGER \"APP\".\"T\" ENABLE"));
assert!(!is_oracle_trigger_statement("CREATE TABLE \"TRIGGER_LOG\" (\"ID\" NUMBER)"));
assert!(!is_oracle_trigger_statement("COMMENT ON TABLE \"T\" IS 'CREATE TRIGGER x'"));
assert!(!is_oracle_trigger_statement("ALTER TABLE \"T\" DISABLE ALL TRIGGERS"));
}
#[test]
fn oracle_object_ddl_splits_trigger_enable_statement_for_oracle_targets() {
let ddl = "\n CREATE OR REPLACE EDITIONABLE TRIGGER \"APP\".\"ORDERS_TRG\" BEFORE INSERT ON \"APP\".\"ORDERS\" FOR EACH ROW\n\
BEGIN\n NULL;\nEND;\n\
ALTER TRIGGER \"APP\".\"ORDERS_TRG\" ENABLE";
let statements = oracle_object_ddl_statements(ddl, &TransferObjectKind::Trigger, &DatabaseType::Oracle);
assert_eq!(statements.len(), 2, "{statements:#?}");
assert!(statements[0].starts_with("CREATE OR REPLACE EDITIONABLE TRIGGER"));
assert!(statements[0].ends_with("END;"));
assert_eq!(statements[1], "ALTER TRIGGER \"APP\".\"ORDERS_TRG\" ENABLE");
assert_eq!(
oracle_object_ddl_statements(ddl, &TransferObjectKind::Trigger, &DatabaseType::Dameng),
vec![ddl.to_string()]
);
}
// Only trigger DDL carries a trailing statement; other object DDL must reach the
// target exactly as `DBMS_METADATA` returned it, never cut at inner semicolons.
#[test]
fn oracle_object_ddl_keeps_non_trigger_objects_whole() {
let cases = [
(
TransferObjectKind::Procedure,
"\n CREATE OR REPLACE EDITIONABLE PROCEDURE \"APP\".\"P\" AS\n PROCEDURE inner_p IS BEGIN NULL; END;\nBEGIN\n inner_p;\nEND;",
),
(
TransferObjectKind::Function,
"\n CREATE OR REPLACE EDITIONABLE FUNCTION \"APP\".\"F\" RETURN NUMBER AS\n FUNCTION g RETURN NUMBER IS BEGIN RETURN 1; END g;\nBEGIN\n RETURN g;\nEND;",
),
(
TransferObjectKind::View,
"\n CREATE OR REPLACE FORCE EDITIONABLE VIEW \"APP\".\"V\" AS WITH FUNCTION f RETURN NUMBER IS BEGIN RETURN 1; END; SELECT f FROM dual",
),
];
for (kind, ddl) in cases {
assert_eq!(
oracle_object_ddl_statements(ddl, &kind, &DatabaseType::Oracle),
vec![ddl.to_string()],
"{kind:?}"
);
}
}
#[test]
fn oracle_object_ddl_splits_compound_trigger_enable_statement() {
let trigger =
"CREATE OR REPLACE EDITIONABLE TRIGGER \"APP\".\"T\" FOR INSERT ON \"APP\".\"X\" COMPOUND TRIGGER\n \
BEFORE EACH ROW IS BEGIN NULL; END BEFORE EACH ROW;\nEND \"T\";";
let ddl = format!("\n {trigger}\nALTER TRIGGER \"APP\".\"T\" ENABLE");
assert_eq!(
oracle_object_ddl_statements(&ddl, &TransferObjectKind::Trigger, &DatabaseType::Oracle),
vec![trigger.to_string(), "ALTER TRIGGER \"APP\".\"T\" ENABLE".to_string()]
);
}
#[test]
fn oracle_transfer_ddl_drops_compound_trigger_whole() {
let ddl = "\n CREATE TABLE \"APP\".\"X\" (\"ID\" NUMBER);\n\n\
CREATE OR REPLACE EDITIONABLE TRIGGER \"APP\".\"T\" FOR INSERT ON \"APP\".\"X\" COMPOUND TRIGGER\n \
BEFORE EACH ROW IS BEGIN NULL; END BEFORE EACH ROW;\nEND \"T\";\n\
ALTER TRIGGER \"APP\".\"T\" ENABLE;";
assert_eq!(
transfer_ddl_statements(ddl, &DatabaseType::Oracle),
vec!["CREATE TABLE \"APP\".\"X\" (\"ID\" NUMBER)".to_string()]
);
}
#[test]
fn xugu_transfer_ddl_splits_table_constraints_indexes_and_comments() {
let ddl = "CREATE TABLE \"APP\".\"ITEMS\" (\"ID\" INTEGER, \"NOTE\" VARCHAR(100));\n\
@@ -0,0 +1,169 @@
use dbx_core::connection::AppState;
use dbx_core::models::connection::{ConnectionConfig, DatabaseType};
use dbx_core::query::execute_sql_statement;
use dbx_core::transfer::{
ensure_transfer_pool, transfer_schema_objects, transfer_table, TransferContent, TransferMode, TransferObjectKind,
TransferObjectSelection, TransferOwnershipPolicy, TransferRequest, TransferTableNameCase,
};
use std::sync::Arc;
fn live_oracle_config(id: &str, username: &str) -> ConnectionConfig {
let host = std::env::var("DBX_LIVE_ORACLE_HOST").expect("DBX_LIVE_ORACLE_HOST");
let port = std::env::var("DBX_LIVE_ORACLE_PORT").ok().and_then(|value| value.parse::<u16>().ok()).unwrap_or(1521);
let service = std::env::var("DBX_LIVE_ORACLE_SERVICE").expect("DBX_LIVE_ORACLE_SERVICE");
let password = std::env::var("DBX_LIVE_ORACLE_PASSWORD").expect("DBX_LIVE_ORACLE_PASSWORD");
serde_json::from_value(serde_json::json!({
"id": id,
"name": id,
"db_type": DatabaseType::Oracle,
"host": host,
"port": port,
"username": username,
"password": password,
"database": service,
"connect_timeout_secs": 10,
"query_timeout_secs": 60,
"idle_timeout_secs": 60,
"keepalive_interval_secs": 0
}))
.expect("live Oracle config should deserialize")
}
fn cells(result: &dbx_core::types::QueryResult) -> Vec<String> {
result
.rows
.iter()
.filter_map(|row| row.first())
.map(|value| match value {
serde_json::Value::String(value) => value.clone(),
value => value.to_string(),
})
.collect()
}
/// Both users must own disposable schemas and share `DBX_LIVE_ORACLE_PASSWORD`.
#[tokio::test]
#[ignore = "requires DBX_LIVE_ORACLE_SOURCE_USER and DBX_LIVE_ORACLE_TARGET_USER on a disposable Oracle"]
async fn live_oracle_transfer_creates_table_with_index_comment_and_trigger() {
let suffix = uuid::Uuid::new_v4().simple().to_string()[..8].to_uppercase();
let dir = std::env::temp_dir().join(format!("dbx-live-oracle-transfer-{suffix}"));
std::fs::create_dir_all(&dir).unwrap();
let storage = dbx_core::persistence::test_storage::open(&dir.join("storage.db")).await.unwrap();
let state = Arc::new(AppState::new(storage));
let service = std::env::var("DBX_LIVE_ORACLE_SERVICE").unwrap();
let source_schema =
std::env::var("DBX_LIVE_ORACLE_SOURCE_USER").expect("DBX_LIVE_ORACLE_SOURCE_USER").to_uppercase();
let target_schema =
std::env::var("DBX_LIVE_ORACLE_TARGET_USER").expect("DBX_LIVE_ORACLE_TARGET_USER").to_uppercase();
let source_id = format!("live-oracle-transfer-src-{suffix}");
let target_id = format!("live-oracle-transfer-dst-{suffix}");
state.configs.write().await.insert(source_id.clone(), live_oracle_config(&source_id, &source_schema));
state.configs.write().await.insert(target_id.clone(), live_oracle_config(&target_id, &target_schema));
let table = format!("DBX_TRANSFER_ORDERS_{suffix}");
let sequence = format!("DBX_TRANSFER_SEQ_{suffix}");
let trigger = format!("DBX_TRANSFER_TRG_{suffix}");
let run = |connection_id: String, schema: String, sql: String| {
let state = state.clone();
let service = service.clone();
async move { execute_sql_statement(&state, &connection_id, &service, &sql, Some(&schema), None).await }
};
let source = |sql: String| run(source_id.clone(), source_schema.clone(), sql);
let target = |sql: String| run(target_id.clone(), target_schema.clone(), sql);
let drop_all = [format!("DROP TABLE {table} PURGE"), format!("DROP SEQUENCE {sequence}")];
for sql in &drop_all {
let _ = source(sql.clone()).await;
let _ = target(sql.clone()).await;
}
for sql in [
format!("CREATE TABLE {table} (ID NUMBER PRIMARY KEY, NOTE VARCHAR2(50))"),
format!("CREATE INDEX {table}_NOTE_IX ON {table} (NOTE)"),
format!("COMMENT ON TABLE {table} IS 'transfer orders'"),
format!("CREATE SEQUENCE {sequence} START WITH 100 INCREMENT BY 5 NOCACHE"),
format!(
"CREATE OR REPLACE TRIGGER {trigger} BEFORE INSERT ON {table} FOR EACH ROW\n\
BEGIN\n IF :NEW.ID IS NULL THEN SELECT {sequence}.NEXTVAL INTO :NEW.ID FROM DUAL; END IF;\nEND;"
),
format!("INSERT INTO {table} (NOTE) VALUES ('first')"),
] {
source(sql).await.expect("source setup");
}
let request = TransferRequest {
table_filters: Default::default(),
transfer_id: format!("live-oracle-transfer-{suffix}"),
source_connection_id: source_id.clone(),
source_database: service.clone(),
source_schema: source_schema.clone(),
source_catalog: None,
target_connection_id: target_id.clone(),
target_database: service.clone(),
target_schema: target_schema.clone(),
target_catalog: None,
tables: vec![table.clone()],
create_table: true,
drop_target_before_create: false,
drop_target_confirmed: false,
content: TransferContent::StructureAndData,
objects: Some(vec![
TransferObjectSelection { object_type: TransferObjectKind::Sequence, names: vec![sequence.clone()] },
TransferObjectSelection { object_type: TransferObjectKind::Trigger, names: vec![trigger.clone()] },
]),
mode: TransferMode::Append,
target_table_name_case: TransferTableNameCase::Preserve,
quote_target_column_names: true,
ownership_policy: TransferOwnershipPolicy::Preserve,
batch_size: 100,
};
let source_pool = ensure_transfer_pool(&state, &source_id, &service, None).await.unwrap();
let target_pool = ensure_transfer_pool(&state, &target_id, &service, None).await.unwrap();
let table_result = transfer_table(
&state,
&request,
&table,
0,
&DatabaseType::Oracle,
&DatabaseType::Oracle,
&source_pool,
&target_pool,
&Default::default(),
&mut Vec::new(),
None,
|_| {},
)
.await;
let mut object_errors = Vec::new();
let objects_result = transfer_schema_objects(&state, &request, &source_pool, &target_pool, |progress| {
object_errors.extend(progress.error);
})
.await;
let indexes = target(format!("SELECT INDEX_NAME FROM USER_INDEXES WHERE TABLE_NAME = '{table}'")).await;
let comment = target(format!("SELECT COMMENTS FROM USER_TAB_COMMENTS WHERE TABLE_NAME = '{table}'")).await;
let trigger_state = target(format!(
"SELECT o.STATUS || '/' || t.STATUS || '/' || t.TABLE_OWNER FROM USER_OBJECTS o \
JOIN USER_TRIGGERS t ON t.TRIGGER_NAME = o.OBJECT_NAME WHERE o.OBJECT_NAME = '{trigger}'"
))
.await;
let insert = target(format!("INSERT INTO {table} (NOTE) VALUES ('after')")).await;
let ids = target(format!("SELECT ID FROM {table} ORDER BY ID")).await;
let source_triggers = source(format!("SELECT COUNT(*) FROM ALL_TRIGGERS WHERE TABLE_NAME = '{table}'")).await;
for sql in &drop_all {
let _ = source(sql.clone()).await;
let _ = target(sql.clone()).await;
}
let _ = std::fs::remove_dir_all(dir);
assert_eq!(table_result, Ok(1));
let objects = objects_result.expect("schema objects");
assert!(objects.failed.is_empty(), "{objects:?} {object_errors:?}");
assert!(cells(&indexes.unwrap()).contains(&format!("{table}_NOTE_IX")));
assert_eq!(cells(&comment.unwrap()), vec!["transfer orders".to_string()]);
assert_eq!(cells(&trigger_state.unwrap()), vec![format!("VALID/ENABLED/{target_schema}")]);
insert.expect("the transferred trigger fills ID from the transferred sequence");
assert_eq!(cells(&ids.unwrap()), vec!["100".to_string(), "105".to_string()]);
assert_eq!(cells(&source_triggers.unwrap()), vec!["1".to_string()]);
}
+85 -1
View File
@@ -3094,6 +3094,10 @@ impl OraclePlSqlBlock {
}
None => Vec::new(),
};
// A stored PROCEDURE/FUNCTION has no DECLARE keyword: its declare section
// starts right after its own IS/AS. Opening it there lets a local subprogram
// own its BEGIN ... END instead of ending the routine.
let mut awaiting_routine_declarations = self.is_create_routine();
let mut saw_begin = false;
let mut complete = false;
@@ -3105,7 +3109,10 @@ impl OraclePlSqlBlock {
continue;
}
if token.is_word("DECLARE") {
if awaiting_routine_declarations && token.is_any_word(&["IS", "AS"]) {
awaiting_routine_declarations = false;
scopes.push(OraclePlSqlScope::Declaration);
} else if token.is_word("DECLARE") {
if !matches!(scopes.last(), Some(OraclePlSqlScope::Declaration)) {
scopes.push(OraclePlSqlScope::Declaration);
}
@@ -3175,12 +3182,38 @@ impl OraclePlSqlBlock {
[object, body, ..] if object.is_any_word(&["PACKAGE", "TYPE"]) && body.is_word("BODY") => {
Some(OraclePlSqlCreateObjectKind::Body)
}
// A compound trigger's timing-point sections each own a BEGIN ... END
// <timing point>, and only the outer END [trigger] finishes it.
[object, header @ ..] if object.is_word("TRIGGER") && Self::is_compound_trigger_header(header) => {
Some(OraclePlSqlCreateObjectKind::Body)
}
// Only PACKAGE specs lack BEGIN; plain TYPE objects are ordinary SQL.
[object, ..] if object.is_word("PACKAGE") => Some(OraclePlSqlCreateObjectKind::Spec),
_ => None,
}
}
/// Whether a trigger header (the tokens after `TRIGGER`) declares a `COMPOUND TRIGGER`.
/// Only the header is inspected: it ends at the body of a simple trigger.
fn is_compound_trigger_header(tokens: &[OraclePlSqlToken]) -> bool {
let header_end = tokens
.iter()
.position(|token| token.is_semicolon() || token.is_any_word(&["BEGIN", "DECLARE", "CALL"]))
.unwrap_or(tokens.len());
tokens[..header_end].windows(2).any(|pair| pair[0].is_word("COMPOUND") && pair[1].is_word("TRIGGER"))
}
/// `CREATE [OR REPLACE] [EDITIONABLE] PROCEDURE|FUNCTION ...`.
fn is_create_routine(&self) -> bool {
let [first, rest @ ..] = self.tokens.as_slice() else {
return false;
};
first.is_word("CREATE")
&& Self::skip_create_modifiers(rest)
.first()
.is_some_and(|object| object.is_any_word(&["PROCEDURE", "FUNCTION"]))
}
/// Skip OR REPLACE / FORCE / NOFORCE / EDITIONABLE modifiers after CREATE.
fn skip_create_modifiers(tokens: &[OraclePlSqlToken]) -> &[OraclePlSqlToken] {
let mut rest = tokens;
@@ -4775,6 +4808,57 @@ SELECT 2 FROM DUMMY;";
);
}
// A stored routine declares its local subprograms right after its own IS/AS, with
// no DECLARE keyword, so their END must not finish the routine.
#[test]
fn oracle_split_keeps_stored_routines_with_local_subprograms_together() {
let cases = [
"CREATE OR REPLACE EDITIONABLE PROCEDURE \"A\".\"P\" AS\n PROCEDURE inner_p IS BEGIN NULL; END;\nBEGIN\n inner_p;\nEND;",
"CREATE PROCEDURE p(x NUMBER) IS\n v NUMBER;\n PROCEDURE inner_p IS\n BEGIN\n NULL;\n END inner_p;\nBEGIN\n inner_p;\nEND p;",
"CREATE OR REPLACE FUNCTION f RETURN NUMBER AS\n FUNCTION g RETURN NUMBER IS BEGIN RETURN 1; END g;\nBEGIN\n RETURN g;\nEND;",
"CREATE OR REPLACE PROCEDURE p AS\n PROCEDURE fwd(x NUMBER);\n PROCEDURE fwd(x NUMBER) IS BEGIN NULL; END;\nBEGIN\n fwd(1);\nEND;",
"CREATE OR REPLACE PROCEDURE p AS\n CURSOR c IS SELECT 1 FROM dual;\nBEGIN\n NULL;\nEND;",
];
for routine in cases {
for database in [DatabaseType::Oracle, DatabaseType::Dameng, DatabaseType::Xugu] {
assert_eq!(
split_sql_statements_for_database(&format!("{routine}\nSELECT 1 FROM dual;"), database),
vec![routine.to_string(), "SELECT 1 FROM dual".to_string()],
"{database:?}: {routine}"
);
}
}
}
#[test]
fn oracle_split_keeps_compound_trigger_together() {
let trigger = "\
CREATE OR REPLACE EDITIONABLE TRIGGER \"A\".\"T\" FOR INSERT OR UPDATE ON \"A\".\"X\" COMPOUND TRIGGER
total NUMBER := 0;
BEFORE EACH ROW IS
BEGIN
IF :NEW.ID IS NULL THEN total := total + 1; END IF;
END BEFORE EACH ROW;
AFTER STATEMENT IS
BEGIN
NULL;
END AFTER STATEMENT;
END \"T\";";
assert_eq!(
split_sql_statements_for_database(
&format!("{trigger}\nALTER TRIGGER \"A\".\"T\" ENABLE;"),
DatabaseType::Oracle
),
vec![trigger.to_string(), "ALTER TRIGGER \"A\".\"T\" ENABLE".to_string()]
);
// A simple trigger still ends at its own END.
let simple = "CREATE TRIGGER t BEFORE INSERT ON x FOR EACH ROW BEGIN NULL; END;";
assert_eq!(
split_sql_statements_for_database(&format!("{simple}\nSELECT 1 FROM dual;"), DatabaseType::Oracle),
vec![simple.to_string(), "SELECT 1 FROM dual".to_string()]
);
}
#[test]
fn oracle_like_split_keeps_anonymous_plsql_block_together() {
let sql = "\