Файли
openidea/crates/ide-db/src/plan.rs
T
Budi Santosoтаboenkkk 9069c322e8 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]>
2026-11-06 09:57:03 +08:00

381 рядки
17 KiB
Rust

//! Execution plans (IDEA's Explain Plan): the `EXPLAIN` statement of each
//! dialect and its output read into one flat tree of operations.
use crate::DbDriver;
use crate::conn::QueryResult;
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use specta::Type;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbPlanProperty")]
pub struct PlanProperty {
pub name: String,
pub value: String,
}
/// One operation of a plan; `parent` indexes the plan's node list (roots: `None`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[serde(rename = "DbPlanNode")]
pub struct PlanNode {
pub parent: Option<u32>,
/// `Full Scan`, `Index Scan`, `Hash Join`…
pub operation: String,
/// The object it works on and its key details (`table: o;`).
pub params: String,
pub properties: Vec<PlanProperty>,
}
/// The statement that explains `statement` without running it.
pub fn explain_statement(driver: DbDriver, statement: &str) -> String {
let s = statement.trim().trim_end_matches(';');
match driver {
DbDriver::Sqlite => format!("EXPLAIN QUERY PLAN {s}"),
DbDriver::Postgres => format!("EXPLAIN (FORMAT JSON) {s}"),
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");
let json: Value = serde_json::from_str(&text).map_err(|e| IdeError::invalid(format!("plan: {e}")))?;
let mut out = Vec::new();
for root in json.as_array().into_iter().flatten() {
if let Some(plan) = root.get("Plan") {
postgres(plan, None, &mut out);
}
}
Ok(out)
}
DbDriver::Mysql => {
let names: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect();
Ok(mysql(&names, &result.rows))
}
DbDriver::SqlServer => Ok(Vec::new()),
}
}
fn prop(name: &str, value: impl Into<String>) -> PlanProperty {
PlanProperty { name: name.to_owned(), value: value.into() }
}
/// SQLite rows: `id, parent, notused, detail` (`SCAN o`, `SEARCH c USING INTEGER PRIMARY KEY (rowid=?)`).
fn sqlite(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, detail) = (cell(0), cell(1), cell(3));
let parent = ids.iter().position(|x| *x == parent).map(|i| i as u32);
let (operation, params) = sqlite_operation(&detail);
out.push(PlanNode { parent, operation, params, properties: vec![prop("Detail", detail.clone())] });
ids.push(id);
}
out
}
fn sqlite_operation(detail: &str) -> (String, String) {
let words: Vec<&str> = detail.split_whitespace().collect();
let object = |i: usize| words.get(i).map(|t| format!("table: {t};")).unwrap_or_default();
let using = || detail.find(" USING ").map(|i| detail[i + 1..].to_owned()).unwrap_or_default();
match words.first().copied() {
Some("SCAN") if detail.contains(" USING ") => ("Index Scan".into(), format!("{} {}", object(1), using())),
Some("SCAN") => ("Full Scan".into(), object(1)),
Some("SEARCH") => ("Index Scan".into(), format!("{} {}", object(1), using()).trim().to_owned()),
Some("USE") if detail.contains("ORDER BY") => ("Order By".into(), String::new()),
Some("USE") if detail.contains("GROUP BY") => ("Group By".into(), String::new()),
Some("USE") if detail.contains("DISTINCT") => ("Distinct".into(), String::new()),
_ => (detail.to_owned(), String::new()),
}
}
/// PostgreSQL JSON plan node: `Node Type`, `Relation Name`, nested `Plans`.
fn postgres(node: &Value, parent: Option<u32>, out: &mut Vec<PlanNode>) {
let text = |k: &str| node.get(k).and_then(Value::as_str).unwrap_or_default();
let mut operation = text("Node Type").to_owned();
if let Some(j) = node.get("Join Type").and_then(Value::as_str).filter(|j| *j != "Inner") {
operation = format!("{operation} ({j})");
}
let mut params = Vec::new();
if let Some(r) = node.get("Relation Name").and_then(Value::as_str) {
let alias = text("Alias");
params.push(if alias.is_empty() || alias == r {
format!("table: {r};")
} else {
format!("table: {r} {alias};")
});
}
if let Some(i) = node.get("Index Name").and_then(Value::as_str) {
params.push(format!("index: {i};"));
}
for k in ["Index Cond", "Hash Cond", "Merge Cond", "Filter", "Join Filter"] {
if let Some(c) = node.get(k).and_then(Value::as_str) {
params.push(format!("{}: {c};", k.to_lowercase()));
}
}
let properties = node
.as_object()
.into_iter()
.flatten()
.filter(|(k, _)| k.as_str() != "Plans")
.map(|(k, v)| {
let value = match v {
Value::String(s) => s.clone(),
Value::Array(a) => a
.iter()
.map(|x| x.as_str().map_or_else(|| x.to_string(), str::to_owned))
.collect::<Vec<_>>()
.join(", "),
other => other.to_string(),
};
prop(k, value)
})
.collect();
let me = out.len() as u32;
out.push(PlanNode { parent, operation, params: params.join(" "), properties });
for child in node.get("Plans").and_then(Value::as_array).into_iter().flatten() {
postgres(child, Some(me), out);
}
}
/// 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| {
names.iter().position(|c| c.eq_ignore_ascii_case(n)).and_then(|i| r.get(i).cloned().flatten())
};
rows.iter()
.map(|r| {
let access = col(r, "type").unwrap_or_default();
let operation = match access.as_str() {
"ALL" => "Full Scan".to_owned(),
"index" => "Full Index Scan".to_owned(),
"range" => "Index Range Scan".to_owned(),
"ref" | "eq_ref" | "ref_or_null" => "Index Lookup".to_owned(),
"const" | "system" => "Constant Lookup".to_owned(),
"" => col(r, "Extra").unwrap_or_else(|| "Select".to_owned()),
other => other.to_owned(),
};
let mut params = Vec::new();
if let Some(t) = col(r, "table") {
params.push(format!("table: {t};"));
}
if let Some(k) = col(r, "key") {
params.push(format!("key: {k};"));
}
let properties = names.iter().zip(r).filter_map(|(n, v)| v.as_ref().map(|v| prop(n, v.clone()))).collect();
PlanNode { parent: None, operation, params: params.join(" "), properties }
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::conn::ResultColumn;
fn result(columns: &[&str], rows: Vec<Vec<Option<&str>>>) -> QueryResult {
QueryResult {
statement: String::new(),
columns: columns.iter().map(|c| ResultColumn { name: (*c).into(), type_name: String::new() }).collect(),
rows: rows.into_iter().map(|r| r.into_iter().map(|v| v.map(str::to_owned)).collect()).collect(),
affected: None,
has_more: false,
elapsed_ms: 0.0,
execution_ms: 0.0,
}
}
#[test]
fn explains_per_dialect() {
assert_eq!(explain_statement(DbDriver::Sqlite, "select 1;"), "EXPLAIN QUERY PLAN select 1");
assert_eq!(explain_statement(DbDriver::Postgres, " select 1 "), "EXPLAIN (FORMAT JSON) select 1");
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]
fn reads_sqlite_plans_like_idea() {
let r = result(
&["id", "parent", "notused", "detail"],
vec![
vec![Some("3"), Some("0"), Some("0"), Some("SCAN o")],
vec![Some("5"), Some("0"), Some("0"), Some("SEARCH c USING INTEGER PRIMARY KEY (rowid=?)")],
vec![Some("9"), Some("0"), Some("0"), Some("USE TEMP B-TREE FOR ORDER BY")],
],
);
let plan = parse(DbDriver::Sqlite, &r).unwrap();
let ops: Vec<(&str, &str)> = plan.iter().map(|n| (n.operation.as_str(), n.params.as_str())).collect();
assert_eq!(
ops,
[
("Full Scan", "table: o;"),
("Index Scan", "table: c; USING INTEGER PRIMARY KEY (rowid=?)"),
("Order By", "")
]
);
assert!(plan.iter().all(|n| n.parent.is_none()));
}
#[test]
fn nests_sqlite_children_under_their_parent() {
let r = result(
&["id", "parent", "notused", "detail"],
vec![
vec![Some("2"), Some("0"), Some("0"), Some("CO-ROUTINE sub")],
vec![Some("4"), Some("2"), Some("0"), Some("SCAN t")],
],
);
let plan = parse(DbDriver::Sqlite, &r).unwrap();
assert_eq!(plan[1].parent, Some(0));
}
#[test]
fn reads_postgres_json_trees() {
let json = r#"[{"Plan": {"Node Type": "Hash Join", "Join Type": "Inner", "Hash Cond": "(o.customer_id = c.id)",
"Total Cost": 12.5, "Plans": [
{"Node Type": "Seq Scan", "Relation Name": "orders", "Alias": "o", "Filter": "(status <> 'x')"},
{"Node Type": "Hash", "Plans": [{"Node Type": "Seq Scan", "Relation Name": "customers", "Alias": "c"}]}
]}}]"#;
let plan = parse(DbDriver::Postgres, &result(&["QUERY PLAN"], vec![vec![Some(json)]])).unwrap();
assert_eq!(plan.len(), 4);
assert_eq!(plan[0].operation, "Hash Join");
assert_eq!(plan[0].params, "hash cond: (o.customer_id = c.id);");
assert!(plan[0].properties.iter().any(|p| p.name == "Total Cost" && p.value == "12.5"));
assert_eq!((plan[1].parent, plan[1].params.as_str()), (Some(0), "table: orders o; filter: (status <> 'x');"));
assert_eq!((plan[3].parent, plan[3].operation.as_str()), (Some(2), "Seq Scan"));
}
#[test]
fn reads_clickhouse_json_plans_and_rejects_invalid_json() {
let json = r#"[{"Plan": {"Node Type": "Expression", "Node Id": "Expression_5",
"Description": "Project names", "Plans": [
{"Node Type": "ReadFromMergeTree", "Node Id": "ReadFromMergeTree_0",
"Description": "default.events"}
]}}]"#;
let plan = parse(DbDriver::Clickhouse, &result(&["explain"], vec![vec![Some(json)]])).unwrap();
assert_eq!(plan.len(), 2);
assert_eq!((plan[0].parent, plan[0].operation.as_str()), (None, "Expression"));
assert_eq!((plan[1].parent, plan[1].operation.as_str()), (Some(0), "ReadFromMergeTree"));
assert!(plan[1].properties.iter().any(|p| p.name == "Description" && p.value == "default.events"));
assert!(parse(DbDriver::Clickhouse, &result(&["explain"], vec![vec![Some("invalid")]])).is_err());
}
#[test]
fn reads_mysql_rows() {
let r = result(
&["id", "select_type", "table", "type", "key", "rows", "Extra"],
vec![
vec![Some("1"), Some("SIMPLE"), Some("o"), Some("ALL"), None, Some("20"), Some("Using where")],
vec![Some("1"), Some("SIMPLE"), Some("c"), Some("eq_ref"), Some("PRIMARY"), Some("1"), None],
],
);
let plan = parse(DbDriver::Mysql, &r).unwrap();
assert_eq!((plan[0].operation.as_str(), plan[0].params.as_str()), ("Full Scan", "table: o;"));
assert_eq!((plan[1].operation.as_str(), plan[1].params.as_str()), ("Index Lookup", "table: c; key: PRIMARY;"));
assert!(plan[0].properties.iter().any(|p| p.name == "rows" && p.value == "20"));
}
}