feat(oracle): add TNS connections and trigger editing
This commit is contained in:
1 parent
9a5edae219
commit
3b004abb1e
23 files changed
+980
-53
No files matched your search
@@ -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"));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user