diff --git a/Cargo.lock b/Cargo.lock index 4ed4ec68b..8d338f08b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1552,6 +1552,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" +dependencies = [ + "darling_core 0.13.4", + "darling_macro 0.13.4", +] + [[package]] name = "darling" version = "0.23.0" @@ -1572,6 +1582,20 @@ dependencies = [ "darling_macro 0.24.1", ] +[[package]] +name = "darling_core" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", +] + [[package]] name = "darling_core" version = "0.23.0" @@ -1581,7 +1605,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.119", ] @@ -1594,10 +1618,21 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 3.0.9", ] +[[package]] +name = "darling_macro" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" +dependencies = [ + "darling_core 0.13.4", + "quote", + "syn 1.0.109", +] + [[package]] name = "darling_macro" version = "0.23.0" @@ -4202,6 +4237,7 @@ dependencies = [ "ide-core", "ide-http", "ide-ssh", + "oracle", "parking_lot", "reqwest", "rustls", @@ -6287,6 +6323,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "odpic-sys" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920b5474a5128a9f0232df5a0ffc50aaa5b077b29b8b06ab0131985ac82793ed" +dependencies = [ + "cc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -6398,6 +6443,32 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "oracle" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db40fe6e4df881b683691ade5ef1f7b1afd52aefa115581f7b92855524d7ec0" +dependencies = [ + "cc", + "odpic-sys", + "once_cell", + "oracle_procmacro", + "paste", + "rustversion", +] + +[[package]] +name = "oracle_procmacro" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad247f3421d57de56a0d0408d3249d4b1048a522be2013656d92f022c3d8af27" +dependencies = [ + "darling 0.13.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -8698,6 +8769,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strsim" version = "0.11.1" @@ -8734,6 +8811,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", + "quote", "unicode-ident", ] @@ -8956,7 +9034,7 @@ dependencies = [ "serde", "serde_json", "static_vcruntime", - "strsim", + "strsim 0.11.1", "tauri-utils", "tauri-winres", "walkdir", diff --git a/Cargo.toml b/Cargo.toml index 507145e37..04da822d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -103,6 +103,7 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std" rustls-platform-verifier = "0.7" sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] } tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] } +oracle = { version = "0.6", default-features = false } bb8 = "0.9" uuid = { version = "1", features = ["v4"] } # OS credential stores diff --git a/README.md b/README.md index 50ea82fd9..ef5b34eda 100644 --- a/README.md +++ b/README.md @@ -68,4 +68,7 @@ the [official plugins](plugins/official). The plugin API is still a preview. [Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md). +Database tool window notes, including Oracle Instant Client setup, are in +[`docs/databases.md`](docs/databases.md). + Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o. diff --git a/crates/ide-build/src/compile/mod.rs b/crates/ide-build/src/compile/mod.rs index 91bbdbf35..e0cfd9aee 100644 --- a/crates/ide-build/src/compile/mod.rs +++ b/crates/ide-build/src/compile/mod.rs @@ -882,7 +882,8 @@ mod tests { model.gradle[0].env.insert("JAVA_HOME".into(), "/external-jdk".into()); let all = super::plan(&root, &model, super::BuildKind::Build, None, None).unwrap(); assert_eq!(all.steps[0].cwd, external); - assert_eq!(all.steps[0].program, external.join(wrapper).to_string_lossy()); + // Linked roots are saved with forward slashes, so on Windows the program mixes separators. + assert_eq!(std::path::Path::new(&all.steps[0].program), external.join(wrapper)); let module = super::plan(&root, &model, super::BuildKind::Rebuild, Some(&source), None).unwrap(); assert_eq!(module.steps[0].cwd, external); assert_eq!(module.steps[0].env["JAVA_HOME"], "/external-jdk"); diff --git a/crates/ide-db/Cargo.toml b/crates/ide-db/Cargo.toml index e72eccf23..709cacc15 100644 --- a/crates/ide-db/Cargo.toml +++ b/crates/ide-db/Cargo.toml @@ -17,6 +17,7 @@ parking_lot.workspace = true futures.workspace = true sqlx.workspace = true tiberius.workspace = true +oracle.workspace = true bb8.workspace = true tokio-util = { workspace = true, features = ["compat"] } dirs.workspace = true diff --git a/crates/ide-db/src/conn.rs b/crates/ide-db/src/conn.rs index 020dc0c6c..af0825e06 100644 --- a/crates/ide-db/src/conn.rs +++ b/crates/ide-db/src/conn.rs @@ -107,6 +107,7 @@ pub enum Pool { Mysql(MySqlPool), SqlServer(Bb8Pool), Clickhouse(crate::clickhouse::ClickhousePool), + Oracle(crate::oracle::OraclePool), } /// Runs `$body` with `$p` bound to the concrete pool of each driver. @@ -116,7 +117,7 @@ macro_rules! with_pool { Pool::Sqlite($p) => $body, Pool::Postgres($p) => $body, Pool::Mysql($p) => $body, - Pool::SqlServer(_) | Pool::Clickhouse(_) => unreachable!(), + Pool::SqlServer(_) | Pool::Clickhouse(_) | Pool::Oracle(_) => unreachable!(), } }; } @@ -262,7 +263,7 @@ fn tiberius_cell(cell: &tiberius::ColumnData<'_>, ty: tiberius::ColumnType) -> O } } -fn hex(bytes: &[u8]) -> String { +pub(crate) fn hex(bytes: &[u8]) -> String { let mut s = String::with_capacity(2 + bytes.len() * 2); s.push_str("0x"); for b in bytes { @@ -386,7 +387,7 @@ macro_rules! fetch { } /// Columns, rows, rows affected, more rows beyond the limit, time of the first answer. -type Fetched = (Vec, Vec>>, Option, bool, Option); +pub(crate) type Fetched = (Vec, Vec>>, Option, bool, Option); async fn mssql_command(conn: &mut MssqlClient, sql: &str) -> IdeResult<()> { // Transaction control must be a batch: sp_executesql rejects a changed transaction count. @@ -503,6 +504,18 @@ impl Setup { } } DbDriver::Mysql if source.read_only => s.statements.push("SET SESSION TRANSACTION READ ONLY".into()), + DbDriver::Oracle => { + let schema = o.default_schema.trim(); + if !schema.is_empty() { + if !is_identifier(schema) { + return Err(IdeError::invalid(format!("invalid schema name: {schema}"))); + } + s.statements.push(format!("ALTER SESSION SET CURRENT_SCHEMA = {schema}")); + } + if !o.time_zone.trim().is_empty() { + s.statements.push(format!("ALTER SESSION SET TIME_ZONE = {}", mysql_value(o.time_zone.trim()))); + } + } _ => {} } for p in &source.properties { @@ -527,6 +540,9 @@ impl Setup { (DbDriver::Mysql, "charset") => s.charset = Some(value.to_owned()), (DbDriver::Mysql, "collation") => s.collation = Some(value.to_owned()), (DbDriver::Mysql, _) => s.statements.push(format!("SET SESSION {name} = {}", mysql_value(value))), + (DbDriver::Oracle, _) => { + s.statements.push(format!("ALTER SESSION SET {name} = {}", mysql_value(value))) + } _ => s.params.push((name, value.to_owned())), } } @@ -549,6 +565,15 @@ impl Pool { Pool::Mysql(_) => DbDriver::Mysql, Pool::SqlServer(_) => DbDriver::SqlServer, Pool::Clickhouse(_) => DbDriver::Clickhouse, + Pool::Oracle(_) => DbDriver::Oracle, + } + } + + /// The keep-alive ping (Oracle needs a FROM clause). + pub fn ping_sql(&self) -> &'static str { + match self { + Pool::Oracle(_) => "SELECT 1 FROM DUAL", + _ => "SELECT 1", } } @@ -559,6 +584,9 @@ impl Pool { if source.driver == DbDriver::Clickhouse { return crate::clickhouse::ClickhousePool::connect(source, password, setup).await.map(Pool::Clickhouse); } + if source.driver == DbDriver::Oracle { + return crate::oracle::OraclePool::connect(source, password, &setup).await.map(Pool::Oracle); + } let timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30)); let init = Arc::new(setup.statements); // Runs the session statements (startup script…) on every new connection. @@ -721,12 +749,13 @@ impl Pool { Ok(Pool::SqlServer(pool)) } DbDriver::Clickhouse => unreachable!(), + DbDriver::Oracle => unreachable!(), } } pub async fn close(&self) { match self { - Pool::SqlServer(_) => {} + Pool::SqlServer(_) | Pool::Oracle(_) => {} Pool::Clickhouse(p) => p.close(), _ => with_pool!(self, p => p.close().await), } @@ -735,6 +764,7 @@ impl Pool { pub fn is_closed(&self) -> bool { match self { Pool::SqlServer(_) => false, + Pool::Oracle(p) => p.is_closed(), Pool::Clickhouse(p) => p.is_closed(), _ => with_pool!(self, p => p.is_closed()), } @@ -745,6 +775,11 @@ impl Pool { if let Pool::Clickhouse(p) = self { return Ok(p.execute(sql, max, &CancellationToken::new()).await?.rows); } + if let Pool::Oracle(p) = self { + let mut conn = p.get().await?; + let fetched = crate::oracle::run(&mut conn, sql, 0, max, Cells::Text, &CancellationToken::new()).await?; + return Ok(fetched.1); + } let fetched: IdeResult = match self { Pool::SqlServer(p) => { let mut conn = p.get_owned().await.map_err(mssql_pool_err)?; @@ -811,6 +846,15 @@ impl Pool { } result } + Pool::Oracle(p) => { + let mut conn = p.get().await?; + let result = crate::oracle::transaction(&mut conn, statements).await; + if result.is_err() { + // An open transaction must never return to the pool with the connection. + conn.discard_inner(); + } + result + } _ => with_pool!(self, p => { let mut tx = p.begin().await.map_err(db_err)?; let mut total = 0; @@ -838,6 +882,7 @@ pub enum Held { Mysql(PoolConnection), SqlServer(Box>), Clickhouse(crate::clickhouse::ClickhousePool), + Oracle(crate::oracle::OracleConnection), } impl Held { @@ -853,6 +898,7 @@ impl Held { } Held::Clickhouse(p.clone()) } + Pool::Oracle(p) => Held::Oracle(p.get().await?), }) } @@ -861,13 +907,20 @@ impl Held { match self { Held::SqlServer(c) => mssql_command(c, sql).await, Held::Clickhouse(_) => Err(crate::clickhouse::transaction_error()), + Held::Oracle(c) => { + let sql = sql.trim(); + if sql.is_empty() { + return Ok(()); // Oracle transactions open implicitly. + } + crate::oracle::run(c, sql, 0, 0, Cells::Text, &CancellationToken::new()).await.map(drop) + } other => { let sql = sqlx::AssertSqlSafe(sql.to_owned()); match other { Held::Sqlite(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop), Held::Postgres(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop), Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop), - Held::SqlServer(_) | Held::Clickhouse(_) => unreachable!(), + Held::SqlServer(_) | Held::Clickhouse(_) | Held::Oracle(_) => unreachable!(), } .map_err(db_err) } @@ -889,6 +942,7 @@ impl Held { c.0.take(); } Held::Clickhouse(_) => {} + Held::Oracle(mut c) => c.discard_inner(), } } @@ -959,6 +1013,7 @@ impl Held { () = cancel.cancelled() => return Err(IdeError::cancelled()), } } + Held::Oracle(c) => crate::oracle::run(c, sql, skip, max_rows, Cells::Display, cancel).await?, Held::Clickhouse(_) => unreachable!(), }; let (columns, rows, affected, has_more, first) = fetched; @@ -980,6 +1035,7 @@ pub fn begin_statement(driver: DbDriver) -> &'static str { match driver { DbDriver::Mysql => "START TRANSACTION", DbDriver::SqlServer => "BEGIN TRAN", + DbDriver::Oracle => "", // opens implicitly with autocommit off DbDriver::Sqlite | DbDriver::Postgres | DbDriver::Clickhouse => "BEGIN", } } diff --git a/crates/ide-db/src/lib.rs b/crates/ide-db/src/lib.rs index b549395d2..dabe98f99 100644 --- a/crates/ide-db/src/lib.rs +++ b/crates/ide-db/src/lib.rs @@ -5,6 +5,7 @@ mod clickhouse; pub mod conn; pub mod history; +mod oracle; pub mod plan; pub mod secrets; pub mod source; @@ -303,7 +304,7 @@ fn keep_alive(pool: &Arc, every: Duration) { loop { tick.tick().await; let Some(p) = pool.upgrade().filter(|p| !p.is_closed()) else { break }; - if let Err(e) = p.rows("SELECT 1", 1).await { + if let Err(e) = p.rows(p.ping_sql(), 1).await { tracing::debug!("keep-alive: {}", e.message); } } @@ -614,6 +615,7 @@ impl DbManager { DbDriver::Postgres | DbDriver::Clickhouse => "SELECT version()", DbDriver::Mysql => "SELECT CONCAT(@@version_comment, ' ', VERSION())", DbDriver::SqlServer => "SELECT @@VERSION", + DbDriver::Oracle => "SELECT banner FROM v$version WHERE banner LIKE 'Oracle%' AND ROWNUM = 1", }; let result = pool.rows(version, 1).await; pool.close().await; @@ -905,6 +907,33 @@ impl DbManager { .next(); ddl.map(|d| format!("{d};\n")).ok_or_else(missing) } + DbDriver::Oracle => { + let (s, n) = (sql::quote_literal(driver, schema), sql::quote_literal(driver, name)); + let kind = pool + .rows( + &format!( + "SELECT object_type FROM all_objects WHERE owner = {s} AND object_name = {n} \ + AND object_type IN ('TABLE', 'VIEW') AND ROWNUM = 1" + ), + 1, + ) + .await? + .into_iter() + .next() + .and_then(|r| r.into_iter().next().flatten()) + .ok_or_else(missing)?; + let ddl = cells( + pool.rows( + &format!("SELECT DBMS_METADATA.GET_DDL('{}', {n}, {s}) FROM DUAL", kind.replace('\'', "''")), + 1, + ) + .await?, + 0, + ) + .into_iter() + .next(); + ddl.map(|d| format!("{};\n", d.trim_end().trim_end_matches(';'))).ok_or_else(missing) + } DbDriver::Postgres => { let rel = pool.rows(&sql::postgres_relation(schema, name), 1).await?.into_iter().next(); let (oid, kind) = match rel.as_deref() { @@ -1057,6 +1086,29 @@ impl DbManager { /// The execution plan of `statement` (explained, not run). pub async fn explain(&self, id: &str, statement: &str, cancel: &CancellationToken) -> IdeResult> { let driver = self.pool(id).await?.driver(); + if driver == DbDriver::Oracle { + // EXPLAIN PLAN writes PLAN_TABLE; the plan is read (and cleaned up) separately. + let plan_id = format!( + "OPENIDEA{}", + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos()) + ); + self.run( + id, + &format!( + "EXPLAIN PLAN SET STATEMENT_ID = '{}' FOR {}", + plan_id, + statement.trim().trim_end_matches(';') + ), + 0, + MAX_META_ROWS, + cancel, + ) + .await?; + let result = self.run(id, &plan::oracle_plan_query(&plan_id), 0, MAX_META_ROWS, cancel).await?; + let nodes = plan::parse(driver, &result); + let _ = self.run(id, &plan::oracle_plan_cleanup(&plan_id), 0, 0, cancel).await; + return nodes; + } let result = self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel).await?; plan::parse(driver, &result) } diff --git a/crates/ide-db/src/oracle.rs b/crates/ide-db/src/oracle.rs new file mode 100644 index 000000000..5ece541a7 --- /dev/null +++ b/crates/ide-db/src/oracle.rs @@ -0,0 +1,639 @@ +//! Oracle connections through the synchronous `rust-oracle` driver (ODPI-C). +//! The Oracle client library (Instant Client) is loaded once per process from +//! the Options tab or `ORACLE_HOME`; every blocking call runs on the blocking +//! pool, and cancelling a statement closes its connection. + +use crate::conn::{Cells, Fetched, ResultColumn, Setup, hex}; +use crate::source::{DataSource, DbDriver, DbTxMode}; +use bb8::ManageConnection; +use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind}; +use oracle::sql_type::{Blob, Clob, OracleType}; +use std::io::Read; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::mpsc; + +/// Characters of a LOB column shown in the grid; catalog text reads more. +const MAX_GRID_LOB: u64 = 4096; +const MAX_TEXT_LOB: u64 = 1 << 16; + +/// Everything a new connection needs, built once per pool. +#[derive(Debug)] +struct State { + user: String, + password: String, + /// EZConnect `//host:port/service`, a TNS descriptor or an alias. + connect: String, + init: Vec, + auto_commit: bool, + read_only: bool, + statement_cache: Option, + timeout: Duration, +} + +impl State { + fn of(source: &DataSource, password: Option<&str>, setup: &Setup) -> IdeResult { + let user = source.user.trim(); + if user.is_empty() { + return Err(IdeError::invalid("Oracle data sources need a user name")); + } + Ok(Self { + user: user.to_owned(), + password: password.unwrap_or_default().to_owned(), + connect: connect_string(source), + init: setup.statements.clone(), + auto_commit: source.options.transaction != DbTxMode::Manual, + read_only: source.read_only, + statement_cache: setup.statement_cache.map(|n| n as u32), + timeout: setup.connect_timeout.unwrap_or(Duration::from_secs(30)), + }) + } +} + +/// `//host:port/service`; the database field may also hold a TNS descriptor or alias. +fn connect_string(source: &DataSource) -> String { + let db = source.database.trim(); + if db.starts_with('(') || db.contains('=') { + return db.to_owned(); + } + let host = if source.host.trim().is_empty() { "localhost" } else { source.host.trim() }; + let port = source.port.unwrap_or(1521); + if db.is_empty() { format!("//{host}:{port}") } else { format!("//{host}:{port}/{db}") } +} + +/// The expanded Instant Client directory, `None` when the search path is used. +fn client_dir(dir: &str) -> IdeResult> { + let dir = dir.trim(); + if dir.is_empty() { + return Ok(None); + } + let expanded: PathBuf = ide_ssh::expand(dir); + if !expanded.is_dir() { + return Err(IdeError::invalid(format!("Oracle Instant Client directory not found: {}", expanded.display()))); + } + Ok(Some(expanded.to_string_lossy().into_owned())) +} + +/// Loads the Oracle client once per process; the first data source's directory wins. +/// Failures are not cached: a corrected directory works without restarting. +fn init_client(dir: &str) -> IdeResult<()> { + static LOADED: std::sync::Mutex> = std::sync::Mutex::new(None); + let dir = dir.trim(); + let mut loaded = LOADED.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(used) = loaded.as_deref() { + if !dir.is_empty() && !used.is_empty() && used != dir { + tracing::warn!("Oracle client already loaded from {used}; ignoring {dir}"); + } + return Ok(()); + } + let mut params = oracle::InitParams::new(); + if let Some(lib_dir) = client_dir(dir)? { + params + .oracle_client_lib_dir(lib_dir) + .map_err(|e| IdeError::invalid(format!("Oracle Instant Client directory: {e}")))?; + } + params.init().map_err(|e| { + let hint = if e.to_string().contains("DPI-1047") { + format!("{e} — set the Instant Client directory (Options tab) or ORACLE_HOME") + } else { + e.to_string() + }; + IdeError::new(ErrorKind::Io, format!("Oracle client: {hint}")) + })?; + *loaded = Some(dir.to_owned()); + Ok(()) +} + +/// `rust-oracle` errors are not `Sync`; the pool wants a shareable error. +#[derive(Debug, Clone)] +pub struct OracleError(Arc); + +impl OracleError { + fn new(e: oracle::Error) -> Self { + Self(Arc::new(e)) + } + + fn message(msg: impl Into) -> Self { + Self::new(oracle::Error::new(oracle::ErrorKind::InvalidArgument, msg.into())) + } + + fn into_ide(self) -> IdeError { + oracle_err(clone_db(&self.0)) + } +} + +/// Database errors (the interesting ones) rebuild cheaply for cloning. +fn clone_db(e: &oracle::Error) -> oracle::Error { + match e.db_error() { + Some(db) => oracle::Error::new(oracle::ErrorKind::OciError, db.message().to_owned()), + None => message_error(&e.to_string()), + } +} + +fn message_error(msg: &str) -> oracle::Error { + oracle::Error::new(oracle::ErrorKind::InvalidArgument, msg.to_owned()) +} + +/// Wrong or missing password: ORA-01017 / ORA-01005; unreachable server: ORA-12xxx / ORA-31xx. +pub(crate) fn oracle_err(e: oracle::Error) -> IdeError { + let Some(db) = e.db_error() else { + return IdeError::new(ErrorKind::InvalidInput, e.to_string()); + }; + let msg = db.message().to_owned(); + if msg.contains("DPI-1047") { + return IdeError::new( + ErrorKind::Io, + format!("{msg} — set the Instant Client directory (Options tab) or ORACLE_HOME"), + ); + } + let code = db.code(); + let full = format!("[ORA-{code:05}] {msg}"); + match code { + 1005 | 1017 | 28000 | 28001 => IdeError::new(ErrorKind::PermissionDenied, full), + 12150..=12199 | 12537 | 12540 | 12541 | 12543 | 12545 | 3113 | 3114 => IdeError::new(ErrorKind::Io, full), + _ => IdeError::new(ErrorKind::InvalidInput, full), + } +} + +#[derive(Debug)] +pub struct OraclePool { + state: Arc, + pool: Arc>, +} + +impl OraclePool { + pub(crate) async fn connect(source: &DataSource, password: Option<&str>, setup: &Setup) -> IdeResult { + init_client(&source.options.instant_client)?; + let state = Arc::new(State::of(source, password, setup)?); + let pool = bb8::Pool::builder() + .max_size(4) + .connection_timeout(state.timeout) + .build(OracleConnectionManager(state.clone())) + .await + .map_err(|e| e.into_ide())?; + // bb8 connects lazily and reports failed attempts as a bare timeout, which + // would turn a wrong password into "timed out connecting to the database". + let opened = tokio::task::spawn_blocking({ + let state = state.clone(); + move || open(&state) + }) + .await + .map_err(|e| IdeError::internal(format!("Oracle connect task: {e}")))? + .map_err(oracle_err)?; + let _ = pool.add(OracleHandle { conn: Some(opened) }); + Ok(Self { state, pool: Arc::new(pool) }) + } + + /// A connection taken out of the pool for one statement or a held session. + pub async fn get(&self) -> IdeResult { + let pooled = self.pool.get_owned().await.map_err(|e| pool_err(e).into_ide())?; + Ok(OracleConnection { state: self.state.clone(), pooled }) + } + + pub fn is_closed(&self) -> bool { + let state = self.pool.state(); + state.connections == 0 && state.idle_connections == 0 + } +} + +fn pool_err(e: bb8::RunError) -> OracleError { + match e { + bb8::RunError::User(e) => e, + bb8::RunError::TimedOut => OracleError::message("timed out connecting to the database"), + } +} + +/// A pooled connection's payload; `conn` is `None` once the connection is +/// discarded (a cancelled statement leaves it in the blocking task). +pub struct OracleHandle { + conn: Option, +} + +pub struct OracleConnectionManager(Arc); + +impl ManageConnection for OracleConnectionManager { + type Connection = OracleHandle; + type Error = OracleError; + + async fn connect(&self) -> Result { + let state = self.0.clone(); + let opened = tokio::task::spawn_blocking(move || open(&state)) + .await + .map_err(|e| OracleError::message(format!("Oracle connect task: {e}")))?; + let conn = opened.map_err(OracleError::new)?; + Ok(OracleHandle { conn: Some(conn) }) + } + + async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> { + let state = self.0.clone(); + let Some(raw) = conn.conn.take() else { + return Err(OracleError::message("Oracle connection is closed")); + }; + let ping = tokio::time::timeout( + state.timeout, + tokio::task::spawn_blocking(move || { + let result = (|| -> oracle::Result<()> { + // `execute` rejects selects, so read one row. + let mut stmt = raw.statement("SELECT 1 FROM DUAL").build()?; + let mut rows = stmt.query(&[])?; + if let Some(row) = rows.next() { + row?; + } + Ok(()) + })(); + (raw, result) + }), + ) + .await; + match ping { + Ok(Ok((raw, Ok(())))) => { + conn.conn = Some(raw); + Ok(()) + } + Ok(Ok((_, Err(e)))) => Err(OracleError::new(e)), + Ok(Err(e)) => Err(OracleError::message(format!("Oracle ping task: {e}"))), + // Timed out (the task keeps the connection and closes it): discard this one. + Err(_) => Err(OracleError::message("timed out waiting for the Oracle server")), + } + } + + fn has_broken(&self, conn: &mut Self::Connection) -> bool { + conn.conn.is_none() + } +} + +/// A connection borrowed from the pool. It returns to the pool when dropped, +/// unless its statement was cancelled (then it is discarded). +pub struct OracleConnection { + state: Arc, + pooled: bb8::PooledConnection<'static, OracleConnectionManager>, +} + +impl OracleConnection { + pub fn is_closed(&self) -> bool { + self.pooled.conn.is_none() + } + + /// Drops the connection instead of returning it; the server rolls an open + /// transaction back. + pub(crate) fn discard_inner(&mut self) { + self.pooled.conn.take(); + } +} + +fn open(state: &State) -> oracle::Result { + let mut connector = oracle::Connector::new(state.user.clone(), state.password.clone(), state.connect.clone()); + connector.driver_name("OpenIDEA"); + if let Some(n) = state.statement_cache { + connector.stmt_cache_size(n); + } + let mut conn = connector.connect()?; + conn.set_autocommit(state.auto_commit); + for sql in &state.init { + statement(&mut conn, sql, 0, 0, Cells::Text).map_err(|e| message_error(&e.message))?; + } + Ok(conn) +} + +/// Runs `f` with the connection on the blocking pool. Cancelling drops the +/// connection instead of returning it (its statement keeps running server-side). +async fn with_conn( + c: &mut OracleConnection, + cancel: &CancellationToken, + f: impl FnOnce(&mut oracle::Connection) -> IdeResult + Send + 'static, +) -> IdeResult +where + T: Send + 'static, +{ + let Some(conn) = c.pooled.conn.take() else { + return Err(IdeError::new(ErrorKind::Io, "Oracle connection is closed")); + }; + let (tx, mut rx) = mpsc::channel::<(oracle::Connection, IdeResult)>(1); + tokio::task::spawn_blocking(move || { + let mut slot = Some(conn); + let result = match slot.as_mut() { + Some(conn) => f(conn), + None => unreachable!("connection taken above"), + }; + // A closed receiver (cancelled) drops the connection, closing it. + let _ = tx.blocking_send((slot.take().expect("some"), result)); + }); + tokio::select! { + item = rx.recv() => match item { + Some((conn, result)) => { + c.pooled.conn = Some(conn); + result + } + None => Err(IdeError::internal("the Oracle connection task failed")), + }, + () = cancel.cancelled() => { + rx.close(); + Err(IdeError::cancelled()) + } + } +} + +/// Runs a script on `c` (see [`crate::conn::Pool::rows`] and `Held::execute`). +pub(crate) async fn run( + c: &mut OracleConnection, + sql: &str, + skip: usize, + max: usize, + mode: Cells, + cancel: &CancellationToken, +) -> IdeResult { + let state = c.state.clone(); + let sql = sql.to_owned(); + with_conn(c, cancel, move |conn| statements(conn, &state, &sql, skip, max, mode)).await +} + +/// The table editor's edits in one transaction. +pub(crate) async fn transaction(c: &mut OracleConnection, statements: &[(String, bool)]) -> IdeResult { + if c.state.read_only { + return Err(IdeError::new(ErrorKind::PermissionDenied, "read-only connection: edits are not allowed")); + } + let script = statements.iter().map(|(sql, _)| sql.clone()).collect::>(); + let expect: Vec = statements.iter().map(|(_, e)| *e).collect(); + let apply = move |conn: &mut oracle::Connection| -> IdeResult { + conn.set_autocommit(false); + let mut total = 0; + for (sql, expect_one) in script.iter().zip(&expect) { + let mut stmt = conn.statement(sql).build().map_err(oracle_err)?; + stmt.execute(&[]).map_err(oracle_err)?; + let n = stmt.row_count().map_err(oracle_err)?; + if *expect_one && n != 1 { + conn.rollback().ok(); + return Err(IdeError::invalid(format!( + "{sql}: expected to change 1 row, changed {n}; nothing was saved" + ))); + } + total += n; + } + conn.commit().map_err(oracle_err)?; + Ok(total) + }; + let cancel = CancellationToken::new(); + with_conn(c, &cancel, apply).await +} + +/// Runs every statement of `sql` (one script, PL/SQL blocks kept whole): the +/// first query's rows are the result, otherwise the total rows changed. +fn statements( + conn: &mut oracle::Connection, + state: &State, + sql: &str, + skip: usize, + max: usize, + mode: Cells, +) -> IdeResult { + conn.set_autocommit(state.auto_commit); + let mut query: Option = None; + let mut affected: u64 = 0; + let mut first: Option = None; + for s in crate::split::split(sql, DbDriver::Oracle) { + if state.read_only && is_write(&s.text) { + return Err(IdeError::new( + ErrorKind::PermissionDenied, + format!("read-only connection: {} statements are not allowed", first_word(&s.text)), + )); + } + let r = statement(conn, &s.text, skip, max, mode)?; + first.get_or_insert_with(|| r.4.unwrap_or_else(Instant::now)); + if r.0.is_empty() { + affected += r.2.unwrap_or(0); + } else if query.is_none() { + query = Some(r); + } + } + Ok(query.unwrap_or((Vec::new(), Vec::new(), Some(affected), false, first))) +} + +/// Reads one statement; `skip` rows are fetched and dropped (result pagination). +fn statement(conn: &mut oracle::Connection, sql: &str, skip: usize, max: usize, mode: Cells) -> IdeResult { + let started = Instant::now(); + // LOB locators let `cell` read only the shown prefix (the default fetches whole LOBs). + let mut stmt = conn + .statement(sql) + .fetch_array_size((max as u32).clamp(1, 1000) + 1) + .lob_locator() + .build() + .map_err(oracle_err)?; + match stmt.statement_type() { + oracle::StatementType::Select => { + let rows = stmt.query(&[]).map_err(oracle_err)?; + let columns: Vec = rows + .column_info() + .iter() + .map(|c| ResultColumn { name: c.name().to_owned(), type_name: c.oracle_type().to_string() }) + .collect(); + let mut out: Vec>> = Vec::new(); + let mut has_more = false; + let mut to_skip = skip; + for row in rows { + if to_skip > 0 { + to_skip -= 1; + continue; + } + if out.len() >= max { + has_more = true; + break; + } + let row = row.map_err(oracle_err)?; + out.push(row.sql_values().iter().map(|v| cell(v, mode)).collect()); + } + Ok((columns, out, None, has_more, Some(started))) + } + _ => { + stmt.execute(&[]).map_err(oracle_err)?; + let affected = stmt.row_count().map_err(oracle_err)?; + Ok((Vec::new(), Vec::new(), Some(affected), false, Some(started))) + } + } +} + +fn first_word(sql: &str) -> String { + sql.lines() + .map(str::trim) + .find(|l| !l.is_empty() && !l.starts_with("--")) + .and_then(|l| l.split_whitespace().next()) + .unwrap_or("") + .to_ascii_uppercase() +} + +/// Whether `sql` changes data or schema (read-only connections refuse it). +fn is_write(sql: &str) -> bool { + !matches!(first_word(sql).as_str(), "SELECT" | "WITH" | "SHOW" | "EXPLAIN" | "DESCRIBE" | "DESC" | "SET") +} + +fn cell(v: &oracle::SqlValue, mode: Cells) -> Option { + if v.is_null().unwrap_or(false) { + return None; + } + match v.oracle_type().ok() { + Some(OracleType::CLOB | OracleType::NCLOB) => { + let lob: Clob = v.get().ok()?; + let cap = if mode == Cells::Text { MAX_TEXT_LOB } else { MAX_GRID_LOB }; + let mut s = String::new(); + lob.take(cap).read_to_string(&mut s).ok()?; + Some(s) + } + Some(OracleType::BLOB) => { + let lob: Blob = v.get().ok()?; + let cap = if mode == Cells::Text { MAX_TEXT_LOB } else { MAX_GRID_LOB }; + let mut buf = Vec::new(); + lob.take(cap).read_to_end(&mut buf).ok()?; + Some(hex(&buf)) + } + _ => v.get::().ok(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn connect_strings() { + let s = |db: &str| DataSource { + driver: DbDriver::Oracle, + host: "db.local".into(), + port: Some(1521), + database: db.into(), + ..Default::default() + }; + assert_eq!(connect_string(&s("ORCLPDB1")), "//db.local:1521/ORCLPDB1"); + assert_eq!(connect_string(&s("")), "//db.local:1521"); + assert_eq!( + connect_string(&DataSource { host: String::new(), port: None, database: "ORCL".into(), ..s("") }), + "//localhost:1521/ORCL" + ); + assert_eq!(connect_string(&s("(DESCRIPTION=(ADDRESS=...))")), "(DESCRIPTION=(ADDRESS=...))"); + assert_eq!(connect_string(&s("PROD=MYDB")), "PROD=MYDB"); + } + + #[test] + fn write_detection() { + assert!(!is_write("SELECT 1 FROM DUAL")); + assert!(!is_write("-- x\nWITH q AS (SELECT 1) SELECT * FROM q")); + assert!(!is_write("DESC users")); + assert!(is_write("INSERT INTO t VALUES (1)")); + assert!(is_write("begin\n null;\nend;")); + assert!(is_write("DROP TABLE t")); + } + + #[test] + fn missing_user_is_rejected_before_connecting() { + let setup = Setup::default(); + let err = State::of(&DataSource { driver: DbDriver::Oracle, ..Default::default() }, None, &setup).unwrap_err(); + assert!(err.message.contains("user name")); + let state = State::of( + &DataSource { driver: DbDriver::Oracle, user: "scott".into(), ..Default::default() }, + Some("tiger"), + &setup, + ) + .unwrap(); + assert_eq!( + (state.user.as_str(), state.password.as_str(), state.connect.as_str()), + ("scott", "tiger", "//localhost:1521") + ); + assert!(state.auto_commit); + } + + #[test] + fn oracle_errors_map_to_kinds() { + let auth = oracle_err(message_error("x")).kind; // no database error: invalid input + assert_eq!(auth, ErrorKind::InvalidInput); + } + + #[tokio::test] + async fn a_discarded_connection_is_broken_but_a_live_one_is_not() { + // No server is reachable here, so the pooled connection is a husk: `has_broken` + // decides whether bb8 keeps it, which is what cancellation relies on. + let manager = OracleConnectionManager(Arc::new( + State::of( + &DataSource { driver: DbDriver::Oracle, user: "scott".into(), ..Default::default() }, + None, + &Setup::default(), + ) + .unwrap(), + )); + let mut broken = OracleHandle { conn: None }; + assert!(manager.has_broken(&mut broken)); + // A connection that cannot be opened reports the failure without panicking + // (a client-load error, or ORA-12541 when a client is available). + match manager.connect().await { + Ok(_) => unreachable!("no server is listening"), + Err(err) => assert!(!err.into_ide().message.is_empty()), + } + } + + #[test] + fn connect_reports_a_missing_client_directory() { + let err = client_dir("/nonexistent/instantclient").expect_err("no such directory"); + assert!(err.message.contains("Instant Client directory not found"), "{}", err.message); + assert!(client_dir("").expect("the search path").is_none()); + } + + /// A live server, skipped unless `OPENIDEA_ORACLE_TEST` is + /// `user/password@host:port/service` (and `OPENIDEA_ORACLE_CLIENT` names the + /// Instant Client when it is not on the search path). + /// + /// Checking out a connection runs `is_valid`, so this fails if the ping is + /// not a query (bb8 silently retries a rejected check-out until it times out). + #[tokio::test] + async fn a_pooled_connection_runs_queries() { + let Some(spec) = std::env::var("OPENIDEA_ORACLE_TEST").ok().filter(|s| !s.is_empty()) else { return }; + let (auth, addr) = spec.split_once('@').expect("user/password@host:port/service"); + let (user, password) = auth.split_once('/').expect("user/password"); + let (host, rest) = addr.split_once(':').expect("host:port/service"); + let (port, service) = rest.split_once('/').expect("port/service"); + let source = DataSource { + driver: DbDriver::Oracle, + host: host.into(), + port: Some(port.parse().expect("port")), + database: service.into(), + user: user.into(), + options: crate::source::DbOptions { + instant_client: std::env::var("OPENIDEA_ORACLE_CLIENT").unwrap_or_default(), + ..Default::default() + }, + ..Default::default() + }; + // A missing or wrong password asks for one (the UI prompts on PermissionDenied). + for wrong in [None, Some("wrong")] { + let Err(err) = crate::conn::Pool::connect(&source, wrong, None).await else { panic!("bad password") }; + assert_eq!(err.kind, ErrorKind::PermissionDenied, "{}", err.message); + } + let pool = crate::conn::Pool::connect(&source, Some(password), None).await.expect("connect"); + let rows = pool.rows("SELECT 1 FROM DUAL", 1).await.expect("query through the pool"); + assert_eq!(rows.first().and_then(|r| r.first().cloned().flatten()).as_deref(), Some("1")); + + // The result grid reads later pages by skipping rows client-side. + let page = pool + .execute_from("SELECT LEVEL FROM DUAL CONNECT BY LEVEL <= 10", 4, 3, &CancellationToken::new()) + .await + .expect("second page"); + let values: Vec = page.rows.iter().filter_map(|r| r.first().cloned().flatten()).collect(); + assert_eq!(values, ["5", "6", "7"]); + assert!(page.has_more); + + // PL/SQL units reach the server with their closing `END;`. + pool.rows("begin null; end;", 0).await.expect("anonymous block"); + pool.rows("declare n number := 1; begin n := n + 1; end;", 0).await.expect("declare block"); + let proc = "create or replace procedure openidea_p(a number) is\n x number := a;\nbegin\n x := x + 1;\nend;"; + let rows = pool.rows(&format!("{proc}\nselect 7 from dual;"), 1).await.expect("procedure, then a query"); + assert_eq!(rows.first().and_then(|r| r.first().cloned().flatten()).as_deref(), Some("7")); + pool.rows("begin openidea_p(1); end;", 0).await.expect("call the procedure"); + let package = "create or replace package openidea_k as\n function f return number;\nend openidea_k;\n/\n\ + create or replace package body openidea_k as\n function f return number is begin return 8; end;\nend openidea_k;\n/"; + pool.rows(package, 0).await.expect("package spec and body"); + let rows = pool.rows("select openidea_k.f from dual", 1).await.expect("call the package"); + assert_eq!(rows.first().and_then(|r| r.first().cloned().flatten()).as_deref(), Some("8")); + pool.rows("drop package openidea_k; drop procedure openidea_p", 0).await.expect("clean up"); + + // LOB cells are read through locators. + let rows = pool.rows("select to_clob('abc'), to_blob(hextoraw('0A0B')) from dual", 1).await.expect("LOBs"); + assert_eq!(rows.first().cloned(), Some(vec![Some("abc".into()), Some("0x0A0B".into())])); + } +} diff --git a/crates/ide-db/src/plan.rs b/crates/ide-db/src/plan.rs index 32b217b6e..db4f22deb 100644 --- a/crates/ide-db/src/plan.rs +++ b/crates/ide-db/src/plan.rs @@ -38,13 +38,28 @@ pub fn explain_statement(driver: DbDriver, statement: &str) -> String { DbDriver::Clickhouse => format!("EXPLAIN json = 1 {s}"), DbDriver::Mysql => format!("EXPLAIN {s}"), DbDriver::SqlServer => format!("SET SHOWPLAN_TEXT ON; {s}"), + DbDriver::Oracle => format!("EXPLAIN PLAN FOR {s}"), } } +/// Reads the plan `EXPLAIN PLAN SET STATEMENT_ID = '{plan_id}'` wrote (see [`parse`] with `DbDriver::Oracle`). +pub fn oracle_plan_query(plan_id: &str) -> String { + format!( + "SELECT id, parent_id, operation, options, object_name, cost, cardinality, bytes \ + FROM plan_table WHERE statement_id = '{plan_id}' ORDER BY id" + ) +} + +/// Removes a read plan (PLAN_TABLE keeps rows until the session ends). +pub fn oracle_plan_cleanup(plan_id: &str) -> String { + format!("DELETE FROM plan_table WHERE statement_id = '{plan_id}'") +} + /// Reads the result of [`explain_statement`]. pub fn parse(driver: DbDriver, result: &QueryResult) -> IdeResult> { match driver { DbDriver::Sqlite => Ok(sqlite(&result.rows)), + DbDriver::Oracle => Ok(oracle(&result.rows)), DbDriver::Postgres | DbDriver::Clickhouse => { let text: String = result.rows.iter().filter_map(|r| r.first().cloned().flatten()).collect::>().join("\n"); @@ -148,6 +163,40 @@ fn postgres(node: &Value, parent: Option, out: &mut Vec) { } } +/// Oracle PLAN_TABLE rows: `id, parent_id, operation, options, object_name, cost, cardinality, bytes`. +fn oracle(rows: &[Vec>]) -> Vec { + let mut ids: Vec = Vec::new(); + let mut out = Vec::new(); + for r in rows { + let cell = |i: usize| r.get(i).cloned().flatten().unwrap_or_default(); + let (id, parent, operation, options, object) = (cell(0), cell(1), cell(2), cell(3), cell(4)); + let parent = ids.iter().position(|x| *x == parent).map(|i| i as u32); + let op = format!("{operation} {options}").trim().to_owned(); + let (operation, params) = match (operation.as_str(), options.as_str()) { + ("TABLE ACCESS", "FULL") => ("Full Scan".into(), object), + ("TABLE ACCESS", "BY INDEX ROWID" | "BY ROWID RANGE" | "BY USER ROWID") => ("Table Access".into(), object), + ("INDEX", "RANGE SCAN" | "RANGE SCAN DESCENDING") => ("Index Range Scan".into(), object), + ("INDEX", "UNIQUE SCAN") => ("Index Lookup".into(), object), + ("INDEX", "FULL SCAN" | "FAST FULL SCAN" | "SKIP SCAN") => ("Full Index Scan".into(), object), + ("INDEX", "SAMPLE FAST FULL SCAN") => ("Full Index Scan".into(), object), + ("SORT", "ORDER BY") => ("Order By".into(), String::new()), + ("SORT", "GROUP BY") => ("Group By".into(), String::new()), + ("SORT", "UNIQUE") => ("Distinct".into(), String::new()), + ("HASH", "GROUP BY") => ("Group By".into(), String::new()), + _ => (op, object), + }; + let mut properties = Vec::new(); + for (name, value) in [("Cost", 5), ("Rows", 6), ("Bytes", 7)] { + if let Some(v) = r.get(value).cloned().flatten() { + properties.push(prop(name, v)); + } + } + out.push(PlanNode { parent, operation, params, properties }); + ids.push(id); + } + out +} + /// MySQL's tabular `EXPLAIN`: one row per table access, flat. fn mysql(names: &[&str], rows: &[Vec>]) -> Vec { let col = |r: &[Option], n: &str| { @@ -202,6 +251,49 @@ mod tests { assert_eq!(explain_statement(DbDriver::Mysql, "select 1"), "EXPLAIN select 1"); assert_eq!(explain_statement(DbDriver::Clickhouse, " select 1;"), "EXPLAIN json = 1 select 1"); assert_eq!(explain_statement(DbDriver::SqlServer, "select 1"), "SET SHOWPLAN_TEXT ON; select 1"); + assert_eq!(explain_statement(DbDriver::Oracle, "select 1"), "EXPLAIN PLAN FOR select 1"); + let q = oracle_plan_query("OI1"); + assert!(q.contains("FROM plan_table WHERE statement_id = 'OI1' ORDER BY id")); + assert!(oracle_plan_cleanup("OI1").contains("DELETE FROM plan_table WHERE statement_id = 'OI1'")); + } + + #[test] + fn reads_oracle_plan_table_rows() { + let r = result( + &["id", "parent_id", "operation", "options", "object_name", "cost", "cardinality", "bytes"], + vec![ + vec![Some("1"), Some("0"), Some("SELECT STATEMENT"), None, None, Some("3"), Some("41"), Some("1230")], + vec![Some("2"), Some("1"), Some("HASH"), Some("GROUP BY"), None, Some("3"), Some("41"), Some("1230")], + vec![ + Some("4"), + Some("2"), + Some("TABLE ACCESS"), + Some("FULL"), + Some("orders"), + Some("2"), + Some("41"), + Some("1230"), + ], + vec![ + Some("5"), + Some("2"), + Some("INDEX"), + Some("UNIQUE SCAN"), + Some("c_pk"), + Some("0"), + Some("1"), + Some("6"), + ], + ], + ); + let plan = parse(DbDriver::Oracle, &r).unwrap(); + let ops: Vec<(&str, &str)> = plan.iter().map(|n| (n.operation.as_str(), n.params.as_str())).collect(); + assert_eq!( + ops, + [("SELECT STATEMENT", ""), ("Group By", ""), ("Full Scan", "orders"), ("Index Lookup", "c_pk")] + ); + assert_eq!((plan[2].parent, plan[3].parent), (Some(1), Some(1))); + assert!(plan[0].properties.iter().any(|p| p.name == "Cost" && p.value == "3")); } #[test] diff --git a/crates/ide-db/src/source.rs b/crates/ide-db/src/source.rs index 1acb4ea40..da8ddec2f 100644 --- a/crates/ide-db/src/source.rs +++ b/crates/ide-db/src/source.rs @@ -20,6 +20,8 @@ pub enum DbDriver { /// Microsoft SQL Server. SqlServer, Clickhouse, + /// Oracle Database (via Oracle Instant Client). + Oracle, } impl DbDriver { @@ -30,6 +32,7 @@ impl DbDriver { DbDriver::Mysql => Some(3306), DbDriver::SqlServer => Some(1433), DbDriver::Clickhouse => Some(8123), + DbDriver::Oracle => Some(1521), } } } @@ -85,6 +88,8 @@ pub struct DbOptions { pub auto_disconnect_secs: Option, /// PostgreSQL: show only the configured database, its schemas right under the data source. pub single_database: bool, + /// Oracle: directory of the Oracle Instant Client (empty: `ORACLE_HOME` / system search path). + pub instant_client: String, } /// Schemas tab: the databases / schemas the tree shows. Names may use `*` and `?`. @@ -261,6 +266,7 @@ pub fn scheme_driver(scheme: &str) -> Option { "sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer), "sqlite" | "sqlite3" => Some(DbDriver::Sqlite), "clickhouse" | "http" | "https" => Some(DbDriver::Clickhouse), + "oracle" | "oracledb" => Some(DbDriver::Oracle), _ => None, } } @@ -273,6 +279,12 @@ pub fn parse_url(raw: &str) -> IdeResult { let url = url.strip_prefix("jdbc:").or_else(|| url.strip_prefix("JDBC:")).unwrap_or(url); let (scheme, rest) = url.split_once(':').ok_or_else(bad)?; let driver = scheme_driver(scheme).ok_or_else(bad)?; + // JDBC thin URLs: `jdbc:oracle:thin:@//host:port/service` (after the `jdbc:` prefix). + let rest = if driver == DbDriver::Oracle { + rest.strip_prefix("thin:@").or_else(|| rest.strip_prefix("thin:/@")).unwrap_or(rest) + } else { + rest + }; if driver == DbDriver::Sqlite { let file = rest.strip_prefix("//").unwrap_or(rest); let file = file.split('?').next().unwrap_or_default(); @@ -540,7 +552,11 @@ mod tests { assert_eq!(parse_url("jdbc:redshift://r:5439/dev").unwrap().driver, Some(DbDriver::Postgres)); assert_eq!(parse_url("tidb://h:4000").unwrap().driver, Some(DbDriver::Mysql)); assert_eq!(parse_url("sqlite:/tmp/a.db").unwrap().file.as_deref(), Some("/tmp/a.db")); - assert!(parse_url("oracle://x").is_err()); + let u = parse_url("oracle://scott:tiger@db.local:1521/ORCLPDB1").unwrap(); + assert_eq!(u.driver, Some(DbDriver::Oracle)); + assert_eq!((u.host.as_str(), u.port, u.database.as_str()), ("db.local", Some(1521), "ORCLPDB1")); + assert_eq!((u.user.as_str(), u.password.as_deref()), ("scott", Some("tiger"))); + assert_eq!(parse_url("oracledb://hr@db.local/hrdb").unwrap().driver, Some(DbDriver::Oracle)); assert!(parse_url("postgres://h:99999/db").is_err()); } diff --git a/crates/ide-db/src/split.rs b/crates/ide-db/src/split.rs index b2ee3a108..785aa62ed 100644 --- a/crates/ide-db/src/split.rs +++ b/crates/ide-db/src/split.rs @@ -9,7 +9,8 @@ use specta::Type; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct SqlStatement { - /// UTF-16 offsets of the statement text (whitespace trimmed, `;` excluded). + /// UTF-16 offsets of the statement text (whitespace trimmed, `;` excluded except + /// after an Oracle PL/SQL block, whose `END;` needs it). pub from: u32, pub to: u32, pub text: String, @@ -29,6 +30,8 @@ enum State { pub fn split(text: &str, driver: DbDriver) -> Vec { let mysql = driver == DbDriver::Mysql; let clickhouse = driver == DbDriver::Clickhouse; + // PL/SQL: `;` inside BEGIN…END / DECLARE…END blocks does not terminate a statement. + let plsql = driver == DbDriver::Oracle; let chars: Vec<(usize, char)> = text.char_indices().collect(); // UTF-16 offset of each char index (plus the end). let mut u16_at = Vec::with_capacity(chars.len() + 1); @@ -46,6 +49,14 @@ pub fn split(text: &str, driver: DbDriver) -> Vec { // Whether the current segment has anything but whitespace / comments. let mut has_code = false; let mut i = 0usize; + // PL/SQL block depth (BEGIN / CASE / LOOP open, END closes). + let mut depth = 0usize; + // Inside a top-level block's declaration section (`DECLARE … BEGIN`). + let mut declarations = false; + // The segment is a PL/SQL block: its closing `;` is part of the statement. + let mut block = false; + // `CREATE PACKAGE` / `TYPE BODY`: only `/` on its own line (or the end) ends it. + let mut package = false; let push = |out: &mut Vec, s: usize, e: usize, has_code: bool| { if !has_code { return; @@ -69,11 +80,24 @@ pub fn split(text: &str, driver: DbDriver) -> Vec { let next = chars.get(i + 1).map(|c| c.1); match state { State::Code => match c { - ';' => { - push(&mut out, start, i, has_code); + _ if plsql && !has_code && c.is_alphabetic() => { + match plsql_unit(&chars, i) { + Unit::Sql => {} + Unit::Block => block = true, + Unit::Subprogram => (block, declarations) = (true, true), + Unit::Package => (block, package) = (true, true), + } + has_code = true; + // Re-read this character as code (it may start BEGIN / DECLARE). + continue; + } + ';' if depth == 0 && !declarations && !package => { + push(&mut out, start, if block { i + 1 } else { i }, has_code); start = i + 1; has_code = false; + block = false; } + ';' => has_code = true, '\'' => { state = State::Single; has_code = true; @@ -86,15 +110,28 @@ pub fn split(text: &str, driver: DbDriver) -> Vec { state = State::Backtick; has_code = true; } + '/' if next == Some('*') => { + state = State::BlockComment; + i += 1; + } + '/' if plsql && ends_line(&chars, i) && starts_line(&chars, i) => { + // SQL*Plus block terminator: `/` alone on its line ends the statement. + push(&mut out, start, i, has_code); + while chars.get(i).is_some_and(|&(_, c)| c != '\n') { + i += 1; + } + start = i + 1; + has_code = false; + depth = 0; + declarations = false; + block = false; + package = false; + } '-' if next == Some('-') => { state = State::LineComment; i += 1; } '#' if mysql => state = State::LineComment, - '/' if next == Some('*') => { - state = State::BlockComment; - i += 1; - } '$' if driver == DbDriver::Postgres => { // `$tag$ … $tag$` (tag may be empty); `$1` parameters are not quotes. let rest = &text[byte_at(i)..]; @@ -114,6 +151,34 @@ pub fn split(text: &str, driver: DbDriver) -> Vec { continue; } } + _ if plsql && word_at(&chars, i, "BEGIN") => { + depth += 1; + declarations = false; + has_code = true; + } + _ if plsql && word_at(&chars, i, "CASE") || plsql && word_at(&chars, i, "LOOP") => { + depth += 1; + has_code = true; + } + // `DECLARE … BEGIN … END;` is one block: the declarations of a top-level block + // end at BEGIN, so their `;` must not split it. + _ if plsql && word_at(&chars, i, "DECLARE") => { + if depth == 0 { + declarations = true; + } + has_code = true; + } + _ if plsql && word_at(&chars, i, "END") => { + // `END IF` closes an IF (which never opened a block); END LOOP / END CASE + // close the block their keyword opened. Other ENDs close a block. + let after = skip_ws(&chars, i + 3); + let closing = ["LOOP", "CASE", "IF"].into_iter().find(|w| word_at(&chars, after, w)); + if closing != Some("IF") && depth > 0 { + depth -= 1; + } + i = closing.map_or(i + 3, |w| after + w.len()) - 1; + has_code = true; + } c if !c.is_whitespace() => has_code = true, _ => {} }, @@ -164,6 +229,73 @@ pub fn split(text: &str, driver: DbDriver) -> Vec { out } +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Unit { + Sql, + /// `DECLARE` / `BEGIN`: an anonymous block. + Block, + /// `CREATE PROCEDURE` / `FUNCTION` / `TRIGGER`: declarations up to the body's BEGIN. + Subprogram, + /// `CREATE PACKAGE [BODY]` / `TYPE BODY`. + Package, +} + +/// What kind of PL/SQL unit, if any, a statement starting at `i` is. +fn plsql_unit(chars: &[(usize, char)], mut i: usize) -> Unit { + let mut words: Vec = Vec::new(); + while words.len() < 6 { + let word: String = chars[i.min(chars.len())..] + .iter() + .map(|&(_, c)| c) + .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$') + .collect(); + if word.is_empty() { + break; + } + i = skip_ws(chars, i + word.chars().count()); + words.push(word.to_ascii_uppercase()); + } + let w = |n: usize| words.get(n).map_or("", String::as_str); + match w(0) { + "DECLARE" | "BEGIN" => return Unit::Block, + "CREATE" => {} + _ => return Unit::Sql, + } + let mut n = if (w(1), w(2)) == ("OR", "REPLACE") { 3 } else { 1 }; + if matches!(w(n), "EDITIONABLE" | "NONEDITIONABLE") { + n += 1; + } + match (w(n), w(n + 1)) { + ("PROCEDURE" | "FUNCTION" | "TRIGGER", _) => Unit::Subprogram, + ("PACKAGE", _) | ("TYPE", "BODY") => Unit::Package, + _ => Unit::Sql, + } +} + +/// Whether the characters at `i` are the keyword `word` (identifier bounds respected). +fn word_at(chars: &[(usize, char)], i: usize, word: &str) -> bool { + let (before, after) = (chars[..i].last().map(|&(_, c)| c), chars.get(i).map(|&(_, c)| c)); + before.is_none_or(|c| !c.is_alphanumeric() && c != '_' && c != '$') + && after.is_some_and(|c| c.is_ascii_alphabetic()) + && chars[i..].iter().take(word.len()).map(|&(_, c)| c.to_ascii_uppercase()).eq(word.chars()) + && chars.get(i + word.len()).is_none_or(|&(_, c)| !c.is_alphanumeric() && c != '_' && c != '$') +} + +/// Whether only whitespace follows `i` until the end of its line. +fn ends_line(chars: &[(usize, char)], i: usize) -> bool { + chars[i + 1..].iter().take_while(|&&(_, c)| c != '\n').all(|&(_, c)| c.is_whitespace()) +} + +/// Whether only whitespace precedes `i` since the start of its line. +fn starts_line(chars: &[(usize, char)], i: usize) -> bool { + chars[..i].iter().rev().take_while(|&&(_, c)| c != '\n').all(|&(_, c)| c.is_whitespace()) +} + +/// The index of the first non-whitespace character at or after `i`. +fn skip_ws(chars: &[(usize, char)], i: usize) -> usize { + chars[i..].iter().position(|&(_, c)| !c.is_whitespace()).map_or(chars.len(), |n| i + n) +} + /// The statement to run for a caret at `offset` (UTF-16): the one containing /// it, else the nearest one before it (caret after `;` or on a blank line /// below), else the next one. @@ -277,4 +409,62 @@ mod tests { assert_eq!(s.iter().map(|s| s.text.as_str()).collect::>(), vec!["select 1", "select 2"]); assert_eq!((s[1].from, s[1].to), (13, 21)); } + + #[test] + fn oracle_keeps_plsql_blocks_as_one_statement() { + let block = "begin\n for r in (select 1 from dual) loop\n null; -- ;\n end loop;\nend"; + assert_eq!( + texts(&format!("{block};\nselect 2;"), DbDriver::Oracle), + vec![format!("{block};"), "select 2".into()] + ); + let nested = "declare\n x number;\nbegin\n if x = 1 then\n case when x = 2 then\n null;\n end case;\n begin\n null;\n end;\n end if;\nend"; + assert_eq!( + texts(&format!("{nested};\nselect 3;"), DbDriver::Oracle), + vec![format!("{nested};"), "select 3".into()] + ); + // Plain SQL with CASE is unaffected; END IF does not close a block twice. + assert_eq!( + texts("select case when a = 1 then 2 end from t; select 2", DbDriver::Oracle), + vec!["select case when a = 1 then 2 end from t", "select 2"] + ); + } + + #[test] + fn oracle_slash_terminator_and_other_drivers_unaffected() { + // `END;` ends the block; a following `/` (SQL*Plus's own terminator) adds nothing. + assert_eq!( + texts("begin\n null;\nend;\n/\nselect 2", DbDriver::Oracle), + vec!["begin\n null;\nend;", "select 2"] + ); + // A division is not a terminator. + assert_eq!(texts("select 1 /\n 2 from dual", DbDriver::Oracle), vec!["select 1 /\n 2 from dual"]); + assert_eq!(texts("select 'a;b' from t; select 2", DbDriver::Oracle), vec!["select 'a;b' from t", "select 2"]); + } + + #[test] + fn oracle_create_units_keep_their_declarations_and_end() { + let proc = "create or replace procedure p(a number) is\n x number := 1;\nbegin\n null;\nend p;"; + assert_eq!(texts(&format!("{proc}\nselect 1 from dual;"), DbDriver::Oracle), vec![proc, "select 1 from dual"]); + let trigger = + "CREATE TRIGGER t BEFORE INSERT ON x FOR EACH ROW\nDECLARE n NUMBER;\nBEGIN\n :new.id := 1;\nEND;"; + assert_eq!( + texts(&format!("{trigger}\nselect 2 from dual"), DbDriver::Oracle), + vec![trigger, "select 2 from dual"] + ); + // Packages end at `/` (or the end of the script), like in SQL*Plus. + let spec = "create package k as\n procedure a;\n function f return number;\nend k;"; + let body = "create package body k as\n procedure a is begin null; end;\n function f return number is begin return 1; end;\nbegin\n null;\nend k;"; + assert_eq!( + texts(&format!("{spec}\n/\n{body}\n/\nselect 3 from dual;"), DbDriver::Oracle), + vec![spec, body, "select 3 from dual"] + ); + // Other CREATE statements and object types stay plain SQL. + assert_eq!( + texts( + "create view v as select 1 x from dual; create type t as object (a number); select 4", + DbDriver::Oracle + ), + vec!["create view v as select 1 x from dual", "create type t as object (a number)", "select 4"] + ); + } } diff --git a/crates/ide-db/src/sql.rs b/crates/ide-db/src/sql.rs index bd281a859..9e2172610 100644 --- a/crates/ide-db/src/sql.rs +++ b/crates/ide-db/src/sql.rs @@ -46,6 +46,23 @@ pub fn databases(templates: bool) -> &'static str { /// Databases of managed services that their users cannot use (hidden unless connected to). pub const INTERNAL_DATABASES: &[&str] = &["rdsadmin", "azure_maintenance", "azure_sys", "cloudsqladmin"]; +/// Oracle system accounts hidden unless `show_system` (see the Schemas tab). +pub const ORACLE_SYSTEM_SCHEMAS: &[&str] = &[ + "CTXSYS", + "DBSFWUSER", + "DVSYS", + "GSMADMIN_INTERNAL", + "LBACSYS", + "MDDATA", + "MDSYS", + "OLAPSYS", + "OUTLN", + "SYS", + "SYSTEM", + "XDB", + "XS$NULL", +]; + pub fn schemas(driver: DbDriver, system: bool) -> &'static str { match driver { DbDriver::Sqlite => "SELECT name FROM pragma_database_list WHERE name <> 'temp' ORDER BY seq", @@ -59,6 +76,12 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str { } DbDriver::Mysql if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1", DbDriver::SqlServer if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1", + DbDriver::Oracle if system => "SELECT username FROM all_users ORDER BY username", + DbDriver::Oracle => { + "SELECT username FROM all_users WHERE username NOT IN \ + ('CTXSYS', 'DBSFWUSER', 'DVSYS', 'GSMADMIN_INTERNAL', 'LBACSYS', 'MDDATA', 'MDSYS', 'OLAPSYS', \ + 'OUTLN', 'SYS', 'SYSTEM', 'XDB', 'XS$NULL') ORDER BY username" + } DbDriver::Postgres => { "SELECT schema_name FROM information_schema.schemata \ WHERE schema_name NOT IN ('pg_catalog', 'information_schema') \ @@ -89,6 +112,12 @@ pub fn tables(driver: DbDriver, schema: &str) -> String { AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name", quote_ident(driver, schema) ), + DbDriver::Oracle => format!( + "SELECT table_name, 'table' FROM all_tables WHERE owner = {} \ + UNION ALL SELECT view_name, 'view' FROM all_views WHERE owner = {} ORDER BY 1", + quote_literal(driver, schema), + quote_literal(driver, schema) + ), DbDriver::Postgres => format!( "SELECT c.relname, CASE c.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE 'table' END \ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \ @@ -148,6 +177,17 @@ pub fn columns(driver: DbDriver, schema: &str, table: &str) -> String { FROM information_schema.columns c WHERE c.table_schema = {s} AND c.table_name = {t} \ ORDER BY c.ordinal_position" ), + DbDriver::Oracle => format!( + "SELECT c.column_name, c.data_type, DECODE(c.nullable, 'Y', 'YES', 'NO'), c.data_default, \ + CASE WHEN k.column_name IS NOT NULL THEN 1 ELSE 0 END \ + FROM all_tab_columns c \ + LEFT JOIN (SELECT kc.column_name FROM all_constraints con \ + JOIN all_cons_columns kc ON kc.owner = con.owner AND kc.table_name = con.table_name \ + AND kc.constraint_name = con.constraint_name \ + WHERE con.constraint_type = 'P' AND con.owner = {s} AND con.table_name = {t}) k \ + ON k.column_name = c.column_name \ + WHERE c.owner = {s} AND c.table_name = {t} ORDER BY c.column_id" + ), } } @@ -185,6 +225,12 @@ pub fn indexes(driver: DbDriver, schema: &str, table: &str) -> String { WHERE OBJECT_SCHEMA_NAME(i.object_id) = {s} AND OBJECT_NAME(i.object_id) = {t} AND i.name IS NOT NULL \ ORDER BY i.name, ic.key_ordinal" ), + DbDriver::Oracle => format!( + "SELECT i.index_name, CASE WHEN i.uniqueness = 'UNIQUE' THEN 1 ELSE 0 END, ic.column_name \ + FROM all_indexes i JOIN all_ind_columns ic \ + ON ic.index_owner = i.owner AND ic.index_name = i.index_name \ + WHERE i.owner = {s} AND i.table_name = {t} ORDER BY i.index_name, ic.column_position" + ), } } @@ -245,6 +291,21 @@ pub fn completion_columns(driver: DbDriver) -> &'static str { WHERE c.table_schema NOT IN ('sys', 'information_schema') \ ORDER BY c.table_schema, c.table_name, c.ordinal_position" } + DbDriver::Oracle => { + "SELECT c.owner, c.table_name, CASE WHEN v.view_name IS NOT NULL THEN 'view' ELSE 'table' END, \ + c.column_name, c.data_type, DECODE(c.nullable, 'Y', 'YES', 'NO'), \ + CASE WHEN k.column_name IS NOT NULL THEN 1 ELSE 0 END, \ + CASE WHEN c.data_default IS NOT NULL THEN 1 ELSE 0 END \ + FROM all_tab_columns c \ + LEFT JOIN all_views v ON v.owner = c.owner AND v.view_name = c.table_name \ + LEFT JOIN (SELECT con.owner, con.table_name, kc.column_name FROM all_constraints con \ + JOIN all_cons_columns kc ON kc.owner = con.owner AND kc.table_name = con.table_name \ + AND kc.constraint_name = con.constraint_name WHERE con.constraint_type = 'P') k \ + ON k.owner = c.owner AND k.table_name = c.table_name AND k.column_name = c.column_name \ + WHERE c.owner NOT IN ('CTXSYS', 'DBSFWUSER', 'DVSYS', 'GSMADMIN_INTERNAL', 'LBACSYS', 'MDDATA', \ + 'MDSYS', 'OLAPSYS', 'OUTLN', 'SYS', 'SYSTEM', 'XDB', 'XS$NULL') \ + ORDER BY c.owner, c.table_name, c.column_id FETCH FIRST 50000 ROWS ONLY" + } } } @@ -284,6 +345,19 @@ pub fn foreign_keys(driver: DbDriver) -> &'static str { AND table_schema NOT IN ('sys', 'information_schema') \ ORDER BY table_schema, table_name, constraint_name, ordinal_position" } + DbDriver::Oracle => { + "SELECT con.owner, con.table_name, con.constraint_name, col.column_name, \ + rcon.owner, rcon.table_name, rcol.column_name \ + FROM all_constraints con \ + JOIN all_cons_columns col ON col.owner = con.owner AND col.table_name = con.table_name \ + AND col.constraint_name = con.constraint_name \ + JOIN all_constraints rcon ON rcon.owner = con.r_owner AND rcon.constraint_name = con.r_constraint_name \ + JOIN all_cons_columns rcol ON rcol.owner = rcon.owner AND rcol.table_name = rcon.table_name \ + AND rcol.constraint_name = rcon.constraint_name \ + WHERE con.constraint_type = 'R' AND con.owner NOT IN ('CTXSYS', 'DBSFWUSER', 'DVSYS', \ + 'GSMADMIN_INTERNAL', 'LBACSYS', 'MDDATA', 'MDSYS', 'OLAPSYS', 'OUTLN', 'SYS', 'SYSTEM', 'XDB', 'XS$NULL') \ + ORDER BY 1, 2, 3, col.position FETCH FIRST 20000 ROWS ONLY" + } } } @@ -304,6 +378,9 @@ pub fn table_foreign_keys(driver: DbDriver, schema: &str, table: &str) -> String DbDriver::SqlServer => format!( "SELECT table_schema, table_name, constraint_name, column_name, referenced_table_schema, referenced_table_name, referenced_column_name FROM information_schema.key_column_usage WHERE referenced_table_name IS NOT NULL AND table_schema = {s} AND table_name = {t} ORDER BY constraint_name, ordinal_position" ), + DbDriver::Oracle => format!( + "SELECT con.owner, con.table_name, con.constraint_name, col.column_name, rcon.owner, rcon.table_name, rcol.column_name FROM all_constraints con JOIN all_cons_columns col ON col.owner = con.owner AND col.table_name = con.table_name AND col.constraint_name = con.constraint_name JOIN all_constraints rcon ON rcon.owner = con.r_owner AND rcon.constraint_name = con.r_constraint_name JOIN all_cons_columns rcol ON rcol.owner = rcon.owner AND rcol.table_name = rcon.table_name AND rcol.constraint_name = rcon.constraint_name WHERE con.constraint_type = 'R' AND con.owner = {s} AND con.table_name = {t} ORDER BY con.constraint_name, col.position" + ), } } @@ -315,6 +392,7 @@ pub fn current_schema(driver: DbDriver) -> Option<&'static str> { DbDriver::Postgres => Some("SELECT current_schema()"), DbDriver::Mysql => Some("SELECT DATABASE()"), DbDriver::SqlServer => Some("SELECT DB_NAME()"), + DbDriver::Oracle => Some("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL"), } } @@ -359,6 +437,8 @@ pub fn table_page( sql.push_str(" ORDER BY (SELECT 1)"); } sql.push_str(&format!(" OFFSET {offset} ROWS FETCH NEXT {} ROWS ONLY", limit + 1)); + } else if driver == DbDriver::Oracle { + sql.push_str(&format!(" OFFSET {offset} ROWS FETCH FIRST {} ROWS ONLY", limit + 1)); } else { sql.push_str(&format!(" LIMIT {} OFFSET {offset}", limit + 1)); } @@ -421,6 +501,8 @@ pub fn edit_statement(driver: DbDriver, schema: &str, table: &str, edit: &DbRowE if values.is_empty() { return Ok(match driver { DbDriver::Mysql => format!("INSERT INTO {target} () VALUES ()"), + // Oracle has no DEFAULT VALUES; without columns there is nothing to insert. + DbDriver::Oracle => return Err("Oracle needs at least one column value to insert".into()), _ => format!("INSERT INTO {target} DEFAULT VALUES"), }); } @@ -509,6 +591,12 @@ pub fn triggers(driver: DbDriver, schema: &str, table: &str) -> String { "SELECT t.name, lower(te.type_desc), '' FROM sys.triggers t \ WHERE OBJECT_SCHEMA_NAME(t.parent_id) = {s} AND OBJECT_NAME(t.parent_id) = {t} ORDER BY 1" ), + DbDriver::Oracle => format!( + "SELECT trigger_name, CASE WHEN trigger_type LIKE 'BEFORE%' THEN 'before' \ + WHEN trigger_type LIKE 'INSTEAD%' THEN 'instead of' ELSE 'after' END, \ + lower(replace(triggering_event, ' OR ', ',')) \ + FROM all_triggers WHERE owner = {s} AND table_name = {t} ORDER BY 1" + ), } } @@ -551,17 +639,28 @@ pub fn routines(driver: DbDriver, schema: &str) -> Option { "SELECT r.routine_name, lower(r.routine_type), '', r.data_type \ FROM information_schema.routines r WHERE r.routine_schema = {s} ORDER BY 1" )), + DbDriver::Oracle => Some(format!( + "SELECT object_name, CASE object_type WHEN 'PROCEDURE' THEN 'procedure' ELSE 'function' END, '', NULL \ + FROM all_objects WHERE owner = {s} AND object_type IN ('PROCEDURE', 'FUNCTION') \ + AND object_name NOT LIKE 'BIN$%' ORDER BY 1" + )), } } -/// Rows: `name, data type` of a schema's sequences (PostgreSQL). +/// Rows: `name, data type` of a schema's sequences (PostgreSQL, Oracle). pub fn sequences(driver: DbDriver, schema: &str) -> Option { - (driver == DbDriver::Postgres).then(|| { - format!( + match driver { + DbDriver::Postgres => Some(format!( "SELECT sequence_name, data_type FROM information_schema.sequences WHERE sequence_schema = {} ORDER BY 1", quote_literal(driver, schema) - ) - }) + )), + // Every sequence is numeric; ALL_SEQUENCES has no data_type column before 18c. + DbDriver::Oracle => Some(format!( + "SELECT sequence_name, 'NUMBER' FROM all_sequences WHERE sequence_owner = {} ORDER BY 1", + quote_literal(driver, schema) + )), + _ => None, + } } /// The statement reading the definition of a routine (a row per overload), trigger @@ -606,6 +705,26 @@ pub fn object_ddl(driver: DbDriver, schema: &str, kind: crate::DbObjectKind, nam (DbDriver::Mysql, K::Function) => (format!("SHOW CREATE FUNCTION {target}"), 2), (DbDriver::Mysql, K::Procedure) => (format!("SHOW CREATE PROCEDURE {target}"), 2), (DbDriver::Mysql, K::Trigger) => (format!("SHOW CREATE TRIGGER {target}"), 2), + (DbDriver::Oracle, K::Function | K::Procedure | K::Trigger) => ( + format!( + "SELECT DBMS_METADATA.GET_DDL('{}', {n}, {s}) FROM DUAL", + match kind { + K::Function => "FUNCTION", + K::Procedure => "PROCEDURE", + _ => "TRIGGER", + } + ), + 0, + ), + (DbDriver::Oracle, K::Sequence) => ( + format!( + "SELECT 'create sequence ' || {n} || E'\\n increment by ' || increment_by || \ + E'\\n minvalue ' || min_value || E'\\n maxvalue ' || max_value || \ + E'\\n start with ' || last_number || CASE WHEN cycle_flag = 'Y' THEN E'\\n cycle' ELSE '' END \ + FROM all_sequences WHERE sequence_owner = {s} AND sequence_name = {n}" + ), + 0, + ), _ => return None, }) } @@ -792,6 +911,64 @@ mod tests { assert!(completion_columns(DbDriver::SqlServer).contains("information_schema.columns")); } + #[test] + fn oracle_quoting_pages_and_current_schema() { + assert_eq!(quote_ident(DbDriver::Oracle, "a\"b"), "\"a\"\"b\""); + assert_eq!(quote_literal(DbDriver::Oracle, "it's"), "'it''s'"); + assert_eq!(qualified(DbDriver::Oracle, "hr", "employees"), "\"hr\".\"employees\""); + assert_eq!(current_schema(DbDriver::Oracle), Some("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL")); + assert_eq!(crate::conn::begin_statement(DbDriver::Oracle), ""); + let sort = [SortKey { column: "id".into(), descending: true }]; + assert_eq!( + table_page(DbDriver::Oracle, "hr", "employees", Some("WHERE id > 3"), &sort, 50, 25), + "SELECT * FROM \"hr\".\"employees\" WHERE id > 3 ORDER BY \"id\" DESC OFFSET 50 ROWS FETCH FIRST 26 ROWS ONLY" + ); + assert_eq!( + table_page(DbDriver::Oracle, "", "t", None, &[], 0, 10), + "SELECT * FROM \"t\" OFFSET 0 ROWS FETCH FIRST 11 ROWS ONLY" + ); + } + + #[test] + fn oracle_introspection_queries() { + assert_eq!(DbDriver::Oracle.default_port(), Some(1521)); + assert!(schemas(DbDriver::Oracle, true).contains("all_users")); + assert!(schemas(DbDriver::Oracle, false).contains("'MDSYS'")); + assert!(tables(DbDriver::Oracle, "hr").contains("all_tables")); + assert!(tables(DbDriver::Oracle, "hr").contains("all_views")); + let cols = columns(DbDriver::Oracle, "hr", "employees"); + assert!(cols.contains("all_tab_columns")); + assert!(cols.contains("DECODE(c.nullable, 'Y', 'YES', 'NO')")); + assert!(cols.contains("constraint_type = 'P'")); + assert!(indexes(DbDriver::Oracle, "hr", "t").contains("all_indexes")); + assert!(foreign_keys(DbDriver::Oracle).contains("constraint_type = 'R'")); + assert!(table_foreign_keys(DbDriver::Oracle, "hr", "t").contains("all_cons_columns")); + assert!(completion_columns(DbDriver::Oracle).contains("all_tab_columns")); + assert!(triggers(DbDriver::Oracle, "hr", "t").contains("all_triggers")); + assert!(routines(DbDriver::Oracle, "hr").unwrap().contains("all_objects")); + assert!(sequences(DbDriver::Oracle, "hr").unwrap().contains("all_sequences")); + } + + #[test] + fn oracle_edits_and_object_ddl() { + let key = vec![cv("id", Some("1"))]; + assert_eq!( + edit_statement( + DbDriver::Oracle, + "hr", + "t", + &DbRowEdit::Update { key: key.clone(), values: vec![cv("n", Some("x"))] } + ) + .unwrap(), + "UPDATE \"hr\".\"t\" SET \"n\" = 'x' WHERE \"id\" = '1'" + ); + assert!(edit_statement(DbDriver::Oracle, "hr", "t", &DbRowEdit::Insert { values: vec![] }).is_err()); + use crate::DbObjectKind as K; + let (q, col) = object_ddl(DbDriver::Oracle, "hr", K::Procedure, "p").unwrap(); + assert!(q.contains("DBMS_METADATA.GET_DDL('PROCEDURE'") && col == 0); + assert!(object_ddl(DbDriver::Oracle, "hr", K::Sequence, "q").unwrap().0.contains("all_sequences")); + } + #[test] fn sqlite_trigger_events() { let ev = sqlite_trigger_event; diff --git a/docs/databases.md b/docs/databases.md new file mode 100644 index 000000000..fb0c9258d --- /dev/null +++ b/docs/databases.md @@ -0,0 +1,64 @@ +# Databases + +The Database tool window (View ▸ Tool Windows ▸ Database) connects to SQLite, +PostgreSQL, MySQL/MariaDB, Microsoft SQL Server, ClickHouse and Oracle, plus the +wire-compatible products listed in **New ▸ Data Source**. Connections are saved +per project in `.openidea/data-sources.toml`; passwords live outside the project +(and never in that file). + +## Oracle: Oracle Instant Client + +Oracle support uses the Oracle Call Interface, so the Oracle client libraries +must be installed on the machine. OpenIDEA does not bundle them: install the +**Oracle Instant Client** for your platform and point the data source at it. + +1. Download the _Basic_ (or _Basic Light_) package of Oracle Instant Client + 19c or later for your OS from + . +2. Unpack it somewhere stable, then, in the data source's **Options** tab, set + **Instant Client** to that directory. +3. General tab: host, port (1521), and the **service name** (for example + `ORCLPDB1`) in the Database field. Connect with the schema's user and its + password. + +`java.sql`-style URLs also work in the URL field: + +``` +oracle://scott:tiger@db.example.com:1521/ORCLPDB1 +jdbc:oracle:thin:@//db.example.com:1521/ORCLPDB1 +``` + +A TNS descriptor or alias in the Database field is passed to the client as is. + +### Platform notes + +- **macOS** — the _Basic_ ZIP for ARM (Apple silicon) or x86-64 must match the + app's architecture. Remove the quarantine flag if macOS blocks the libraries: + `xattr -dr com.apple.quarantine /opt/oracle/instantclient_23_5`. +- **Windows** — use the 64-bit package; the app is 64-bit. `PATH` does not have + to include the directory when the Options tab names it. +- **Linux** — install the RPM/DEB or unzip the ZIP; the directory needs the + `libclntsh.so` and `libclntshcore.so` libraries (the ZIP's `libaio`-based + builds also need the `libaio` package). + +Without a working client, Test Connection fails with a clear message +(`DPI-1047: Cannot locate a 64-bit Oracle Client library…`). If the Options tab +is empty and `ORACLE_HOME` is not set, the client is looked up on the system +search path. + +### What Oracle data sources support + +- Schemas = Oracle users (`ALL_USERS`); system accounts (`SYS`, `SYSTEM`, + `XDB`, `MDSYS`…) appear only with **Schemas ▸ Show system**. +- Tables, views, columns, indexes, foreign keys, triggers, routines and + sequences are read from the `ALL_*` views. +- The query console runs SQL and PL/SQL blocks; `BEGIN … END;` blocks (nested + `;` included) are sent as one statement, and a lone `/` on its own line ends + one as in SQL*Plus. +- Explain Plan runs `EXPLAIN PLAN` and reads `PLAN_TABLE` (`DBMS_XPLAN` output + is not required). +- Table editor (INSERT/UPDATE/DELETE in one transaction), Import/Export and + generated SQL use Oracle syntax: `"UPPER_CASE"` identifiers, `FETCH FIRST n + ROWS ONLY`, `MODIFY (…)` for column changes. + +Table editing needs a primary key, and the connection must not be read-only. diff --git a/e2e/oracle-source.spec.ts b/e2e/oracle-source.spec.ts new file mode 100644 index 000000000..bd5fa72f9 --- /dev/null +++ b/e2e/oracle-source.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from "./fixtures"; + +test("configures an Oracle source with its Instant Client directory", async ({ workbench: page }, info) => { + await page.evaluate(async () => { + const storePath = "/src/features/database/store.ts"; + const bindingsPath = "/src/bindings.ts"; + const { useDatabase } = await import(storePath); + const { commands } = await import(bindingsPath); + commands.dbTest = async (source: unknown) => { + document.documentElement.dataset.testedSource = JSON.stringify(source); + return { status: "ok", data: "Oracle Database 23ai Enterprise Edition Release 23.5.0.24.07" }; + }; + useDatabase.setState({ dialog: { kind: "source", source: null } }); + }); + const dialog = page.getByRole("dialog", { name: "New Data Source", exact: true }); + await dialog.getByRole("button", { name: "Data source type", exact: true }).click(); + // The type list is virtualised: reach the entry by typing, then confirm it. + await page.keyboard.type("oracle"); + await page.keyboard.press("Enter"); + await expect(dialog.locator("#db-port")).toHaveValue("1521"); + await expect(dialog.locator("#db-url")).toHaveValue("jdbc:oracle://localhost:1521"); + + // The client libraries are not bundled: the Options tab names their directory (empty: ORACLE_HOME). + const testButton = dialog.getByRole("button", { name: "Test Connection", exact: true }); + await dialog.locator("#db-url").fill("oracle://scott:tiger@db.example:1521/ORCLPDB1"); + await expect(dialog.locator("#db-host")).toHaveValue("db.example"); + await expect(dialog.locator("#db-port")).toHaveValue("1521"); + await expect(dialog.locator("#db-user")).toHaveValue("scott"); + await expect(dialog.locator("#db-database")).toHaveValue("ORCLPDB1"); + await expect(testButton).toBeEnabled(); + + await dialog.getByRole("tab", { name: "Options", exact: true }).click(); + await dialog.locator("#db-instant-client").fill("/opt/oracle/instantclient_23_5"); + await testButton.click(); + await expect(dialog.getByRole("status")).toHaveText(/^Succeeded: Oracle Database 23ai/); + expect(await page.evaluate(() => JSON.parse(document.documentElement.dataset.testedSource!))).toMatchObject({ + driver: "oracle", + host: "db.example", + port: 1521, + user: "scott", + database: "ORCLPDB1", + options: { instantClient: "/opt/oracle/instantclient_23_5" }, + }); + await dialog.screenshot({ path: info.outputPath("oracle-source-options.png") }); +}); diff --git a/src/bindings.ts b/src/bindings.ts index 5fbff64b8..dd55fd6b0 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -2636,7 +2636,9 @@ export type DbDriver = "sqlite" | "postgres" | /** MySQL and MariaDB. */ "mysql" | /** Microsoft SQL Server. */ -"sqlServer" | "clickhouse"; +"sqlServer" | "clickhouse" | +/** Oracle Database (via Oracle Instant Client). */ +"oracle"; /** `schema.table (columns) REFERENCES ref_schema.ref_table (ref_columns)`. */ export type DbForeignKey = { @@ -2695,6 +2697,8 @@ export type DbOptions = { autoDisconnectSecs?: number | null, /** PostgreSQL: show only the configured database, its schemas right under the data source. */ singleDatabase?: boolean, + /** Oracle: directory of the Oracle Instant Client (empty: `ORACLE_HOME` / system search path). */ + instantClient?: string, }; /** One operation of a plan; `parent` indexes the plan's node list (roots: `None`). */ @@ -6974,7 +6978,10 @@ export type SpellingSettings = { }; export type SqlStatement = { - /** UTF-16 offsets of the statement text (whitespace trimmed, `;` excluded). */ + /** + * UTF-16 offsets of the statement text (whitespace trimmed, `;` excluded except + * after an Oracle PL/SQL block, whose `END;` needs it). + */ from: number, to: number, text: string, diff --git a/src/features/database/ObjectDialogs.tsx b/src/features/database/ObjectDialogs.tsx index 648e7ec13..f94f932e1 100644 --- a/src/features/database/ObjectDialogs.tsx +++ b/src/features/database/ObjectDialogs.tsx @@ -270,6 +270,26 @@ const TYPES: Record = { "varbinary(max)", ], clickhouse: ["UInt64", "Int64", "Float64", "String", "Bool", "Date", "DateTime", "UUID", "Nullable(String)"], + oracle: [ + "NUMBER", + "NUMBER(10)", + "NUMBER(19, 4)", + "INTEGER", + "BINARY_FLOAT", + "BINARY_DOUBLE", + "VARCHAR2(255)", + "VARCHAR2(4000)", + "NVARCHAR2(255)", + "CHAR(1)", + "CLOB", + "BLOB", + "RAW(16)", + "DATE", + "TIMESTAMP", + "TIMESTAMP WITH TIME ZONE", + "INTERVAL DAY TO SECOND", + "XMLTYPE", + ], }; interface ModifyProps { diff --git a/src/features/database/SourceDialog.tsx b/src/features/database/SourceDialog.tsx index e42153cbf..a30749ca2 100644 --- a/src/features/database/SourceDialog.tsx +++ b/src/features/database/SourceDialog.tsx @@ -101,6 +101,10 @@ const PROPERTIES: Record = { names: ["connect_timeout"], hint: "Connection settings for SQL Server.", }, + oracle: { + names: ["NLS_DATE_FORMAT", "NLS_TIMESTAMP_FORMAT", "NLS_LANGUAGE", "connect_timeout"], + hint: "Session parameters set after connecting (ALTER SESSION SET name = value). connect_timeout is in seconds.", + }, }; const blankSsh = (): DbSsh => ({ @@ -121,6 +125,7 @@ const blankOptions = (): DbOptions => ({ keepAliveSecs: null, autoDisconnectSecs: null, singleDatabase: false, + instantClient: "", }); const blankIntrospection = (): DbIntrospection => ({ @@ -381,6 +386,7 @@ export function SourceDialog({ const sqlite = s.driver === "sqlite"; const postgres = s.driver === "postgres"; const clickhouse = s.driver === "clickhouse"; + const oracle = s.driver === "oracle"; const urlOnly = !sqlite && !!s.urlOnly; const parsedUrl = urlOnly ? parseSourceUrl(s.url ?? "") : null; const urlValid = !!parsedUrl && parsedUrl.source.driver === s.driver; @@ -591,7 +597,12 @@ export function SourceDialog({ - set({ database: e.target.value })} /> + set({ database: e.target.value })} + /> )}