diff --git a/src/explain.rs b/src/explain.rs new file mode 100644 index 0000000..70c09e7 --- /dev/null +++ b/src/explain.rs @@ -0,0 +1,407 @@ +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct PlanNode { + pub node_type: String, + pub estimated_cost: Option<(f64, f64)>, + pub actual_time: Option<(f64, f64)>, + pub estimated_rows: Option, + pub actual_rows: Option, + pub loops: Option, + pub details: Vec, + pub children: Vec, + pub depth: usize, +} + +#[derive(Debug, Clone)] +pub struct QueryPlan { + pub root: PlanNode, + pub total_time: Option, + pub planning_time: Option, + pub execution_time: Option, +} + +pub fn is_explain_query(query: &str) -> bool { + let trimmed = query.trim().to_uppercase(); + trimmed.starts_with("EXPLAIN") +} + +pub fn parse_explain_output(text: &str) -> Option { + let lines: Vec<&str> = text.lines().collect(); + if lines.is_empty() { + return None; + } + + let mut planning_time = None; + let mut execution_time = None; + + // Extract timing info from the end + for line in lines.iter().rev() { + let trimmed = line.trim(); + if let Some(time_str) = trimmed.strip_prefix("Planning Time:") { + planning_time = parse_time_ms(time_str); + } else if let Some(time_str) = trimmed.strip_prefix("Execution Time:") { + execution_time = parse_time_ms(time_str); + } else if let Some(time_str) = trimmed.strip_prefix("Planning time:") { + planning_time = parse_time_ms(time_str); + } else if let Some(time_str) = trimmed.strip_prefix("Execution time:") { + execution_time = parse_time_ms(time_str); + } + } + + // Parse the plan tree + let plan_lines: Vec<&str> = lines + .iter() + .filter(|l| { + let t = l.trim(); + !t.starts_with("Planning Time:") + && !t.starts_with("Planning time:") + && !t.starts_with("Execution Time:") + && !t.starts_with("Execution time:") + && !t.starts_with("QUERY PLAN") + && !t.starts_with("---") + && !t.is_empty() + }) + .copied() + .collect(); + + if plan_lines.is_empty() { + return None; + } + + let root = parse_node(&plan_lines, 0).0?; + + let total_time = root.actual_time.map(|(_, end)| end); + + Some(QueryPlan { + root, + total_time, + planning_time, + execution_time, + }) +} + +fn parse_time_ms(s: &str) -> Option { + let s = s.trim().trim_end_matches("ms").trim(); + s.parse::().ok() +} + +fn parse_node(lines: &[&str], start: usize) -> (Option, usize) { + if start >= lines.len() { + return (None, start); + } + + let first_line = lines[start]; + let node_indent = get_indent(first_line); + + // Parse the node type and cost/timing info from the first line + let content = first_line.trim().trim_start_matches("-> "); + + let (node_type, estimated_cost, actual_time, estimated_rows, actual_rows, loops) = + parse_node_header(content); + + let mut details = Vec::new(); + let mut children = Vec::new(); + let mut idx = start + 1; + + while idx < lines.len() { + let line = lines[idx]; + let indent = get_indent(line); + let trimmed = line.trim(); + + if indent <= node_indent && !trimmed.starts_with("-> ") && idx > start + 1 { + // Back at same or lower indent level — this line belongs to parent + break; + } + + if trimmed.starts_with("-> ") && indent > node_indent { + // Child node + let (child, next_idx) = parse_node(lines, idx); + if let Some(child) = child { + children.push(child); + } + idx = next_idx; + } else if indent > node_indent { + // Detail line for this node + details.push(trimmed.to_string()); + idx += 1; + } else { + break; + } + } + + let node = PlanNode { + node_type, + estimated_cost, + actual_time, + estimated_rows, + actual_rows, + loops, + details, + children, + depth: 0, + }; + + (Some(node), idx) +} + +fn get_indent(line: &str) -> usize { + line.len() - line.trim_start().len() +} + +type NodeHeader = ( + String, + Option<(f64, f64)>, + Option<(f64, f64)>, + Option, + Option, + Option, +); + +fn parse_node_header(s: &str) -> NodeHeader { + let mut node_type = s.to_string(); + let mut estimated_cost = None; + let mut actual_time = None; + let mut estimated_rows = None; + let mut actual_rows = None; + let mut loops = None; + + // Extract (cost=X..Y rows=N width=W) + if let Some(cost_start) = s.find("(cost=") { + node_type = s[..cost_start].trim().to_string(); + + let rest = &s[cost_start..]; + // Parse cost + if let Some(cost_str) = extract_between(rest, "(cost=", " ") { + let parts: Vec<&str> = cost_str.split("..").collect(); + if parts.len() == 2 { + if let (Ok(a), Ok(b)) = (parts[0].parse::(), parts[1].parse::()) { + estimated_cost = Some((a, b)); + } + } + } + // Parse estimated rows + if let Some(rows_str) = extract_between(rest, "rows=", " ") { + estimated_rows = rows_str.parse::().ok(); + } + + // Parse actual time + if let Some(actual_str) = extract_between(rest, "(actual time=", " ") { + let parts: Vec<&str> = actual_str.split("..").collect(); + if parts.len() == 2 { + if let (Ok(a), Ok(b)) = (parts[0].parse::(), parts[1].parse::()) { + actual_time = Some((a, b)); + } + } + } + // Parse actual rows + if let Some(arows_str) = extract_between(rest, "rows=", " loops") { + // This might match the estimated rows= too, so look for the one after "actual" + if rest.contains("actual") { + // Find the second "rows=" after "actual" + if let Some(actual_pos) = rest.find("actual") { + let after_actual = &rest[actual_pos..]; + if let Some(rows_str) = extract_between(after_actual, "rows=", " ") { + actual_rows = rows_str.parse::().ok(); + } + } + } else { + actual_rows = arows_str.parse::().ok(); + } + } + // Parse loops + if let Some(loops_str) = extract_between(rest, "loops=", ")") { + loops = loops_str.parse::().ok(); + } + } + + ( + node_type, + estimated_cost, + actual_time, + estimated_rows, + actual_rows, + loops, + ) +} + +fn extract_between<'a>(s: &'a str, start: &str, end: &str) -> Option<&'a str> { + let start_pos = s.find(start)? + start.len(); + let remaining = &s[start_pos..]; + let end_pos = remaining.find(end)?; + Some(&remaining[..end_pos]) +} + +pub fn node_color_class(node: &PlanNode, total_time: Option) -> NodeColorClass { + if let (Some((_, end)), Some(total)) = (node.actual_time, total_time) { + if total <= 0.0 { + return NodeColorClass::Fast; + } + let ratio = end / total; + if ratio > 0.3 { + NodeColorClass::Slow + } else if ratio > 0.1 { + NodeColorClass::Moderate + } else { + NodeColorClass::Fast + } + } else { + NodeColorClass::Fast + } +} + +pub fn rows_mismatch(node: &PlanNode) -> bool { + if let (Some(est), Some(actual)) = (node.estimated_rows, node.actual_rows) { + if est == 0 || actual == 0 { + return est != actual; + } + let ratio = actual as f64 / est as f64; + !(0.1..=10.0).contains(&ratio) + } else { + false + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum NodeColorClass { + Fast, + Moderate, + Slow, +} + +pub fn format_duration_ms(ms: f64) -> String { + if ms >= 1000.0 { + format!("{:.2}s", ms / 1000.0) + } else { + format!("{:.2}ms", ms) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_explain_query() { + assert!(is_explain_query("EXPLAIN SELECT 1")); + assert!(is_explain_query("explain analyze select * from t")); + assert!(is_explain_query(" EXPLAIN (ANALYZE, BUFFERS) SELECT 1")); + assert!(!is_explain_query("SELECT 1")); + assert!(!is_explain_query("-- EXPLAIN SELECT 1")); + } + + #[test] + fn test_parse_simple_explain() { + let output = "Seq Scan on users (cost=0.00..35.50 rows=2550 width=36)"; + let plan = parse_explain_output(output).unwrap(); + assert_eq!(plan.root.node_type, "Seq Scan on users"); + assert_eq!(plan.root.estimated_cost, Some((0.0, 35.5))); + assert_eq!(plan.root.estimated_rows, Some(2550)); + } + + #[test] + fn test_parse_explain_analyze() { + let output = "\ +Seq Scan on users (cost=0.00..35.50 rows=100 width=36) (actual time=0.010..0.100 rows=100 loops=1) +Planning Time: 0.100 ms +Execution Time: 0.200 ms"; + let plan = parse_explain_output(output).unwrap(); + assert_eq!(plan.root.actual_time, Some((0.01, 0.1))); + assert_eq!(plan.root.actual_rows, Some(100)); + assert_eq!(plan.root.loops, Some(1)); + assert_eq!(plan.planning_time, Some(0.1)); + assert_eq!(plan.execution_time, Some(0.2)); + } + + #[test] + fn test_parse_nested_plan() { + let output = "\ +Sort (cost=100.00..100.25 rows=100 width=40) + Sort Key: name + -> Seq Scan on users (cost=0.00..35.50 rows=100 width=40) + Filter: (age > 18)"; + let plan = parse_explain_output(output).unwrap(); + assert_eq!(plan.root.node_type, "Sort"); + assert_eq!(plan.root.children.len(), 1); + assert_eq!(plan.root.children[0].node_type, "Seq Scan on users"); + assert!(plan.root.children[0] + .details + .iter() + .any(|d| d.contains("Filter"))); + } + + #[test] + fn test_node_color_class() { + let fast_node = PlanNode { + node_type: "Scan".to_string(), + estimated_cost: None, + actual_time: Some((0.0, 1.0)), + estimated_rows: None, + actual_rows: None, + loops: None, + details: vec![], + children: vec![], + depth: 0, + }; + assert_eq!( + node_color_class(&fast_node, Some(100.0)), + NodeColorClass::Fast + ); + + let slow_node = PlanNode { + actual_time: Some((0.0, 50.0)), + ..fast_node.clone() + }; + assert_eq!( + node_color_class(&slow_node, Some(100.0)), + NodeColorClass::Slow + ); + } + + #[test] + fn test_rows_mismatch() { + let node = PlanNode { + node_type: "Scan".to_string(), + estimated_cost: None, + actual_time: None, + estimated_rows: Some(10), + actual_rows: Some(10000), + loops: None, + details: vec![], + children: vec![], + depth: 0, + }; + assert!(rows_mismatch(&node)); + + let good_node = PlanNode { + estimated_rows: Some(100), + actual_rows: Some(95), + ..node.clone() + }; + assert!(!rows_mismatch(&good_node)); + } + + #[test] + fn test_format_duration_ms() { + assert_eq!(format_duration_ms(0.5), "0.50ms"); + assert_eq!(format_duration_ms(100.0), "100.00ms"); + assert_eq!(format_duration_ms(1500.0), "1.50s"); + } + + #[test] + fn test_extract_between() { + assert_eq!( + extract_between("cost=1.00..2.00 rows=10", "cost=", " "), + Some("1.00..2.00") + ); + assert_eq!( + extract_between("rows=100 width=40", "rows=", " "), + Some("100") + ); + assert_eq!(extract_between("no match", "foo=", " "), None); + } + + #[test] + fn test_is_not_explain() { + assert!(!is_explain_query("SELECT * FROM explain_table")); + } +} diff --git a/src/main.rs b/src/main.rs index 5dbea52..6671681 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ pub mod ast; mod db; mod editor; +mod explain; mod export; mod ui; diff --git a/src/ui/app.rs b/src/ui/app.rs index 72dd4ea..25958e7 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -10,6 +10,7 @@ use crate::db::{ IndexInfo, QueryResult, SchemaInfo, SslMode, TableInfo, }; use crate::editor::{HistoryEntry, QueryHistory, TextBuffer}; +use crate::explain::{is_explain_query, parse_explain_output, QueryPlan}; use crate::ui::Theme; pub const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; @@ -139,6 +140,11 @@ pub struct App { // Help pub show_help: bool, + // EXPLAIN plan + pub explain_plans: Vec>, + pub show_visual_plan: bool, + pub plan_scroll: usize, + // Table Inspector pub table_inspector: Option, @@ -304,6 +310,11 @@ impl App { loading_message: String::new(), spinner_frame: 0, show_help: false, + + explain_plans: Vec::new(), + show_visual_plan: true, + plan_scroll: 0, + table_inspector: None, export_selected: 0, pending_connection: None, @@ -934,6 +945,18 @@ impl App { self.result_scroll_y = 0; } } + KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { + // Toggle between visual plan and raw text for EXPLAIN results + if self + .explain_plans + .get(self.current_result) + .and_then(|p| p.as_ref()) + .is_some() + { + self.show_visual_plan = !self.show_visual_plan; + self.plan_scroll = 0; + } + } _ => {} } Ok(()) @@ -1499,10 +1522,31 @@ impl App { ); } + // Parse EXPLAIN plan if applicable + let plan = if is_explain_query(&query) { + // Build the text output from the result rows + let text: String = result + .rows + .iter() + .filter_map(|row| row.first().map(|cell| cell.display())) + .collect::>() + .join("\n"); + parse_explain_output(&text) + } else { + None + }; + self.results.push(result); + self.explain_plans.push(plan); self.current_result = self.results.len() - 1; self.result_selected_row = 0; self.result_selected_col = 0; + self.plan_scroll = 0; + self.show_visual_plan = self + .explain_plans + .last() + .map(|p| p.is_some()) + .unwrap_or(false); } else { self.set_status("Not connected to database".to_string(), StatusType::Error); } diff --git a/src/ui/components.rs b/src/ui/components.rs index fb06b8a..af7a802 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -7,6 +7,9 @@ use ratatui::{ }; use crate::db::SslMode; +use crate::explain::{ + format_duration_ms, node_color_class, rows_mismatch, NodeColorClass, PlanNode, QueryPlan, +}; use crate::ui::{ is_sql_function, is_sql_keyword, is_sql_type, App, Focus, SidebarTab, StatusType, Theme, EXPORT_FORMATS, SPINNER_FRAMES, @@ -658,7 +661,19 @@ fn draw_results(frame: &mut Frame, app: &App, area: Rect) { let inner = block.inner(area); frame.render_widget(block, area); - if let Some(result) = app.results.get(app.current_result) { + // Check if we should show a visual explain plan + let show_plan = app.show_visual_plan + && app + .explain_plans + .get(app.current_result) + .and_then(|p| p.as_ref()) + .is_some(); + + if show_plan { + if let Some(Some(plan)) = app.explain_plans.get(app.current_result) { + draw_explain_plan(frame, app, plan, inner); + } + } else if let Some(result) = app.results.get(app.current_result) { if let Some(error) = &result.error { let error_text = Paragraph::new(error.as_str()) .style(theme.status_error()) @@ -763,6 +778,174 @@ fn draw_result_table(frame: &mut Frame, app: &App, result: &crate::db::QueryResu frame.render_widget(table, area); } +fn draw_explain_plan(frame: &mut Frame, app: &App, plan: &QueryPlan, area: Rect) { + let theme = &app.theme; + let mut lines: Vec = Vec::new(); + + // Header with total time + let header = if let Some(total) = plan.total_time { + format!("Query Plan (total: {})", format_duration_ms(total)) + } else { + "Query Plan".to_string() + }; + lines.push(Line::from(Span::styled( + header, + Style::default() + .fg(theme.text_accent) + .add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from("")); + + // Render the tree + render_plan_node(&plan.root, plan.total_time, theme, &mut lines, "", true); + + // Planning/Execution time footer + lines.push(Line::from("")); + if let Some(pt) = plan.planning_time { + lines.push(Line::from(Span::styled( + format!("Planning Time: {}", format_duration_ms(pt)), + Style::default().fg(theme.text_secondary), + ))); + } + if let Some(et) = plan.execution_time { + lines.push(Line::from(Span::styled( + format!("Execution Time: {}", format_duration_ms(et)), + Style::default().fg(theme.text_secondary), + ))); + } + + // Hint + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Ctrl+E: Toggle raw/visual view", + Style::default().fg(theme.text_muted), + ))); + + // Apply scroll + let visible_height = area.height as usize; + let display_lines: Vec = lines + .into_iter() + .skip(app.plan_scroll) + .take(visible_height) + .collect(); + + let paragraph = Paragraph::new(display_lines); + frame.render_widget(paragraph, area); +} + +fn render_plan_node<'a>( + node: &PlanNode, + total_time: Option, + theme: &'a Theme, + lines: &mut Vec>, + prefix: &str, + is_last: bool, +) { + let connector = if prefix.is_empty() { + "" + } else if is_last { + "└─ " + } else { + "├─ " + }; + + // Color based on cost + let color_class = node_color_class(node, total_time); + let node_color = match color_class { + NodeColorClass::Fast => theme.success, + NodeColorClass::Moderate => theme.warning, + NodeColorClass::Slow => theme.error, + }; + + let check = match color_class { + NodeColorClass::Fast => " ✓", + NodeColorClass::Moderate => " !", + NodeColorClass::Slow => " ✗", + }; + + // Build the node line + let mut spans: Vec = Vec::new(); + spans.push(Span::styled( + format!("{}{}", prefix, connector), + Style::default().fg(theme.text_muted), + )); + spans.push(Span::styled( + node.node_type.clone(), + Style::default().fg(node_color).add_modifier(Modifier::BOLD), + )); + + // Cost info + if let Some((start, end)) = node.estimated_cost { + spans.push(Span::styled( + format!(" (cost={:.2}..{:.2}", start, end), + Style::default().fg(theme.text_secondary), + )); + if let Some(rows) = node.estimated_rows { + spans.push(Span::styled( + format!(" rows={}", rows), + Style::default().fg(theme.text_secondary), + )); + } + spans.push(Span::styled( + ")".to_string(), + Style::default().fg(theme.text_secondary), + )); + } + + // Actual time + if let Some((start, end)) = node.actual_time { + spans.push(Span::styled( + format!(" [actual: {}]", format_duration_ms(end - start)), + Style::default().fg(node_color), + )); + } + + // Rows mismatch indicator + if rows_mismatch(node) { + if let (Some(est), Some(actual)) = (node.estimated_rows, node.actual_rows) { + spans.push(Span::styled( + format!(" ⚠ est={} actual={}", est, actual), + Style::default() + .fg(theme.warning) + .add_modifier(Modifier::BOLD), + )); + } + } + + spans.push(Span::styled(check, Style::default().fg(node_color))); + + lines.push(Line::from(spans)); + + // Details + let child_prefix = if prefix.is_empty() { + " ".to_string() + } else if is_last { + format!("{} ", prefix) + } else { + format!("{}│ ", prefix) + }; + + for detail in &node.details { + lines.push(Line::from(Span::styled( + format!("{} {}", child_prefix, detail), + Style::default().fg(theme.text_secondary), + ))); + } + + // Children + for (i, child) in node.children.iter().enumerate() { + let child_is_last = i == node.children.len() - 1; + render_plan_node( + child, + total_time, + theme, + lines, + &child_prefix, + child_is_last, + ); + } +} + fn draw_status_bar(frame: &mut Frame, app: &App, area: Rect) { let theme = &app.theme; @@ -1317,6 +1500,7 @@ fn draw_help_overlay(frame: &mut Frame, app: &App) { " Arrow keys Navigate cells", " Esc Back to editor", " Ctrl+C Copy cell value", + " Ctrl+E Toggle EXPLAIN plan view", " Ctrl+S Export results", " Ctrl+[/] Prev/Next result set", " PageUp/Down Scroll results",