From d10cdaa0fb4a3b9ed1c37f91a037f4e6f919ee09 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 13 Aug 2026 02:22:58 +0000 Subject: [PATCH] fix(oracle): support explain plans with custom JDBC drivers Closes #6050 --- .../__tests__/useSqlExecution.spec.ts | 24 +++ .../src/composables/useSqlExecution.ts | 4 +- crates/dbx-core/src/agent_explain.rs | 184 +++++++++++++++++- .../main/java/app/dbx/jdbc/DbxJdbcPlugin.java | 54 ++++- .../java/app/dbx/jdbc/DbxJdbcPluginTest.java | 102 ++++++++++ 5 files changed, 357 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts index d0b503e36e..20cae81642 100644 --- a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts @@ -734,6 +734,30 @@ SELECT @value AS Message;`; expect(activeTab.value?.result).toEqual(result); }); + it("uses the inferred Oracle dialect when explaining through custom JDBC", async () => { + const sql = "SELECT * FROM DUAL"; + const activeTab = ref({ ...queryTab("ORCL"), sql }); + const activeConnection = ref({ + ...connection("jdbc"), + connection_string: "jdbc:oracle:thin:@127.0.0.1:1521:ORCL", + jdbc_driver_class: "oracle.jdbc.OracleDriver", + }); + const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result"); + const queryStore = useQueryStore(); + const explainTabSql = vi.spyOn(queryStore, "explainTabSql").mockResolvedValue({ ok: true, sql }); + + const execution = useSqlExecution({ + activeTab: computed(() => activeTab.value), + activeConnection: computed(() => activeConnection.value), + executableSql: computed(() => sql), + activeOutputView, + }); + + await execution.tryExplain(); + + expect(explainTabSql).toHaveBeenCalledWith("tab-1", sql, "oracle", "explain"); + }); + it("keeps the new-result-tab intent through Redis command confirmation", async () => { const sql = "DEL user:1"; const activeTab = ref({ ...queryTab("0"), sql }); diff --git a/apps/desktop/src/composables/useSqlExecution.ts b/apps/desktop/src/composables/useSqlExecution.ts index 266aff4f39..caa09718c5 100644 --- a/apps/desktop/src/composables/useSqlExecution.ts +++ b/apps/desktop/src/composables/useSqlExecution.ts @@ -484,7 +484,9 @@ export function useSqlExecution(deps: { } deps.activeOutputView.value = "explain"; - const result = await queryStore.explainTabSql(tab.id, sql, deps.activeConnection.value?.db_type, explainMode.value); + const connection = deps.activeConnection.value; + const databaseType = effectiveDatabaseTypeForConnection(connection) ?? connection?.db_type; + const result = await queryStore.explainTabSql(tab.id, sql, databaseType, explainMode.value); if (!result.ok) { toast(explainReasonMessage(result.reason), 5000); return; diff --git a/crates/dbx-core/src/agent_explain.rs b/crates/dbx-core/src/agent_explain.rs index 15028992e4..3806871c78 100644 --- a/crates/dbx-core/src/agent_explain.rs +++ b/crates/dbx-core/src/agent_explain.rs @@ -1,6 +1,9 @@ +use std::time::Duration; + use serde_json::Value; use crate::connection::{AppState, PoolKind}; +use crate::models::connection::{ConnectionConfig, DatabaseType}; use crate::query_execution_sql::{is_safe_dameng_autotrace_sql, is_safe_explain_sql_for_database}; pub async fn get_agent_explain_info_core( @@ -14,21 +17,34 @@ pub async fn get_agent_explain_info_core( let mode = mode.unwrap_or("explain"); let (database_type, timeout_secs) = { let configs = state.configs.read().await; - let config = configs.get(connection_id).ok_or_else(|| "Connection config not found".to_string())?; - (config.db_type, config.query_timeout_secs) + let config = configs.get(connection_id).ok_or_else(|| "Connection config not found".to_string())?.clone(); + let database_type = explain_database_type(&config); + let timeout_secs = config.effective_query_timeout_secs(); + (database_type, timeout_secs) }; if !is_safe_agent_explain_sql(sql, mode, database_type) { return Err("unsafe".to_string()); } let database_for_pool = database.filter(|value| !value.trim().is_empty()); - state.get_or_create_pool(connection_id, database_for_pool).await?; + let pool_key = state.get_or_create_pool(connection_id, database_for_pool).await?; - let client = { + enum ExplainTarget { + Agent(std::sync::Arc), + External { + config: std::sync::Arc, + session: std::sync::Arc, + }, + } + + let target = { let connections = state.connections.read().await; - let pool = connections.get(connection_id).ok_or_else(|| "Connection not found".to_string())?; + let pool = connections.get(&pool_key).ok_or_else(|| "Connection not found".to_string())?; match pool { - PoolKind::Agent(client) => client.clone(), + PoolKind::Agent(client) => ExplainTarget::Agent(client.clone()), + PoolKind::ExternalDriver { config, session, .. } => { + ExplainTarget::External { config: config.clone(), session: session.clone() } + } _ => return Err("Connection is not an agent-based connection".to_string()), } }; @@ -40,12 +56,43 @@ pub async fn get_agent_explain_info_core( "timeoutSecs": timeout_secs as i64, "mode": mode, }); - let mut client = client.lock().await; - let result: Value = client.get_explain_info(params).await?; + let result: Value = match target { + ExplainTarget::Agent(client) => client.lock().await.get_explain_info(params).await?, + ExplainTarget::External { config, session } => { + let mut params = params; + params["connection"] = serde_json::to_value(config.as_ref()).map_err(|error| error.to_string())?; + let timeout = (timeout_secs > 0).then(|| Duration::from_secs(timeout_secs)); + session.invoke_with_timeout("getExplainInfo", params, timeout).await? + } + }; decode_agent_explain_result(result) } -fn is_safe_agent_explain_sql(sql: &str, mode: &str, database_type: crate::models::connection::DatabaseType) -> bool { +fn explain_database_type(config: &ConnectionConfig) -> DatabaseType { + if config.db_type != DatabaseType::Jdbc { + return config.db_type; + } + let oracle_hint = [ + config.connection_string.as_deref(), + config.jdbc_driver_class.as_deref(), + config.driver_profile.as_deref(), + config.driver_label.as_deref(), + config.database_info.as_ref().and_then(|info| info.product_name.as_deref()), + config.database_info.as_ref().and_then(|info| info.server_comment.as_deref()), + config.database_info.as_ref().and_then(|info| info.driver_name.as_deref()), + ] + .into_iter() + .flatten() + .chain(config.jdbc_driver_paths.iter().map(String::as_str)) + .any(|value| value.to_ascii_lowercase().contains("oracle")); + if oracle_hint { + DatabaseType::Oracle + } else { + DatabaseType::Jdbc + } +} + +fn is_safe_agent_explain_sql(sql: &str, mode: &str, database_type: DatabaseType) -> bool { if mode.eq_ignore_ascii_case("autotrace") { is_safe_dameng_autotrace_sql(sql) } else { @@ -64,7 +111,15 @@ fn decode_agent_explain_result(result: Value) -> Result { #[cfg(test)] mod tests { use super::*; + #[cfg(unix)] + use crate::connection::PoolKind; use crate::models::connection::DatabaseType; + #[cfg(unix)] + use crate::plugins::{ + InstalledPlugin, PluginDriverManifest, PluginDriverSession, PluginManifest, PluginRuntimeEnv, + }; + #[cfg(unix)] + use std::sync::Arc; #[test] fn allows_oracle_dml_explain_without_relaxing_autotrace() { @@ -86,4 +141,115 @@ mod tests { ); assert!(decode_agent_explain_result(serde_json::json!(["unexpected"])).is_err()); } + + #[test] + fn infers_only_oracle_custom_jdbc_for_oracle_explain_safety() { + let oracle: ConnectionConfig = serde_json::from_value(serde_json::json!({ + "id": "oracle-jdbc", + "name": "Oracle JDBC", + "db_type": "jdbc", + "host": "127.0.0.1", + "port": 1521, + "username": "system", + "password": "oracle", + "connection_string": "jdbc:oracle:thin:@127.0.0.1:1521:XE" + })) + .unwrap(); + let unknown: ConnectionConfig = serde_json::from_value(serde_json::json!({ + "id": "unknown-jdbc", + "name": "Unknown JDBC", + "db_type": "jdbc", + "host": "127.0.0.1", + "port": 9000, + "username": "user", + "password": "secret", + "connection_string": "jdbc:unknown://127.0.0.1:9000/app" + })) + .unwrap(); + + assert_eq!(explain_database_type(&oracle), DatabaseType::Oracle); + assert_eq!(explain_database_type(&unknown), DatabaseType::Jdbc); + assert!(is_safe_agent_explain_sql("UPDATE T SET C = 1", "explain", explain_database_type(&oracle))); + assert!(!is_safe_agent_explain_sql("UPDATE T SET C = 1", "explain", explain_database_type(&unknown))); + } + + #[cfg(unix)] + #[tokio::test] + async fn routes_oracle_jdbc_explain_through_the_external_driver_session() { + use std::os::unix::fs::PermissionsExt; + + let dir = std::env::temp_dir().join(format!("dbx-agent-explain-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let executable = dir.join("plugin.sh"); + std::fs::write( + &executable, + "#!/bin/sh\nwhile IFS= read -r line; do\n id=$(printf '%s' \"$line\" | sed -E 's/.*\"id\":([0-9]+).*/\\1/')\n printf '{\"id\":%s,\"result\":{\"plan\":\"TABLE ACCESS FULL DUAL\"}}\\n' \"$id\"\ndone\n", + ) + .unwrap(); + let mut permissions = std::fs::metadata(&executable).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&executable, permissions).unwrap(); + + let plugin = InstalledPlugin { + manifest: PluginManifest { + id: "jdbc".to_string(), + name: "JDBC".to_string(), + version: "test".to_string(), + protocol_version: 1, + description: String::new(), + executable: Some("plugin.sh".to_string()), + drivers: vec![PluginDriverManifest { + id: "jdbc".to_string(), + label: "JDBC".to_string(), + kind: "external".to_string(), + database_type: Some("jdbc".to_string()), + }], + }, + path: dir.clone(), + }; + let session = Arc::new( + PluginDriverSession::start_for_test(plugin, "jdbc".to_string(), PluginRuntimeEnv::default()).await.unwrap(), + ); + let config: crate::models::connection::ConnectionConfig = serde_json::from_value(serde_json::json!({ + "id": "oracle-jdbc", + "name": "Oracle JDBC", + "db_type": "jdbc", + "host": "127.0.0.1", + "port": 1521, + "username": "system", + "password": "oracle", + "database": "ORCL", + "connection_string": "jdbc:oracle:thin:@127.0.0.1:1521:ORCL", + "jdbc_driver_class": "oracle.jdbc.OracleDriver", + "query_timeout_secs": 30 + })) + .unwrap(); + let storage = crate::storage::Storage::open(&dir.join("storage.db")).await.unwrap(); + let state = AppState::new(storage); + state.configs.write().await.insert(config.id.clone(), config.clone()); + state.connections.write().await.insert( + "oracle-jdbc".to_string(), + PoolKind::ExternalDriver { + driver_id: "jdbc".to_string(), + config: Arc::new(config), + session: session.clone(), + }, + ); + + let plan = get_agent_explain_info_core( + &state, + "oracle-jdbc", + Some("ORCL"), + Some("SYSTEM"), + "UPDATE T SET C = 1", + Some("explain"), + ) + .await + .unwrap(); + + assert_eq!(plan, "TABLE ACCESS FULL DUAL"); + session.shutdown().await; + drop(state); + std::fs::remove_dir_all(dir).unwrap(); + } } diff --git a/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java b/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java index 5978c10cc3..7797395a1f 100644 --- a/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java +++ b/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java @@ -1076,6 +1076,11 @@ public final class DbxJdbcPlugin { String planText = null; String dmMethod = null; + if (!autotrace && isOracleConnection(connection)) { + planText = getOracleExplainInfo(conn, sql, timeoutSecs); + dmMethod = "oracle-plan-table"; + } + if (autotrace) { if (!isSafeAutotraceSql(sql)) { throw new IllegalArgumentException("unsafe"); @@ -1118,7 +1123,7 @@ public final class DbxJdbcPlugin { } catch (Exception ignored) {} } } - } else { + } else if (planText == null) { // ── Explain mode: direct plan via getExplainInfo(sqlStr), no execution ── try { Class dmConnClass = Class.forName("dm.jdbc.driver.DmdbConnection"); @@ -1153,6 +1158,53 @@ public final class DbxJdbcPlugin { return result; } + private static boolean isOracleConnection(JsonNode connection) { + String url = optionalText(connection, "connection_string"); + return url != null && url.regionMatches(true, 0, "jdbc:oracle:", 0, "jdbc:oracle:".length()); + } + + private static String getOracleExplainInfo(Connection connection, String sql, int timeoutSecs) throws SQLException { + String statementId = "DBX_" + UUID.randomUUID().toString().replace("-", "").substring(0, 26); + StringBuilder plan = new StringBuilder(); + try { + try (PreparedStatement explain = connection.prepareStatement( + "EXPLAIN PLAN SET STATEMENT_ID = '" + statementId + "' FOR " + trimStatementSql(sql) + )) { + applyExplainTimeout(explain, timeoutSecs); + explain.execute(); + } + try (PreparedStatement read = connection.prepareStatement( + "SELECT PLAN_TABLE_OUTPUT FROM TABLE(DBMS_XPLAN.DISPLAY('PLAN_TABLE', ?, 'TYPICAL +PREDICATE'))" + )) { + applyExplainTimeout(read, timeoutSecs); + read.setString(1, statementId); + try (ResultSet rows = read.executeQuery()) { + while (rows.next()) { + if (plan.length() > 0) plan.append('\n'); + plan.append(rows.getString(1)); + } + } + } + return plan.toString(); + } finally { + try (PreparedStatement cleanup = connection.prepareStatement( + "DELETE FROM PLAN_TABLE WHERE STATEMENT_ID = ?" + )) { + applyExplainTimeout(cleanup, timeoutSecs); + cleanup.setString(1, statementId); + cleanup.executeUpdate(); + } catch (SQLException ignored) {} + } + } + + private static void applyExplainTimeout(Statement statement, int timeoutSecs) throws SQLException { + if (timeoutSecs >= 0) { + try { + statement.setQueryTimeout(timeoutSecs); + } catch (SQLFeatureNotSupportedException | UnsupportedOperationException ignored) {} + } + } + private static void applyStatementOptions( Statement statement, int maxRows, diff --git a/plugins/jdbc/src/test/java/app/dbx/jdbc/DbxJdbcPluginTest.java b/plugins/jdbc/src/test/java/app/dbx/jdbc/DbxJdbcPluginTest.java index 68c7804d18..7aa2d53a9d 100644 --- a/plugins/jdbc/src/test/java/app/dbx/jdbc/DbxJdbcPluginTest.java +++ b/plugins/jdbc/src/test/java/app/dbx/jdbc/DbxJdbcPluginTest.java @@ -1184,6 +1184,47 @@ final class DbxJdbcPluginTest { assertEquals(true, calls.contains("setQueryTimeout")); } + @Test + void oracleExplainUsesPlanTableOnTheSharedConnectionAndCleansUp() throws Exception { + List calls = new ArrayList<>(); + OracleExplainDriver driver = new OracleExplainDriver(calls); + DriverManager.registerDriver(driver); + String connection = """ + { + "connection_string": "jdbc:oracle:dbx-explain:test", + "username": "system", + "query_timeout_secs": 30 + } + """; + try { + JsonNode response = request("getExplainInfo", """ + { + "connection": %s, + "sql": "SELECT * FROM DUAL", + "timeoutSecs": 30, + "mode": "explain" + } + """.formatted(connection)); + + assertFalse(response.has("error"), response.toString()); + assertEquals("Plan hash value: 123\nTABLE ACCESS FULL DUAL", response.path("result").path("plan").asText()); + assertEquals(1, calls.stream().filter(call -> call.equals("connect")).count()); + String explainCall = calls.stream() + .filter(call -> call.startsWith("prepare:EXPLAIN PLAN SET STATEMENT_ID = 'DBX_")) + .findFirst() + .orElseThrow(); + String statementId = explainCall.substring( + explainCall.indexOf("'") + 1, + explainCall.indexOf("'", explainCall.indexOf("'") + 1) + ); + assertEquals(1, calls.stream().filter(call -> call.startsWith("prepare:SELECT PLAN_TABLE_OUTPUT FROM TABLE(DBMS_XPLAN.DISPLAY")).count()); + assertEquals(1, calls.stream().filter(call -> call.equals("prepare:DELETE FROM PLAN_TABLE WHERE STATEMENT_ID = ?")).count()); + assertEquals(2, calls.stream().filter(call -> call.equals("bind:1:" + statementId)).count()); + } finally { + closeAndDeregister(connection, driver); + } + } + @Test void optInStatementOptionsCanApplyDriverMaxRowsProtection() throws Exception { Method method = DbxJdbcPlugin.class.getDeclaredMethod( @@ -2987,6 +3028,67 @@ final class DbxJdbcPluginTest { ); } + private static final class OracleExplainDriver implements Driver { + private final List calls; + + private OracleExplainDriver(List calls) { + this.calls = calls; + } + + @Override + public Connection connect(String url, Properties info) { + if (!acceptsURL(url)) return null; + calls.add("connect"); + return oracleExplainConnection(calls); + } + + @Override + public boolean acceptsURL(String url) { + return url != null && url.startsWith("jdbc:oracle:dbx-explain:"); + } + + @Override public DriverPropertyInfo[] getPropertyInfo(String url, Properties info) { return new DriverPropertyInfo[0]; } + @Override public int getMajorVersion() { return 1; } + @Override public int getMinorVersion() { return 0; } + @Override public boolean jdbcCompliant() { return false; } + @Override public java.util.logging.Logger getParentLogger() { return java.util.logging.Logger.getGlobal(); } + } + + private static Connection oracleExplainConnection(List calls) { + return (Connection) Proxy.newProxyInstance( + DbxJdbcPluginTest.class.getClassLoader(), + new Class[] { Connection.class }, + (proxy, method, args) -> switch (method.getName()) { + case "prepareStatement" -> oracleExplainStatement(String.valueOf(args[0]), calls); + case "isClosed" -> false; + case "close" -> null; + default -> defaultValue(method.getReturnType()); + } + ); + } + + private static PreparedStatement oracleExplainStatement(String sql, List calls) { + calls.add("prepare:" + sql); + return (PreparedStatement) Proxy.newProxyInstance( + DbxJdbcPluginTest.class.getClassLoader(), + new Class[] { PreparedStatement.class }, + (proxy, method, args) -> switch (method.getName()) { + case "setString" -> { + calls.add("bind:" + args[0] + ":" + args[1]); + yield null; + } + case "setQueryTimeout", "close" -> null; + case "execute" -> true; + case "executeUpdate" -> 1; + case "executeQuery" -> rowsResultSet( + new String[] { "PLAN_TABLE_OUTPUT" }, + new Object[][] { { "Plan hash value: 123" }, { "TABLE ACCESS FULL DUAL" } } + ); + default -> defaultValue(method.getReturnType()); + } + ); + } + private static final class OracleDateDriver implements Driver { private final Timestamp[] values; private final List calls;