sz_orm_explain/parsers/
postgres.rs1use crate::{ExplainError, ExplainParser, ExplainPlan, ScanType};
13
14#[derive(Debug)]
16pub struct PostgresParser;
17
18impl ExplainParser for PostgresParser {
19 fn parse(&self, raw: &str) -> Result<ExplainPlan, ExplainError> {
20 let top = raw
22 .lines()
23 .map(str::trim)
24 .find(|l| !l.is_empty())
25 .ok_or_else(|| ExplainError::Unparseable {
26 reason: "empty explain output".into(),
27 })?;
28
29 let (scan_type, table, index) = classify_operation(top);
30 let rows = crate::parsers::extract_rows_from_kv(raw).unwrap_or(0);
31 let extra = collect_extra(raw);
32
33 Ok(ExplainPlan {
34 scan_type,
35 table,
36 index,
37 rows,
38 extra,
39 })
40 }
41}
42
43fn classify_operation(top: &str) -> (ScanType, String, Option<String>) {
45 if let Some(rest) = strip_prefix_ci(top, "Index Scan using") {
47 let (index, table) = split_index_table(rest);
48 return (ScanType::IndexRange, table, Some(index));
49 }
50 if let Some(rest) = strip_prefix_ci(top, "Index Only Scan using") {
52 let (index, table) = split_index_table(rest);
53 return (ScanType::IndexRange, table, Some(index));
54 }
55 if let Some(rest) = strip_prefix_ci(top, "Seq Scan on") {
57 return (ScanType::FullTable, extract_table(rest), None);
58 }
59 if let Some(rest) = strip_prefix_ci(top, "Bitmap Heap Scan on") {
61 return (ScanType::Other, extract_table(rest), None);
62 }
63 if let Some(rest) = strip_prefix_ci(top, "Bitmap Index Scan on") {
65 return (
66 ScanType::IndexRange,
67 "<unknown>".into(),
68 Some(extract_name(rest)),
69 );
70 }
71 (
73 ScanType::Other,
74 top.split_whitespace()
75 .nth(2)
76 .unwrap_or("<unknown>")
77 .to_string(),
78 None,
79 )
80}
81
82fn split_index_table(rest: &str) -> (String, String) {
84 let lower = rest.to_ascii_lowercase();
85 if let Some(pos) = lower.find(" on ") {
86 let index = rest[..pos].trim().to_string();
87 let table = extract_table(&rest[pos + 4..]);
88 (index, table)
89 } else {
90 (rest.trim().to_string(), "<unknown>".into())
91 }
92}
93
94fn extract_table(rest: &str) -> String {
96 let name = rest.split_whitespace().next().unwrap_or("<unknown>");
97 if name.starts_with('(') {
98 "<unknown>".into()
99 } else {
100 name.to_string()
101 }
102}
103
104fn extract_name(rest: &str) -> String {
106 rest.split_whitespace()
107 .next()
108 .unwrap_or("<unknown>")
109 .to_string()
110}
111
112fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
113 if s.len() >= prefix.len() && s[..prefix.len()].eq_ignore_ascii_case(prefix) {
114 Some(&s[prefix.len()..])
115 } else {
116 None
117 }
118}
119
120fn collect_extra(raw: &str) -> Vec<String> {
122 let mut extra = Vec::new();
123 for line in raw.lines() {
124 let t = line.trim();
125 for key in ["Filter:", "Index Cond:", "Heap Blocks:", "Buffers:"] {
126 if let Some(pos) = t.find(key) {
127 extra.push(t[pos..].to_string());
128 }
129 }
130 }
131 extra
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 const SEQ_SCAN: &str = "\
139Seq Scan on users (cost=0.00..21.50 rows=1150 width=36)
140 Filter: (age > 18)
141";
142
143 const INDEX_SCAN: &str = "\
144Index Scan using users_pkey on users (cost=0.29..8.31 rows=1 width=36)
145 Index Cond: (id = 1)
146";
147
148 const INDEX_ONLY: &str = "\
149Index Only Scan using orders_user_idx on orders (cost=0.42..8.44 rows=1 width=8)
150 Index Cond: (user_id = 42)
151";
152
153 const JOIN_PLAN: &str = "\
154Hash Join (cost=25.31..49.02 rows=1150 width=72)
155 Hash Cond: (o.user_id = u.id)
156 -> Seq Scan on orders (cost=0.00..16.50 rows=1150 width=36)
157 -> Hash (cost=21.50..21.50 rows=1150 width=36)
158 -> Seq Scan on users (cost=0.00..21.50 rows=1150 width=36)
159";
160
161 #[test]
162 fn parses_seq_scan() {
163 let plan = PostgresParser.parse(SEQ_SCAN).expect("parse ok");
164 assert_eq!(plan.scan_type, ScanType::FullTable);
165 assert_eq!(plan.table, "users");
166 assert!(plan.index.is_none());
167 assert_eq!(plan.rows, 1150);
168 assert!(plan.extra.iter().any(|e| e.contains("Filter")));
169 assert!(plan.missing_index(1000));
170 }
171
172 #[test]
173 fn parses_index_scan() {
174 let plan = PostgresParser.parse(INDEX_SCAN).expect("parse ok");
175 assert_eq!(plan.scan_type, ScanType::IndexRange);
176 assert_eq!(plan.index.as_deref(), Some("users_pkey"));
177 assert_eq!(plan.table, "users");
178 assert_eq!(plan.rows, 1);
179 }
180
181 #[test]
182 fn parses_index_only_scan() {
183 let plan = PostgresParser.parse(INDEX_ONLY).expect("parse ok");
184 assert_eq!(plan.scan_type, ScanType::IndexRange);
185 assert_eq!(plan.index.as_deref(), Some("orders_user_idx"));
186 assert_eq!(plan.table, "orders");
187 }
188
189 #[test]
190 fn top_level_join_is_other() {
191 let plan = PostgresParser.parse(JOIN_PLAN).expect("parse ok");
192 assert_eq!(plan.scan_type, ScanType::Other);
193 assert_eq!(plan.rows, 1150);
194 }
195
196 #[test]
197 fn rejects_empty_input() {
198 let err = PostgresParser.parse(" \n \n").unwrap_err();
199 assert!(matches!(err, ExplainError::Unparseable { .. }));
200 }
201}