Skip to main content

sz_orm_explain/
lib.rs

1//! # sz-orm-explain — 跨方言 EXPLAIN 结果解析器
2//!
3//! 将 MySQL / PostgreSQL / SQLite / Oracle / MSSQL 的 `EXPLAIN` 输出解析为
4//! 统一的 [`ExplainPlan`],用于:
5//!
6//! - 全表扫描检测(`ScanType::FullTable`)
7//! - 缺失索引检测(`index == None` 且扫描行数超阈值)
8//! - 执行计划回归检测([`crate::regression`],需 `explain-analyzer` feature)
9//!
10//! 典型用法(配合 `sz-orm-macros` 的 `query!` 宏 db-verify 模式):
11//!
12//! ```rust,ignore
13//! let plan = sz_orm_explain::parser_for(ExplainDialect::Postgres)?.parse(explain_raw)?;
14//! if plan.scan_type == ScanType::FullTable {
15//!     // 编译期/CI 警告:全表扫描
16//! }
17//! ```
18//!
19//! 解析失败返回 [`ExplainError::Unparseable`],不 panic,可降级为"无警告"。
20//!
21//! 注意:本包刻意零依赖 `sz-orm-core`,通过自带的 [`ExplainDialect`] 枚举
22//! 描述方言,避免 core → macros → explain → core 依赖循环。
23
24#[cfg(feature = "explain-analyzer")]
25pub mod analyzer;
26pub mod dialect;
27pub mod parsers;
28#[cfg(feature = "perf-baseline")]
29pub mod perf_baseline;
30#[cfg(feature = "explain-analyzer")]
31pub mod regression;
32
33/// 支持的 EXPLAIN 解析方言(与数据库引擎对应,家族内格式兼容)
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum ExplainDialect {
36    /// MySQL(及 MariaDB/TiDB/PolarDB/OceanBase)
37    MySql,
38    /// PostgreSQL(及 CockroachDB/YugabyteDB)
39    Postgres,
40    /// SQLite(及 DuckDB)
41    Sqlite,
42    /// Oracle(及 Dameng)
43    Oracle,
44    /// SQL Server(及 Sybase)
45    Mssql,
46}
47
48impl ExplainDialect {
49    pub fn as_str(&self) -> &'static str {
50        match self {
51            ExplainDialect::MySql => "mysql",
52            ExplainDialect::Postgres => "postgres",
53            ExplainDialect::Sqlite => "sqlite",
54            ExplainDialect::Oracle => "oracle",
55            ExplainDialect::Mssql => "mssql",
56        }
57    }
58
59    pub fn all() -> &'static [ExplainDialect] {
60        &[
61            ExplainDialect::MySql,
62            ExplainDialect::Postgres,
63            ExplainDialect::Sqlite,
64            ExplainDialect::Oracle,
65            ExplainDialect::Mssql,
66        ]
67    }
68
69    pub fn parse_name(s: &str) -> Option<ExplainDialect> {
70        match s.to_lowercase().as_str() {
71            "mysql" => Some(ExplainDialect::MySql),
72            "postgres" | "postgresql" => Some(ExplainDialect::Postgres),
73            "sqlite" => Some(ExplainDialect::Sqlite),
74            "oracle" => Some(ExplainDialect::Oracle),
75            "mssql" | "sqlserver" => Some(ExplainDialect::Mssql),
76            _ => None,
77        }
78    }
79}
80
81/// 扫描类型分类(各方言扫描方式的统一抽象)
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83#[cfg_attr(
84    feature = "explain-analyzer",
85    derive(serde::Serialize, serde::Deserialize)
86)]
87pub enum ScanType {
88    /// 全表扫描(MySQL `ALL` / PG `Seq Scan` / SQLite `SCAN` / Oracle `TABLE ACCESS FULL` / MSSQL `Table Scan`)
89    FullTable,
90    /// 索引范围扫描(MySQL `range` / PG `Index Scan` / SQLite `SEARCH ... USING INDEX` / Oracle `INDEX RANGE SCAN` / MSSQL `Index Scan`)
91    IndexRange,
92    /// 索引等值/点查(MySQL `ref`/`eq_ref` / Oracle `INDEX UNIQUE SCAN`)
93    IndexLookup,
94    /// 主键/唯一索引点查(MySQL `const` / PG `Index Scan ... pkey` / MSSQL `Index Seek`)
95    UniqueLookup,
96    /// 其他(物化、排序、聚合等)
97    Other,
98}
99
100impl ScanType {
101    /// 是否为全表扫描(性能警告触发条件)
102    pub fn is_full_table_scan(&self) -> bool {
103        matches!(self, ScanType::FullTable)
104    }
105
106    pub fn as_str(&self) -> &'static str {
107        match self {
108            ScanType::FullTable => "full-table",
109            ScanType::IndexRange => "index-range",
110            ScanType::IndexLookup => "index-lookup",
111            ScanType::UniqueLookup => "unique-lookup",
112            ScanType::Other => "other",
113        }
114    }
115
116    pub fn all() -> &'static [ScanType] {
117        &[
118            ScanType::FullTable,
119            ScanType::IndexRange,
120            ScanType::IndexLookup,
121            ScanType::UniqueLookup,
122            ScanType::Other,
123        ]
124    }
125
126    pub fn is_index_scan(&self) -> bool {
127        matches!(
128            self,
129            ScanType::IndexRange | ScanType::IndexLookup | ScanType::UniqueLookup
130        )
131    }
132
133    pub fn is_index_lookup(&self) -> bool {
134        matches!(self, ScanType::IndexLookup | ScanType::UniqueLookup)
135    }
136
137    pub fn parse_name(s: &str) -> Option<ScanType> {
138        match s.to_lowercase().as_str() {
139            "full-table" | "full" | "seq" => Some(ScanType::FullTable),
140            "index-range" | "range" => Some(ScanType::IndexRange),
141            "index-lookup" | "ref" => Some(ScanType::IndexLookup),
142            "unique-lookup" | "const" | "unique" => Some(ScanType::UniqueLookup),
143            "other" => Some(ScanType::Other),
144            _ => None,
145        }
146    }
147}
148
149/// 解析后的执行计划摘要
150#[derive(Debug, Clone, PartialEq, Eq)]
151#[cfg_attr(
152    feature = "explain-analyzer",
153    derive(serde::Serialize, serde::Deserialize)
154)]
155pub struct ExplainPlan {
156    /// 扫描类型
157    pub scan_type: ScanType,
158    /// 涉及的表名(未知时为 `"<unknown>"`)
159    pub table: String,
160    /// 使用的索引名(全表扫描/未知时为 `None`)
161    pub index: Option<String>,
162    /// 预估扫描行数
163    pub rows: u64,
164    /// 额外信息(filter/using where 等)
165    pub extra: Vec<String>,
166}
167
168impl ExplainPlan {
169    pub fn new(
170        scan_type: ScanType,
171        table: impl Into<String>,
172        index: Option<String>,
173        rows: u64,
174        extra: Vec<String>,
175    ) -> Self {
176        Self {
177            scan_type,
178            table: table.into(),
179            index,
180            rows,
181            extra,
182        }
183    }
184
185    pub fn is_full_table_scan(&self) -> bool {
186        self.scan_type.is_full_table_scan()
187    }
188
189    pub fn has_index(&self) -> bool {
190        self.index.is_some()
191    }
192
193    pub fn is_large_result(&self, threshold: u64) -> bool {
194        self.rows >= threshold
195    }
196
197    pub fn table_name(&self) -> &str {
198        &self.table
199    }
200
201    pub fn index_name(&self) -> Option<&str> {
202        self.index.as_deref()
203    }
204
205    pub fn estimated_rows(&self) -> u64 {
206        self.rows
207    }
208
209    pub fn extra_info(&self) -> &[String] {
210        &self.extra
211    }
212
213    pub fn has_extra(&self, keyword: &str) -> bool {
214        self.extra.iter().any(|e| e.contains(keyword))
215    }
216
217    pub fn summary(&self) -> String {
218        let idx = self.index.as_deref().unwrap_or("none");
219        format!(
220            "scan={} table={} index={} rows={}",
221            self.scan_type.as_str(),
222            self.table,
223            idx,
224            self.rows
225        )
226    }
227
228    /// 缺失索引判断:全表扫描或行数超阈值且未使用索引
229    pub fn missing_index(&self, row_threshold: u64) -> bool {
230        self.index.is_none() && (self.scan_type.is_full_table_scan() || self.rows >= row_threshold)
231    }
232}
233
234/// EXPLAIN 解析错误
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub enum ExplainError {
237    /// 无法解析(非 EXPLAIN 输出 / 格式不识别)
238    Unparseable { reason: String },
239    /// 方言不受支持(无对应解析器)
240    UnsupportedDialect,
241}
242
243impl std::fmt::Display for ExplainError {
244    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245        match self {
246            ExplainError::Unparseable { reason } => {
247                write!(f, "unparseable explain output: {reason}")
248            }
249            ExplainError::UnsupportedDialect => {
250                write!(f, "unsupported dialect for explain parsing")
251            }
252        }
253    }
254}
255
256impl std::error::Error for ExplainError {}
257
258impl ExplainError {
259    pub fn is_unparseable(&self) -> bool {
260        matches!(self, ExplainError::Unparseable { .. })
261    }
262
263    pub fn is_unsupported_dialect(&self) -> bool {
264        matches!(self, ExplainError::UnsupportedDialect)
265    }
266}
267
268/// 方言 EXPLAIN 解析器 trait
269pub trait ExplainParser: Send + Sync + std::fmt::Debug {
270    /// 解析原始 EXPLAIN 输出为统一 [`ExplainPlan`]
271    fn parse(&self, raw: &str) -> Result<ExplainPlan, ExplainError>;
272
273    fn parse_or_default(&self, raw: &str) -> ExplainPlan {
274        self.parse(raw).unwrap_or(ExplainPlan {
275            scan_type: ScanType::Other,
276            table: "<unknown>".into(),
277            index: None,
278            rows: 0,
279            extra: Vec::new(),
280        })
281    }
282}
283
284/// 按方言分派解析器
285pub fn parser_for(dialect: ExplainDialect) -> Result<Box<dyn ExplainParser>, ExplainError> {
286    match dialect {
287        ExplainDialect::MySql => Ok(Box::new(parsers::mysql::MySqlParser)),
288        ExplainDialect::Postgres => Ok(Box::new(parsers::postgres::PostgresParser)),
289        ExplainDialect::Sqlite => Ok(Box::new(parsers::sqlite::SqliteParser)),
290        ExplainDialect::Oracle => Ok(Box::new(parsers::oracle::OracleParser)),
291        ExplainDialect::Mssql => Ok(Box::new(parsers::mssql::MssqlParser)),
292    }
293}
294
295/// 便捷解析函数:按方言解析 EXPLAIN 输出
296pub fn parse_explain(dialect: ExplainDialect, raw: &str) -> Result<ExplainPlan, ExplainError> {
297    let parser = parser_for(dialect)?;
298    parser.parse(raw)
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn test_dialect_and_scan_type_parse_name() {
307        assert_eq!(
308            ExplainDialect::parse_name("mysql"),
309            Some(ExplainDialect::MySql)
310        );
311        assert_eq!(
312            ExplainDialect::parse_name("postgresql"),
313            Some(ExplainDialect::Postgres)
314        );
315        assert_eq!(ExplainDialect::parse_name("unknown"), None);
316        assert_eq!(ExplainDialect::all().len(), 5);
317
318        assert_eq!(ScanType::parse_name("full"), Some(ScanType::FullTable));
319        assert_eq!(ScanType::parse_name("range"), Some(ScanType::IndexRange));
320        assert_eq!(ScanType::parse_name("invalid"), None);
321        assert_eq!(ScanType::all().len(), 5);
322        assert!(ScanType::IndexRange.is_index_scan());
323        assert!(ScanType::UniqueLookup.is_index_lookup());
324        assert!(!ScanType::FullTable.is_index_scan());
325    }
326
327    #[test]
328    fn test_explain_plan_helpers() {
329        let plan = ExplainPlan::new(
330            ScanType::FullTable,
331            "users",
332            None,
333            50000,
334            vec!["Using where".to_string()],
335        );
336        assert!(plan.is_full_table_scan());
337        assert!(!plan.has_index());
338        assert!(plan.is_large_result(1000));
339        assert_eq!(plan.table_name(), "users");
340        assert!(plan.index_name().is_none());
341        assert_eq!(plan.estimated_rows(), 50000);
342        assert!(plan.has_extra("Using"));
343        assert!(!plan.has_extra("Filesort"));
344        assert!(plan.summary().contains("full-table"));
345        assert!(plan.missing_index(1000));
346
347        let indexed = ExplainPlan::new(
348            ScanType::IndexLookup,
349            "orders",
350            Some("idx_user".to_string()),
351            10,
352            vec![],
353        );
354        assert!(!indexed.is_full_table_scan());
355        assert!(indexed.has_index());
356        assert_eq!(indexed.index_name(), Some("idx_user"));
357        assert!(!indexed.missing_index(1000));
358    }
359
360    #[test]
361    fn test_explain_error_helpers() {
362        let err = ExplainError::Unparseable {
363            reason: "bad format".into(),
364        };
365        assert!(err.is_unparseable());
366        assert!(!err.is_unsupported_dialect());
367
368        let err2 = ExplainError::UnsupportedDialect;
369        assert!(!err2.is_unparseable());
370        assert!(err2.is_unsupported_dialect());
371    }
372}