mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 04:10:49 +00:00
fix(transfer): split reused Oracle table DDL and transferred trigger DDL
This commit is contained in:
@@ -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()]);
|
||||
}
|
||||
@@ -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 = "\
|
||||
|
||||
Reference in New Issue
Block a user