1#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum ExplainDialect {
36 MySql,
38 Postgres,
40 Sqlite,
42 Oracle,
44 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83#[cfg_attr(
84 feature = "explain-analyzer",
85 derive(serde::Serialize, serde::Deserialize)
86)]
87pub enum ScanType {
88 FullTable,
90 IndexRange,
92 IndexLookup,
94 UniqueLookup,
96 Other,
98}
99
100impl ScanType {
101 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#[derive(Debug, Clone, PartialEq, Eq)]
151#[cfg_attr(
152 feature = "explain-analyzer",
153 derive(serde::Serialize, serde::Deserialize)
154)]
155pub struct ExplainPlan {
156 pub scan_type: ScanType,
158 pub table: String,
160 pub index: Option<String>,
162 pub rows: u64,
164 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 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#[derive(Debug, Clone, PartialEq, Eq)]
236pub enum ExplainError {
237 Unparseable { reason: String },
239 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
268pub trait ExplainParser: Send + Sync + std::fmt::Debug {
270 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
284pub 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
295pub 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}