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

+19
View File
@@ -262,6 +262,11 @@ pub fn oracle_alternate_connect_configs(config: &ConnectionConfig, err: &str) ->
if config.db_type != DatabaseType::Oracle {
return Vec::new();
}
if config.oracle_connection_type.as_deref() == Some("tns") {
// TNS owns its complete address/failover descriptor; host-based retries would
// replace the configured alias with unrelated Service Name/SID URLs.
return Vec::new();
}
if config.connection_string.as_deref().is_some_and(|value| !value.trim().is_empty()) {
return Vec::new();
}
@@ -804,6 +809,20 @@ mod tests {
assert_eq!(service["connection_string"], "jdbc:oracle:thin:@//oracle.example.com:1521/ORCL");
}
#[test]
fn oracle_tns_does_not_retry_with_host_based_descriptors() {
let mut cfg = config(DatabaseType::Oracle, Some("DBX_FAILOVER"));
cfg.oracle_connection_type = Some("tns".to_string());
cfg.connection_string =
Some("jdbc:oracle:thin:@DBX_FAILOVER?TNS_ADMIN=%2Fopt%2Foracle%2Fnetwork%2Fadmin".to_string());
assert!(oracle_alternate_connect_configs(
&cfg,
"ORA-12514: listener does not currently know of service requested"
)
.is_empty());
}
#[test]
fn oracle_sys_user_connects_as_sysdba_for_agent_protocol() {
let mut cfg = config(DatabaseType::Oracle, Some("ORCLPDB1"));
+19
View File
@@ -1796,6 +1796,11 @@ impl AppState {
if transport_layers.is_empty() {
return Ok((config.host.clone(), config.port));
}
if config.uses_oracle_tns() {
// A TNS descriptor may contain several failover addresses, so rewriting it
// through one local tunnel endpoint would silently break Oracle Net routing.
return Err("Oracle TNS connections cannot be combined with SSH, proxy, or HTTP tunnel layers. Remove the transport layer or use Service Name/SID mode.".to_string());
}
let (remote_host, remote_port) = connection_remote_endpoint(config);
let local_port = db::transport_layer_tunnel::start_transport_layers(
@@ -5547,6 +5552,20 @@ for line in sys.stdin:
}
}
#[tokio::test]
async fn oracle_tns_connection_rejects_transport_layers() {
let (state, dir) = test_app_state().await;
let mut config = mysql_config(Some("DBX_FAILOVER"));
config.db_type = DatabaseType::Oracle;
config.oracle_connection_type = Some("tns".to_string());
config.transport_layers = vec![TransportLayerConfig::Ssh(ssh_layer("tns-tunnel", ""))];
let error = state.connection_host_port("oracle-tns", &config).await.unwrap_err();
assert!(error.contains("cannot be combined with SSH, proxy, or HTTP tunnel"));
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn resolved_transport_layers_substitutes_shared_profiles() {
let (state, dir) = test_app_state().await;
+5
View File
@@ -810,6 +810,11 @@ impl ConnectionConfig {
!self.effective_transport_layers().is_empty()
}
pub fn uses_oracle_tns(&self) -> bool {
self.db_type == DatabaseType::Oracle
&& self.oracle_connection_type.as_deref().is_some_and(|mode| mode.eq_ignore_ascii_case("tns"))
}
pub fn has_effective_ssh_tunnels(&self) -> bool {
self.effective_transport_layers().iter().any(|layer| matches!(layer, TransportLayerConfig::Ssh(_)))
}
@@ -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()
}