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