use crate::{ExplainError, ExplainParser, ExplainPlan, ScanType};
#[derive(Debug)]
pub struct SqliteParser;
impl ExplainParser for SqliteParser {
fn parse(&self, raw: &str) -> Result<ExplainPlan, ExplainError> {
let mut plans: Vec<(ScanType, String, Option<String>)> = Vec::new();
for line in raw.lines() {
let line = line.trim();
if line.is_empty() || line.eq_ignore_ascii_case("QUERY PLAN") {
continue;
}
let body = strip_prefix(line);
let body = if body
.split_whitespace()
.next()
.unwrap_or("")
.chars()
.all(|c| c.is_ascii_digit())
{
body.split_whitespace()
.skip(3)
.collect::<Vec<_>>()
.join(" ")
} else {
body
};
if body.is_empty() {
continue;
}
if let Some(rest) = strip_prefix_ci(&body, "SCAN") {
plans.push((ScanType::FullTable, extract_table(rest), None));
} else if let Some(rest) = strip_prefix_ci(&body, "SEARCH") {
let (scan_type, table, index) = classify_search(rest);
plans.push((scan_type, table, index));
} else if strip_prefix_ci(&body, "USE TEMP B-TREE").is_some() {
plans.push((ScanType::Other, "<temp>".into(), None));
}
}
let (scan_type, table, index) =
plans
.first()
.cloned()
.ok_or_else(|| ExplainError::Unparseable {
reason: "no SCAN/SEARCH row found".into(),
})?;
Ok(ExplainPlan {
scan_type,
table,
index,
rows: 0,
extra: Vec::new(),
})
}
}
fn strip_prefix(line: &str) -> String {
let mut s = line.to_string();
for p in ["|--", "`--", "--"] {
if let Some(rest) = s.strip_prefix(p) {
s = rest.trim().to_string();
}
}
s
}
fn extract_table(rest: &str) -> String {
rest.split_whitespace()
.find(|w| !w.eq_ignore_ascii_case("TABLE") && !w.starts_with('('))
.unwrap_or("<unknown>")
.to_string()
}
fn classify_search(rest: &str) -> (ScanType, String, Option<String>) {
let lower = rest.to_ascii_lowercase();
let table = extract_table(rest);
let Some(pos) = lower.find("using") else {
return (ScanType::Other, table, None);
};
let using = lower[pos + 5..].trim_start();
if using.starts_with("primary key") {
return (ScanType::UniqueLookup, table, None);
}
if let Some(idx) = using.strip_prefix("covering index ") {
let idx = idx.split_whitespace().next().unwrap_or("<unknown>");
return (ScanType::IndexRange, table, Some(idx.to_string()));
}
if let Some(idx) = using.strip_prefix("index ") {
let idx = idx.split_whitespace().next().unwrap_or("<unknown>");
return (ScanType::IndexRange, table, Some(idx.to_string()));
}
(ScanType::Other, table, None)
}
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
}
}
#[cfg(test)]
mod tests {
use super::*;
const TREE_FORMAT: &str = "\
QUERY PLAN
|--SCAN users
`--SEARCH orders USING INDEX idx_orders_user (user_id=?)
";
const TABLE_FORMAT: &str = "\
0 0 0 SCAN TABLE users
0 1 0 SEARCH TABLE orders USING INDEX idx_orders_user (user_id=?)
";
const PK_LOOKUP: &str = "\
QUERY PLAN
|--SEARCH users USING PRIMARY KEY (rowid=?)
";
const COVERING: &str = "\
QUERY PLAN
`--SEARCH orders USING COVERING INDEX idx_orders_user (user_id=?)
";
#[test]
fn parses_tree_format() {
let plan = SqliteParser.parse(TREE_FORMAT).expect("parse ok");
assert_eq!(plan.scan_type, ScanType::FullTable);
assert_eq!(plan.table, "users");
assert!(plan.index.is_none());
assert_eq!(plan.rows, 0);
}
#[test]
fn parses_table_format() {
let plan = SqliteParser.parse(TABLE_FORMAT).expect("parse ok");
assert_eq!(plan.scan_type, ScanType::FullTable);
assert_eq!(plan.table, "users");
}
#[test]
fn parses_primary_key_search() {
let plan = SqliteParser.parse(PK_LOOKUP).expect("parse ok");
assert_eq!(plan.scan_type, ScanType::UniqueLookup);
assert_eq!(plan.table, "users");
}
#[test]
fn parses_covering_index() {
let plan = SqliteParser.parse(COVERING).expect("parse ok");
assert_eq!(plan.scan_type, ScanType::IndexRange);
assert_eq!(plan.index.as_deref(), Some("idx_orders_user"));
}
#[test]
fn rejects_unknown_input() {
let err = SqliteParser.parse("hello world").unwrap_err();
assert!(matches!(err, ExplainError::Unparseable { .. }));
}
}