use crate::{ExplainError, ExplainParser, ExplainPlan, ScanType};
#[derive(Debug)]
pub struct PostgresParser;
impl ExplainParser for PostgresParser {
fn parse(&self, raw: &str) -> Result<ExplainPlan, ExplainError> {
let top = raw
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.ok_or_else(|| ExplainError::Unparseable {
reason: "empty explain output".into(),
})?;
let (scan_type, table, index) = classify_operation(top);
let rows = crate::parsers::extract_rows_from_kv(raw).unwrap_or(0);
let extra = collect_extra(raw);
Ok(ExplainPlan {
scan_type,
table,
index,
rows,
extra,
})
}
}
fn classify_operation(top: &str) -> (ScanType, String, Option<String>) {
if let Some(rest) = strip_prefix_ci(top, "Index Scan using") {
let (index, table) = split_index_table(rest);
return (ScanType::IndexRange, table, Some(index));
}
if let Some(rest) = strip_prefix_ci(top, "Index Only Scan using") {
let (index, table) = split_index_table(rest);
return (ScanType::IndexRange, table, Some(index));
}
if let Some(rest) = strip_prefix_ci(top, "Seq Scan on") {
return (ScanType::FullTable, extract_table(rest), None);
}
if let Some(rest) = strip_prefix_ci(top, "Bitmap Heap Scan on") {
return (ScanType::Other, extract_table(rest), None);
}
if let Some(rest) = strip_prefix_ci(top, "Bitmap Index Scan on") {
return (
ScanType::IndexRange,
"<unknown>".into(),
Some(extract_name(rest)),
);
}
(
ScanType::Other,
top.split_whitespace()
.nth(2)
.unwrap_or("<unknown>")
.to_string(),
None,
)
}
fn split_index_table(rest: &str) -> (String, String) {
let lower = rest.to_ascii_lowercase();
if let Some(pos) = lower.find(" on ") {
let index = rest[..pos].trim().to_string();
let table = extract_table(&rest[pos + 4..]);
(index, table)
} else {
(rest.trim().to_string(), "<unknown>".into())
}
}
fn extract_table(rest: &str) -> String {
let name = rest.split_whitespace().next().unwrap_or("<unknown>");
if name.starts_with('(') {
"<unknown>".into()
} else {
name.to_string()
}
}
fn extract_name(rest: &str) -> String {
rest.split_whitespace()
.next()
.unwrap_or("<unknown>")
.to_string()
}
fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
if s.len() >= prefix.len() && s[..prefix.len()].eq_ignore_ascii_case(prefix) {
Some(&s[prefix.len()..])
} else {
None
}
}
fn collect_extra(raw: &str) -> Vec<String> {
let mut extra = Vec::new();
for line in raw.lines() {
let t = line.trim();
for key in ["Filter:", "Index Cond:", "Heap Blocks:", "Buffers:"] {
if let Some(pos) = t.find(key) {
extra.push(t[pos..].to_string());
}
}
}
extra
}
#[cfg(test)]
mod tests {
use super::*;
const SEQ_SCAN: &str = "\
Seq Scan on users (cost=0.00..21.50 rows=1150 width=36)
Filter: (age > 18)
";
const INDEX_SCAN: &str = "\
Index Scan using users_pkey on users (cost=0.29..8.31 rows=1 width=36)
Index Cond: (id = 1)
";
const INDEX_ONLY: &str = "\
Index Only Scan using orders_user_idx on orders (cost=0.42..8.44 rows=1 width=8)
Index Cond: (user_id = 42)
";
const JOIN_PLAN: &str = "\
Hash Join (cost=25.31..49.02 rows=1150 width=72)
Hash Cond: (o.user_id = u.id)
-> Seq Scan on orders (cost=0.00..16.50 rows=1150 width=36)
-> Hash (cost=21.50..21.50 rows=1150 width=36)
-> Seq Scan on users (cost=0.00..21.50 rows=1150 width=36)
";
#[test]
fn parses_seq_scan() {
let plan = PostgresParser.parse(SEQ_SCAN).expect("parse ok");
assert_eq!(plan.scan_type, ScanType::FullTable);
assert_eq!(plan.table, "users");
assert!(plan.index.is_none());
assert_eq!(plan.rows, 1150);
assert!(plan.extra.iter().any(|e| e.contains("Filter")));
assert!(plan.missing_index(1000));
}
#[test]
fn parses_index_scan() {
let plan = PostgresParser.parse(INDEX_SCAN).expect("parse ok");
assert_eq!(plan.scan_type, ScanType::IndexRange);
assert_eq!(plan.index.as_deref(), Some("users_pkey"));
assert_eq!(plan.table, "users");
assert_eq!(plan.rows, 1);
}
#[test]
fn parses_index_only_scan() {
let plan = PostgresParser.parse(INDEX_ONLY).expect("parse ok");
assert_eq!(plan.scan_type, ScanType::IndexRange);
assert_eq!(plan.index.as_deref(), Some("orders_user_idx"));
assert_eq!(plan.table, "orders");
}
#[test]
fn top_level_join_is_other() {
let plan = PostgresParser.parse(JOIN_PLAN).expect("parse ok");
assert_eq!(plan.scan_type, ScanType::Other);
assert_eq!(plan.rows, 1150);
}
#[test]
fn rejects_empty_input() {
let err = PostgresParser.parse(" \n \n").unwrap_err();
assert!(matches!(err, ExplainError::Unparseable { .. }));
}
}