1#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum ExplainDialect {
39 MySql,
41 Postgres,
43 Sqlite,
45 Oracle,
47 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86#[cfg_attr(
87 feature = "explain-analyzer",
88 derive(serde::Serialize, serde::Deserialize)
89)]
90pub enum ScanType {
91 FullTable,
93 IndexRange,
95 IndexLookup,
97 UniqueLookup,
99 Other,
101}
102
103impl ScanType {
104 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#[derive(Debug, Clone, PartialEq, Eq)]
154#[cfg_attr(
155 feature = "explain-analyzer",
156 derive(serde::Serialize, serde::Deserialize)
157)]
158pub struct ExplainPlan {
159 pub scan_type: ScanType,
161 pub table: String,
163 pub index: Option<String>,
165 pub rows: u64,
167 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 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#[derive(Debug, Clone, PartialEq, Eq)]
239pub enum ExplainError {
240 Unparseable { reason: String },
242 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
271pub trait ExplainParser: Send + Sync + std::fmt::Debug {
273 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
287pub 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
298pub 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}