feat(oracle): add TNS connections and trigger editing

This commit is contained in:
t8y2 committed 2026-07-23 23:03:45 +08:00
1 parent 9a5edae219
commit 3b004abb1e
23 files changed
+980 -53

No files matched your search

@@ -3243,6 +3243,115 @@ fn builds_mysql_trigger_changes() {
);
}
#[test]
fn builds_oracle_multi_event_row_trigger_change() {
let mut existing = trigger(
"DBX_TRIGGER_4320_AUDIT",
"AFTER EACH ROW",
"INSERT OR UPDATE OR DELETE",
"DECLARE\n v_event VARCHAR2(10);\nBEGIN\n v_event := CASE WHEN INSERTING THEN 'INSERT' WHEN UPDATING THEN 'UPDATE' ELSE 'DELETE' END;\nEND;",
);
existing.original = Some(TriggerInfo {
name: "DBX_TRIGGER_4320_AUDIT".to_string(),
event: "INSERT OR UPDATE OR DELETE".to_string(),
timing: "AFTER EACH ROW".to_string(),
statement: Some("BEGIN\n NULL;\nEND;".to_string()),
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Oracle),
schema: Some("APP".to_string()),
table_name: "DBX_TRIGGER_4320".to_string(),
columns: Vec::new(),
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: vec![existing],
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(
result.statements,
vec![
"CREATE OR REPLACE TRIGGER \"APP\".\"DBX_TRIGGER_4320_AUDIT\" AFTER INSERT OR UPDATE OR DELETE ON \"APP\".\"DBX_TRIGGER_4320\"\nFOR EACH ROW\nDECLARE\n v_event VARCHAR2(10);\nBEGIN\n v_event := CASE WHEN INSERTING THEN 'INSERT' WHEN UPDATING THEN 'UPDATE' ELSE 'DELETE' END;\nEND;",
]
);
}
#[test]
fn builds_oracle_statement_trigger_without_row_clause() {
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Oracle),
schema: Some("APP".to_string()),
table_name: "ORDERS".to_string(),
columns: Vec::new(),
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: vec![trigger("ORDERS_AUDIT", "BEFORE STATEMENT", "UPDATE OF STATUS", "BEGIN\n NULL;\nEND;\n/")],
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(
result.statements,
vec![
"CREATE OR REPLACE TRIGGER \"APP\".\"ORDERS_AUDIT\" BEFORE UPDATE OF STATUS ON \"APP\".\"ORDERS\"\nBEGIN\n NULL;\nEND;",
]
);
}
#[test]
fn renaming_oracle_trigger_drops_old_name_before_create() {
let mut existing = trigger("ORDERS_AUDIT_V2", "AFTER EACH ROW", "INSERT", "BEGIN\n NULL;\nEND;");
existing.original = Some(TriggerInfo {
name: "ORDERS_AUDIT".to_string(),
event: "INSERT".to_string(),
timing: "AFTER EACH ROW".to_string(),
statement: Some("BEGIN\n NULL;\nEND;".to_string()),
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Oracle),
schema: Some("APP".to_string()),
table_name: "ORDERS".to_string(),
columns: Vec::new(),
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: vec![existing],
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(
result.statements,
vec![
"DROP TRIGGER \"APP\".\"ORDERS_AUDIT\";",
"CREATE OR REPLACE TRIGGER \"APP\".\"ORDERS_AUDIT_V2\" AFTER INSERT ON \"APP\".\"ORDERS\"\nFOR EACH ROW\nBEGIN\n NULL;\nEND;",
]
);
}
#[test]
fn rejects_unsupported_oracle_compound_trigger_shape() {
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Oracle),
schema: Some("APP".to_string()),
table_name: "ORDERS".to_string(),
columns: Vec::new(),
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: vec![trigger("ORDERS_CT", "COMPOUND", "UPDATE", "BEGIN\n NULL;\nEND;")],
table_comment: None,
original_table_comment: None,
});
assert!(result.statements.is_empty());
assert_eq!(result.warnings, vec!["Unsupported Oracle trigger timing \"COMPOUND\"."]);
}
#[test]
fn mysql_varchar_default_is_quoted() {
let mut col = column("name");
@@ -7,19 +7,20 @@ pub(super) fn build_trigger_sql(options: &TableStructureSqlOptions, warnings: &m
return Vec::new();
}
let dialect = super::dialect::capabilities_for(options.database_type).dialect;
let database_label = database_label(options.database_type);
if super::dialect::capabilities_for(options.database_type).dialect != StructureDialect::Mysql {
if !matches!(dialect, StructureDialect::Mysql | StructureDialect::Oracle) {
warnings.push(format!("Editing triggers is not supported for {database_label} from this editor."));
return Vec::new();
}
let table = qualified_table(StructureDialect::Mysql, options.schema.as_deref(), &options.table_name);
let table = qualified_table(dialect, options.schema.as_deref(), &options.table_name);
let mut statements = Vec::new();
for trigger in &options.triggers {
if trigger.marked_for_drop {
if let Some(original) = &trigger.original {
statements.push(drop_trigger_sql(options.schema.as_deref(), &original.name));
statements.push(drop_trigger_sql(dialect, options.schema.as_deref(), &original.name));
}
continue;
}
@@ -28,10 +29,13 @@ pub(super) fn build_trigger_sql(options: &TableStructureSqlOptions, warnings: &m
if !has_trigger_change(trigger, original) {
continue;
}
statements.push(drop_trigger_sql(options.schema.as_deref(), &original.name));
// Oracle can replace a trigger in place; renames still require dropping the old object.
if dialect != StructureDialect::Oracle || clean(&trigger.name) != clean(&original.name) {
statements.push(drop_trigger_sql(dialect, options.schema.as_deref(), &original.name));
}
}
if let Some(sql) = create_trigger_sql(&table, trigger, warnings) {
if let Some(sql) = create_trigger_sql(dialect, options.schema.as_deref(), &table, trigger, warnings) {
statements.push(sql);
}
}
@@ -46,45 +50,125 @@ fn has_trigger_change(trigger: &EditableStructureTrigger, original: &TriggerInfo
|| normalize_statement(&trigger.statement) != normalize_statement(original.statement.as_deref().unwrap_or(""))
}
fn drop_trigger_sql(schema: Option<&str>, name: &str) -> String {
fn drop_trigger_sql(dialect: StructureDialect, schema: Option<&str>, name: &str) -> String {
let qualified_name = if schema.is_some_and(|schema| !schema.trim().is_empty()) {
format!(
"{}.{}",
quote_ident(StructureDialect::Mysql, schema.unwrap()),
quote_ident(StructureDialect::Mysql, name)
)
format!("{}.{}", quote_ident(dialect, schema.unwrap()), quote_ident(dialect, name))
} else {
quote_ident(StructureDialect::Mysql, name)
quote_ident(dialect, name)
};
format!("DROP TRIGGER {qualified_name};")
}
fn create_trigger_sql(table: &str, trigger: &EditableStructureTrigger, warnings: &mut Vec<String>) -> Option<String> {
fn create_trigger_sql(
dialect: StructureDialect,
schema: Option<&str>,
table: &str,
trigger: &EditableStructureTrigger,
warnings: &mut Vec<String>,
) -> Option<String> {
let name = clean(&trigger.name);
let timing = normalize_keyword(&trigger.timing);
let event = normalize_keyword(&trigger.event);
let event = clean(&trigger.event);
let statement = clean(&trigger.statement);
if name.is_empty() || timing.is_empty() || event.is_empty() || statement.is_empty() {
warnings.push("Trigger name, timing, event, and statement are required.".to_string());
return None;
}
if !matches!(timing.as_str(), "BEFORE" | "AFTER") {
warnings.push(format!("Unsupported trigger timing \"{}\".", clean(&trigger.timing)));
match dialect {
StructureDialect::Mysql => create_mysql_trigger_sql(table, &name, &timing, &event, &statement, warnings),
StructureDialect::Oracle => {
create_oracle_trigger_sql(schema, table, &name, &timing, &event, &statement, warnings)
}
_ => None,
}
}
fn create_mysql_trigger_sql(
table: &str,
name: &str,
timing: &str,
event: &str,
statement: &str,
warnings: &mut Vec<String>,
) -> Option<String> {
let event = normalize_keyword(event);
if !matches!(timing, "BEFORE" | "AFTER") {
warnings.push(format!("Unsupported trigger timing \"{timing}\"."));
return None;
}
if !matches!(event.as_str(), "INSERT" | "UPDATE" | "DELETE") {
warnings.push(format!("Unsupported trigger event \"{}\".", clean(&trigger.event)));
warnings.push(format!("Unsupported trigger event \"{}\".", clean(&event)));
return None;
}
Some(format!(
"CREATE TRIGGER {} {timing} {event} ON {table} FOR EACH ROW\n{};",
quote_ident(StructureDialect::Mysql, &name),
quote_ident(StructureDialect::Mysql, name),
statement.trim_end_matches(';').trim_end()
))
}
fn create_oracle_trigger_sql(
schema: Option<&str>,
table: &str,
name: &str,
timing: &str,
event: &str,
statement: &str,
warnings: &mut Vec<String>,
) -> Option<String> {
let Some((timing_clause, row_level)) = oracle_trigger_timing(timing) else {
warnings.push(format!("Unsupported Oracle trigger timing \"{timing}\"."));
return None;
};
if !is_supported_oracle_trigger_event(event) {
warnings.push(format!("Unsupported Oracle trigger event \"{}\".", clean(event)));
return None;
}
let trigger_name = if schema.is_some_and(|schema| !schema.trim().is_empty()) {
format!(
"{}.{}",
quote_ident(StructureDialect::Oracle, schema.unwrap()),
quote_ident(StructureDialect::Oracle, name)
)
} else {
quote_ident(StructureDialect::Oracle, name)
};
let row_clause = if row_level { "\nFOR EACH ROW" } else { "" };
let statement = statement.trim_end().trim_end_matches('/').trim_end().trim_end_matches(';').trim_end();
Some(format!(
"CREATE OR REPLACE TRIGGER {trigger_name} {timing_clause} {event} ON {table}{row_clause}\n{statement};"
))
}
fn oracle_trigger_timing(timing: &str) -> Option<(&'static str, bool)> {
match timing {
"BEFORE" | "BEFORE EACH ROW" => Some(("BEFORE", true)),
"AFTER" | "AFTER EACH ROW" => Some(("AFTER", true)),
"INSTEAD OF" | "INSTEAD OF EACH ROW" => Some(("INSTEAD OF", true)),
"BEFORE STATEMENT" => Some(("BEFORE", false)),
"AFTER STATEMENT" => Some(("AFTER", false)),
_ => None,
}
}
fn is_supported_oracle_trigger_event(event: &str) -> bool {
if event.contains([';', '\n', '\r']) || event.contains("--") || event.contains("/*") {
return false;
}
let upper = event.to_ascii_uppercase();
let clauses: Vec<&str> = upper.split(" OR ").map(str::trim).collect();
!clauses.is_empty()
&& clauses.iter().all(|clause| {
matches!(*clause, "INSERT" | "DELETE")
|| *clause == "UPDATE"
|| clause.strip_prefix("UPDATE OF ").is_some_and(|columns| !columns.trim().is_empty())
})
}
fn normalize_keyword(value: &str) -> String {
clean(value).to_ascii_uppercase()
}