diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f013cf7a7..795d958fb0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,13 @@ jobs: - name: Setup Rust uses: dtolnay/rust-toolchain@stable + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + - name: Rust cache uses: swatinem/rust-cache@v2 with: @@ -50,3 +57,6 @@ jobs: - name: Cargo check run: cargo check --workspace --locked + + - name: JDBC plugin package check + run: ./plugins/jdbc/package.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd48fb00df..258c22742c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -154,8 +154,40 @@ jobs: Compress-Archive -Path (Join-Path $portableDir "*") -DestinationPath $zipName -Force gh release upload "${env:GITHUB_REF_NAME}" $zipName --repo "${env:GITHUB_REPOSITORY}" --clobber + jdbc-plugin: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + + - name: Read JDBC plugin version + id: jdbc-plugin + shell: bash + run: | + VERSION="$(grep -m1 '' plugins/jdbc/pom.xml | sed -E 's/.*([^<]+)<.*/\1/')" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Package JDBC plugin + run: ./plugins/jdbc/package.sh + + - name: Upload JDBC plugin asset + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${GITHUB_REF_NAME}" \ + "plugins/jdbc/dist/dbx-jdbc-plugin-${{ steps.jdbc-plugin.outputs.version }}.zip" \ + --repo "${GITHUB_REPOSITORY}" \ + --clobber + publish: - needs: [build, docker-manifest] + needs: [build, docker-manifest, jdbc-plugin] runs-on: ubuntu-latest steps: - name: Publish draft release diff --git a/.gitignore b/.gitignore index 041d42b5f1..7224d08bb6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ node_modules/ # Build output dist/ /target/ +plugins/jdbc/target/ +plugins/jdbc/dependency-reduced-pom.xml +plugins/jdbc/lib/*.jar +plugins/jdbc/dist/ docs/.next/ docs/out/ docs/.source/ diff --git a/Cargo.lock b/Cargo.lock index 2a6d80ad4b..882b386b0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -846,6 +846,15 @@ dependencies = [ "serde", ] +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + [[package]] name = "cairo-rs" version = "0.18.5" @@ -1189,6 +1198,12 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "convert_case" version = "0.10.0" @@ -1655,6 +1670,7 @@ dependencies = [ "tokio", "tokio-util", "uuid", + "zip 4.6.1", ] [[package]] @@ -1716,6 +1732,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2" +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + [[package]] name = "delegate" version = "0.13.5" @@ -3731,6 +3753,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "libbz2-rs-sys" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a6a8c165077efc8f3a971534c50ea6a1a18b329ef4a66e897a7e3a1494565f" + [[package]] name = "libc" version = "0.2.186" @@ -3783,6 +3811,26 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "liblzma" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6033b77c21d1f56deeae8014eb9fbe7bdf1765185a6c508b5ca82eeaed7f899" +dependencies = [ + "liblzma-sys", +] + +[[package]] +name = "liblzma-sys" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a60851d15cd8c5346eca4ab8babff585be2ae4bc8097c067291d3ffe2add3b6" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "libm" version = "0.2.16" @@ -5396,6 +5444,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppmd-rust" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -9985,6 +10039,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" @@ -10025,12 +10093,25 @@ version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" dependencies = [ + "aes 0.8.4", "arbitrary", + "bzip2", + "constant_time_eq", "crc32fast", + "deflate64", "flate2", + "getrandom 0.3.4", + "hmac 0.12.1", "indexmap 2.14.0", + "liblzma", "memchr", + "pbkdf2 0.12.2", + "ppmd-rust", + "sha1 0.10.6", + "time", + "zeroize", "zopfli", + "zstd", ] [[package]] @@ -10070,3 +10151,31 @@ dependencies = [ "log", "simd-adler32", ] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index 94b8fd93ae..9083f8ad83 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -1,13 +1,18 @@ use std::collections::HashMap; +use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Mutex; use crate::db; use crate::db::ssh_tunnel::TunnelManager; use crate::models::connection::{parse_mongo_first_host, ConnectionConfig, DatabaseType}; +use crate::plugins::{PluginDriverSession, PluginRegistry}; use crate::query_cancel::RunningQueries; use crate::storage::Storage; +pub const JDBC_PLUGIN_NOT_INSTALLED: &str = + "JDBC plugin is not installed. Install the optional JDBC plugin to use this connection."; + pub fn expand_tilde(path: &str) -> String { if path == "~" || path.starts_with("~/") { if let Ok(home) = std::env::var(if cfg!(windows) { "USERPROFILE" } else { "HOME" }) { @@ -37,6 +42,7 @@ pub enum PoolKind { Elasticsearch(db::elasticsearch_driver::EsClient), Dameng(Arc>), Gaussdb(Arc>), + ExternalDriver { driver_id: String, config: ConnectionConfig, session: Arc }, } pub struct AppState { @@ -45,6 +51,7 @@ pub struct AppState { pub running_queries: RunningQueries, pub tunnels: TunnelManager, pub storage: Storage, + pub plugins: PluginRegistry, } pub fn metadata_connection_config(config: &ConnectionConfig) -> ConnectionConfig { @@ -67,15 +74,41 @@ pub fn database_connection_config(config: &ConnectionConfig, database: Option<&s impl AppState { pub fn new(storage: Storage) -> Self { + Self::new_with_plugin_dir(storage, default_plugin_dir()) + } + + pub fn new_with_plugin_dir(storage: Storage, plugin_dir: PathBuf) -> Self { Self { connections: Mutex::new(HashMap::new()), configs: Mutex::new(HashMap::new()), running_queries: RunningQueries::default(), tunnels: TunnelManager::new(), storage, + plugins: PluginRegistry::new(plugin_dir), } } + pub fn jdbc_unavailable_error(&self) -> String { + match self.plugins.find_driver("jdbc") { + Ok(Some(_)) => "JDBC plugin is installed, but the connection could not be opened.".to_string(), + Ok(None) => JDBC_PLUGIN_NOT_INSTALLED.to_string(), + Err(err) => format!("Failed to inspect JDBC plugin: {err}"), + } + } + + pub async fn test_external_driver(&self, driver_id: &str, config: &ConnectionConfig) -> Result { + let params = serde_json::json!({ "connection": config }); + self.plugins.invoke_driver::(driver_id, "testConnection", params).await?; + Ok("Connection successful".to_string()) + } + + pub async fn external_driver_pool(&self, driver_id: &str, config: &ConnectionConfig) -> Result { + let session = self.plugins.start_driver_session(driver_id).await?; + let params = serde_json::json!({ "connection": config }); + session.invoke::("connect", params).await?; + Ok(PoolKind::ExternalDriver { driver_id: driver_id.to_string(), config: config.clone(), session }) + } + pub async fn get_or_create_pool(&self, connection_id: &str, database: Option<&str>) -> Result { let db_type = { let configs = self.configs.lock().await; @@ -87,7 +120,8 @@ impl AppState { return Ok(connection_id.to_string()); } - let is_single_conn = matches!(db_type, Some(DatabaseType::Oracle) | Some(DatabaseType::Dameng)); + let is_single_conn = + matches!(db_type, Some(DatabaseType::Oracle) | Some(DatabaseType::Dameng) | Some(DatabaseType::Jdbc)); let pool_key = if is_single_conn { connection_id.to_string() } else { @@ -210,6 +244,7 @@ impl AppState { .await?; PoolKind::Gaussdb(Arc::new(tokio::sync::Mutex::new(client))) } + DatabaseType::Jdbc => self.external_driver_pool("jdbc", &db_config).await?, }; self.connections.lock().await.insert(pool_key.clone(), pool); @@ -285,6 +320,11 @@ impl AppState { } } +fn default_plugin_dir() -> PathBuf { + let home = std::env::var(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).unwrap_or_else(|_| ".".to_string()); + PathBuf::from(home).join(".dbx").join("plugins") +} + pub fn connection_url_for_endpoint(config: &ConnectionConfig, host: &str, port: u16) -> String { if host == config.host && port == config.port { config.connection_url() @@ -305,6 +345,7 @@ pub async fn probe_connection_endpoint(config: &ConnectionConfig, host: &str, po match config.db_type { DatabaseType::Sqlite | DatabaseType::DuckDb => Ok(()), DatabaseType::MongoDb if config.connection_string.as_deref().is_some_and(|value| !value.is_empty()) => Ok(()), + DatabaseType::Jdbc => Ok(()), _ => db::probe_tcp_endpoint(&format!("{:?}", config.db_type), host, port).await, } } @@ -354,6 +395,8 @@ mod tests { ssl: false, sysdba: false, connection_string: None, + jdbc_driver_class: None, + jdbc_driver_paths: Vec::new(), } } diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs index 08eece7f27..30697b8bd2 100644 --- a/crates/dbx-core/src/lib.rs +++ b/crates/dbx-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod db; pub mod history; pub mod models; pub mod mongo_ops; +pub mod plugins; pub mod query; pub mod query_cancel; pub mod redis_ops; diff --git a/crates/dbx-core/src/models/connection.rs b/crates/dbx-core/src/models/connection.rs index 6392ddc1f4..f12aad6e92 100644 --- a/crates/dbx-core/src/models/connection.rs +++ b/crates/dbx-core/src/models/connection.rs @@ -43,6 +43,10 @@ pub struct ConnectionConfig { pub sysdba: bool, #[serde(default)] pub connection_string: Option, + #[serde(default)] + pub jdbc_driver_class: Option, + #[serde(default)] + pub jdbc_driver_paths: Vec, } fn default_ssh_port() -> u16 { @@ -78,6 +82,7 @@ pub enum DatabaseType { Redshift, Dameng, Gaussdb, + Jdbc, } impl ConnectionConfig { @@ -172,6 +177,7 @@ impl ConnectionConfig { DatabaseType::Elasticsearch => format!("http://{host}:{port}"), DatabaseType::Dameng => format!("dm://{host}:{port}{db_part}"), DatabaseType::Gaussdb => format!("gaussdb://{host}:{port}{db_part}"), + DatabaseType::Jdbc => "jdbc:".to_string(), } } @@ -242,6 +248,9 @@ impl ConnectionConfig { DatabaseType::Gaussdb => { format!("gaussdb://{}:{}@{host}:{port}{db_part}", username, password) } + DatabaseType::Jdbc => { + self.connection_string.as_deref().filter(|value| !value.is_empty()).unwrap_or("jdbc:").to_string() + } } } diff --git a/crates/dbx-core/src/plugins.rs b/crates/dbx-core/src/plugins.rs new file mode 100644 index 0000000000..ca3f742a24 --- /dev/null +++ b/crates/dbx-core/src/plugins.rs @@ -0,0 +1,357 @@ +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; +use tokio::sync::Mutex; +use tokio::time::timeout; + +const PLUGIN_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +pub const SUPPORTED_PLUGIN_PROTOCOL_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginManifest { + pub id: String, + pub name: String, + #[serde(default)] + pub version: String, + #[serde(default = "default_plugin_protocol_version")] + pub protocol_version: u32, + #[serde(default)] + pub description: String, + #[serde(default)] + pub executable: Option, + #[serde(default)] + pub drivers: Vec, +} + +fn default_plugin_protocol_version() -> u32 { + 1 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginDriverManifest { + pub id: String, + pub label: String, + pub kind: String, + #[serde(default)] + pub database_type: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct InstalledPlugin { + pub manifest: PluginManifest, + pub path: PathBuf, +} + +#[derive(Debug, Clone)] +pub struct PluginRegistry { + root_dir: PathBuf, +} + +impl PluginRegistry { + pub fn new(root_dir: PathBuf) -> Self { + Self { root_dir } + } + + pub fn root_dir(&self) -> &Path { + &self.root_dir + } + + pub fn list_installed(&self) -> Result, String> { + let entries = match std::fs::read_dir(&self.root_dir) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), + Err(err) => return Err(err.to_string()), + }; + + let mut plugins = Vec::new(); + for entry in entries { + let entry = entry.map_err(|err| err.to_string())?; + let path = entry.path(); + if !path.is_dir() { + continue; + } + let manifest_path = path.join("manifest.json"); + if !manifest_path.exists() { + continue; + } + let raw = std::fs::read_to_string(&manifest_path).map_err(|err| err.to_string())?; + let manifest: PluginManifest = serde_json::from_str(&raw) + .map_err(|err| format!("Failed to parse plugin manifest {}: {err}", manifest_path.display()))?; + plugins.push(InstalledPlugin { manifest, path }); + } + plugins.sort_by(|a, b| a.manifest.id.cmp(&b.manifest.id)); + Ok(plugins) + } + + pub fn find_driver(&self, driver_id: &str) -> Result, String> { + Ok(self.list_installed()?.into_iter().find(|plugin| { + plugin + .manifest + .drivers + .iter() + .any(|driver| driver.id == driver_id || driver.database_type.as_deref() == Some(driver_id)) + })) + } + + pub async fn invoke_driver(&self, driver_id: &str, method: &str, params: serde_json::Value) -> Result + where + T: DeserializeOwned, + { + let plugin = + self.find_driver(driver_id)?.ok_or_else(|| format!("Plugin driver '{driver_id}' is not installed"))?; + ensure_plugin_protocol_compatible(&plugin.manifest)?; + timeout(PLUGIN_REQUEST_TIMEOUT, invoke_plugin(&plugin, driver_id, method, params)).await.map_err(|_| { + format!("Plugin '{}' timed out after {} seconds", plugin.manifest.id, PLUGIN_REQUEST_TIMEOUT.as_secs()) + })? + } + + pub async fn start_driver_session(&self, driver_id: &str) -> Result, String> { + let plugin = + self.find_driver(driver_id)?.ok_or_else(|| format!("Plugin driver '{driver_id}' is not installed"))?; + ensure_plugin_protocol_compatible(&plugin.manifest)?; + PluginDriverSession::start(plugin, driver_id.to_string()).await.map(Arc::new) + } +} + +fn ensure_plugin_protocol_compatible(manifest: &PluginManifest) -> Result<(), String> { + if manifest.protocol_version == SUPPORTED_PLUGIN_PROTOCOL_VERSION { + return Ok(()); + } + Err(format!( + "Plugin '{}' uses protocol version {}, but this DBX build supports protocol version {}", + manifest.id, manifest.protocol_version, SUPPORTED_PLUGIN_PROTOCOL_VERSION + )) +} + +#[derive(Debug, Serialize)] +struct PluginRequest { + jsonrpc: &'static str, + id: u64, + driver: String, + method: String, + params: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +struct PluginResponse { + id: u64, + #[serde(default)] + result: Option, + #[serde(default)] + error: Option, +} + +#[derive(Debug, Deserialize)] +struct PluginError { + message: String, +} + +pub struct PluginDriverSession { + plugin: InstalledPlugin, + driver_id: String, + process: Mutex, + next_request_id: AtomicU64, +} + +struct PluginProcess { + child: Child, + stdin: ChildStdin, + stdout: BufReader, +} + +impl PluginDriverSession { + async fn start(plugin: InstalledPlugin, driver_id: String) -> Result { + let mut child = spawn_plugin_child(&plugin)?; + let stdin = child.stdin.take().ok_or("Plugin stdin unavailable")?; + let stdout = child.stdout.take().ok_or("Plugin stdout unavailable")?; + if let Some(stderr) = child.stderr.take() { + let plugin_id = plugin.manifest.id.clone(); + tokio::spawn(async move { + let mut stderr = BufReader::new(stderr); + let mut line = String::new(); + loop { + line.clear(); + match stderr.read_line(&mut line).await { + Ok(0) => break, + Ok(_) => log::warn!("[plugin:{plugin_id}] {}", line.trim_end()), + Err(err) => { + log::warn!("[plugin:{plugin_id}] failed to read stderr: {err}"); + break; + } + } + } + }); + } + + Ok(Self { + plugin, + driver_id, + process: Mutex::new(PluginProcess { child, stdin, stdout: BufReader::new(stdout) }), + next_request_id: AtomicU64::new(1), + }) + } + + pub async fn invoke(&self, method: &str, params: serde_json::Value) -> Result + where + T: DeserializeOwned, + { + let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed); + let result = timeout(PLUGIN_REQUEST_TIMEOUT, async { + let mut process = self.process.lock().await; + self.invoke_locked(&mut process, request_id, method, params).await + }) + .await; + + match result { + Ok(result) => result, + Err(_) => { + self.kill().await; + Err(format!( + "Plugin '{}' timed out after {} seconds", + self.plugin.manifest.id, + PLUGIN_REQUEST_TIMEOUT.as_secs() + )) + } + } + } + + async fn invoke_locked( + &self, + process: &mut PluginProcess, + request_id: u64, + method: &str, + params: serde_json::Value, + ) -> Result + where + T: DeserializeOwned, + { + let request = PluginRequest { + jsonrpc: "2.0", + id: request_id, + driver: self.driver_id.clone(), + method: method.to_string(), + params, + }; + let line = serde_json::to_string(&request).map_err(|err| err.to_string())?; + process.stdin.write_all(line.as_bytes()).await.map_err(|err| err.to_string())?; + process.stdin.write_all(b"\n").await.map_err(|err| err.to_string())?; + process.stdin.flush().await.map_err(|err| err.to_string())?; + + let mut response_line = String::new(); + let read = process.stdout.read_line(&mut response_line).await.map_err(|err| err.to_string())?; + if read == 0 { + let status = process.child.try_wait().map_err(|err| err.to_string())?; + return Err(match status { + Some(status) => format!("Plugin '{}' exited with status {}", self.plugin.manifest.id, status), + None => format!("Plugin '{}' closed stdout without a response", self.plugin.manifest.id), + }); + } + decode_plugin_response(&self.plugin, request_id, &response_line) + } + + async fn kill(&self) { + let mut process = self.process.lock().await; + let _ = process.child.kill().await; + } +} + +async fn invoke_plugin( + plugin: &InstalledPlugin, + driver_id: &str, + method: &str, + params: serde_json::Value, +) -> Result +where + T: DeserializeOwned, +{ + let mut child = spawn_plugin_child(plugin)?; + + let request = + PluginRequest { jsonrpc: "2.0", id: 1, driver: driver_id.to_string(), method: method.to_string(), params }; + let line = serde_json::to_string(&request).map_err(|err| err.to_string())?; + + let mut stdin = child.stdin.take().ok_or("Plugin stdin unavailable")?; + stdin.write_all(line.as_bytes()).await.map_err(|err| err.to_string())?; + stdin.write_all(b"\n").await.map_err(|err| err.to_string())?; + drop(stdin); + + let stdout = child.stdout.take().ok_or("Plugin stdout unavailable")?; + let mut stderr = child.stderr.take().ok_or("Plugin stderr unavailable")?; + let mut reader = BufReader::new(stdout); + let mut response_line = String::new(); + let read = reader.read_line(&mut response_line).await.map_err(|err| err.to_string())?; + let mut stderr_text = String::new(); + stderr.read_to_string(&mut stderr_text).await.map_err(|err| err.to_string())?; + let status = child.wait().await.map_err(|err| err.to_string())?; + + if read == 0 { + let stderr = stderr_text.trim().to_string(); + return Err(if stderr.is_empty() { + format!("Plugin '{}' exited without a response", plugin.manifest.id) + } else { + format!("Plugin '{}' exited without a response: {stderr}", plugin.manifest.id) + }); + } + if !status.success() { + let stderr = stderr_text.trim().to_string(); + return Err(if stderr.is_empty() { + format!("Plugin '{}' exited with status {}", plugin.manifest.id, status) + } else { + format!("Plugin '{}' failed: {stderr}", plugin.manifest.id) + }); + } + + decode_plugin_response(plugin, request.id, &response_line) +} + +fn spawn_plugin_child(plugin: &InstalledPlugin) -> Result { + let executable = plugin + .manifest + .executable + .as_deref() + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("Plugin '{}' does not declare an executable", plugin.manifest.id))?; + let executable_path = resolve_plugin_executable(&plugin.path, executable); + + Command::new(&executable_path) + .current_dir(&plugin.path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(|err| format!("Failed to start plugin '{}': {err}", plugin.manifest.id)) +} + +fn decode_plugin_response(plugin: &InstalledPlugin, request_id: u64, response_line: &str) -> Result +where + T: DeserializeOwned, +{ + let response: PluginResponse = serde_json::from_str(response_line) + .map_err(|err| format!("Failed to parse plugin '{}' response: {err}", plugin.manifest.id))?; + if response.id != request_id { + return Err(format!("Plugin '{}' returned mismatched response id", plugin.manifest.id)); + } + if let Some(error) = response.error { + return Err(error.message); + } + let result = response.result.unwrap_or(serde_json::Value::Null); + serde_json::from_value(result) + .map_err(|err| format!("Failed to decode plugin '{}' result: {err}", plugin.manifest.id)) +} + +fn resolve_plugin_executable(plugin_dir: &Path, executable: &str) -> PathBuf { + let path = PathBuf::from(executable); + if path.is_absolute() { + path + } else { + plugin_dir.join(path) + } +} diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs index f8c21a52d1..8a234b7797 100644 --- a/crates/dbx-core/src/query.rs +++ b/crates/dbx-core/src/query.rs @@ -237,6 +237,23 @@ pub async fn do_execute( .await .map(truncate_result) } + PoolKind::ExternalDriver { config, session, .. } => { + let config = config.clone(); + let session = session.clone(); + let sql = sql.to_string(); + let schema = schema.map(str::to_string); + drop(connections); + wait_for_query(cancel_token, async move { + let params = serde_json::json!({ + "connection": config, + "sql": sql, + "schema": schema, + }); + session.invoke::("executeQuery", params).await + }) + .await + .map(truncate_result) + } } } @@ -396,7 +413,8 @@ pub async fn execute_statements_in_transaction( | PoolKind::Redis(_) | PoolKind::MongoDb(_) | PoolKind::Oracle(_) - | PoolKind::Elasticsearch(_) => TxPath::None, + | PoolKind::Elasticsearch(_) + | PoolKind::ExternalDriver { .. } => TxPath::None, }) }; diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 59ab32ac9b..7836581d80 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -125,6 +125,14 @@ pub fn extract_gaussdb( pub async fn list_databases_core(state: &AppState, connection_id: &str) -> Result, String> { { let connections = state.connections.lock().await; + if let Some(PoolKind::ExternalDriver { config, session, .. }) = connections.get(connection_id) { + let config = config.clone(); + let session = session.clone(); + drop(connections); + return session + .invoke::>("listDatabases", serde_json::json!({ "connection": config })) + .await; + } if let Some(client) = extract_clickhouse(&connections, connection_id) { drop(connections); return db::clickhouse_driver::list_databases(&client).await; @@ -174,6 +182,14 @@ pub async fn list_schemas_core(state: &AppState, connection_id: &str, database: { let connections = state.connections.lock().await; + if let Some(PoolKind::ExternalDriver { config, session, .. }) = connections.get(&pool_key) { + let config = config.clone(); + let session = session.clone(); + drop(connections); + return session + .invoke::>("listSchemas", serde_json::json!({ "connection": config, "database": database })) + .await; + } if let Some(client) = extract_sqlserver(&connections, &pool_key) { drop(connections); let mut client = client.lock().await; @@ -215,6 +231,17 @@ pub async fn list_tables_core( { let connections = state.connections.lock().await; + if let Some(PoolKind::ExternalDriver { config, session, .. }) = connections.get(&pool_key) { + let config = config.clone(); + let session = session.clone(); + drop(connections); + return session + .invoke::>( + "listTables", + serde_json::json!({ "connection": config, "database": database, "schema": schema }), + ) + .await; + } if let Some(con) = extract_duckdb(&connections, &pool_key) { drop(connections); let con = con.lock().map_err(|e| e.to_string())?; @@ -274,6 +301,22 @@ pub async fn get_columns_core( { let connections = state.connections.lock().await; + if let Some(PoolKind::ExternalDriver { config, session, .. }) = connections.get(&pool_key) { + let config = config.clone(); + let session = session.clone(); + drop(connections); + return session + .invoke::>( + "getColumns", + serde_json::json!({ + "connection": config, + "database": database, + "schema": schema, + "table": table, + }), + ) + .await; + } if let Some(con) = extract_duckdb(&connections, &pool_key) { drop(connections); let con = con.lock().map_err(|e| e.to_string())?; diff --git a/docs/content/docs/meta.cn.json b/docs/content/docs/meta.cn.json index 943161d0e3..9469b63700 100644 --- a/docs/content/docs/meta.cn.json +++ b/docs/content/docs/meta.cn.json @@ -21,6 +21,7 @@ "ai-assistant", "mcp", "---设置---", + "plugins", "config-export", "ssh-tunnel" ] diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index 691ff0ebeb..48e70b6bb0 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -21,6 +21,7 @@ "ai-assistant", "mcp", "---Settings---", + "plugins", "config-export", "ssh-tunnel" ] diff --git a/docs/content/docs/plugins.cn.mdx b/docs/content/docs/plugins.cn.mdx new file mode 100644 index 0000000000..85815ed22f --- /dev/null +++ b/docs/content/docs/plugins.cn.mdx @@ -0,0 +1,56 @@ +--- +title: 插件 +description: DBX 可选插件与驱动清单。 +--- + +DBX 可以从应用数据目录下的 `plugins` 目录发现可选插件。插件不会打包进主应用,因此可以在不增加默认安装包体积的前提下扩展特殊数据库支持。 + +每个插件使用独立目录,并需要包含 `manifest.json`: + +```json +{ + "id": "jdbc", + "name": "DBX JDBC Plugin", + "version": "0.1.0", + "protocol_version": 1, + "description": "Adds JDBC driver support.", + "executable": "bin/dbx-jdbc-plugin", + "drivers": [ + { + "id": "jdbc", + "label": "JDBC", + "kind": "external", + "database_type": "jdbc" + } + ] +} +``` + +JDBC 连接类型是可选能力。DBX 主应用只保存 JDBC 连接元数据,真正执行 JDBC 查询需要安装提供驱动协议的插件进程。可选 JDBC 插件使用独立版本号,并通过 `protocol_version` 声明兼容的插件协议;它使用用户本机的 Java runtime,数据库对应的 JDBC driver JAR 由用户在设置中导入或在连接表单里填写路径。 + +## 驱动协议 + +DBX 会启动插件可执行文件,并通过 stdin/stdout 发送 JSON Lines 请求与响应。连接建立后,DBX 会复用同一个插件进程处理该连接上的查询和元数据请求。 + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "driver": "jdbc", + "method": "executeQuery", + "params": { + "connection": {}, + "database": "default", + "schema": "", + "sql": "SELECT 1" + } +} +``` + +响应格式如下: + +```json +{ "id": 1, "result": { "columns": ["x"], "rows": [[1]], "affected_rows": 0, "execution_time_ms": 12, "truncated": false } } +``` + +第一版协议支持 `testConnection`、`connect`、`executeQuery`、`listDatabases`、`listSchemas`、`listTables` 和 `getColumns`。 diff --git a/docs/content/docs/plugins.mdx b/docs/content/docs/plugins.mdx new file mode 100644 index 0000000000..6adce785d8 --- /dev/null +++ b/docs/content/docs/plugins.mdx @@ -0,0 +1,56 @@ +--- +title: Plugins +description: Optional DBX plugins and driver manifests. +--- + +DBX can discover optional plugins from the app data `plugins` directory. Plugins are not bundled with the main app, so they can add specialized database support without increasing the default installer size. + +Each plugin lives in its own folder and must include a `manifest.json` file: + +```json +{ + "id": "jdbc", + "name": "DBX JDBC Plugin", + "version": "0.1.0", + "protocol_version": 1, + "description": "Adds JDBC driver support.", + "executable": "bin/dbx-jdbc-plugin", + "drivers": [ + { + "id": "jdbc", + "label": "JDBC", + "kind": "external", + "database_type": "jdbc" + } + ] +} +``` + +The JDBC connection type is intentionally optional. The main DBX app stores JDBC connection metadata, but actual JDBC execution requires a plugin process that provides the driver protocol. The optional JDBC plugin has its own version and declares compatibility through `protocol_version`. It uses the local Java runtime, and users can import database-specific JDBC driver JARs in settings or provide paths in the connection form. + +## Driver protocol + +DBX starts the plugin executable and exchanges JSON Lines requests and responses over stdin/stdout. After a connection is established, DBX reuses the same plugin process for queries and metadata requests on that connection. + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "driver": "jdbc", + "method": "executeQuery", + "params": { + "connection": {}, + "database": "default", + "schema": "", + "sql": "SELECT 1" + } +} +``` + +Responses follow the same shape: + +```json +{ "id": 1, "result": { "columns": ["x"], "rows": [[1]], "affected_rows": 0, "execution_time_ms": 12, "truncated": false } } +``` + +Supported initial methods are `testConnection`, `connect`, `executeQuery`, `listDatabases`, `listSchemas`, `listTables`, and `getColumns`. diff --git a/plugins/jdbc/README.md b/plugins/jdbc/README.md new file mode 100644 index 0000000000..de9c61e0ac --- /dev/null +++ b/plugins/jdbc/README.md @@ -0,0 +1,37 @@ +# DBX JDBC Plugin Prototype + +This is an optional sidecar plugin for DBX. It is not bundled with the main DBX app. + +## Build + +```sh +mvn -q -DskipTests package +mkdir -p lib +cp target/dbx-jdbc-plugin-*-all.jar lib/dbx-jdbc-plugin.jar +``` + +## Package for release + +```sh +./package.sh +``` + +The package version follows the JDBC plugin version in `pom.xml` and `manifest.json`. + +## Install for local DBX + +Copy this folder to the DBX app data plugin directory: + +```text +/plugins/jdbc +``` + +The folder must contain: + +```text +manifest.json +bin/dbx-jdbc-plugin +lib/dbx-jdbc-plugin.jar +``` + +DBX does not bundle Java or JDBC drivers. Install Java locally and add database-specific driver JAR paths in the DBX JDBC connection form. diff --git a/plugins/jdbc/bin/dbx-jdbc-plugin b/plugins/jdbc/bin/dbx-jdbc-plugin new file mode 100755 index 0000000000..e0b203710c --- /dev/null +++ b/plugins/jdbc/bin/dbx-jdbc-plugin @@ -0,0 +1,18 @@ +#!/usr/bin/env sh +set -eu +DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" + +if [ -n "${JAVA_HOME:-}" ] && [ -x "$JAVA_HOME/bin/java" ]; then + JAVA_BIN="$JAVA_HOME/bin/java" +elif [ -x "/opt/homebrew/opt/openjdk/bin/java" ]; then + JAVA_BIN="/opt/homebrew/opt/openjdk/bin/java" +elif [ -x "/usr/local/opt/openjdk/bin/java" ]; then + JAVA_BIN="/usr/local/opt/openjdk/bin/java" +elif command -v java >/dev/null 2>&1 && java -version >/dev/null 2>&1; then + JAVA_BIN="java" +else + echo "Java runtime not found. Install Java or the optional DBX JDBC runtime." >&2 + exit 127 +fi + +exec "$JAVA_BIN" -jar "$DIR/../lib/dbx-jdbc-plugin.jar" diff --git a/plugins/jdbc/manifest.json b/plugins/jdbc/manifest.json new file mode 100644 index 0000000000..ccd02fee6f --- /dev/null +++ b/plugins/jdbc/manifest.json @@ -0,0 +1,16 @@ +{ + "id": "jdbc", + "name": "DBX JDBC Plugin", + "version": "0.1.0", + "protocol_version": 1, + "description": "Adds optional JDBC driver support to DBX.", + "executable": "bin/dbx-jdbc-plugin", + "drivers": [ + { + "id": "jdbc", + "label": "JDBC", + "kind": "external", + "database_type": "jdbc" + } + ] +} diff --git a/plugins/jdbc/package.sh b/plugins/jdbc/package.sh new file mode 100755 index 0000000000..b33eeafca0 --- /dev/null +++ b/plugins/jdbc/package.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env sh +set -eu + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +VERSION="$(grep -m1 '' "$ROOT/pom.xml" | sed -E 's/.*([^<]+)<.*/\1/')" +PACKAGE_DIR="$ROOT/dist/dbx-jdbc-plugin-$VERSION" +ZIP_PATH="$ROOT/dist/dbx-jdbc-plugin-$VERSION.zip" + +cd "$ROOT" +mvn -q -DskipTests package + +rm -rf "$PACKAGE_DIR" "$ZIP_PATH" +mkdir -p "$PACKAGE_DIR/bin" "$PACKAGE_DIR/lib" +cp "$ROOT/manifest.json" "$PACKAGE_DIR/manifest.json" +cp "$ROOT/bin/dbx-jdbc-plugin" "$PACKAGE_DIR/bin/dbx-jdbc-plugin" +cp "$ROOT/target/dbx-jdbc-plugin-$VERSION-all.jar" "$PACKAGE_DIR/lib/dbx-jdbc-plugin.jar" +chmod +x "$PACKAGE_DIR/bin/dbx-jdbc-plugin" + +(cd "$ROOT/dist" && zip -qr "dbx-jdbc-plugin-$VERSION.zip" "dbx-jdbc-plugin-$VERSION") +echo "$ZIP_PATH" diff --git a/plugins/jdbc/pom.xml b/plugins/jdbc/pom.xml new file mode 100644 index 0000000000..e90523f8d4 --- /dev/null +++ b/plugins/jdbc/pom.xml @@ -0,0 +1,50 @@ + + 4.0.0 + app.dbx + dbx-jdbc-plugin + 0.1.0 + + + 17 + 17 + UTF-8 + + + + + com.fasterxml.jackson.core + jackson-databind + 2.17.2 + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.3 + + + package + + shade + + + false + true + all + + + app.dbx.jdbc.DbxJdbcPlugin + + + + + + + + + diff --git a/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java b/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java new file mode 100644 index 0000000000..82fb2286eb --- /dev/null +++ b/plugins/jdbc/src/main/java/app/dbx/jdbc/DbxJdbcPlugin.java @@ -0,0 +1,448 @@ +package app.dbx.jdbc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.math.BigDecimal; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.Date; +import java.sql.Driver; +import java.sql.DriverManager; +import java.sql.DriverPropertyInfo; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.sql.Statement; +import java.sql.Time; +import java.sql.Timestamp; +import java.time.temporal.TemporalAccessor; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashSet; +import java.util.List; +import java.util.Properties; +import java.util.ServiceLoader; +import java.util.Set; +import java.util.logging.Logger; + +public final class DbxJdbcPlugin { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final int MAX_ROWS = 10_000; + private static String registeredDriverKey = ""; + private static String sharedConnectionKey = ""; + private static Connection sharedConnection; + + private DbxJdbcPlugin() { + } + + public static void main(String[] args) throws Exception { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + if (line.isBlank()) { + continue; + } + ObjectNode response = handleLine(line); + System.out.println(MAPPER.writeValueAsString(response)); + System.out.flush(); + if (response.path("_dbx_close").asBoolean(false)) { + break; + } + } + } finally { + closeSharedConnection(); + } + } + + private static ObjectNode handleLine(String line) throws Exception { + JsonNode request = MAPPER.readTree(line); + JsonNode id = request.path("id"); + ObjectNode response = MAPPER.createObjectNode(); + response.set("id", id.isMissingNode() ? MAPPER.getNodeFactory().numberNode(1) : id); + + try { + String method = requireText(request, "method"); + JsonNode params = request.path("params"); + JsonNode connection = params.path("connection"); + if ("close".equals(method)) { + closeSharedConnection(); + ObjectNode result = MAPPER.createObjectNode(); + result.put("ok", true); + response.set("result", result); + response.put("_dbx_close", true); + return response; + } + registerDrivers(connection); + response.set("result", handle(method, params, connection)); + } catch (Exception error) { + ObjectNode errorNode = MAPPER.createObjectNode(); + errorNode.put("message", error.getMessage() == null ? error.toString() : error.getMessage()); + response.set("error", errorNode); + } + return response; + } + + private static JsonNode handle(String method, JsonNode params, JsonNode connection) throws Exception { + return switch (method) { + case "testConnection", "connect" -> { + openConnection(connection); + ObjectNode result = MAPPER.createObjectNode(); + result.put("ok", true); + yield result; + } + case "executeQuery" -> executeQuery(connection, requireText(params, "sql")); + case "listDatabases" -> listDatabases(connection); + case "listSchemas" -> listSchemas(connection, optionalText(params, "database")); + case "listTables" -> listTables(connection, optionalText(params, "database"), optionalText(params, "schema")); + case "getColumns" -> getColumns( + connection, + optionalText(params, "database"), + optionalText(params, "schema"), + requireText(params, "table") + ); + default -> throw new IllegalArgumentException("Unsupported JDBC plugin method: " + method); + }; + } + + private static void registerDrivers(JsonNode connection) throws Exception { + String driverKey = driverKey(connection); + if (driverKey.equals(registeredDriverKey)) { + return; + } + List urls = new ArrayList<>(); + JsonNode paths = connection.path("jdbc_driver_paths"); + if (paths.isArray()) { + for (JsonNode path : paths) { + String value = path.asText("").trim(); + if (!value.isEmpty()) { + urls.add(expandHome(value).toUri().toURL()); + } + } + } + + ClassLoader loader = urls.isEmpty() + ? Thread.currentThread().getContextClassLoader() + : new URLClassLoader(urls.toArray(URL[]::new), DbxJdbcPlugin.class.getClassLoader()); + Thread.currentThread().setContextClassLoader(loader); + + String driverClass = optionalText(connection, "jdbc_driver_class"); + if (driverClass != null) { + Driver driver = (Driver) Class.forName(driverClass, true, loader).getDeclaredConstructor().newInstance(); + DriverManager.registerDriver(new DriverShim(driver)); + registeredDriverKey = driverKey; + return; + } + + boolean loaded = false; + for (Driver driver : ServiceLoader.load(Driver.class, loader)) { + DriverManager.registerDriver(new DriverShim(driver)); + loaded = true; + } + if (!loaded && !urls.isEmpty()) { + throw new IllegalArgumentException("No JDBC driver was discovered. Enter the driver class name for this JAR."); + } + registeredDriverKey = driverKey; + } + + private static Connection openConnection(JsonNode connection) throws SQLException { + String url = optionalText(connection, "connection_string"); + if (url == null) { + throw new IllegalArgumentException("JDBC URL is required."); + } + String key = connectionKey(connection); + if (sharedConnection != null && key.equals(sharedConnectionKey) && !sharedConnection.isClosed()) { + return sharedConnection; + } + closeSharedConnection(); + + Properties properties = new Properties(); + String username = optionalText(connection, "username"); + String password = optionalText(connection, "password"); + if (username != null) { + properties.setProperty("user", username); + } + if (password != null) { + properties.setProperty("password", password); + } + sharedConnection = DriverManager.getConnection(url, properties); + sharedConnectionKey = key; + return sharedConnection; + } + + private static JsonNode executeQuery(JsonNode connection, String sql) throws SQLException { + long start = System.nanoTime(); + Connection conn = openConnection(connection); + try (Statement statement = conn.createStatement()) { + statement.setMaxRows(MAX_ROWS + 1); + boolean hasResultSet = statement.execute(sql); + ObjectNode result = MAPPER.createObjectNode(); + ArrayNode columns = MAPPER.createArrayNode(); + ArrayNode rows = MAPPER.createArrayNode(); + boolean truncated = false; + + if (hasResultSet) { + try (ResultSet rs = statement.getResultSet()) { + ResultSetMetaData meta = rs.getMetaData(); + int columnCount = meta.getColumnCount(); + for (int i = 1; i <= columnCount; i++) { + String label = meta.getColumnLabel(i); + columns.add(label == null || label.isBlank() ? meta.getColumnName(i) : label); + } + while (rs.next()) { + if (rows.size() >= MAX_ROWS) { + truncated = true; + break; + } + ArrayNode row = MAPPER.createArrayNode(); + for (int i = 1; i <= columnCount; i++) { + row.add(MAPPER.valueToTree(readValue(rs, i))); + } + rows.add(row); + } + } + } + + result.set("columns", columns); + result.set("rows", rows); + result.put("affected_rows", hasResultSet ? 0 : Math.max(statement.getUpdateCount(), 0)); + result.put("execution_time_ms", (System.nanoTime() - start) / 1_000_000); + result.put("truncated", truncated); + return result; + } + } + + private static JsonNode listDatabases(JsonNode connection) throws SQLException { + ArrayNode result = MAPPER.createArrayNode(); + Connection conn = openConnection(connection); + try (ResultSet rs = conn.getMetaData().getCatalogs()) { + while (rs.next()) { + String name = rs.getString("TABLE_CAT"); + if (name != null && !name.isBlank()) { + ObjectNode item = MAPPER.createObjectNode(); + item.put("name", name); + result.add(item); + } + } + } + if (result.isEmpty() && conn.getCatalog() != null) { + ObjectNode item = MAPPER.createObjectNode(); + item.put("name", conn.getCatalog()); + result.add(item); + } + return result; + } + + private static JsonNode listSchemas(JsonNode connection, String database) throws SQLException { + ArrayNode result = MAPPER.createArrayNode(); + Connection conn = openConnection(connection); + DatabaseMetaData meta = conn.getMetaData(); + try (ResultSet rs = meta.getSchemas(emptyToNull(database), null)) { + while (rs.next()) { + String schema = rs.getString("TABLE_SCHEM"); + if (schema != null && !schema.isBlank()) { + result.add(schema); + } + } + } catch (SQLFeatureNotSupportedException ignored) { + try (ResultSet rs = meta.getSchemas()) { + while (rs.next()) { + String schema = rs.getString("TABLE_SCHEM"); + if (schema != null && !schema.isBlank()) { + result.add(schema); + } + } + } + } + if (result.isEmpty() && conn.getSchema() != null) { + result.add(conn.getSchema()); + } + return result; + } + + private static JsonNode listTables(JsonNode connection, String database, String schema) throws SQLException { + ArrayNode result = MAPPER.createArrayNode(); + String[] types = new String[] {"TABLE", "VIEW", "MATERIALIZED VIEW", "SYSTEM TABLE", "SYSTEM VIEW"}; + Connection conn = openConnection(connection); + try (ResultSet rs = conn.getMetaData().getTables(emptyToNull(database), emptyToNull(schema), "%", types)) { + while (rs.next()) { + ObjectNode item = MAPPER.createObjectNode(); + item.put("name", rs.getString("TABLE_NAME")); + item.put("table_type", rs.getString("TABLE_TYPE")); + putNullable(item, "comment", rs.getString("REMARKS")); + result.add(item); + } + } + return result; + } + + private static JsonNode getColumns(JsonNode connection, String database, String schema, String table) throws SQLException { + ArrayNode result = MAPPER.createArrayNode(); + Connection conn = openConnection(connection); + DatabaseMetaData meta = conn.getMetaData(); + Set primaryKeys = primaryKeys(meta, database, schema, table); + try (ResultSet rs = meta.getColumns(emptyToNull(database), emptyToNull(schema), table, "%")) { + while (rs.next()) { + String name = rs.getString("COLUMN_NAME"); + ObjectNode item = MAPPER.createObjectNode(); + item.put("name", name); + item.put("data_type", rs.getString("TYPE_NAME")); + item.put("is_nullable", rs.getInt("NULLABLE") != DatabaseMetaData.columnNoNulls); + putNullable(item, "column_default", rs.getString("COLUMN_DEF")); + item.put("is_primary_key", primaryKeys.contains(name)); + item.putNull("extra"); + putNullable(item, "comment", rs.getString("REMARKS")); + putNullableInt(item, "numeric_precision", rs.getObject("COLUMN_SIZE")); + putNullableInt(item, "numeric_scale", rs.getObject("DECIMAL_DIGITS")); + putNullableInt(item, "character_maximum_length", rs.getObject("COLUMN_SIZE")); + result.add(item); + } + } + return result; + } + + private static void closeSharedConnection() { + if (sharedConnection != null) { + try { + sharedConnection.close(); + } catch (SQLException ignored) { + } + sharedConnection = null; + sharedConnectionKey = ""; + } + } + + private static String driverKey(JsonNode connection) { + return optionalText(connection, "jdbc_driver_class") + "|" + connection.path("jdbc_driver_paths").toString(); + } + + private static String connectionKey(JsonNode connection) { + return optionalText(connection, "connection_string") + "|" + optionalText(connection, "username") + "|" + optionalText(connection, "password"); + } + + private static Set primaryKeys(DatabaseMetaData meta, String database, String schema, String table) throws SQLException { + Set primaryKeys = new HashSet<>(); + try (ResultSet rs = meta.getPrimaryKeys(emptyToNull(database), emptyToNull(schema), table)) { + while (rs.next()) { + primaryKeys.add(rs.getString("COLUMN_NAME")); + } + } + return primaryKeys; + } + + private static Object readValue(ResultSet rs, int index) throws SQLException { + Object value = rs.getObject(index); + if (value == null) { + return null; + } + if (value instanceof byte[] bytes) { + return Base64.getEncoder().encodeToString(bytes); + } + if (value instanceof Date || value instanceof Time || value instanceof Timestamp || value instanceof TemporalAccessor) { + return value.toString(); + } + if (value instanceof BigDecimal decimal) { + return decimal; + } + if (value instanceof Number || value instanceof Boolean || value instanceof String) { + return value; + } + return value.toString(); + } + + private static void putNullable(ObjectNode node, String field, String value) { + if (value == null) { + node.putNull(field); + } else { + node.put(field, value); + } + } + + private static void putNullableInt(ObjectNode node, String field, Object value) { + if (value instanceof Number number) { + node.put(field, number.intValue()); + } else { + node.putNull(field); + } + } + + private static String requireText(JsonNode node, String field) { + String value = optionalText(node, field); + if (value == null) { + throw new IllegalArgumentException(field + " is required."); + } + return value; + } + + private static String optionalText(JsonNode node, String field) { + JsonNode value = node.path(field); + if (value.isMissingNode() || value.isNull()) { + return null; + } + String text = value.asText("").trim(); + return text.isEmpty() ? null : text; + } + + private static String emptyToNull(String value) { + return value == null || value.isBlank() ? null : value; + } + + private static Path expandHome(String path) { + if (path.equals("~") || path.startsWith("~/")) { + return Path.of(System.getProperty("user.home") + path.substring(1)); + } + return Path.of(path); + } + + private static final class DriverShim implements Driver { + private final Driver driver; + + private DriverShim(Driver driver) { + this.driver = driver; + } + + @Override + public Connection connect(String url, Properties info) throws SQLException { + return driver.connect(url, info); + } + + @Override + public boolean acceptsURL(String url) throws SQLException { + return driver.acceptsURL(url); + } + + @Override + public DriverPropertyInfo[] getPropertyInfo(String url, Properties info) throws SQLException { + return driver.getPropertyInfo(url, info); + } + + @Override + public int getMajorVersion() { + return driver.getMajorVersion(); + } + + @Override + public int getMinorVersion() { + return driver.getMinorVersion(); + } + + @Override + public boolean jdbcCompliant() { + return driver.jdbcCompliant(); + } + + @Override + public Logger getParentLogger() throws SQLFeatureNotSupportedException { + return driver.getParentLogger(); + } + } +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 35f6018c88..abb683fbbf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -47,4 +47,5 @@ percent-encoding = "2" russh = "0.60" csv = "1.4.0" calamine = "0.30.1" +zip = "4.6.1" dbx-core = { path = "../crates/dbx-core" } diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 5683bf4863..b60edc6abc 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -130,6 +130,7 @@ pub async fn test_connection(state: State<'_, Arc>, config: Connection ) .await .map(|_| "Connection successful".to_string()), + DatabaseType::Jdbc => state.test_external_driver("jdbc", &config).await, }, }; @@ -230,6 +231,7 @@ pub async fn connect_db(state: State<'_, Arc>, config: ConnectionConfi .await?; PoolKind::Gaussdb(std::sync::Arc::new(tokio::sync::Mutex::new(client))) } + DatabaseType::Jdbc => state.external_driver_pool("jdbc", &db_config).await?, }; state.connections.lock().await.insert(id.clone(), pool); @@ -258,6 +260,7 @@ pub async fn disconnect_db(state: State<'_, Arc>, connection_id: Strin PoolKind::Elasticsearch(_) => {} PoolKind::Dameng(_) => {} PoolKind::Gaussdb(_) => {} + PoolKind::ExternalDriver { .. } => {} } } } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a45b52bafe..57442c8b21 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -5,6 +5,7 @@ mod connection_secrets; pub mod history; pub mod mcp_bridge; pub mod mongo_cmd; +pub mod plugins; pub mod query; pub mod query_cancel; pub mod redis_cmd; diff --git a/src-tauri/src/commands/plugins.rs b/src-tauri/src/commands/plugins.rs new file mode 100644 index 0000000000..97ab9d84b4 --- /dev/null +++ b/src-tauri/src/commands/plugins.rs @@ -0,0 +1,309 @@ +use std::sync::Arc; +use tauri::State; + +use dbx_core::plugins::{InstalledPlugin, PluginManifest, SUPPORTED_PLUGIN_PROTOCOL_VERSION}; +use serde::Serialize; + +use super::connection::AppState; + +const JDBC_PLUGIN_DOWNLOAD_VERSION: &str = "0.1.0"; + +fn jdbc_plugin_download_urls() -> Vec { + let asset = format!("dbx-jdbc-plugin-{JDBC_PLUGIN_DOWNLOAD_VERSION}.zip"); + let latest_url = format!("https://github.com/t8y2/dbx/releases/latest/download/{asset}"); + vec![format!("https://update.hwdns.net/{latest_url}"), format!("https://gh-proxy.org/{latest_url}"), latest_url] +} + +#[tauri::command] +pub async fn list_plugins(state: State<'_, Arc>) -> Result, String> { + state.plugins.list_installed() +} + +#[derive(Debug, Clone, Serialize)] +pub struct JdbcDriverInfo { + pub name: String, + pub path: String, + pub size: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct JdbcPluginStatus { + pub installed: bool, + pub version: Option, + pub protocol_version: Option, + pub compatible: bool, + pub path: String, +} + +#[tauri::command] +pub async fn jdbc_plugin_status(state: State<'_, Arc>) -> Result { + jdbc_plugin_status_from_state(&state) +} + +#[tauri::command] +pub async fn install_jdbc_plugin(state: State<'_, Arc>) -> Result { + let bytes = download_jdbc_plugin_zip().await?; + let plugin_dir = state.plugins.root_dir().join("jdbc"); + install_jdbc_plugin_zip(&bytes, &plugin_dir)?; + jdbc_plugin_status_from_state(&state) +} + +#[tauri::command] +pub async fn uninstall_jdbc_plugin(state: State<'_, Arc>) -> Result { + let plugin_dir = state.plugins.root_dir().join("jdbc"); + for entry in ["manifest.json", "bin", "lib"] { + let path = plugin_dir.join(entry); + if !path.exists() { + continue; + } + if path.is_dir() { + std::fs::remove_dir_all(path).map_err(|err| err.to_string())?; + } else { + std::fs::remove_file(path).map_err(|err| err.to_string())?; + } + } + jdbc_plugin_status_from_state(&state) +} + +#[tauri::command] +pub async fn list_jdbc_drivers(state: State<'_, Arc>) -> Result, String> { + list_jdbc_drivers_from_dir(&jdbc_drivers_dir(&state)) +} + +#[tauri::command] +pub async fn import_jdbc_drivers( + state: State<'_, Arc>, + paths: Vec, +) -> Result, String> { + let drivers_dir = jdbc_drivers_dir(&state); + std::fs::create_dir_all(&drivers_dir).map_err(|err| err.to_string())?; + + for path in paths { + let source = std::path::PathBuf::from(path); + if !source.exists() { + return Err(format!("Driver JAR does not exist: {}", source.display())); + } + if source.extension().and_then(|ext| ext.to_str()).map(|ext| ext.eq_ignore_ascii_case("jar")) != Some(true) { + return Err(format!("Only .jar files can be imported: {}", source.display())); + } + let file_name = source + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| format!("Invalid driver file path: {}", source.display()))?; + let target = unique_target_path(&drivers_dir, file_name); + if source == target { + continue; + } + std::fs::copy(&source, &target) + .map_err(|err| format!("Failed to import {} to {}: {err}", source.display(), target.display()))?; + } + + list_jdbc_drivers_from_dir(&drivers_dir) +} + +#[tauri::command] +pub async fn delete_jdbc_driver(state: State<'_, Arc>, path: String) -> Result, String> { + let drivers_dir = jdbc_drivers_dir(&state); + let drivers_dir = drivers_dir.canonicalize().map_err(|err| err.to_string())?; + let target = std::path::PathBuf::from(path).canonicalize().map_err(|err| err.to_string())?; + if !target.starts_with(&drivers_dir) { + return Err("Driver file is outside the JDBC drivers directory".to_string()); + } + std::fs::remove_file(&target).map_err(|err| err.to_string())?; + list_jdbc_drivers_from_dir(&drivers_dir) +} + +fn jdbc_drivers_dir(state: &AppState) -> std::path::PathBuf { + state.plugins.root_dir().join("jdbc").join("drivers") +} + +fn jdbc_plugin_status_from_state(state: &AppState) -> Result { + let plugin_dir = state.plugins.root_dir().join("jdbc"); + let manifest_path = plugin_dir.join("manifest.json"); + let manifest = match std::fs::read_to_string(&manifest_path) { + Ok(raw) => Some(serde_json::from_str::(&raw).map_err(|err| err.to_string())?), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => None, + Err(err) => return Err(err.to_string()), + }; + let version = + manifest.as_ref().and_then(|manifest| (!manifest.version.is_empty()).then_some(manifest.version.clone())); + let protocol_version = manifest.as_ref().map(|manifest| manifest.protocol_version); + let compatible = match manifest.as_ref() { + Some(manifest) => manifest.protocol_version == SUPPORTED_PLUGIN_PROTOCOL_VERSION, + None => true, + }; + Ok(JdbcPluginStatus { + installed: manifest.is_some(), + version, + protocol_version, + compatible, + path: plugin_dir.to_string_lossy().to_string(), + }) +} + +async fn download_jdbc_plugin_zip() -> Result, String> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .build() + .map_err(|err| err.to_string())?; + let mut last_error = String::new(); + + for url in jdbc_plugin_download_urls() { + match client.get(&url).header(reqwest::header::USER_AGENT, "dbx-jdbc-plugin-installer").send().await { + Ok(response) if response.status().is_success() => { + let bytes = response.bytes().await.map_err(|err| err.to_string())?; + return Ok(bytes.to_vec()); + } + Ok(response) => { + last_error = format!("{url} returned {}", response.status()); + } + Err(err) => { + last_error = format!("{url}: {err}"); + } + } + } + + Err(format!("Failed to download JDBC plugin: {last_error}")) +} + +fn install_jdbc_plugin_zip(bytes: &[u8], plugin_dir: &std::path::Path) -> Result<(), String> { + let cursor = std::io::Cursor::new(bytes); + let mut archive = zip::ZipArchive::new(cursor).map_err(|err| err.to_string())?; + let temp_dir = plugin_dir.with_extension("tmp"); + if temp_dir.exists() { + std::fs::remove_dir_all(&temp_dir).map_err(|err| err.to_string())?; + } + std::fs::create_dir_all(&temp_dir).map_err(|err| err.to_string())?; + + for index in 0..archive.len() { + let mut file = archive.by_index(index).map_err(|err| err.to_string())?; + if file.is_dir() { + continue; + } + let Some(enclosed) = file.enclosed_name().map(|path| path.to_path_buf()) else { + continue; + }; + let relative = strip_zip_root(&enclosed); + if relative.as_os_str().is_empty() { + continue; + } + let output = temp_dir.join(relative); + if let Some(parent) = output.parent() { + std::fs::create_dir_all(parent).map_err(|err| err.to_string())?; + } + let mut target = std::fs::File::create(&output).map_err(|err| err.to_string())?; + std::io::copy(&mut file, &mut target).map_err(|err| err.to_string())?; + } + + if !temp_dir.join("manifest.json").exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + return Err("Downloaded JDBC plugin package is missing manifest.json".to_string()); + } + let manifest_path = temp_dir.join("manifest.json"); + let manifest = std::fs::read_to_string(&manifest_path) + .map_err(|err| format!("Failed to read downloaded JDBC plugin manifest: {err}"))?; + let manifest: PluginManifest = serde_json::from_str(&manifest) + .map_err(|err| format!("Failed to parse downloaded JDBC plugin manifest: {err}"))?; + if manifest.id != "jdbc" { + let _ = std::fs::remove_dir_all(&temp_dir); + return Err(format!("Downloaded plugin has unexpected id '{}'", manifest.id)); + } + if manifest.protocol_version != SUPPORTED_PLUGIN_PROTOCOL_VERSION { + let _ = std::fs::remove_dir_all(&temp_dir); + return Err(format!( + "Downloaded JDBC plugin uses protocol version {}, but this DBX build supports protocol version {}", + manifest.protocol_version, SUPPORTED_PLUGIN_PROTOCOL_VERSION + )); + } + + let drivers_dir = plugin_dir.join("drivers"); + let temp_drivers_dir = temp_dir.join("drivers"); + if drivers_dir.exists() && !temp_drivers_dir.exists() { + copy_dir_all(&drivers_dir, &temp_drivers_dir)?; + } + if plugin_dir.exists() { + std::fs::remove_dir_all(plugin_dir).map_err(|err| err.to_string())?; + } + std::fs::rename(&temp_dir, plugin_dir).map_err(|err| err.to_string())?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let executable = plugin_dir.join("bin").join("dbx-jdbc-plugin"); + if executable.exists() { + let mut permissions = std::fs::metadata(&executable).map_err(|err| err.to_string())?.permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(executable, permissions).map_err(|err| err.to_string())?; + } + } + + Ok(()) +} + +fn strip_zip_root(path: &std::path::Path) -> std::path::PathBuf { + let mut components = path.components(); + let first = components.next(); + if let (Some(std::path::Component::Normal(_)), Some(_)) = (first, components.clone().next()) { + components.collect() + } else { + path.to_path_buf() + } +} + +fn copy_dir_all(source: &std::path::Path, target: &std::path::Path) -> Result<(), String> { + std::fs::create_dir_all(target).map_err(|err| err.to_string())?; + for entry in std::fs::read_dir(source).map_err(|err| err.to_string())? { + let entry = entry.map_err(|err| err.to_string())?; + let file_type = entry.file_type().map_err(|err| err.to_string())?; + let dest = target.join(entry.file_name()); + if file_type.is_dir() { + copy_dir_all(&entry.path(), &dest)?; + } else { + std::fs::copy(entry.path(), dest).map_err(|err| err.to_string())?; + } + } + Ok(()) +} + +fn list_jdbc_drivers_from_dir(drivers_dir: &std::path::Path) -> Result, String> { + let entries = match std::fs::read_dir(drivers_dir) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), + Err(err) => return Err(err.to_string()), + }; + + let mut drivers = Vec::new(); + for entry in entries { + let entry = entry.map_err(|err| err.to_string())?; + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()).map(|ext| ext.eq_ignore_ascii_case("jar")) != Some(true) { + continue; + } + let metadata = entry.metadata().map_err(|err| err.to_string())?; + drivers.push(JdbcDriverInfo { + name: path.file_name().and_then(|name| name.to_str()).unwrap_or("driver.jar").to_string(), + path: path.to_string_lossy().to_string(), + size: metadata.len(), + }); + } + drivers.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(drivers) +} + +fn unique_target_path(dir: &std::path::Path, file_name: &str) -> std::path::PathBuf { + let target = dir.join(file_name); + if !target.exists() { + return target; + } + + let path = std::path::Path::new(file_name); + let stem = path.file_stem().and_then(|value| value.to_str()).unwrap_or("driver"); + let ext = path.extension().and_then(|value| value.to_str()).unwrap_or("jar"); + for index in 1.. { + let candidate = dir.join(format!("{stem}-{index}.{ext}")); + if !candidate.exists() { + return candidate; + } + } + unreachable!() +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2dc56bf088..15396fa2c6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -34,7 +34,7 @@ pub fn run() { s }); - let state = Arc::new(AppState::new(storage)); + let state = Arc::new(AppState::new_with_plugin_dir(storage, data_dir.join("plugins"))); app.manage(state.clone()); let app_handle = app.handle().clone(); @@ -73,6 +73,13 @@ pub fn run() { commands::connection::load_connections, commands::connection::save_sidebar_layout, commands::connection::load_sidebar_layout, + commands::plugins::list_plugins, + commands::plugins::list_jdbc_drivers, + commands::plugins::import_jdbc_drivers, + commands::plugins::delete_jdbc_driver, + commands::plugins::jdbc_plugin_status, + commands::plugins::install_jdbc_plugin, + commands::plugins::uninstall_jdbc_plugin, commands::schema::list_databases, commands::schema::list_tables, commands::schema::list_schemas, diff --git a/src-web/src/main.rs b/src-web/src/main.rs index c977c2f30e..cba7633e70 100644 --- a/src-web/src/main.rs +++ b/src-web/src/main.rs @@ -43,7 +43,7 @@ async fn main() { let db_path = data_dir.join("dbx.db"); let storage = Storage::open(&db_path).await.expect("Failed to open storage"); storage.migrate_from_json(&data_dir).await.expect("Failed to migrate JSON data"); - Arc::new(AppState::new(storage)) + Arc::new(AppState::new_with_plugin_dir(storage, data_dir.join("plugins"))) }; // Password hash: env var takes priority, then database @@ -80,6 +80,7 @@ async fn main() { .route("/connection/disconnect", post(routes::connection::disconnect_db)) .route("/connection/save", post(routes::connection::save_connections)) .route("/connection/list", get(routes::connection::load_connections)) + .route("/plugins", get(routes::plugins::list_plugins)) // Schema .route("/schema/databases", get(routes::schema::list_databases)) .route("/schema/schemas", get(routes::schema::list_schemas)) diff --git a/src-web/src/routes/mod.rs b/src-web/src/routes/mod.rs index 107d202255..8f5a128675 100644 --- a/src-web/src/routes/mod.rs +++ b/src-web/src/routes/mod.rs @@ -3,6 +3,7 @@ pub mod connection; pub mod history; pub mod layout; pub mod mongo; +pub mod plugins; pub mod query; pub mod redis; pub mod saved_sql; diff --git a/src-web/src/routes/plugins.rs b/src-web/src/routes/plugins.rs new file mode 100644 index 0000000000..e7b8c5a456 --- /dev/null +++ b/src-web/src/routes/plugins.rs @@ -0,0 +1,12 @@ +use std::sync::Arc; + +use axum::extract::State; +use axum::Json; +use dbx_core::plugins::InstalledPlugin; + +use crate::error::AppError; +use crate::state::WebState; + +pub async fn list_plugins(State(state): State>) -> Result>, AppError> { + state.app.plugins.list_installed().map(Json).map_err(AppError) +} diff --git a/src/components/connection/ConnectionDialog.vue b/src/components/connection/ConnectionDialog.vue index 329a9e9c7d..cd9d7af39c 100644 --- a/src/components/connection/ConnectionDialog.vue +++ b/src/components/connection/ConnectionDialog.vue @@ -6,16 +6,17 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from " import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import type { ConnectionConfig, DatabaseType } from "@/types/database"; +import type { ConnectionConfig, DatabaseType, JdbcDriverInfo } from "@/types/database"; import { useConnectionStore } from "@/stores/connectionStore"; import { useToast } from "@/composables/useToast"; import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; import * as api from "@/lib/api"; import { isTauriRuntime } from "@/lib/tauriRuntime"; import { applyParsedConnectionUrl, parseConnectionUrl } from "@/lib/connectionUrl"; -import { ArrowLeft, ChevronRight, Copy, FolderOpen, Grid3X3, Link2, List, Search } from "lucide-vue-next"; +import { ArrowLeft, ChevronRight, Copy, ExternalLink, FolderOpen, Grid3X3, Link2, List, Search } from "lucide-vue-next"; type DbOption = { value: string; label: string }; type DbCategory = { key: string; title: string; options: DbOption[] }; @@ -68,12 +69,17 @@ const defaultForm = (): Omit => ({ ssh_connect_timeout_secs: 5, ssl: false, connection_string: undefined, + jdbc_driver_class: undefined, + jdbc_driver_paths: [], }); const form = ref(defaultForm()); const selectedType = ref("mysql"); const customDriverName = ref(""); const mongoUseUrl = ref(false); +const jdbcDriverPathsInput = ref(""); +const jdbcDrivers = ref([]); +const selectedJdbcDriverPath = ref(""); const connectionUrlInput = ref(""); const dialogStep = ref("select"); const dbPickerView = ref("icon"); @@ -170,6 +176,7 @@ const driverProfiles: Record< icon: "cockroachdb", }, dm: { type: "dameng", port: 5236, user: "SYSDBA", label: "DM (Dameng)", icon: "dm" }, + jdbc: { type: "jdbc", port: 0, user: "", label: "JDBC", icon: "jdbc" }, tdengine: { type: "mysql", port: 6030, user: "root", label: "TDengine", icon: "tdengine" }, custom_mysql: { type: "mysql", @@ -220,6 +227,13 @@ function applyProfile(val: string, preserveConnectionFields = false) { if (profile.type === "sqlite" || profile.type === "duckdb") { form.value.host = ""; } + if (profile.type === "jdbc") { + form.value.host = ""; + form.value.connection_string = ""; + form.value.jdbc_driver_class = ""; + form.value.jdbc_driver_paths = []; + jdbcDriverPathsInput.value = ""; + } } } @@ -252,9 +266,12 @@ watch( ssh_connect_timeout_secs: config.ssh_connect_timeout_secs || 5, ssl: config.ssl || false, connection_string: config.connection_string, + jdbc_driver_class: config.jdbc_driver_class, + jdbc_driver_paths: config.jdbc_driver_paths || [], }; selectedType.value = profile; mongoUseUrl.value = !!config.connection_string; + jdbcDriverPathsInput.value = (config.jdbc_driver_paths || []).join("\n"); customDriverName.value = isCustomCompatibleProfile() ? config.driver_label || "" : ""; dialogStep.value = "config"; configTab.value = "connection"; @@ -330,6 +347,7 @@ const iconTypeMap: Record = { cockroachdb: "cockroachdb", tdengine: "tdengine", dm: "dm", + jdbc: "jdbc", custom_mysql: "mysql", custom_postgres: "postgres", }; @@ -360,6 +378,7 @@ const dbOptions = [ { value: "vastbase", label: "Vastbase" }, { value: "redshift", label: "Redshift" }, { value: "cockroachdb", label: "CockroachDB" }, + { value: "jdbc", label: "JDBC" }, { value: "custom_mysql", label: "Custom (MySQL)" }, { value: "custom_postgres", label: "Custom (PostgreSQL)" }, ]; @@ -387,7 +406,8 @@ const filteredDbCategories = computed(() => { const hasDbPickerResults = computed(() => filteredDbCategories.value.some((category) => category.options.length > 0)); const selectedDbIcon = computed(() => iconTypeMap[selectedType.value] || selectedProfile().icon || selectedType.value); -const canUseSsh = computed(() => form.value.db_type !== "sqlite"); +const isJdbcConnection = computed(() => form.value.db_type === "jdbc"); +const canUseSsh = computed(() => form.value.db_type !== "sqlite" && form.value.db_type !== "jdbc"); const testResultMessage = computed(() => { if (!testResult.value) return ""; return testResult.value.ok ? t("connection.testSuccess") : testResult.value.message; @@ -439,6 +459,16 @@ function connectionConfigForSubmit(id: string): ConnectionConfig { if (config.db_type === "mongodb" && !mongoUseUrl.value) { config.connection_string = undefined; } + if (config.db_type === "jdbc") { + config.host = ""; + config.port = 0; + config.connection_string = config.connection_string?.trim() || ""; + config.jdbc_driver_class = config.jdbc_driver_class?.trim() || undefined; + config.jdbc_driver_paths = jdbcDriverPathsInput.value + .split(/\r?\n/) + .map((path) => path.trim()) + .filter(Boolean); + } return config; } @@ -477,6 +507,8 @@ function resetForm() { selectedType.value = "mysql"; customDriverName.value = ""; mongoUseUrl.value = false; + jdbcDriverPathsInput.value = ""; + selectedJdbcDriverPath.value = ""; connectionUrlInput.value = ""; dialogStep.value = "select"; dbPickerView.value = "icon"; @@ -493,6 +525,7 @@ watch(open, (value) => { if (!props.editConfig) { resetForm(); } + void loadJdbcDrivers(); }); watch(canUseSsh, (value) => { @@ -513,6 +546,10 @@ async function save() { } else { const config = connectionConfigForSubmit(uuid()); await store.addConnection(config); + if (config.db_type === "jdbc") { + open.value = false; + return; + } open.value = false; await nextTick(); emit("connectStarted", config.name); @@ -569,6 +606,58 @@ async function browseDbFilePath() { } } } + +async function browseJdbcDriverPaths() { + if (!isTauriRuntime()) return; + const { open } = await import("@tauri-apps/plugin-dialog"); + const selected = await open({ + title: t("connection.jdbcDriverBrowse"), + multiple: true, + filters: [{ name: "JDBC Driver", extensions: ["jar"] }], + }); + if (!selected) return; + + const paths = Array.isArray(selected) ? selected : [selected]; + const existing = jdbcDriverPathsInput.value + .split(/\r?\n/) + .map((path) => path.trim()) + .filter(Boolean); + const merged = Array.from( + new Set([...existing, ...paths.filter((path): path is string => typeof path === "string")]), + ); + jdbcDriverPathsInput.value = merged.join("\n"); +} + +async function loadJdbcDrivers() { + if (!isDesktop) return; + try { + jdbcDrivers.value = await api.listJdbcDrivers(); + } catch { + jdbcDrivers.value = []; + } +} + +function addJdbcDriverPath(path: string) { + const existing = jdbcDriverPathsInput.value + .split(/\r?\n/) + .map((value) => value.trim()) + .filter(Boolean); + jdbcDriverPathsInput.value = Array.from(new Set([...existing, path])).join("\n"); +} + +function onJdbcDriverSelect(path: any) { + if (typeof path !== "string" || !path) return; + selectedJdbcDriverPath.value = path; + addJdbcDriverPath(path); +} + +function openExternalUrl(url: string) { + if (isTauriRuntime()) { + import("@tauri-apps/plugin-shell").then(({ open }) => open(url)); + } else { + window.open(url, "_blank", "noopener,noreferrer"); + } +}