дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
feat(database): Oracle data source support (#1510)
Adds Oracle as a data source through rust-oracle (ODPI-C), which loads the user's Oracle Instant Client at runtime; the client is not bundled. The Options tab takes the Instant Client directory (empty: ORACLE_HOME or the system library path). Covers connecting, the schema tree, consoles with PL/SQL blocks, table editing, Generate DDL (DBMS_METADATA), Explain Plan (PLAN_TABLE), Oracle-specific SQL generation and completion, and jdbc:oracle:thin URLs. Review fixes: PL/SQL units keep their closing END; (the server rejects a block without it), a subprogram's declarations no longer split it, and packages end at a / line; LOB cells are read through locators (they showed as null and broke Generate DDL); ORA-01005 prompts for a password; the Instant Client directory is optional in the dialog. Co-authored-by: boenkkk <[email protected]>
Цей коміт міститься в:
1 джерело
b753d3f536
коміт
9069c322e8
29 files changed
+1735
-45
No files matched your search
Створено
+81
-3
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
<sub>Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o.</sub>
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -107,6 +107,7 @@ pub enum Pool {
|
||||
Mysql(MySqlPool),
|
||||
SqlServer(Bb8Pool<MssqlConnectionManager>),
|
||||
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<ResultColumn>, Vec<Vec<Option<String>>>, Option<u64>, bool, Option<Instant>);
|
||||
pub(crate) type Fetched = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, Option<u64>, bool, Option<Instant>);
|
||||
|
||||
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<Fetched> = 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<MySql>),
|
||||
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Pool>, 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<Vec<PlanNode>> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
auto_commit: bool,
|
||||
read_only: bool,
|
||||
statement_cache: Option<u32>,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn of(source: &DataSource, password: Option<&str>, setup: &Setup) -> IdeResult<Self> {
|
||||
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<Option<String>> {
|
||||
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<Option<String>> = 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<oracle::Error>);
|
||||
|
||||
impl OracleError {
|
||||
fn new(e: oracle::Error) -> Self {
|
||||
Self(Arc::new(e))
|
||||
}
|
||||
|
||||
fn message(msg: impl Into<String>) -> 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<State>,
|
||||
pool: Arc<bb8::Pool<OracleConnectionManager>>,
|
||||
}
|
||||
|
||||
impl OraclePool {
|
||||
pub(crate) async fn connect(source: &DataSource, password: Option<&str>, setup: &Setup) -> IdeResult<Self> {
|
||||
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<OracleConnection> {
|
||||
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>) -> 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<oracle::Connection>,
|
||||
}
|
||||
|
||||
pub struct OracleConnectionManager(Arc<State>);
|
||||
|
||||
impl ManageConnection for OracleConnectionManager {
|
||||
type Connection = OracleHandle;
|
||||
type Error = OracleError;
|
||||
|
||||
async fn connect(&self) -> Result<Self::Connection, Self::Error> {
|
||||
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<State>,
|
||||
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<oracle::Connection> {
|
||||
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<T>(
|
||||
c: &mut OracleConnection,
|
||||
cancel: &CancellationToken,
|
||||
f: impl FnOnce(&mut oracle::Connection) -> IdeResult<T> + Send + 'static,
|
||||
) -> IdeResult<T>
|
||||
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<T>)>(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<Fetched> {
|
||||
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<u64> {
|
||||
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::<Vec<_>>();
|
||||
let expect: Vec<bool> = statements.iter().map(|(_, e)| *e).collect();
|
||||
let apply = move |conn: &mut oracle::Connection| -> IdeResult<u64> {
|
||||
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<Fetched> {
|
||||
conn.set_autocommit(state.auto_commit);
|
||||
let mut query: Option<Fetched> = None;
|
||||
let mut affected: u64 = 0;
|
||||
let mut first: Option<Instant> = 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<Fetched> {
|
||||
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<ResultColumn> = 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<Option<String>>> = 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<String> {
|
||||
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::<String>().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<String> = 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())]));
|
||||
}
|
||||
}
|
||||
@@ -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<Vec<PlanNode>> {
|
||||
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::<Vec<_>>().join("\n");
|
||||
@@ -148,6 +163,40 @@ fn postgres(node: &Value, parent: Option<u32>, out: &mut Vec<PlanNode>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Oracle PLAN_TABLE rows: `id, parent_id, operation, options, object_name, cost, cardinality, bytes`.
|
||||
fn oracle(rows: &[Vec<Option<String>>]) -> Vec<PlanNode> {
|
||||
let mut ids: Vec<String> = 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<Option<String>>]) -> Vec<PlanNode> {
|
||||
let col = |r: &[Option<String>], 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]
|
||||
|
||||
@@ -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<u32>,
|
||||
/// 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<DbDriver> {
|
||||
"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<ParsedUrl> {
|
||||
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:[email protected]: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://[email protected]/hrdb").unwrap().driver, Some(DbDriver::Oracle));
|
||||
assert!(parse_url("postgres://h:99999/db").is_err());
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SqlStatement> {
|
||||
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<SqlStatement> {
|
||||
// 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<SqlStatement>, s: usize, e: usize, has_code: bool| {
|
||||
if !has_code {
|
||||
return;
|
||||
@@ -69,11 +80,24 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
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<SqlStatement> {
|
||||
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<SqlStatement> {
|
||||
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<SqlStatement> {
|
||||
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<String> = 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<_>>(), 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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
"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<String> {
|
||||
(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;
|
||||
|
||||
@@ -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
|
||||
<https://www.oracle.com/database/technologies/instant-client/downloads.html>.
|
||||
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:[email protected]: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.
|
||||
@@ -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:[email protected]: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") });
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -270,6 +270,26 @@ const TYPES: Record<DbDriver, string[]> = {
|
||||
"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 {
|
||||
|
||||
@@ -101,6 +101,10 @@ const PROPERTIES: Record<string, { names: string[]; hint: string }> = {
|
||||
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({
|
||||
<label className={styles.formLabel} htmlFor="db-database">
|
||||
<T>Database:</T>
|
||||
</label>
|
||||
<Input id="db-database" value={s.database ?? ""} onChange={(e) => set({ database: e.target.value })} />
|
||||
<Input
|
||||
id="db-database"
|
||||
value={s.database ?? ""}
|
||||
placeholder={oracle ? "Service name, e.g. ORCLPDB1" : undefined}
|
||||
onChange={(e) => set({ database: e.target.value })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<label className={styles.formLabel} htmlFor="db-url">
|
||||
@@ -650,6 +661,32 @@ export function SourceDialog({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{oracle && (
|
||||
<>
|
||||
<label className={styles.formLabel} htmlFor="db-default-schema">
|
||||
<T>Current schema:</T>
|
||||
</label>
|
||||
<Input
|
||||
id="db-default-schema"
|
||||
value={options.defaultSchema ?? ""}
|
||||
placeholder="HR (ALTER SESSION SET CURRENT_SCHEMA)"
|
||||
onChange={(e) => setOptions({ defaultSchema: e.target.value })}
|
||||
/>
|
||||
<label className={styles.formLabel} htmlFor="db-instant-client">
|
||||
<T>Instant Client:</T>
|
||||
</label>
|
||||
<Input
|
||||
id="db-instant-client"
|
||||
value={options.instantClient ?? ""}
|
||||
placeholder="/opt/oracle/instantclient_23_5"
|
||||
onChange={(e) => setOptions({ instantClient: e.target.value })}
|
||||
/>
|
||||
<span />
|
||||
<span className={styles.formHint}>
|
||||
<T>Directory of Oracle Instant Client 19 or later. Empty: ORACLE_HOME or the system library path.</T>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{!sqlite && (
|
||||
<>
|
||||
<label className={styles.formLabel} htmlFor="db-time-zone">
|
||||
@@ -704,7 +741,9 @@ export function SourceDialog({
|
||||
? "SET max_execution_time = 30;"
|
||||
: sqlite
|
||||
? "PRAGMA foreign_keys = ON;"
|
||||
: "SET SESSION sql_mode = 'ANSI';"
|
||||
: oracle
|
||||
? "ALTER SESSION SET NLS_DATE_FORMAT = 'YYYY-MM-DD HH24:MI:SS';"
|
||||
: "SET SESSION sql_mode = 'ANSI';"
|
||||
}
|
||||
onChange={(e) => setOptions({ startupScript: e.target.value })}
|
||||
/>
|
||||
|
||||
@@ -171,13 +171,24 @@ describe("parseSourceUrl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reads SQLite files and rejects other drivers", () => {
|
||||
it("reads SQLite and Oracle URLs and rejects other drivers", () => {
|
||||
expect(parseSourceUrl("jdbc:sqlite:C:/data/shop.db")?.source).toEqual({
|
||||
driver: "sqlite",
|
||||
file: "C:/data/shop.db",
|
||||
name: "shop",
|
||||
});
|
||||
expect(parseSourceUrl("oracle://x")).toBeNull();
|
||||
expect(parseSourceUrl("oracle://scott:[email protected]:1521/ORCLPDB1")).toEqual({
|
||||
source: { driver: "oracle", host: "db.local", port: 1521, user: "scott", database: "ORCLPDB1" },
|
||||
password: "tiger",
|
||||
});
|
||||
expect(parseSourceUrl("jdbc:oracle:thin:@//db.local:1521/ORCL")?.source).toEqual({
|
||||
driver: "oracle",
|
||||
host: "db.local",
|
||||
port: 1521,
|
||||
user: "",
|
||||
database: "ORCL",
|
||||
});
|
||||
expect(parseSourceUrl("unknown://x")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -190,9 +201,10 @@ describe("data source types", () => {
|
||||
});
|
||||
expect(vendorPreset(vendorById("postgres")!)).toEqual({ driver: "postgres", vendor: "", port: 5432 });
|
||||
expect(vendorPreset(vendorById("supabase")!).ssl).toBe("require");
|
||||
expect(VENDORS.every((v) => ["postgres", "mysql", "sqlite", "sqlServer", "clickhouse"].includes(v.driver))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(vendorPreset(vendorById("oracle")!)).toEqual({ driver: "oracle", vendor: "", port: 1521 });
|
||||
expect(
|
||||
VENDORS.every((v) => ["postgres", "mysql", "sqlite", "sqlServer", "clickhouse", "oracle"].includes(v.driver)),
|
||||
).toBe(true);
|
||||
const labels = VENDORS.map((v) => v.label);
|
||||
expect(labels).toEqual([...labels].sort((a, b) => a.localeCompare(b)));
|
||||
});
|
||||
|
||||
@@ -332,9 +332,11 @@ export function parseSourceUrl(raw: string): { source: Partial<DataSource>; pass
|
||||
const file = sqlite[1]!.replace(/^\/([A-Za-z]:)/, "$1");
|
||||
return { source: { driver: "sqlite", file, name: fileSourceName(file) }, password: null };
|
||||
}
|
||||
// JDBC thin URLs: `jdbc:oracle:thin:@//host:port/service`.
|
||||
const thin = /^(?:oracle|oracledb):thin:@(.+)$/i.exec(url);
|
||||
const m =
|
||||
/^([a-z][a-z0-9]*):\/\/(?:([^:@/]*)(?::([^@/]*))?@)?([^:/?,]+|\[[^\]]+\])(?::(\d+))?(?:,[^/?]*)?(?:\/([^?]*))?(?:\?(.*))?$/i.exec(
|
||||
url,
|
||||
thin ? `oracle://${thin[1]!.replace(/^\/+/, "")}` : url,
|
||||
);
|
||||
const vendor = m ? vendorOfScheme(m[1]!) : undefined;
|
||||
if (!m || !vendor || vendor.driver === "sqlite") return null;
|
||||
|
||||
@@ -182,3 +182,53 @@ describe("database object SQL", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("oracle object SQL", () => {
|
||||
const emp = [col("ID", "NUMBER", { primaryKey: true, nullable: false }), col("NAME", "VARCHAR2(40)")];
|
||||
const d = emp.map(draftOf);
|
||||
|
||||
it("keeps unquoted names that the server folds to upper case", () => {
|
||||
expect(ident("oracle", "EMPLOYEES")).toBe("EMPLOYEES");
|
||||
expect(ident("oracle", "employees")).toBe('"employees"');
|
||||
expect(reference("oracle", { kind: "table", schema: "HR", name: "EMPLOYEES" })).toBe("HR.EMPLOYEES");
|
||||
});
|
||||
|
||||
it("generates Oracle DDL and DML", () => {
|
||||
expect(truncateSql("oracle", "HR", "EMPLOYEES", { restartIdentity: true, cascade: true })).toBe(
|
||||
"truncate table HR.EMPLOYEES;",
|
||||
);
|
||||
expect(scriptSql("oracle", "selectFirst", "HR", "EMPLOYEES", emp)).toContain("fetch first 10 rows only;");
|
||||
expect(scriptSql("oracle", "selectFirst", "HR", "EMPLOYEES", emp)).not.toContain("limit");
|
||||
expect(dropSql("oracle", { kind: "index", schema: "HR", name: "E_IDX" }, { ifExists: true, cascade: false })).toBe(
|
||||
"drop index HR.E_IDX;",
|
||||
);
|
||||
expect(dropSql("oracle", { kind: "schema", schema: "", name: "APP" }, { ifExists: false, cascade: false })).toBe(
|
||||
"drop user APP cascade;",
|
||||
);
|
||||
expect(renameSql("oracle", { kind: "view", schema: "HR", name: "V_EMP" }, "V_STAFF")).toBe(
|
||||
"rename HR.V_EMP to V_STAFF;",
|
||||
);
|
||||
expect(createTableSql("oracle", "HR", "T", d)).toContain("primary key (ID)");
|
||||
});
|
||||
|
||||
it("alters columns with MODIFY and parenthesised ADD", () => {
|
||||
const p = emp.map(draftOf);
|
||||
p[1] = { ...p[1]!, dataType: "VARCHAR2(80)", notNull: true, name: "FULL_NAME" };
|
||||
p.push({
|
||||
key: 99,
|
||||
original: null,
|
||||
name: "HIRED",
|
||||
dataType: "DATE",
|
||||
notNull: false,
|
||||
default: "SYSDATE",
|
||||
primaryKey: false,
|
||||
dropped: false,
|
||||
} satisfies ColumnDraft);
|
||||
expect(alterTableSql("oracle", "HR", "EMPLOYEES", "EMPLOYEES", p)).toEqual([
|
||||
"alter table HR.EMPLOYEES modify (NAME VARCHAR2(80) not null);",
|
||||
"alter table HR.EMPLOYEES modify (NAME not null);",
|
||||
"alter table HR.EMPLOYEES rename column NAME to FULL_NAME;",
|
||||
"alter table HR.EMPLOYEES add (HIRED DATE default SYSDATE);",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -12,14 +12,17 @@ export function quoteIdent(driver: DbDriver, name: string): string {
|
||||
return `"${name.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
/** Quotes only when needed: lower-case (PostgreSQL) / plain words stay bare, like IDEA's generated SQL. */
|
||||
/** Quotes only when needed: lower-case (PostgreSQL) / UPPER-case (Oracle) / plain words stay bare, like IDEA's SQL. */
|
||||
export function ident(driver: DbDriver, name: string): string {
|
||||
const plain =
|
||||
driver === "postgres"
|
||||
? /^[a-z_][a-z0-9_$]*$/
|
||||
: driver === "clickhouse"
|
||||
? /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
: /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
||||
: // Oracle folds unquoted names to upper case.
|
||||
driver === "oracle"
|
||||
? /^[A-Z_][A-Z0-9_$]*$/
|
||||
: driver === "clickhouse"
|
||||
? /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
: /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
||||
return plain.test(name) && !RESERVED.has(name.toUpperCase()) ? name : quoteIdent(driver, name);
|
||||
}
|
||||
|
||||
@@ -87,6 +90,7 @@ export function renameSql(driver: DbDriver, o: DbObjectRef, to: string): string
|
||||
return `alter index ${q} rename to ${ident(driver, to)};`;
|
||||
case "view":
|
||||
if (driver === "postgres") return `alter view ${q} rename to ${ident(driver, to)};`;
|
||||
if (driver === "oracle") return `rename ${q} to ${ident(driver, to)};`;
|
||||
return `rename table ${q} to ${qualifiedName(driver, o.schema, to)};`;
|
||||
default:
|
||||
if (driver === "mysql" || driver === "clickhouse")
|
||||
@@ -118,8 +122,11 @@ export function dropSql(driver: DbDriver, o: DbObjectRef, opts: DropOptions): st
|
||||
case "index":
|
||||
if (driver === "mysql")
|
||||
return `drop index ${ident(driver, o.name)} on ${qualifiedName(driver, o.schema, o.table ?? "")};`;
|
||||
// Oracle's DROP INDEX takes no IF EXISTS.
|
||||
if (driver === "oracle") return `drop index ${qualifiedName(driver, o.schema, o.name)};`;
|
||||
return `drop index ${ifExists}${qualifiedName(driver, o.schema, o.name)}${cascade};`;
|
||||
case "schema":
|
||||
if (driver === "oracle") return `drop user ${ident(driver, o.name)} cascade;`;
|
||||
return `drop ${driver === "mysql" || driver === "clickhouse" ? "database" : "schema"} ${ifExists}${ident(driver, o.name)}${cascade};`;
|
||||
case "materializedView":
|
||||
if (driver === "clickhouse") return `drop table ${ifExists}${qualifiedName(driver, o.schema, o.name)};`;
|
||||
@@ -147,7 +154,8 @@ export function truncateSql(
|
||||
): string {
|
||||
const q = qualifiedName(driver, schema, table);
|
||||
if (driver === "sqlite") return `delete from ${q};`;
|
||||
if (driver === "mysql" || driver === "sqlServer" || driver === "clickhouse") return `truncate table ${q};`;
|
||||
if (driver === "mysql" || driver === "sqlServer" || driver === "clickhouse" || driver === "oracle")
|
||||
return `truncate table ${q};`;
|
||||
return `truncate table ${q}${opts.restartIdentity ? " restart identity" : ""}${opts.cascade ? " cascade" : ""};`;
|
||||
}
|
||||
|
||||
@@ -177,7 +185,9 @@ export function scriptSql(
|
||||
case "selectAll":
|
||||
return `select *\nfrom ${q};\n`;
|
||||
case "selectFirst":
|
||||
return `select *\nfrom ${q}\nlimit 10;\n`;
|
||||
return driver === "oracle"
|
||||
? `select *\nfrom ${q}\nfetch first 10 rows only;\n`
|
||||
: `select *\nfrom ${q}\nlimit 10;\n`;
|
||||
case "insert":
|
||||
return `insert into ${q} (${names.join(", ")})\nvalues (${names.map(() => "?").join(", ")});\n`;
|
||||
case "update": {
|
||||
@@ -223,7 +233,9 @@ export function draftOf(c: DbColumnInfo, key: number): ColumnDraft {
|
||||
}
|
||||
|
||||
function columnDefinition(driver: DbDriver, c: ColumnDraft): string {
|
||||
let s = `${ident(driver, c.name)} ${c.dataType.trim() || (driver === "clickhouse" ? "String" : driver === "sqlite" ? "TEXT" : "text")}`;
|
||||
const fallback =
|
||||
driver === "clickhouse" ? "String" : driver === "sqlite" ? "TEXT" : driver === "oracle" ? "VARCHAR2(255)" : "text";
|
||||
let s = `${ident(driver, c.name)} ${c.dataType.trim() || fallback}`;
|
||||
if (c.default.trim()) s += ` default ${c.default.trim()}`;
|
||||
if (c.notNull && driver !== "clickhouse") s += " not null";
|
||||
return s;
|
||||
@@ -266,7 +278,14 @@ export function alterTableSql(
|
||||
for (const c of columns) {
|
||||
const o = c.original;
|
||||
if (!o) {
|
||||
if (!c.dropped && c.name.trim()) out.push(`alter table ${q()} add column ${columnDefinition(driver, c)};`);
|
||||
if (!c.dropped && c.name.trim()) {
|
||||
// Oracle takes the added column's definition in parentheses.
|
||||
out.push(
|
||||
driver === "oracle"
|
||||
? `alter table ${q()} add (${columnDefinition(driver, c)});`
|
||||
: `alter table ${q()} add column ${columnDefinition(driver, c)};`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c.dropped) {
|
||||
@@ -280,6 +299,26 @@ export function alterTableSql(
|
||||
out.push(`alter table ${q()} change column ${ident(driver, o.name)} ${columnDefinition(driver, c)};`);
|
||||
continue;
|
||||
}
|
||||
if (driver === "oracle") {
|
||||
const col = ident(driver, o.name);
|
||||
if (c.dataType.trim() && c.dataType.trim() !== o.dataType) {
|
||||
out.push(`alter table ${q()} modify (${col} ${c.dataType.trim()}${c.notNull ? " not null" : ""});`);
|
||||
}
|
||||
if (c.notNull !== !o.nullable) {
|
||||
out.push(
|
||||
c.notNull ? `alter table ${q()} modify (${col} not null);` : `alter table ${q()} modify (${col} null);`,
|
||||
);
|
||||
}
|
||||
if (c.default.trim() !== (o.default ?? "").trim()) {
|
||||
out.push(
|
||||
c.default.trim()
|
||||
? `alter table ${q()} modify (${col} default ${c.default.trim()});`
|
||||
: `alter table ${q()} modify (${col} default null);`,
|
||||
);
|
||||
}
|
||||
if (renamed) out.push(`alter table ${q()} rename column ${col} to ${ident(driver, c.name)};`);
|
||||
continue;
|
||||
}
|
||||
if (renamed) out.push(`alter table ${q()} rename column ${ident(driver, o.name)} to ${ident(driver, c.name)};`);
|
||||
if (driver !== "postgres") continue;
|
||||
const col = ident(driver, renamed ? c.name : o.name);
|
||||
|
||||
@@ -68,9 +68,12 @@ export function quoteIdent(name: string, driver: DbDriver): string {
|
||||
const plain =
|
||||
driver === "postgres"
|
||||
? /^[a-z_][a-z0-9_$]*$/
|
||||
: driver === "clickhouse"
|
||||
? /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
: /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
||||
: // Oracle folds unquoted names to upper case; a lower-case name must stay quoted.
|
||||
driver === "oracle"
|
||||
? /^[A-Z_][A-Z0-9_$]*$/
|
||||
: driver === "clickhouse"
|
||||
? /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
: /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
||||
if (plain.test(name) && !isReserved(name)) return name;
|
||||
if (driver === "clickhouse") return `"${name.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
const q = driver === "mysql" ? "`" : '"';
|
||||
|
||||
@@ -92,6 +92,20 @@ export function statementStarters(driver: DbDriver): string[] {
|
||||
"CREATE DATABASE",
|
||||
"START TRANSACTION",
|
||||
];
|
||||
if (driver === "oracle")
|
||||
return [
|
||||
...common,
|
||||
"TRUNCATE TABLE",
|
||||
"MERGE INTO",
|
||||
"CREATE SEQUENCE",
|
||||
"CREATE OR REPLACE PROCEDURE",
|
||||
"CREATE OR REPLACE FUNCTION",
|
||||
"CREATE OR REPLACE TRIGGER",
|
||||
"CREATE OR REPLACE VIEW",
|
||||
"ALTER SESSION SET",
|
||||
"GRANT",
|
||||
"REVOKE",
|
||||
];
|
||||
return [
|
||||
...common,
|
||||
"TRUNCATE",
|
||||
@@ -109,6 +123,7 @@ export function joinKeywords(driver: DbDriver): string[] {
|
||||
const j = ["JOIN", "LEFT JOIN", "INNER JOIN", "CROSS JOIN", "RIGHT JOIN"];
|
||||
if (driver !== "mysql") j.push("FULL JOIN");
|
||||
if (driver === "postgres") j.push("JOIN LATERAL", "LEFT JOIN LATERAL");
|
||||
if (driver === "oracle") j.push("JOIN", "(+)");
|
||||
return j;
|
||||
}
|
||||
|
||||
@@ -132,6 +147,7 @@ export function predicateKeywords(driver: DbDriver): string[] {
|
||||
if (driver === "postgres") k.push("ILIKE", "IS DISTINCT FROM", "ANY", "SIMILAR TO");
|
||||
if (driver === "mysql") k.push("REGEXP");
|
||||
if (driver === "sqlite") k.push("GLOB", "REGEXP");
|
||||
if (driver === "oracle") k.push("CONNECT BY PRIOR", "START WITH", "NOT IN", "IS NOT NULL");
|
||||
return k;
|
||||
}
|
||||
|
||||
@@ -231,6 +247,31 @@ export const DATA_TYPES: Record<DbDriver, string[]> = {
|
||||
"LowCardinality",
|
||||
"JSON",
|
||||
],
|
||||
oracle: [
|
||||
"NUMBER",
|
||||
"NUMBER(10)",
|
||||
"NUMBER(19, 4)",
|
||||
"INTEGER",
|
||||
"BINARY_FLOAT",
|
||||
"BINARY_DOUBLE",
|
||||
"VARCHAR2(255)",
|
||||
"VARCHAR2(4000)",
|
||||
"NVARCHAR2(255)",
|
||||
"CHAR(1)",
|
||||
"CLOB",
|
||||
"NCLOB",
|
||||
"BLOB",
|
||||
"RAW(16)",
|
||||
"DATE",
|
||||
"TIMESTAMP",
|
||||
"TIMESTAMP WITH TIME ZONE",
|
||||
"TIMESTAMP WITH LOCAL TIME ZONE",
|
||||
"INTERVAL DAY TO SECOND",
|
||||
"INTERVAL YEAR TO MONTH",
|
||||
"ROWID",
|
||||
"XMLTYPE",
|
||||
"BOOLEAN",
|
||||
],
|
||||
};
|
||||
|
||||
const f = (name: string, params: string, returns?: string, aggregate?: boolean): SqlFunction => ({
|
||||
@@ -383,6 +424,39 @@ const FUNCTIONS: Record<DbDriver, SqlFunction[]> = {
|
||||
f("tuple", "value, ...", "Tuple"),
|
||||
f("cast", "expr AS type"),
|
||||
],
|
||||
oracle: [
|
||||
...COMMON,
|
||||
f("nvl", "expr, replacement"),
|
||||
f("nvl2", "expr, value_if_not_null, value_if_null"),
|
||||
f("decode", "expr, search, result, ..., default"),
|
||||
f("greatest", "value, ..."),
|
||||
f("least", "value, ..."),
|
||||
f("instr", "text, substring, position, occurrence", "number"),
|
||||
f("translate", "text, from, to"),
|
||||
f("lpad", "text, length, pad"),
|
||||
f("rpad", "text, length, pad"),
|
||||
f("regexp_replace", "text, pattern, replacement, position, occurrence"),
|
||||
f("regexp_like", "text, pattern", "boolean"),
|
||||
f("to_char", "value, format", "varchar2"),
|
||||
f("to_date", "text, format", "date"),
|
||||
f("to_number", "text, format", "number"),
|
||||
f("trunc", "date_or_number, format"),
|
||||
f("add_months", "date, months", "date"),
|
||||
f("months_between", "date, date", "number"),
|
||||
f("last_day", "date", "date"),
|
||||
f("next_day", "date, weekday", "date"),
|
||||
f("sysdate", "", "date"),
|
||||
f("systimestamp", "", "timestamp with time zone"),
|
||||
f("extract", "field FROM source", "number"),
|
||||
f("listagg", "expr, delimiter ON OVERFLOW TRUNCATE", "varchar2", true),
|
||||
f("row_number", "", "number"),
|
||||
f("rank", "", "number"),
|
||||
f("dense_rank", "", "number"),
|
||||
f("lag", "value, offset, default"),
|
||||
f("lead", "value, offset, default"),
|
||||
f("raise_application_error", "code, message"),
|
||||
f("sys_context", "namespace, parameter", "varchar2"),
|
||||
],
|
||||
};
|
||||
|
||||
export function functions(driver: DbDriver): SqlFunction[] {
|
||||
|
||||
@@ -210,6 +210,19 @@ describe("completion candidates", () => {
|
||||
expect(labels(complete("select 1; ins|"))).toContain("insert into");
|
||||
});
|
||||
|
||||
it("offers Oracle vocabulary and quotes names the server folds to upper case", () => {
|
||||
expect(statementStarters("oracle")).toEqual(expect.arrayContaining(["MERGE INTO", "TRUNCATE TABLE"]));
|
||||
expect(statementStarters("oracle")).not.toContain("SET search_path TO");
|
||||
expect(DATA_TYPES.oracle).toEqual(expect.arrayContaining(["VARCHAR2(255)", "NUMBER", "CLOB"]));
|
||||
expect(complete("SELECT | FROM users", "oracle").find((c) => c.label === "NVL")).toMatchObject({
|
||||
snippet: "NVL(${})",
|
||||
});
|
||||
// Upper-case names are plain; lower-case ones need quotes (the server folds unquoted names).
|
||||
expect(quoteIdent("EMPLOYEES", "oracle")).toBe("EMPLOYEES");
|
||||
expect(quoteIdent("employees", "oracle")).toBe('"employees"');
|
||||
expect(quoteIdent('a"b', "oracle")).toBe('"a""b"');
|
||||
});
|
||||
|
||||
it("offers tables after FROM, with schema and data source", () => {
|
||||
const c = complete("select * from |");
|
||||
expect(labels(c, "table")).toEqual(expect.arrayContaining(["users", "orders", "order_items", "products"]));
|
||||
|
||||
@@ -64,6 +64,7 @@ export const VENDORS: readonly DbVendor[] = [
|
||||
icon: "db-oceanbase",
|
||||
scheme: "oceanbase",
|
||||
},
|
||||
{ id: "oracle", label: "Oracle", driver: "oracle", port: 1521, icon: "db-oracle", scheme: "oracle" },
|
||||
{ id: "postgres", label: "PostgreSQL", driver: "postgres", port: 5432, icon: "db-postgres", scheme: "postgresql" },
|
||||
{
|
||||
id: "singlestore",
|
||||
@@ -137,6 +138,8 @@ const SCHEMES: Record<string, string> = {
|
||||
singlestore: "singlestore",
|
||||
memsql: "singlestore",
|
||||
oceanbase: "oceanbase",
|
||||
oracle: "oracle",
|
||||
oracledb: "oracle",
|
||||
sqlserver: "sqlServer",
|
||||
mssql: "sqlServer",
|
||||
};
|
||||
|
||||
@@ -6323,5 +6323,11 @@
|
||||
"The project is mounted here; paths and the working directory are mapped into it.": "项目挂载在此处;路径和工作目录会映射到其中。",
|
||||
"Published ports": "发布的端口",
|
||||
"Run as the image's user, not the project folder's owner (Linux)": "以镜像的用户运行,而不是项目文件夹的所有者(Linux)",
|
||||
"Docker connections come from the Services tool window. Each run gets a new container, removed when it ends; debugging, coverage and profiling stay on this machine.": "Docker 连接来自“服务”工具窗口。每次运行都会使用一个新容器,运行结束后删除;调试、覆盖率和性能分析仍在本机进行。"
|
||||
"Docker connections come from the Services tool window. Each run gets a new container, removed when it ends; debugging, coverage and profiling stay on this machine.": "Docker 连接来自“服务”工具窗口。每次运行都会使用一个新容器,运行结束后删除;调试、覆盖率和性能分析仍在本机进行。",
|
||||
"Service name, e.g. ORCLPDB1": "服务名,例如 ORCLPDB1",
|
||||
"Current schema:": "当前模式:",
|
||||
"HR (ALTER SESSION SET CURRENT_SCHEMA)": "HR(ALTER SESSION SET CURRENT_SCHEMA)",
|
||||
"Instant Client:": "Instant Client:",
|
||||
"Directory of Oracle Instant Client 19 or later. Empty: ORACLE_HOME or the system library path.": "Oracle Instant Client 19 或更高版本的目录。留空则使用 ORACLE_HOME 或系统库路径。",
|
||||
"Session parameters set after connecting (ALTER SESSION SET name = value). connect_timeout is in seconds.": "连接后设置的会话参数(ALTER SESSION SET name = value)。connect_timeout 以秒为单位。"
|
||||
}
|
||||
@@ -164,6 +164,7 @@ const UNTRANSLATED = new Set<string>([
|
||||
"Python",
|
||||
"npm",
|
||||
"OpenIDEA",
|
||||
"Oracle",
|
||||
"Spring Boot",
|
||||
"PostgreSQL",
|
||||
"Pyrefly",
|
||||
|
||||
@@ -313,6 +313,7 @@ const ICONS = {
|
||||
"db-clickhouse": "openidea/database/clickhouse",
|
||||
"db-mysql": "openidea/database/mysql",
|
||||
"db-sqlserver": "openidea/database/sqlserver",
|
||||
"db-oracle": "openidea/database/oracle",
|
||||
"db-mariadb": "openidea/database/mariadb",
|
||||
"db-aurora": "openidea/database/aurora",
|
||||
"db-redshift": "openidea/database/redshift",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<!-- OpenIDEA: Oracle data source -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.8 8.2C1.8 5.3 4.6 3 8 3C11.4 3 14.2 5.3 14.2 8.2C14.2 11.1 11.4 13.4 8 13.4C4.6 13.4 1.8 11.1 1.8 8.2Z" fill="#C74634" stroke="#FFFFFF" stroke-width="0.8"/>
|
||||
<path d="M5 8.2C5 6.7 6.3 5.5 8 5.5C9.7 5.5 11 6.7 11 8.2C11 9.7 9.7 10.9 8 10.9C6.3 10.9 5 9.7 5 8.2Z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
Після Ширина: | Висота: | Розмір: 431 B |
Reference in new issue
Заблокувати користувача