From 2e971ce1525c943aa898496138a4a2fd3c193e50 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 30 Apr 2026 04:28:33 +0800 Subject: [PATCH] feat: add Oracle database support --- src-tauri/Cargo.lock | 195 +++++++++++++- src-tauri/Cargo.toml | 1 + src-tauri/src/commands/connection.rs | 64 ++++- src-tauri/src/commands/query.rs | 9 + src-tauri/src/commands/schema.rs | 79 ++++++ src-tauri/src/db/mod.rs | 1 + src-tauri/src/db/oracle_driver.rs | 250 ++++++++++++++++++ src-tauri/src/models/connection.rs | 6 + src/App.vue | 9 +- .../connection/ConnectionDialog.vue | 3 + src/components/icons/DatabaseIcon.vue | 11 +- src/components/sidebar/TreeItem.vue | 6 +- src/types/database.ts | 2 +- 13 files changed, 617 insertions(+), 19 deletions(-) create mode 100644 src-tauri/src/db/oracle_driver.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cd88a6ad9d..bc3964a8e1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.7.8" @@ -353,6 +364,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "base64" version = "0.21.7" @@ -422,6 +455,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -633,6 +675,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.61" @@ -698,6 +749,25 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "combine" version = "4.6.7" @@ -1048,6 +1118,7 @@ dependencies = [ "duckdb", "log", "mongodb", + "oracle-rs", "portpicker", "redis", "reqwest 0.12.28", @@ -1593,6 +1664,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "funty" version = "2.0.0" @@ -2172,6 +2249,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.2.1", +] + [[package]] name = "html5ever" version = "0.29.1" @@ -2267,7 +2355,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.7", ] [[package]] @@ -2492,6 +2580,16 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipconfig" version = "0.3.4" @@ -3112,7 +3210,7 @@ dependencies = [ "tokio-util", "typed-builder", "uuid", - "webpki-roots", + "webpki-roots 1.0.7", ] [[package]] @@ -3505,6 +3603,39 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "oracle-rs" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9ca364b441f92b717658c62e85207158b30285deb01811ccd923017f61612e" +dependencies = [ + "aes", + "async-trait", + "bytes", + "cbc", + "chrono", + "hex", + "hmac", + "hostname", + "indexmap 2.14.0", + "md-5", + "pbkdf2", + "pkcs8", + "rand 0.8.6", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_json", + "sha1", + "sha2", + "thiserror 1.0.69", + "tokio", + "tokio-rustls", + "tracing", + "webpki-roots 0.26.11", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -3582,6 +3713,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest", + "hmac", ] [[package]] @@ -3803,6 +3935,21 @@ dependencies = [ "spki", ] +[[package]] +name = "pkcs5" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" +dependencies = [ + "aes", + "cbc", + "der", + "pbkdf2", + "scrypt", + "sha2", + "spki", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -3810,6 +3957,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ "der", + "pkcs5", + "rand_core 0.6.4", "spki", ] @@ -4359,7 +4508,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 1.0.7", ] [[package]] @@ -4563,6 +4712,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -4572,6 +4722,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -4588,6 +4747,7 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -4605,6 +4765,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -4680,6 +4849,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2", +] + [[package]] name = "seahash" version = "4.1.0" @@ -6853,6 +7033,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + [[package]] name = "webpki-roots" version = "1.0.7" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 527f0f4644..db857e4187 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -42,3 +42,4 @@ tiberius = { version = "0.12.3", features = ["tds73", "chrono"] } tokio-util = { version = "0.7", features = ["compat"] } reqwest = { version = "0.12", features = ["json"] } mongodb = "3.2.5" +oracle-rs = "0.1" diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 8ea9ca444a..ac2693afad 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -16,6 +16,7 @@ pub enum PoolKind { MongoDb(mongodb::Client), ClickHouse(db::clickhouse_driver::ChClient), SqlServer(std::sync::Arc>), + Oracle(std::sync::Arc>), } pub struct AppState { @@ -38,20 +39,24 @@ impl AppState { connection_id: &str, database: Option<&str>, ) -> Result { - let is_embedded = { + let db_type = { let configs = self.configs.lock().await; - configs.get(connection_id) - .map(|c| c.db_type == DatabaseType::Sqlite || c.db_type == DatabaseType::DuckDb) - .unwrap_or(false) + configs.get(connection_id).map(|c| c.db_type.clone()) }; + let is_embedded = matches!(db_type, Some(DatabaseType::Sqlite) | Some(DatabaseType::DuckDb)); if is_embedded { return Ok(connection_id.to_string()); } - let pool_key = match database { - Some(db) => format!("{connection_id}:{db}"), - None => connection_id.to_string(), + let is_single_conn = matches!(db_type, Some(DatabaseType::Oracle)); + let pool_key = if is_single_conn { + connection_id.to_string() + } else { + match database { + Some(db) => format!("{connection_id}:{db}"), + None => connection_id.to_string(), + } }; let conns = self.connections.lock().await; @@ -69,7 +74,9 @@ impl AppState { let mut db_config = config.clone(); if let Some(db) = database { - db_config.database = Some(db.to_string()); + if db_config.db_type != DatabaseType::Oracle { + db_config.database = Some(db.to_string()); + } } let url = db_config.connection_url(); @@ -102,6 +109,14 @@ impl AppState { ).await?; PoolKind::SqlServer(std::sync::Arc::new(tokio::sync::Mutex::new(client))) } + DatabaseType::Oracle => { + let pool = db::oracle_driver::OraclePool::connect( + &db_config.host, db_config.port, + db_config.database.as_deref().unwrap_or("ORCL"), + &db_config.username, &db_config.password, + ).await?; + PoolKind::Oracle(std::sync::Arc::new(tokio::sync::Mutex::new(pool))) + } }; self.connections.lock().await.insert(pool_key.clone(), pool); @@ -113,9 +128,19 @@ impl AppState { connection_id: &str, database: Option<&str>, ) -> Result { - let pool_key = match database { - Some(db) => format!("{connection_id}:{db}"), - None => connection_id.to_string(), + let is_single_conn = { + let configs = self.configs.lock().await; + configs.get(connection_id) + .map(|c| c.db_type == DatabaseType::Oracle) + .unwrap_or(false) + }; + let pool_key = if is_single_conn { + connection_id.to_string() + } else { + match database { + Some(db) => format!("{connection_id}:{db}"), + None => connection_id.to_string(), + } }; self.connections.lock().await.remove(&pool_key); self.get_or_create_pool(connection_id, database).await @@ -196,6 +221,14 @@ pub async fn test_connection(config: ConnectionConfig) -> Result ).await?; Ok("Connection successful".to_string()) } + DatabaseType::Oracle => { + let _pool = db::oracle_driver::OraclePool::connect( + &config.host, config.port, + config.database.as_deref().unwrap_or("ORCL"), + &config.username, &config.password, + ).await?; + Ok("Connection successful".to_string()) + } } } @@ -245,6 +278,14 @@ pub async fn connect_db( config.database.as_deref(), ).await?; PoolKind::SqlServer(std::sync::Arc::new(tokio::sync::Mutex::new(client))) } + DatabaseType::Oracle => { + let pool = db::oracle_driver::OraclePool::connect( + &config.host, config.port, + config.database.as_deref().unwrap_or("ORCL"), + &config.username, &config.password, + ).await?; + PoolKind::Oracle(std::sync::Arc::new(tokio::sync::Mutex::new(pool))) + } }; state.connections.lock().await.insert(id.clone(), pool); @@ -275,6 +316,7 @@ pub async fn disconnect_db( PoolKind::MongoDb(_) => {}, PoolKind::ClickHouse(_) => {}, PoolKind::SqlServer(_) => {}, + PoolKind::Oracle(_) => {}, } } } diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index e7f0eb137c..bfa67d5caf 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -132,6 +132,15 @@ async fn do_execute( .map_err(|_| format!("Query timed out after {} seconds", QUERY_TIMEOUT.as_secs()))? .map(truncate_result) } + PoolKind::Oracle(pool) => { + let pool = pool.clone(); + drop(connections); + let mut pool = pool.lock().await; + timeout(QUERY_TIMEOUT, db::oracle_driver::execute_query(&mut pool, sql)) + .await + .map_err(|_| format!("Query timed out after {} seconds", QUERY_TIMEOUT.as_secs()))? + .map(truncate_result) + } PoolKind::Redis(_) => Err("Use Redis-specific commands".to_string()), PoolKind::MongoDb(_) => Err("Use MongoDB-specific commands".to_string()), } diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index fd100404ec..fd79f65843 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -75,6 +75,13 @@ fn extract_clickhouse(connections: &std::collections::HashMap, } } +fn extract_oracle(connections: &std::collections::HashMap, key: &str) -> Option>> { + match connections.get(key)? { + PoolKind::Oracle(pool) => Some(pool.clone()), + _ => None, + } +} + #[tauri::command] pub async fn list_databases( state: State<'_, Arc>, @@ -91,6 +98,11 @@ pub async fn list_databases( let mut client = client.lock().await; return db::sqlserver::list_databases(&mut client).await; } + if let Some(pool) = extract_oracle(&connections, &connection_id) { + drop(connections); + let pool = pool.lock().await; + return db::oracle_driver::list_databases(&pool).await; + } } let connections = state.connections.lock().await; @@ -120,6 +132,11 @@ pub async fn list_schemas( let mut client = client.lock().await; return db::sqlserver::list_schemas(&mut client).await; } + if let Some(pool) = extract_oracle(&connections, &pool_key) { + drop(connections); + let pool = pool.lock().await; + return db::oracle_driver::list_schemas(&pool).await; + } } let connections = state.connections.lock().await; @@ -156,6 +173,11 @@ pub async fn list_tables( let mut client = client.lock().await; return db::sqlserver::list_tables(&mut client, &schema).await; } + if let Some(pool) = extract_oracle(&connections, &pool_key) { + drop(connections); + let pool = pool.lock().await; + return db::oracle_driver::list_tables(&pool, &schema).await; + } } let connections = state.connections.lock().await; @@ -195,6 +217,11 @@ pub async fn get_columns( let mut client = client.lock().await; return db::sqlserver::get_columns(&mut client, &schema, &table).await; } + if let Some(pool) = extract_oracle(&connections, &pool_key) { + drop(connections); + let pool = pool.lock().await; + return db::oracle_driver::get_columns(&pool, &schema, &table).await; + } } let connections = state.connections.lock().await; @@ -225,6 +252,11 @@ pub async fn list_indexes( let mut client = client.lock().await; return db::sqlserver::list_indexes(&mut client, &schema, &table).await; } + if let Some(pool) = extract_oracle(&connections, &pool_key) { + drop(connections); + let pool = pool.lock().await; + return db::oracle_driver::list_indexes(&pool, &schema, &table).await; + } } let connections = state.connections.lock().await; @@ -255,6 +287,11 @@ pub async fn list_foreign_keys( let mut client = client.lock().await; return db::sqlserver::list_foreign_keys(&mut client, &schema, &table).await; } + if let Some(pool) = extract_oracle(&connections, &pool_key) { + drop(connections); + let pool = pool.lock().await; + return db::oracle_driver::list_foreign_keys(&pool, &schema, &table).await; + } } let connections = state.connections.lock().await; @@ -285,6 +322,11 @@ pub async fn list_triggers( let mut client = client.lock().await; return db::sqlserver::list_triggers(&mut client, &schema, &table).await; } + if let Some(pool) = extract_oracle(&connections, &pool_key) { + drop(connections); + let pool = pool.lock().await; + return db::oracle_driver::list_triggers(&pool, &schema, &table).await; + } } let connections = state.connections.lock().await; @@ -336,6 +378,11 @@ pub async fn get_table_ddl( let mut client = client.lock().await; return build_sqlserver_ddl(&mut client, &schema, &table).await; } + if let Some(pool) = extract_oracle(&connections, &pool_key) { + drop(connections); + let pool = pool.lock().await; + return build_oracle_ddl(&pool, &schema, &table).await; + } } let connections = state.connections.lock().await; @@ -429,3 +476,35 @@ async fn build_sqlserver_ddl(client: &mut db::sqlserver::SqlServerClient, schema } Ok(ddl) } + +async fn build_oracle_ddl(pool: &db::oracle_driver::OraclePool, schema: &str, table: &str) -> Result { + let columns = db::oracle_driver::get_columns(pool, schema, table).await?; + let indexes = db::oracle_driver::list_indexes(pool, schema, table).await?; + let fkeys = db::oracle_driver::list_foreign_keys(pool, schema, table).await?; + + let mut ddl = format!("CREATE TABLE \"{schema}\".\"{table}\" (\n"); + let col_lines: Vec = columns.iter().map(|c| { + let mut line = format!(" \"{}\" {}", c.name, c.data_type); + if !c.is_nullable { line.push_str(" NOT NULL"); } + if let Some(ref def) = c.column_default { line.push_str(&format!(" DEFAULT {def}")); } + line + }).collect(); + ddl.push_str(&col_lines.join(",\n")); + + let pks: Vec<&str> = columns.iter().filter(|c| c.is_primary_key).map(|c| c.name.as_str()).collect(); + if !pks.is_empty() { + ddl.push_str(&format!(",\n PRIMARY KEY ({})", pks.iter().map(|k| format!("\"{k}\"")).collect::>().join(", "))); + } + for fk in &fkeys { + ddl.push_str(&format!(",\n CONSTRAINT \"{}\" FOREIGN KEY (\"{}\") REFERENCES \"{}\"(\"{}\")", fk.name, fk.column, fk.ref_table, fk.ref_column)); + } + ddl.push_str("\n);\n"); + + for idx in &indexes { + if idx.is_primary { continue; } + let unique = if idx.is_unique { "UNIQUE " } else { "" }; + let cols = idx.columns.iter().map(|c| format!("\"{c}\"")).collect::>().join(", "); + ddl.push_str(&format!("\nCREATE {unique}INDEX \"{}\" ON \"{schema}\".\"{table}\" ({cols});", idx.name)); + } + Ok(ddl) +} diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index f911c375fd..5c04ac8f34 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -1,6 +1,7 @@ pub mod clickhouse_driver; pub mod mongo_driver; pub mod mysql; +pub mod oracle_driver; pub mod postgres; pub mod redis_driver; pub mod sqlite; diff --git a/src-tauri/src/db/oracle_driver.rs b/src-tauri/src/db/oracle_driver.rs new file mode 100644 index 0000000000..bf71cca996 --- /dev/null +++ b/src-tauri/src/db/oracle_driver.rs @@ -0,0 +1,250 @@ +use oracle_rs::{Config, Connection}; +use std::time::Instant; + +use super::{ColumnInfo, DatabaseInfo, ForeignKeyInfo, IndexInfo, QueryResult, TableInfo, TriggerInfo}; + +pub struct OraclePool { + pub conn: Connection, + host: String, + port: u16, + service: String, + user: String, + pass: String, +} + +impl OraclePool { + pub async fn connect(host: &str, port: u16, service: &str, user: &str, pass: &str) -> Result { + let config = Config::new(host, port, service, user, pass); + let conn = Connection::connect_with_config(config) + .await + .map_err(|e| format!("Oracle connection failed: {e}"))?; + Ok(Self { + conn, + host: host.to_string(), + port, + service: service.to_string(), + user: user.to_string(), + pass: pass.to_string(), + }) + } + + pub async fn reconnect(&mut self) -> Result<(), String> { + let config = Config::new(&self.host, self.port, &self.service, &self.user, &self.pass); + self.conn = Connection::connect_with_config(config) + .await + .map_err(|e| format!("Oracle reconnect failed: {e}"))?; + Ok(()) + } +} + +fn value_to_json(val: &oracle_rs::Value) -> serde_json::Value { + match val { + oracle_rs::Value::Null => serde_json::Value::Null, + oracle_rs::Value::String(s) => serde_json::Value::String(s.clone()), + oracle_rs::Value::Integer(n) => serde_json::Value::Number((*n).into()), + oracle_rs::Value::Float(f) => serde_json::Number::from_f64(*f) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), + oracle_rs::Value::Boolean(b) => serde_json::Value::Bool(*b), + oracle_rs::Value::Json(v) => v.clone(), + _ => serde_json::Value::String(format!("{val:?}")), + } +} + +pub async fn list_databases(pool: &OraclePool) -> Result, String> { + let result = pool.conn.query( + "SELECT username FROM all_users ORDER BY username", + &[], + ).await.map_err(|e| e.to_string())?; + Ok(result.rows.iter().map(|row| { + DatabaseInfo { + name: row.get_string(0).unwrap_or("").to_string(), + } + }).collect()) +} + +pub async fn list_schemas(pool: &OraclePool) -> Result, String> { + let result = pool.conn.query( + "SELECT username FROM all_users ORDER BY username", + &[], + ).await.map_err(|e| e.to_string())?; + Ok(result.rows.iter().map(|row| { + row.get_string(0).unwrap_or("").to_string() + }).collect()) +} + +pub async fn list_tables(pool: &OraclePool, schema: &str) -> Result, String> { + let sql = format!( + "SELECT table_name, 'TABLE' AS table_type FROM all_tables WHERE owner = '{s}' \ + UNION ALL \ + SELECT view_name, 'VIEW' FROM all_views WHERE owner = '{s}' \ + ORDER BY 1", + s = schema.replace('\'', "''") + ); + let result = pool.conn.query(&sql, &[]).await.map_err(|e| e.to_string())?; + Ok(result.rows.iter().map(|row| { + TableInfo { + name: row.get_string(0).unwrap_or("").to_string(), + table_type: row.get_string(1).unwrap_or("TABLE").to_string(), + } + }).collect()) +} + +pub async fn get_columns(pool: &OraclePool, schema: &str, table: &str) -> Result, String> { + let s = schema.replace('\'', "''"); + let t = table.replace('\'', "''"); + + let pk_result = pool.conn.query( + &format!( + "SELECT cols.COLUMN_NAME FROM ALL_CONS_COLUMNS cols \ + JOIN ALL_CONSTRAINTS cons ON cols.CONSTRAINT_NAME = cons.CONSTRAINT_NAME AND cols.OWNER = cons.OWNER \ + WHERE cons.CONSTRAINT_TYPE = 'P' AND cons.OWNER = '{s}' AND cons.TABLE_NAME = '{t}'" + ), + &[], + ).await.map_err(|e| e.to_string())?; + let pk_names: std::collections::HashSet = pk_result.rows.iter() + .filter_map(|row| row.get_string(0).map(|s| s.to_string())) + .collect(); + + let col_result = pool.conn.query( + &format!( + "SELECT COLUMN_NAME, DATA_TYPE, NULLABLE \ + FROM ALL_TAB_COLUMNS \ + WHERE OWNER = '{s}' AND TABLE_NAME = '{t}' \ + ORDER BY COLUMN_ID" + ), + &[], + ).await.map_err(|e| e.to_string())?; + + Ok(col_result.rows.iter().map(|row| { + let name = row.get_string(0).unwrap_or("").to_string(); + ColumnInfo { + is_primary_key: pk_names.contains(&name), + name, + data_type: row.get_string(1).unwrap_or("").to_string(), + is_nullable: row.get_string(2).unwrap_or("N") == "Y", + column_default: None, + extra: None, + } + }).collect()) +} + +pub async fn list_indexes(pool: &OraclePool, schema: &str, table: &str) -> Result, String> { + let sql = format!( + "SELECT i.INDEX_NAME, \ + LISTAGG(ic.COLUMN_NAME, ',') WITHIN GROUP (ORDER BY ic.COLUMN_POSITION) AS columns, \ + i.UNIQUENESS, \ + CASE WHEN c.CONSTRAINT_TYPE = 'P' THEN 1 ELSE 0 END AS IS_PK \ + FROM ALL_INDEXES i \ + JOIN ALL_IND_COLUMNS ic ON i.INDEX_NAME = ic.INDEX_NAME AND i.TABLE_OWNER = ic.TABLE_OWNER \ + LEFT JOIN ALL_CONSTRAINTS c ON i.INDEX_NAME = c.INDEX_NAME AND i.TABLE_OWNER = c.OWNER \ + AND c.CONSTRAINT_TYPE = 'P' \ + WHERE i.TABLE_OWNER = '{s}' AND i.TABLE_NAME = '{t}' \ + GROUP BY i.INDEX_NAME, i.UNIQUENESS, c.CONSTRAINT_TYPE \ + ORDER BY i.INDEX_NAME", + s = schema.replace('\'', "''"), t = table.replace('\'', "''") + ); + let result = pool.conn.query(&sql, &[]).await.map_err(|e| e.to_string())?; + Ok(result.rows.iter().map(|row| { + let cols_str = row.get_string(1).unwrap_or(""); + IndexInfo { + name: row.get_string(0).unwrap_or("").to_string(), + columns: cols_str.split(',').map(|s| s.to_string()).collect(), + is_unique: row.get_string(2).unwrap_or("") == "UNIQUE", + is_primary: row.get_i64(3).unwrap_or(0) == 1, + } + }).collect()) +} + +pub async fn list_foreign_keys(pool: &OraclePool, schema: &str, table: &str) -> Result, String> { + let sql = format!( + "SELECT c.CONSTRAINT_NAME, cc.COLUMN_NAME, rc.TABLE_NAME, rcc.COLUMN_NAME \ + FROM ALL_CONSTRAINTS c \ + JOIN ALL_CONS_COLUMNS cc ON c.CONSTRAINT_NAME = cc.CONSTRAINT_NAME AND c.OWNER = cc.OWNER \ + JOIN ALL_CONSTRAINTS rc ON c.R_CONSTRAINT_NAME = rc.CONSTRAINT_NAME AND c.R_OWNER = rc.OWNER \ + JOIN ALL_CONS_COLUMNS rcc ON rc.CONSTRAINT_NAME = rcc.CONSTRAINT_NAME AND rc.OWNER = rcc.OWNER \ + WHERE c.CONSTRAINT_TYPE = 'R' AND c.OWNER = '{s}' AND c.TABLE_NAME = '{t}' \ + ORDER BY c.CONSTRAINT_NAME", + s = schema.replace('\'', "''"), t = table.replace('\'', "''") + ); + let result = pool.conn.query(&sql, &[]).await.map_err(|e| e.to_string())?; + Ok(result.rows.iter().map(|row| { + ForeignKeyInfo { + name: row.get_string(0).unwrap_or("").to_string(), + column: row.get_string(1).unwrap_or("").to_string(), + ref_table: row.get_string(2).unwrap_or("").to_string(), + ref_column: row.get_string(3).unwrap_or("").to_string(), + } + }).collect()) +} + +pub async fn list_triggers(pool: &OraclePool, schema: &str, table: &str) -> Result, String> { + let sql = format!( + "SELECT TRIGGER_NAME, TRIGGERING_EVENT, TRIGGER_TYPE \ + FROM ALL_TRIGGERS \ + WHERE OWNER = '{s}' AND TABLE_NAME = '{t}' \ + ORDER BY TRIGGER_NAME", + s = schema.replace('\'', "''"), t = table.replace('\'', "''") + ); + let result = pool.conn.query(&sql, &[]).await.map_err(|e| e.to_string())?; + Ok(result.rows.iter().map(|row| { + TriggerInfo { + name: row.get_string(0).unwrap_or("").to_string(), + event: row.get_string(1).unwrap_or("").to_string(), + timing: row.get_string(2).unwrap_or("").to_string(), + } + }).collect()) +} + +pub async fn execute_query(pool: &mut OraclePool, sql: &str) -> Result { + let start = Instant::now(); + let sql = sql.trim().trim_end_matches(';'); + let trimmed = sql.to_uppercase(); + + if trimmed.starts_with("SELECT") + || trimmed.starts_with("WITH") + || trimmed.starts_with("SHOW") + || trimmed.starts_with("DESCRIBE") + || trimmed.starts_with("EXPLAIN") + { + let result = pool.conn.query(sql, &[]).await.map_err(|e| e.to_string())?; + let columns: Vec = result.columns.iter().map(|c| c.name.clone()).collect(); + let rows: Vec> = result.rows.iter().map(|row| { + (0..columns.len()).map(|i| { + row.get(i) + .map(|v| value_to_json(v)) + .unwrap_or(serde_json::Value::Null) + }).collect() + }).collect(); + + Ok(QueryResult { + columns, + rows, + affected_rows: 0, + execution_time_ms: start.elapsed().as_millis(), + truncated: false, + }) + } else { + match pool.conn.execute(sql, &[]).await { + Ok(result) => { + pool.conn.commit().await.map_err(|e| e.to_string())?; + Ok(QueryResult { + columns: vec![], + rows: vec![], + affected_rows: result.rows_affected, + execution_time_ms: start.elapsed().as_millis(), + truncated: false, + }) + } + Err(e) => { + let msg = e.to_string(); + if msg.contains("Server rejected") || msg.contains("closed the connection") { + let _ = pool.reconnect().await; + Err("Operation failed — possibly a constraint violation (foreign key, unique, or check constraint).".to_string()) + } else { + Err(msg) + } + } + } + } +} diff --git a/src-tauri/src/models/connection.rs b/src-tauri/src/models/connection.rs index 62616f7610..795c8c6184 100644 --- a/src-tauri/src/models/connection.rs +++ b/src-tauri/src/models/connection.rs @@ -41,6 +41,8 @@ pub enum DatabaseType { SqlServer, #[serde(rename = "mongodb")] MongoDb, + #[serde(rename = "oracle")] + Oracle, } impl ConnectionConfig { @@ -84,6 +86,10 @@ impl ConnectionConfig { format!("mongodb://{}:{}@{host}:{port}{db_part}", self.username, self.password) } } + DatabaseType::Oracle => format!( + "oracle://{}:{}@{host}:{port}{db_part}", + self.username, self.password + ), } } } diff --git a/src/App.vue b/src/App.vue index d1f287f4ec..3eb31ab0f3 100644 --- a/src/App.vue +++ b/src/App.vue @@ -277,7 +277,7 @@ function quoteIdent(tab: ActiveTab, name: string): string { function qualifiedTableName(tab: NonNullable): string { const config = connectionStore.getConfig(tab.connectionId); if (!tab.tableMeta) return ""; - if (config?.db_type === "postgres" && tab.tableMeta.schema) { + if ((config?.db_type === "postgres" || config?.db_type === "oracle") && tab.tableMeta.schema) { return `${quoteIdent(tab, tab.tableMeta.schema)}.${quoteIdent(tab, tab.tableMeta.tableName)}`; } return quoteIdent(tab, tab.tableMeta.tableName); @@ -293,9 +293,16 @@ function buildTableSql( tab: NonNullable, options: { orderBy?: string; limit?: number; offset?: number } = {}, ): string { + const config = connectionStore.getConfig(tab.connectionId); const limit = options.limit ?? 100; const orderBy = options.orderBy ?? defaultOrderBy(tab); const order = orderBy ? ` ORDER BY ${orderBy}` : ""; + + if (config?.db_type === "oracle") { + const offset = options.offset ? ` OFFSET ${options.offset} ROWS` : ""; + return `SELECT * FROM ${qualifiedTableName(tab)}${order}${offset} FETCH FIRST ${limit} ROWS ONLY`; + } + const offset = options.offset ? ` OFFSET ${options.offset}` : ""; return `SELECT * FROM ${qualifiedTableName(tab)}${order} LIMIT ${limit}${offset};`; } diff --git a/src/components/connection/ConnectionDialog.vue b/src/components/connection/ConnectionDialog.vue index 5d01f43d07..2cf618825c 100644 --- a/src/components/connection/ConnectionDialog.vue +++ b/src/components/connection/ConnectionDialog.vue @@ -87,6 +87,7 @@ function onDbTypeChange(val: string) { mongodb: { type: "mongodb", port: 27017, user: "" }, clickhouse: { type: "clickhouse", port: 8123, user: "default" }, sqlserver: { type: "sqlserver", port: 1433, user: "sa" }, + oracle: { type: "oracle", port: 1521, user: "system" }, tidb: { type: "mysql", port: 4000, user: "root" }, oceanbase: { type: "mysql", port: 2881, user: "root" }, goldendb: { type: "mysql", port: 3306, user: "root" }, @@ -110,6 +111,7 @@ function onDbTypeChange(val: string) { const iconTypeMap: Record = { mysql: "mysql", postgres: "postgres", sqlite: "sqlite", redis: "redis", mongodb: "mongodb", duckdb: "duckdb", clickhouse: "clickhouse", sqlserver: "sqlserver", + oracle: "oracle", mariadb: "mariadb", tidb: "tidb", oceanbase: "mysql", goldendb: "mysql", opengauss: "postgres", gaussdb: "postgres", kingbase: "postgres", vastbase: "postgres", }; @@ -123,6 +125,7 @@ const dbOptions = [ { value: "duckdb", label: "DuckDB" }, { value: "clickhouse", label: "ClickHouse" }, { value: "sqlserver", label: "SQL Server" }, + { value: "oracle", label: "Oracle" }, { value: "mariadb", label: "MariaDB" }, ]; diff --git a/src/components/icons/DatabaseIcon.vue b/src/components/icons/DatabaseIcon.vue index e40fc0b932..5698989a51 100644 --- a/src/components/icons/DatabaseIcon.vue +++ b/src/components/icons/DatabaseIcon.vue @@ -21,10 +21,19 @@ const brandIcons: Record = { - sqlserver: { letter: "S", color: "#CC2927" }, oceanbase: { letter: "O", color: "#0064FF" }, opengauss: { letter: "G", color: "#34A853" }, gaussdb: { letter: "G", color: "#CF0A2C" }, diff --git a/src/components/sidebar/TreeItem.vue b/src/components/sidebar/TreeItem.vue index 59f5d76ab5..037ccfc9d7 100644 --- a/src/components/sidebar/TreeItem.vue +++ b/src/components/sidebar/TreeItem.vue @@ -144,7 +144,7 @@ async function openData() { if (!(node.type === "table" || node.type === "view") || !node.connectionId || !node.database) return; await connectionStore.ensureConnected(node.connectionId); const config = connectionStore.getConfig(node.connectionId); - const qualifiedName = config?.db_type === "postgres" && node.schema + const qualifiedName = (config?.db_type === "postgres" || config?.db_type === "oracle") && node.schema ? `${quoteIdent(node.schema)}.${quoteIdent(node.label)}` : quoteIdent(node.label); const tabId = queryStore.createTab(node.connectionId, node.database, node.label, "data"); @@ -153,7 +153,9 @@ async function openData() { const columns = await api.getColumns(node.connectionId, node.database, querySchema, node.label); const pks = columns.filter((c) => c.is_primary_key).map((c) => c.name); const order = pks.length ? ` ORDER BY ${pks.map((pk) => `${quoteIdent(pk)} ASC`).join(", ")}` : ""; - const sql = `SELECT * FROM ${qualifiedName}${order} LIMIT 100;`; + const sql = config?.db_type === "oracle" + ? `SELECT * FROM ${qualifiedName}${order} FETCH FIRST 100 ROWS ONLY` + : `SELECT * FROM ${qualifiedName}${order} LIMIT 100;`; queryStore.updateSql(tabId, sql); queryStore.setTableMeta(tabId, { schema: node.schema, diff --git a/src/types/database.ts b/src/types/database.ts index 14f3ebedcb..cf59ce9e20 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -1,4 +1,4 @@ -export type DatabaseType = "mysql" | "postgres" | "sqlite" | "redis" | "duckdb" | "clickhouse" | "sqlserver" | "mongodb"; +export type DatabaseType = "mysql" | "postgres" | "sqlite" | "redis" | "duckdb" | "clickhouse" | "sqlserver" | "mongodb" | "oracle"; export interface ConnectionConfig { id: string;