Skip to main content

sz_orm_explain/parsers/
mysql.rs

1//! MySQL EXPLAIN 解析器(同时支持两种输出格式)
2//!
3//! 格式一:树形文本(MySQL 8.0.22+ / 9.x 默认输出,类似 PostgreSQL):
4//!
5//! ```text
6//! -> Filter: (sz_orm_explain_users.`name` = 'alice')  (cost=0.45 rows=1)
7//!     -> Table scan on sz_orm_explain_users  (cost=0.45 rows=2)
8//! -> Covering index lookup on sz_orm_explain_users using idx_email (email = 'a@x.com')  (cost=0.35 rows=1)
9//! ```
10//!
11//! 格式二:经典表格(`EXPLAIN FORMAT=TRADITIONAL` / 旧版本):
12//!
13//! ```text
14//! +----+-------------+-------+-------+---------------+------+---------+------+------+-------------+
15//! | id | select_type | table | type  | possible_keys | key  | key_len | ref  | rows | Extra       |
16//! +----+-------------+-------+-------+---------------+------+---------+------+------+-------------+
17//! |  1 | SIMPLE      | users | ALL   | NULL          | NULL | NULL    | NULL | 1150 | Using where |
18//! +----+-------------+-------+-------+---------------+------+---------+------+------+-------------+
19//! ```
20
21use crate::{ExplainError, ExplainParser, ExplainPlan, ScanType};
22
23/// MySQL EXPLAIN 解析器
24#[derive(Debug)]
25pub struct MySqlParser;
26
27impl ExplainParser for MySqlParser {
28    fn parse(&self, raw: &str) -> Result<ExplainPlan, ExplainError> {
29        // 树形文本格式(以 `->` 开头)优先识别
30        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
37/// 树形文本格式解析(MySQL 8.0.22+ / 9.x)
38fn 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        // 去掉 `->` 前缀
51        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    // rows= 从任意行提取
95    let rows = crate::parsers::extract_rows_from_kv(raw).unwrap_or(0);
96    // Filter: 等收集为 extra
97    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
111/// 从 `on <table> using <idx> (...)` 提取表名与索引名(树格式)
112fn 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
136/// 提取树格式中的表名(取第一个词,忽略 `(cost=...)` 括号)
137fn 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
146/// 经典表格格式解析(`EXPLAIN FORMAT=TRADITIONAL` / 旧版本)
147fn 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        // 跳过表头(包含列名)与分隔行
163        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    // 列布局(MySQL 8.0 前 10 列;8.0+ 含 partitions/filtered 共 12 列):
176    // id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra
177    // 用列名表头识别定位,避免硬编码索引偏移
178    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
230/// 从表格输出中检测表头列名(存在时返回列名列表)
231fn 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    // ---- 树形文本格式(MySQL 8.0.22+ / 9.x)----
312
313    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}