diff --git a/crates/dbx-core/src/data/transfer.rs b/crates/dbx-core/src/data/transfer.rs index 4177c42379..ec347a25a7 100644 --- a/crates/dbx-core/src/data/transfer.rs +++ b/crates/dbx-core/src/data/transfer.rs @@ -115,6 +115,11 @@ static INLINE_FOREIGN_KEY_CONSTRAINT_LINE_RE: std::sync::LazyLock = std:: .expect("valid inline foreign key constraint line regex") }); +static ORACLE_TRIGGER_STATEMENT_RE: std::sync::LazyLock = 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 { } 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( 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 { + // 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( 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\ diff --git a/crates/dbx-core/tests/live_oracle_transfer.rs b/crates/dbx-core/tests/live_oracle_transfer.rs new file mode 100644 index 0000000000..4e6e347d34 --- /dev/null +++ b/crates/dbx-core/tests/live_oracle_transfer.rs @@ -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::().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 { + 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()]); +} diff --git a/crates/dbx-sql-core/src/sql.rs b/crates/dbx-sql-core/src/sql.rs index 0d950284d6..7ec959f0c9 100644 --- a/crates/dbx-sql-core/src/sql.rs +++ b/crates/dbx-sql-core/src/sql.rs @@ -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 + // , 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 = "\