Skip to main content

oxirs_core/query/
query_plan_visualizer.rs

1//! SPARQL Query Plan Visualization and Debugging Tools
2//!
3//! This module provides comprehensive tools for visualizing and debugging SPARQL query execution plans.
4//! It helps developers understand query optimization decisions and identify performance bottlenecks.
5//!
6//! # Features
7//! - ASCII art tree visualization of query plans
8//! - Execution statistics overlay
9//! - Cost model visualization
10//! - Index usage analysis
11//! - Cardinality estimation display
12//! - JSON export for external tools
13//!
14//! # Example
15//! ```rust,ignore
16//! use oxirs_core::query::query_plan_visualizer::QueryPlanVisualizer;
17//!
18//! let visualizer = QueryPlanVisualizer::new();
19//! let plan = create_query_plan();
20//! let ascii_tree = visualizer.visualize_as_tree(&plan);
21//! println!("{}", ascii_tree);
22//! ```
23
24use crate::error::OxirsError;
25use crate::Result;
26use serde::{Deserialize, Serialize};
27use std::fmt;
28
29/// Query plan node representing a step in query execution
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct QueryPlanNode {
32    /// Node type (e.g., "TriplePattern", "Join", "Filter")
33    pub node_type: String,
34    /// Description of the operation
35    pub description: String,
36    /// Estimated cardinality (number of results)
37    pub estimated_cardinality: Option<usize>,
38    /// Actual cardinality (if executed)
39    pub actual_cardinality: Option<usize>,
40    /// Estimated cost (arbitrary units)
41    pub estimated_cost: Option<f64>,
42    /// Actual execution time in microseconds
43    pub execution_time_us: Option<u64>,
44    /// Index used (if applicable)
45    pub index_used: Option<String>,
46    /// Child nodes
47    pub children: Vec<QueryPlanNode>,
48    /// Additional metadata
49    pub metadata: std::collections::HashMap<String, String>,
50}
51
52impl QueryPlanNode {
53    /// Create a new query plan node
54    pub fn new(node_type: impl Into<String>, description: impl Into<String>) -> Self {
55        Self {
56            node_type: node_type.into(),
57            description: description.into(),
58            estimated_cardinality: None,
59            actual_cardinality: None,
60            estimated_cost: None,
61            execution_time_us: None,
62            index_used: None,
63            children: Vec::new(),
64            metadata: std::collections::HashMap::new(),
65        }
66    }
67
68    /// Add a child node
69    pub fn add_child(&mut self, child: QueryPlanNode) {
70        self.children.push(child);
71    }
72
73    /// Set estimated cardinality
74    pub fn with_estimated_cardinality(mut self, cardinality: usize) -> Self {
75        self.estimated_cardinality = Some(cardinality);
76        self
77    }
78
79    /// Set actual cardinality
80    pub fn with_actual_cardinality(mut self, cardinality: usize) -> Self {
81        self.actual_cardinality = Some(cardinality);
82        self
83    }
84
85    /// Set estimated cost
86    pub fn with_estimated_cost(mut self, cost: f64) -> Self {
87        self.estimated_cost = Some(cost);
88        self
89    }
90
91    /// Set execution time
92    pub fn with_execution_time(mut self, time_us: u64) -> Self {
93        self.execution_time_us = Some(time_us);
94        self
95    }
96
97    /// Set index used
98    pub fn with_index(mut self, index: impl Into<String>) -> Self {
99        self.index_used = Some(index.into());
100        self
101    }
102
103    /// Add metadata
104    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
105        self.metadata.insert(key.into(), value.into());
106        self
107    }
108}
109
110/// Query plan visualizer with multiple output formats
111pub struct QueryPlanVisualizer {
112    /// Show execution statistics if available
113    show_stats: bool,
114    /// Show cost estimates
115    show_costs: bool,
116    /// Show index usage
117    show_indexes: bool,
118    /// Show cardinality estimates vs actuals
119    show_cardinality: bool,
120}
121
122impl Default for QueryPlanVisualizer {
123    fn default() -> Self {
124        Self::new()
125    }
126}
127
128impl QueryPlanVisualizer {
129    /// Create a new visualizer with default settings
130    pub fn new() -> Self {
131        Self {
132            show_stats: true,
133            show_costs: true,
134            show_indexes: true,
135            show_cardinality: true,
136        }
137    }
138
139    /// Enable/disable statistics display
140    pub fn with_stats(mut self, show: bool) -> Self {
141        self.show_stats = show;
142        self
143    }
144
145    /// Enable/disable cost display
146    pub fn with_costs(mut self, show: bool) -> Self {
147        self.show_costs = show;
148        self
149    }
150
151    /// Enable/disable index display
152    pub fn with_indexes(mut self, show: bool) -> Self {
153        self.show_indexes = show;
154        self
155    }
156
157    /// Enable/disable cardinality display
158    pub fn with_cardinality(mut self, show: bool) -> Self {
159        self.show_cardinality = show;
160        self
161    }
162
163    /// Visualize query plan as ASCII tree
164    pub fn visualize_as_tree(&self, plan: &QueryPlanNode) -> String {
165        let mut output = String::new();
166        self.render_node(plan, &mut output, "", true);
167        output
168    }
169
170    /// Render a single node with tree structure
171    fn render_node(&self, node: &QueryPlanNode, output: &mut String, prefix: &str, is_last: bool) {
172        // Draw tree structure
173        output.push_str(prefix);
174        if is_last {
175            output.push_str("└─ ");
176        } else {
177            output.push_str("├─ ");
178        }
179
180        // Node type and description
181        output.push_str(&format!("[{}] {}", node.node_type, node.description));
182
183        // Add statistics inline
184        let mut stats = Vec::new();
185
186        if self.show_cardinality {
187            if let Some(est) = node.estimated_cardinality {
188                if let Some(act) = node.actual_cardinality {
189                    stats.push(format!("card: {} → {}", est, act));
190                } else {
191                    stats.push(format!("card: ~{}", est));
192                }
193            }
194        }
195
196        if self.show_costs {
197            if let Some(cost) = node.estimated_cost {
198                stats.push(format!("cost: {:.2}", cost));
199            }
200        }
201
202        if self.show_stats {
203            if let Some(time) = node.execution_time_us {
204                if time >= 1000 {
205                    stats.push(format!("time: {:.2}ms", time as f64 / 1000.0));
206                } else {
207                    stats.push(format!("time: {}μs", time));
208                }
209            }
210        }
211
212        if self.show_indexes {
213            if let Some(idx) = &node.index_used {
214                stats.push(format!("index: {}", idx));
215            }
216        }
217
218        if !stats.is_empty() {
219            output.push_str(&format!(" ({})", stats.join(", ")));
220        }
221
222        output.push('\n');
223
224        // Render children
225        let child_prefix = if is_last {
226            format!("{}   ", prefix)
227        } else {
228            format!("{}│  ", prefix)
229        };
230
231        for (i, child) in node.children.iter().enumerate() {
232            let is_last_child = i == node.children.len() - 1;
233            self.render_node(child, output, &child_prefix, is_last_child);
234        }
235    }
236
237    /// Export query plan as JSON
238    pub fn export_as_json(&self, plan: &QueryPlanNode) -> Result<String> {
239        serde_json::to_string_pretty(plan)
240            .map_err(|e| OxirsError::Query(format!("Failed to serialize query plan: {}", e)))
241    }
242
243    /// Generate execution summary
244    pub fn generate_summary(&self, plan: &QueryPlanNode) -> QueryPlanSummary {
245        let mut summary = QueryPlanSummary::default();
246        Self::collect_stats(plan, &mut summary);
247        summary
248    }
249
250    /// Recursively collect statistics
251    fn collect_stats(node: &QueryPlanNode, summary: &mut QueryPlanSummary) {
252        summary.total_nodes += 1;
253
254        if let Some(time) = node.execution_time_us {
255            summary.total_execution_time_us += time;
256        }
257
258        if let Some(cost) = node.estimated_cost {
259            summary.total_estimated_cost += cost;
260        }
261
262        if let Some(est) = node.estimated_cardinality {
263            if let Some(act) = node.actual_cardinality {
264                let error = (est as f64 - act as f64).abs() / act.max(1) as f64;
265                summary.cardinality_errors.push(error);
266            }
267        }
268
269        if node.index_used.is_some() {
270            summary.index_operations += 1;
271        }
272
273        match node.node_type.as_str() {
274            "TriplePattern" => summary.triple_patterns += 1,
275            "Join" => summary.joins += 1,
276            "Filter" => summary.filters += 1,
277            "Union" => summary.unions += 1,
278            "Optional" => summary.optionals += 1,
279            _ => {}
280        }
281
282        for child in &node.children {
283            Self::collect_stats(child, summary);
284        }
285    }
286
287    /// Identify potential optimizations
288    pub fn suggest_optimizations(&self, plan: &QueryPlanNode) -> Vec<OptimizationHint> {
289        let mut hints = Vec::new();
290        Self::analyze_node(plan, &mut hints);
291        hints
292    }
293
294    /// Analyze a node for optimization opportunities
295    fn analyze_node(node: &QueryPlanNode, hints: &mut Vec<OptimizationHint>) {
296        // Check for cardinality misestimation
297        if let (Some(est), Some(act)) = (node.estimated_cardinality, node.actual_cardinality) {
298            if est > 0 && act > 0 {
299                let ratio = est as f64 / act as f64;
300                if !(0.1..=10.0).contains(&ratio) {
301                    hints.push(OptimizationHint {
302                        severity: HintSeverity::Warning,
303                        node_type: node.node_type.clone(),
304                        description: node.description.clone(),
305                        message: format!(
306                            "Cardinality misestimation: estimated {} but got {} ({}x off)",
307                            est,
308                            act,
309                            if ratio > 1.0 { ratio } else { 1.0 / ratio }
310                        ),
311                        suggestion: "Consider updating statistics or adding histogram data"
312                            .to_string(),
313                    });
314                }
315            }
316        }
317
318        // Check for missing indexes
319        if node.node_type == "TriplePattern" && node.index_used.is_none() {
320            if let Some(card) = node.actual_cardinality {
321                if card > 1000 {
322                    hints.push(OptimizationHint {
323                        severity: HintSeverity::Info,
324                        node_type: node.node_type.clone(),
325                        description: node.description.clone(),
326                        message: format!("Full scan on {} results without index", card),
327                        suggestion: "Consider adding an index for this pattern".to_string(),
328                    });
329                }
330            }
331        }
332
333        // Check for expensive operations
334        if let Some(time) = node.execution_time_us {
335            if time > 100_000 {
336                // > 100ms
337                hints.push(OptimizationHint {
338                    severity: HintSeverity::Warning,
339                    node_type: node.node_type.clone(),
340                    description: node.description.clone(),
341                    message: format!("Slow operation: {:.2}ms", time as f64 / 1000.0),
342                    suggestion: "Consider optimizing this operation or adding caching".to_string(),
343                });
344            }
345        }
346
347        // Recursively analyze children
348        for child in &node.children {
349            Self::analyze_node(child, hints);
350        }
351    }
352}
353
354/// Summary statistics for query plan
355#[derive(Debug, Default, Serialize, Deserialize)]
356pub struct QueryPlanSummary {
357    /// Total number of nodes in plan
358    pub total_nodes: usize,
359    /// Total execution time in microseconds
360    pub total_execution_time_us: u64,
361    /// Total estimated cost
362    pub total_estimated_cost: f64,
363    /// Number of triple patterns
364    pub triple_patterns: usize,
365    /// Number of joins
366    pub joins: usize,
367    /// Number of filters
368    pub filters: usize,
369    /// Number of unions
370    pub unions: usize,
371    /// Number of optional patterns
372    pub optionals: usize,
373    /// Number of operations using indexes
374    pub index_operations: usize,
375    /// Cardinality estimation errors (ratio of estimate/actual)
376    pub cardinality_errors: Vec<f64>,
377}
378
379impl QueryPlanSummary {
380    /// Calculate average cardinality error
381    pub fn avg_cardinality_error(&self) -> f64 {
382        if self.cardinality_errors.is_empty() {
383            0.0
384        } else {
385            self.cardinality_errors.iter().sum::<f64>() / self.cardinality_errors.len() as f64
386        }
387    }
388
389    /// Get execution time in milliseconds
390    pub fn execution_time_ms(&self) -> f64 {
391        self.total_execution_time_us as f64 / 1000.0
392    }
393}
394
395impl fmt::Display for QueryPlanSummary {
396    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
397        writeln!(f, "Query Plan Summary:")?;
398        writeln!(f, "  Nodes:           {}", self.total_nodes)?;
399        writeln!(f, "  Triple Patterns: {}", self.triple_patterns)?;
400        writeln!(f, "  Joins:           {}", self.joins)?;
401        writeln!(f, "  Filters:         {}", self.filters)?;
402        writeln!(f, "  Unions:          {}", self.unions)?;
403        writeln!(f, "  Optionals:       {}", self.optionals)?;
404        writeln!(f, "  Index Ops:       {}", self.index_operations)?;
405        writeln!(f, "  Estimated Cost:  {:.2}", self.total_estimated_cost)?;
406        writeln!(f, "  Execution Time:  {:.2}ms", self.execution_time_ms())?;
407        if !self.cardinality_errors.is_empty() {
408            writeln!(
409                f,
410                "  Avg Card Error:  {:.2}%",
411                self.avg_cardinality_error() * 100.0
412            )?;
413        }
414        Ok(())
415    }
416}
417
418/// Optimization hint for improving query performance
419#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct OptimizationHint {
421    /// Severity level
422    pub severity: HintSeverity,
423    /// Node type
424    pub node_type: String,
425    /// Node description
426    pub description: String,
427    /// Problem description
428    pub message: String,
429    /// Suggested fix
430    pub suggestion: String,
431}
432
433impl fmt::Display for OptimizationHint {
434    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
435        write!(
436            f,
437            "[{:?}] {} - {}: {} → {}",
438            self.severity, self.node_type, self.description, self.message, self.suggestion
439        )
440    }
441}
442
443/// Severity level for optimization hints
444#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
445pub enum HintSeverity {
446    /// Informational hint
447    Info,
448    /// Warning about potential issue
449    Warning,
450    /// Critical performance problem
451    Critical,
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    fn create_sample_plan() -> QueryPlanNode {
459        let mut root = QueryPlanNode::new("Join", "HashJoin on ?person")
460            .with_estimated_cardinality(100)
461            .with_actual_cardinality(95)
462            .with_estimated_cost(150.0)
463            .with_execution_time(5000);
464
465        let child1 = QueryPlanNode::new("TriplePattern", "?person rdf:type foaf:Person")
466            .with_estimated_cardinality(1000)
467            .with_actual_cardinality(1000)
468            .with_index("SPO".to_string())
469            .with_execution_time(1000);
470
471        let child2 = QueryPlanNode::new("TriplePattern", "?person foaf:name ?name")
472            .with_estimated_cardinality(100)
473            .with_actual_cardinality(95)
474            .with_index("SPO".to_string())
475            .with_execution_time(500);
476
477        root.add_child(child1);
478        root.add_child(child2);
479        root
480    }
481
482    #[test]
483    fn test_query_plan_node_creation() {
484        let node = QueryPlanNode::new("TriplePattern", "?s ?p ?o")
485            .with_estimated_cardinality(1000)
486            .with_estimated_cost(100.0);
487
488        assert_eq!(node.node_type, "TriplePattern");
489        assert_eq!(node.description, "?s ?p ?o");
490        assert_eq!(node.estimated_cardinality, Some(1000));
491        assert_eq!(node.estimated_cost, Some(100.0));
492    }
493
494    #[test]
495    fn test_visualizer_tree_output() {
496        let plan = create_sample_plan();
497        let visualizer = QueryPlanVisualizer::new();
498        let tree = visualizer.visualize_as_tree(&plan);
499
500        assert!(tree.contains("[Join] HashJoin on ?person"));
501        assert!(tree.contains("TriplePattern"));
502        assert!(tree.contains("foaf:Person"));
503        assert!(tree.contains("foaf:name"));
504    }
505
506    #[test]
507    fn test_json_export() {
508        let plan = create_sample_plan();
509        let visualizer = QueryPlanVisualizer::new();
510        let json = visualizer
511            .export_as_json(&plan)
512            .expect("operation should succeed");
513
514        assert!(json.contains("\"node_type\": \"Join\""));
515        assert!(json.contains("\"estimated_cardinality\": 100"));
516        assert!(json.contains("\"children\""));
517    }
518
519    #[test]
520    fn test_summary_generation() {
521        let plan = create_sample_plan();
522        let visualizer = QueryPlanVisualizer::new();
523        let summary = visualizer.generate_summary(&plan);
524
525        assert_eq!(summary.total_nodes, 3); // 1 join + 2 triple patterns
526        assert_eq!(summary.joins, 1);
527        assert_eq!(summary.triple_patterns, 2);
528        assert_eq!(summary.index_operations, 2);
529        assert!(summary.total_execution_time_us > 0);
530    }
531
532    #[test]
533    fn test_optimization_hints() {
534        let plan = QueryPlanNode::new("TriplePattern", "?s ?p ?o")
535            .with_estimated_cardinality(100)
536            .with_actual_cardinality(10000); // 100x underestimate
537
538        let visualizer = QueryPlanVisualizer::new();
539        let hints = visualizer.suggest_optimizations(&plan);
540
541        assert!(!hints.is_empty());
542        assert!(hints
543            .iter()
544            .any(|h| h.message.contains("Cardinality misestimation")));
545    }
546
547    #[test]
548    fn test_slow_operation_detection() {
549        let plan = QueryPlanNode::new("Join", "Complex join").with_execution_time(200_000); // 200ms - slow
550
551        let visualizer = QueryPlanVisualizer::new();
552        let hints = visualizer.suggest_optimizations(&plan);
553
554        assert!(hints.iter().any(|h| h.message.contains("Slow operation")));
555    }
556
557    #[test]
558    fn test_visualizer_options() {
559        let plan = create_sample_plan();
560        let visualizer = QueryPlanVisualizer::new()
561            .with_costs(false)
562            .with_indexes(false);
563
564        let tree = visualizer.visualize_as_tree(&plan);
565
566        // Should not contain cost or index info
567        assert!(!tree.contains("cost:"));
568        assert!(!tree.contains("index:"));
569        // But should still contain cardinality and time
570        assert!(tree.contains("card:"));
571        assert!(tree.contains("time:") || tree.contains("μs"));
572    }
573
574    #[test]
575    fn test_summary_display() {
576        let plan = create_sample_plan();
577        let visualizer = QueryPlanVisualizer::new();
578        let summary = visualizer.generate_summary(&plan);
579
580        let display = format!("{}", summary);
581        assert!(display.contains("Query Plan Summary:"));
582        assert!(display.contains("Triple Patterns:"));
583        assert!(display.contains("Joins:"));
584    }
585}