Skip to main content

sz_orm_explain/
lib.rs

1//! # sz-orm-explain — Cross-Dialect EXPLAIN Result Parser
2//!
3//! Parses `EXPLAIN` output from MySQL / PostgreSQL / SQLite / Oracle / MSSQL into
4//! a unified [`ExplainPlan`], used for:
5//!
6//! - Full table scan detection (`ScanType::FullTable`)
7//! - Missing index detection (`index == None` and scanned rows exceed threshold)
8//! - Execution plan regression detection ([`crate::regression`], requires `explain-analyzer` feature)
9//! - Slow query automatic governance ([`crate::slow_query_governance`], requires `slow-query-governance` feature)
10//!
11//! Typical usage (with `sz-orm-macros`'s `query!` macro db-verify mode):
12//!
13//! ```text
14//! let plan = sz_orm_explain::parser_for(ExplainDialect::Postgres)?.parse(explain_raw)?;
15//! if plan.scan_type == ScanType::FullTable {
16//!     // Compile-time/CI warning: full table scan
17//! }
18//! ```
19//!
20//! Parse failure returns [`ExplainError::Unparseable`], does not panic, can degrade to "no warnings".
21//!
22//! Note: This package intentionally has zero dependency on `sz-orm-core`, uses its own [`ExplainDialect`] enum
23//! to describe dialects, avoiding core → macros → explain → core dependency cycle.
24
25#[cfg(feature = "explain-analyzer")]
26pub mod analyzer;
27pub mod dialect;
28pub mod parsers;
29#[cfg(feature = "perf-baseline")]
30pub mod perf_baseline;
31#[cfg(feature = "explain-analyzer")]
32pub mod regression;
33#[cfg(feature = "slow-query-governance")]
34pub mod slow_query_governance;
35
36/// Supported EXPLAIN parse dialects (corresponding to database engines, format compatible within family)
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum ExplainDialect {
39    /// MySQL (and MariaDB/TiDB/PolarDB/OceanBase)
40    MySql,
41    /// PostgreSQL (and CockroachDB/YugabyteDB)
42    Postgres,
43    /// SQLite (and DuckDB)
44    Sqlite,
45    /// Oracle (and Dameng)
46    Oracle,
47    /// SQL Server (and Sybase)
48    Mssql,
49}
50
51impl ExplainDialect {
52    pub fn as_str(&self) -> &'static str {
53        match self {
54            ExplainDialect::MySql => "mysql",
55            ExplainDialect::Postgres => "postgres",
56            ExplainDialect::Sqlite => "sqlite",
57            ExplainDialect::Oracle => "oracle",
58            ExplainDialect::Mssql => "mssql",
59        }
60    }
61
62    pub fn all() -> &'static [ExplainDialect] {
63        &[
64            ExplainDialect::MySql,
65            ExplainDialect::Postgres,
66            ExplainDialect::Sqlite,
67            ExplainDialect::Oracle,
68            ExplainDialect::Mssql,
69        ]
70    }
71
72    pub fn parse_name(s: &str) -> Option<ExplainDialect> {
73        match s.to_lowercase().as_str() {
74            "mysql" => Some(ExplainDialect::MySql),
75            "postgres" | "postgresql" => Some(ExplainDialect::Postgres),
76            "sqlite" => Some(ExplainDialect::Sqlite),
77            "oracle" => Some(ExplainDialect::Oracle),
78            "mssql" | "sqlserver" => Some(ExplainDialect::Mssql),
79            _ => None,
80        }
81    }
82}
83
84/// Scan type classification (unified abstraction of scan methods across dialects)
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86#[cfg_attr(
87    feature = "explain-analyzer",
88    derive(serde::Serialize, serde::Deserialize)
89)]
90pub enum ScanType {
91    /// Full table scan (MySQL `ALL` / PG `Seq Scan` / SQLite `SCAN` / Oracle `TABLE ACCESS FULL` / MSSQL `Table Scan`)
92    FullTable,
93    /// Index range scan (MySQL `range` / PG `Index Scan` / SQLite `SEARCH ... USING INDEX` / Oracle `INDEX RANGE SCAN` / MSSQL `Index Scan`)
94    IndexRange,
95    /// Index equality/point lookup (MySQL `ref`/`eq_ref` / Oracle `INDEX UNIQUE SCAN`)
96    IndexLookup,
97    /// Primary key/unique index point lookup (MySQL `const` / PG `Index Scan ... pkey` / MSSQL `Index Seek`)
98    UniqueLookup,
99    /// Other (materialization, sorting, aggregation, etc.)
100    Other,
101}
102
103impl ScanType {
104    /// Whether it is a full table scan (performance warning trigger condition)
105    pub fn is_full_table_scan(&self) -> bool {
106        matches!(self, ScanType::FullTable)
107    }
108
109    pub fn as_str(&self) -> &'static str {
110        match self {
111            ScanType::FullTable => "full-table",
112            ScanType::IndexRange => "index-range",
113            ScanType::IndexLookup => "index-lookup",
114            ScanType::UniqueLookup => "unique-lookup",
115            ScanType::Other => "other",
116        }
117    }
118
119    pub fn all() -> &'static [ScanType] {
120        &[
121            ScanType::FullTable,
122            ScanType::IndexRange,
123            ScanType::IndexLookup,
124            ScanType::UniqueLookup,
125            ScanType::Other,
126        ]
127    }
128
129    pub fn is_index_scan(&self) -> bool {
130        matches!(
131            self,
132            ScanType::IndexRange | ScanType::IndexLookup | ScanType::UniqueLookup
133        )
134    }
135
136    pub fn is_index_lookup(&self) -> bool {
137        matches!(self, ScanType::IndexLookup | ScanType::UniqueLookup)
138    }
139
140    pub fn parse_name(s: &str) -> Option<ScanType> {
141        match s.to_lowercase().as_str() {
142            "full-table" | "full" | "seq" => Some(ScanType::FullTable),
143            "index-range" | "range" => Some(ScanType::IndexRange),
144            "index-lookup" | "ref" => Some(ScanType::IndexLookup),
145            "unique-lookup" | "const" | "unique" => Some(ScanType::UniqueLookup),
146            "other" => Some(ScanType::Other),
147            _ => None,
148        }
149    }
150}
151
152/// Parsed execution plan summary
153#[derive(Debug, Clone, PartialEq, Eq)]
154#[cfg_attr(
155    feature = "explain-analyzer",
156    derive(serde::Serialize, serde::Deserialize)
157)]
158pub struct ExplainPlan {
159    /// Scan type
160    pub scan_type: ScanType,
161    /// Involved table name (`"<unknown>"` when unknown)
162    pub table: String,
163    /// Index name used (`None` for full table scan/unknown)
164    pub index: Option<String>,
165    /// Estimated scanned row count
166    pub rows: u64,
167    /// Extra info (filter/using where, etc.)
168    pub extra: Vec<String>,
169}
170
171impl ExplainPlan {
172    pub fn new(
173        scan_type: ScanType,
174        table: impl Into<String>,
175        index: Option<String>,
176        rows: u64,
177        extra: Vec<String>,
178    ) -> Self {
179        Self {
180            scan_type,
181            table: table.into(),
182            index,
183            rows,
184            extra,
185        }
186    }
187
188    pub fn is_full_table_scan(&self) -> bool {
189        self.scan_type.is_full_table_scan()
190    }
191
192    pub fn has_index(&self) -> bool {
193        self.index.is_some()
194    }
195
196    pub fn is_large_result(&self, threshold: u64) -> bool {
197        self.rows >= threshold
198    }
199
200    pub fn table_name(&self) -> &str {
201        &self.table
202    }
203
204    pub fn index_name(&self) -> Option<&str> {
205        self.index.as_deref()
206    }
207
208    pub fn estimated_rows(&self) -> u64 {
209        self.rows
210    }
211
212    pub fn extra_info(&self) -> &[String] {
213        &self.extra
214    }
215
216    pub fn has_extra(&self, keyword: &str) -> bool {
217        self.extra.iter().any(|e| e.contains(keyword))
218    }
219
220    pub fn summary(&self) -> String {
221        let idx = self.index.as_deref().unwrap_or("none");
222        format!(
223            "scan={} table={} index={} rows={}",
224            self.scan_type.as_str(),
225            self.table,
226            idx,
227            self.rows
228        )
229    }
230
231    /// Missing index check: full table scan or row count exceeds threshold and no index used
232    pub fn missing_index(&self, row_threshold: u64) -> bool {
233        self.index.is_none() && (self.scan_type.is_full_table_scan() || self.rows >= row_threshold)
234    }
235}
236
237/// EXPLAIN parse error
238#[derive(Debug, Clone, PartialEq, Eq)]
239pub enum ExplainError {
240    /// Unparseable (not EXPLAIN output / format not recognized)
241    Unparseable { reason: String },
242    /// Dialect not supported (no corresponding parser)
243    UnsupportedDialect,
244}
245
246impl std::fmt::Display for ExplainError {
247    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
248        match self {
249            ExplainError::Unparseable { reason } => {
250                write!(f, "unparseable explain output: {reason}")
251            }
252            ExplainError::UnsupportedDialect => {
253                write!(f, "unsupported dialect for explain parsing")
254            }
255        }
256    }
257}
258
259impl std::error::Error for ExplainError {}
260
261impl ExplainError {
262    pub fn is_unparseable(&self) -> bool {
263        matches!(self, ExplainError::Unparseable { .. })
264    }
265
266    pub fn is_unsupported_dialect(&self) -> bool {
267        matches!(self, ExplainError::UnsupportedDialect)
268    }
269}
270
271/// Dialect EXPLAIN parser trait
272pub trait ExplainParser: Send + Sync + std::fmt::Debug {
273    /// Parse raw EXPLAIN output into unified [`ExplainPlan`]
274    fn parse(&self, raw: &str) -> Result<ExplainPlan, ExplainError>;
275
276    fn parse_or_default(&self, raw: &str) -> ExplainPlan {
277        self.parse(raw).unwrap_or(ExplainPlan {
278            scan_type: ScanType::Other,
279            table: "<unknown>".into(),
280            index: None,
281            rows: 0,
282            extra: Vec::new(),
283        })
284    }
285}
286
287/// Dispatch parser by dialect
288pub fn parser_for(dialect: ExplainDialect) -> Result<Box<dyn ExplainParser>, ExplainError> {
289    match dialect {
290        ExplainDialect::MySql => Ok(Box::new(parsers::mysql::MySqlParser)),
291        ExplainDialect::Postgres => Ok(Box::new(parsers::postgres::PostgresParser)),
292        ExplainDialect::Sqlite => Ok(Box::new(parsers::sqlite::SqliteParser)),
293        ExplainDialect::Oracle => Ok(Box::new(parsers::oracle::OracleParser)),
294        ExplainDialect::Mssql => Ok(Box::new(parsers::mssql::MssqlParser)),
295    }
296}
297
298/// Convenience parse function: parse EXPLAIN output by dialect
299pub fn parse_explain(dialect: ExplainDialect, raw: &str) -> Result<ExplainPlan, ExplainError> {
300    let parser = parser_for(dialect)?;
301    parser.parse(raw)
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn test_dialect_and_scan_type_parse_name() {
310        assert_eq!(
311            ExplainDialect::parse_name("mysql"),
312            Some(ExplainDialect::MySql)
313        );
314        assert_eq!(
315            ExplainDialect::parse_name("postgresql"),
316            Some(ExplainDialect::Postgres)
317        );
318        assert_eq!(ExplainDialect::parse_name("unknown"), None);
319        assert_eq!(ExplainDialect::all().len(), 5);
320
321        assert_eq!(ScanType::parse_name("full"), Some(ScanType::FullTable));
322        assert_eq!(ScanType::parse_name("range"), Some(ScanType::IndexRange));
323        assert_eq!(ScanType::parse_name("invalid"), None);
324        assert_eq!(ScanType::all().len(), 5);
325        assert!(ScanType::IndexRange.is_index_scan());
326        assert!(ScanType::UniqueLookup.is_index_lookup());
327        assert!(!ScanType::FullTable.is_index_scan());
328    }
329
330    #[test]
331    fn test_explain_plan_helpers() {
332        let plan = ExplainPlan::new(
333            ScanType::FullTable,
334            "users",
335            None,
336            50000,
337            vec!["Using where".to_string()],
338        );
339        assert!(plan.is_full_table_scan());
340        assert!(!plan.has_index());
341        assert!(plan.is_large_result(1000));
342        assert_eq!(plan.table_name(), "users");
343        assert!(plan.index_name().is_none());
344        assert_eq!(plan.estimated_rows(), 50000);
345        assert!(plan.has_extra("Using"));
346        assert!(!plan.has_extra("Filesort"));
347        assert!(plan.summary().contains("full-table"));
348        assert!(plan.missing_index(1000));
349
350        let indexed = ExplainPlan::new(
351            ScanType::IndexLookup,
352            "orders",
353            Some("idx_user".to_string()),
354            10,
355            vec![],
356        );
357        assert!(!indexed.is_full_table_scan());
358        assert!(indexed.has_index());
359        assert_eq!(indexed.index_name(), Some("idx_user"));
360        assert!(!indexed.missing_index(1000));
361    }
362
363    #[test]
364    fn test_explain_error_helpers() {
365        let err = ExplainError::Unparseable {
366            reason: "bad format".into(),
367        };
368        assert!(err.is_unparseable());
369        assert!(!err.is_unsupported_dialect());
370
371        let err2 = ExplainError::UnsupportedDialect;
372        assert!(!err2.is_unparseable());
373        assert!(err2.is_unsupported_dialect());
374    }
375}