1use crate::{ExplainError, ExplainParser, ExplainPlan, ScanType};
22
23#[derive(Debug)]
25pub struct MySqlParser;
26
27impl ExplainParser for MySqlParser {
28 fn parse(&self, raw: &str) -> Result<ExplainPlan, ExplainError> {
29 if raw.lines().any(|l| l.trim_start().starts_with("->")) {
31 return parse_tree_format(raw);
32 }
33 parse_table_format(raw)
34 }
35}
36
37fn parse_tree_format(raw: &str) -> Result<ExplainPlan, ExplainError> {
39 let mut best: Option<(ScanType, String, Option<String>, u64)> = None;
40 let rank = |s: ScanType| match s {
41 ScanType::FullTable => 5,
42 ScanType::IndexRange => 4,
43 ScanType::UniqueLookup => 3,
44 ScanType::IndexLookup => 2,
45 ScanType::Other => 1,
46 };
47
48 for line in raw.lines() {
49 let line = line.trim();
50 let Some(body) = line.strip_prefix("->") else {
52 continue;
53 };
54 let body = body.trim();
55 let upper = body.to_ascii_uppercase();
56
57 let candidate: Option<(ScanType, String, Option<String>, u64)> =
58 if upper.starts_with("TABLE SCAN ON") {
59 Some((
60 ScanType::FullTable,
61 extract_table_from_tree(&body["TABLE SCAN ON".len()..]),
62 None,
63 0,
64 ))
65 } else if upper.starts_with("PRIMARY KEY LOOKUP ON") {
66 let rest = &body["PRIMARY KEY LOOKUP ON".len()..];
67 Some((
68 ScanType::UniqueLookup,
69 extract_table_from_tree(rest),
70 None,
71 0,
72 ))
73 } else if upper.starts_with("COVERING INDEX LOOKUP ON") {
74 let (table, index) = split_tree_index(body, "COVERING INDEX LOOKUP ON");
75 Some((ScanType::IndexRange, table, Some(index), 0))
76 } else if upper.starts_with("INDEX RANGE SCAN ON") {
77 let (table, index) = split_tree_index(body, "INDEX RANGE SCAN ON");
78 Some((ScanType::IndexRange, table, Some(index), 0))
79 } else if upper.starts_with("INDEX LOOKUP ON") {
80 let (table, index) = split_tree_index(body, "INDEX LOOKUP ON");
81 Some((ScanType::IndexRange, table, Some(index), 0))
82 } else {
83 None
84 };
85 if let Some(c) = candidate {
86 if best.is_none() || rank(c.0) > rank(best.as_ref().unwrap().0) {
87 best = Some(c);
88 }
89 }
90 }
91 let (scan_type, table, index, _) = best.ok_or_else(|| ExplainError::Unparseable {
92 reason: "no recognizable tree operator".into(),
93 })?;
94 let rows = crate::parsers::extract_rows_from_kv(raw).unwrap_or(0);
96 let extra: Vec<String> = raw
98 .lines()
99 .filter(|l| l.trim_start().starts_with("-> Filter:"))
100 .map(|l| l.trim().to_string())
101 .collect();
102 Ok(ExplainPlan {
103 scan_type,
104 table,
105 index,
106 rows,
107 extra,
108 })
109}
110
111fn split_tree_index(body: &str, keyword: &str) -> (String, String) {
113 let rest = &body[keyword.len()..];
114 let lower = rest.to_ascii_lowercase();
115 let table = if let Some(pos) = lower.find(" using ") {
116 rest[..pos].trim().to_string()
117 } else {
118 rest.split_whitespace()
119 .next()
120 .unwrap_or("<unknown>")
121 .to_string()
122 };
123 let index = if let Some(pos) = lower.find(" using ") {
124 let after = &rest[pos + 7..];
125 after
126 .split_whitespace()
127 .next()
128 .unwrap_or("<unknown>")
129 .to_string()
130 } else {
131 "<unknown>".to_string()
132 };
133 (table, index)
134}
135
136fn extract_table_from_tree(rest: &str) -> String {
138 let name = rest.split_whitespace().next().unwrap_or("<unknown>");
139 if name.starts_with('(') {
140 "<unknown>".into()
141 } else {
142 name.to_string()
143 }
144}
145
146fn parse_table_format(raw: &str) -> Result<ExplainPlan, ExplainError> {
148 let mut columns: Vec<Vec<String>> = Vec::new();
149 for line in raw.lines() {
150 let line = line.trim();
151 if !line.starts_with('|') {
152 continue;
153 }
154 let cells: Vec<String> = line
155 .split('|')
156 .map(|c| c.trim().to_string())
157 .filter(|c| !c.is_empty())
158 .collect();
159 if cells.is_empty() {
160 continue;
161 }
162 if cells.iter().any(|c| c.eq_ignore_ascii_case("select_type"))
164 || cells
165 .iter()
166 .all(|c| c.chars().all(|ch| ch == '-' || ch == '+'))
167 {
168 continue;
169 }
170 columns.push(cells);
171 }
172 let cells = columns.first().ok_or_else(|| ExplainError::Unparseable {
173 reason: "no data row found".into(),
174 })?;
175 let header = detect_table_header(raw);
179 let (table_idx, type_idx, key_idx, rows_idx, extra_idx) = match header {
180 Some(cols) => (
181 cols.iter().position(|c| c == "table").unwrap_or(2),
182 cols.iter().position(|c| c == "type").unwrap_or(3),
183 cols.iter().position(|c| c == "key").unwrap_or(5),
184 cols.iter().position(|c| c == "rows").unwrap_or(8),
185 cols.iter().position(|c| c == "Extra").unwrap_or(9),
186 ),
187 None => (2, 3, 5, 8, 9),
188 };
189 let table = cells
190 .get(table_idx)
191 .cloned()
192 .unwrap_or_else(|| "<unknown>".into());
193 let scan_type = match cells
194 .get(type_idx)
195 .map(|s| s.to_ascii_uppercase())
196 .as_deref()
197 {
198 Some("ALL") => ScanType::FullTable,
199 Some("RANGE") | Some("INDEX") => ScanType::IndexRange,
200 Some("REF") | Some("EQ_REF") => ScanType::IndexLookup,
201 Some("CONST") | Some("SYSTEM") => ScanType::UniqueLookup,
202 _ => ScanType::Other,
203 };
204 let index = match cells.get(key_idx).map(|s| s.as_str()) {
205 Some(s) if !s.is_empty() && !s.eq_ignore_ascii_case("null") => Some(s.to_string()),
206 _ => None,
207 };
208 let rows = cells
209 .get(rows_idx)
210 .and_then(|s| crate::parsers::parse_u64(s))
211 .unwrap_or(0);
212 let extra = cells
213 .get(extra_idx)
214 .map(|s| {
215 s.split(',')
216 .map(|e| e.trim().to_string())
217 .filter(|e| !e.is_empty())
218 .collect()
219 })
220 .unwrap_or_default();
221 Ok(ExplainPlan {
222 scan_type,
223 table,
224 index,
225 rows,
226 extra,
227 })
228}
229
230fn detect_table_header(raw: &str) -> Option<Vec<String>> {
232 for line in raw.lines() {
233 let line = line.trim();
234 if !line.starts_with('|') {
235 continue;
236 }
237 let cells: Vec<String> = line
238 .split('|')
239 .map(|c| c.trim().to_string())
240 .filter(|c| !c.is_empty())
241 .collect();
242 if cells.iter().any(|c| c.eq_ignore_ascii_case("select_type")) {
243 return Some(cells);
244 }
245 }
246 None
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 const ALL_SCAN: &str = "\
254+----+-------------+-------+-------+---------------+------+---------+------+------+-------------+
255| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
256+----+-------------+-------+-------+---------------+------+---------+------+------+-------------+
257| 1 | SIMPLE | users | ALL | NULL | NULL | NULL | NULL | 1150 | Using where |
258+----+-------------+-------+-------+---------------+------+---------+------+------+-------------+
259";
260
261 const PK_LOOKUP: &str = "\
262+----+-------------+-------+-------+---------------+---------+---------+-------+------+-------+
263| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
264+----+-------------+-------+-------+---------------+---------+---------+-------+------+-------+
265| 1 | SIMPLE | users | const | PRIMARY | PRIMARY | 8 | const | 1 | NULL |
266+----+-------------+-------+-------+---------------+---------+---------+-------+------+-------+
267";
268
269 const RANGE_SCAN: &str = "\
270+----+-------------+--------+-------+---------------+-----------------+---------+------+------+-------------+
271| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
272+----+-------------+--------+-------+---------------+-----------------+---------+------+------+-------------+
273| 1 | SIMPLE | orders | range | idx_user | idx_user | 8 | NULL | 42 | Using where |
274+----+-------------+--------+-------+---------------+-----------------+---------+------+------+-------------+
275";
276
277 #[test]
278 fn parses_full_table_scan() {
279 let plan = MySqlParser.parse(ALL_SCAN).expect("parse ok");
280 assert_eq!(plan.scan_type, ScanType::FullTable);
281 assert_eq!(plan.table, "users");
282 assert!(plan.index.is_none());
283 assert_eq!(plan.rows, 1150);
284 assert!(plan.extra.iter().any(|e| e.contains("Using where")));
285 assert!(plan.missing_index(1000));
286 }
287
288 #[test]
289 fn parses_pk_lookup() {
290 let plan = MySqlParser.parse(PK_LOOKUP).expect("parse ok");
291 assert_eq!(plan.scan_type, ScanType::UniqueLookup);
292 assert_eq!(plan.index.as_deref(), Some("PRIMARY"));
293 assert_eq!(plan.rows, 1);
294 assert!(!plan.missing_index(1000));
295 }
296
297 #[test]
298 fn parses_range_scan_with_index() {
299 let plan = MySqlParser.parse(RANGE_SCAN).expect("parse ok");
300 assert_eq!(plan.scan_type, ScanType::IndexRange);
301 assert_eq!(plan.index.as_deref(), Some("idx_user"));
302 assert_eq!(plan.rows, 42);
303 }
304
305 #[test]
306 fn rejects_empty_input() {
307 let err = MySqlParser.parse("no explain output here").unwrap_err();
308 assert!(matches!(err, ExplainError::Unparseable { .. }));
309 }
310
311 const TREE_TABLE_SCAN: &str = "\
314-> Filter: (sz_orm_explain_users.`name` = 'alice') (cost=0.45 rows=1)
315 -> Table scan on sz_orm_explain_users (cost=0.45 rows=2)
316";
317
318 const TREE_COVERING_LOOKUP: &str = "\
319-> Covering index lookup on sz_orm_explain_users using idx_email (email = 'a@x.com') (cost=0.35 rows=1)
320";
321
322 const TREE_PK_LOOKUP: &str = "\
323-> Primary key lookup on sz_orm_explain_users (id = 1) (cost=0.35 rows=1)
324";
325
326 #[test]
327 fn parses_tree_table_scan() {
328 let plan = MySqlParser.parse(TREE_TABLE_SCAN).expect("parse ok");
329 assert_eq!(plan.scan_type, ScanType::FullTable);
330 assert_eq!(plan.table, "sz_orm_explain_users");
331 assert!(plan.index.is_none());
332 assert_eq!(plan.rows, 1);
333 assert!(plan.missing_index(1000));
334 }
335
336 #[test]
337 fn parses_tree_covering_index_lookup() {
338 let plan = MySqlParser.parse(TREE_COVERING_LOOKUP).expect("parse ok");
339 assert_eq!(plan.scan_type, ScanType::IndexRange);
340 assert_eq!(plan.table, "sz_orm_explain_users");
341 assert_eq!(plan.index.as_deref(), Some("idx_email"));
342 assert!(!plan.missing_index(1000));
343 }
344
345 #[test]
346 fn parses_tree_primary_key_lookup() {
347 let plan = MySqlParser.parse(TREE_PK_LOOKUP).expect("parse ok");
348 assert_eq!(plan.scan_type, ScanType::UniqueLookup);
349 assert_eq!(plan.table, "sz_orm_explain_users");
350 }
351}