Skip to main content

oxirs_arq/
plan_visualizer.rs

1//! # SPARQL Query Plan Visualizer
2//!
3//! Renders SPARQL query execution plans in multiple formats:
4//! - **DOT / Graphviz**: for `dot`-based graph rendering
5//! - **Text tree**: human-readable indented plan tree
6//! - **JSON**: machine-readable plan representation
7//!
8//! Displays join order, estimated costs, filter placement, and operator types.
9//!
10//! ## Quick Start
11//!
12//! ```rust
13//! use oxirs_arq::plan_visualizer::{
14//!     QueryPlanVisualizer, VisPlanNode, VisOperator, VisOutputFormat,
15//! };
16//!
17//! let leaf = VisPlanNode::leaf(VisOperator::Scan, "?s ?p ?o")
18//!     .with_estimated_rows(1000)
19//!     .with_cost(5.0);
20//! let root = VisPlanNode::unary(VisOperator::Projection, "?s ?o", leaf)
21//!     .with_estimated_rows(1000)
22//!     .with_cost(6.0);
23//!
24//! let viz = QueryPlanVisualizer::new();
25//! let dot = viz.render(&root, VisOutputFormat::Dot)
26//!     .map_err(|e| anyhow::anyhow!(e))?;
27//! assert!(dot.contains("digraph"));
28//! # Ok::<(), anyhow::Error>(())
29//! ```
30
31use std::collections::HashMap;
32use std::fmt;
33
34use serde::{Deserialize, Serialize};
35
36// ---------------------------------------------------------------------------
37// Operator types
38// ---------------------------------------------------------------------------
39
40/// Operator types that appear in a query plan.
41#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
42pub enum VisOperator {
43    /// Triple / quad pattern scan
44    Scan,
45    /// Hash join
46    HashJoin,
47    /// Merge join
48    MergeJoin,
49    /// Nested-loop join
50    NestedLoopJoin,
51    /// Lateral join (SPARQL LATERAL)
52    LateralJoin,
53    /// Left outer join (OPTIONAL)
54    LeftJoin,
55    /// UNION of two branches
56    Union,
57    /// FILTER
58    Filter,
59    /// BIND expression
60    Bind,
61    /// ORDER BY
62    Sort,
63    /// DISTINCT
64    Distinct,
65    /// LIMIT / OFFSET
66    Slice,
67    /// GROUP BY aggregation
68    Aggregate,
69    /// Projection (SELECT columns)
70    Projection,
71    /// SERVICE (federated)
72    Service,
73    /// Sub-query
74    SubQuery,
75    /// VALUES injection
76    Values,
77    /// GRAPH pattern
78    Graph,
79    /// Materialised intermediate result
80    Materialise,
81    /// Custom / user-defined operator
82    Custom(String),
83}
84
85impl fmt::Display for VisOperator {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        match self {
88            Self::Scan => write!(f, "Scan"),
89            Self::HashJoin => write!(f, "HashJoin"),
90            Self::MergeJoin => write!(f, "MergeJoin"),
91            Self::NestedLoopJoin => write!(f, "NestedLoopJoin"),
92            Self::LateralJoin => write!(f, "LateralJoin"),
93            Self::LeftJoin => write!(f, "LeftJoin"),
94            Self::Union => write!(f, "Union"),
95            Self::Filter => write!(f, "Filter"),
96            Self::Bind => write!(f, "Bind"),
97            Self::Sort => write!(f, "Sort"),
98            Self::Distinct => write!(f, "Distinct"),
99            Self::Slice => write!(f, "Slice"),
100            Self::Aggregate => write!(f, "Aggregate"),
101            Self::Projection => write!(f, "Projection"),
102            Self::Service => write!(f, "Service"),
103            Self::SubQuery => write!(f, "SubQuery"),
104            Self::Values => write!(f, "Values"),
105            Self::Graph => write!(f, "Graph"),
106            Self::Materialise => write!(f, "Materialise"),
107            Self::Custom(name) => write!(f, "{name}"),
108        }
109    }
110}
111
112impl VisOperator {
113    /// Short label for DOT nodes.
114    pub fn short_label(&self) -> &str {
115        match self {
116            Self::Scan => "Scan",
117            Self::HashJoin => "HJ",
118            Self::MergeJoin => "MJ",
119            Self::NestedLoopJoin => "NLJ",
120            Self::LateralJoin => "LAT",
121            Self::LeftJoin => "LOJ",
122            Self::Union => "U",
123            Self::Filter => "F",
124            Self::Bind => "B",
125            Self::Sort => "Sort",
126            Self::Distinct => "Dist",
127            Self::Slice => "Slice",
128            Self::Aggregate => "Agg",
129            Self::Projection => "Pi",
130            Self::Service => "Svc",
131            Self::SubQuery => "SQ",
132            Self::Values => "Val",
133            Self::Graph => "G",
134            Self::Materialise => "Mat",
135            Self::Custom(_) => "Cust",
136        }
137    }
138
139    /// Whether this is a join operator.
140    pub fn is_join(&self) -> bool {
141        matches!(
142            self,
143            Self::HashJoin
144                | Self::MergeJoin
145                | Self::NestedLoopJoin
146                | Self::LateralJoin
147                | Self::LeftJoin
148        )
149    }
150}
151
152// ---------------------------------------------------------------------------
153// Plan node
154// ---------------------------------------------------------------------------
155
156/// A node in the visualisable query plan tree.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct VisPlanNode {
159    /// Unique ID (auto-assigned during rendering if zero).
160    pub id: u64,
161    /// The operator at this node.
162    pub operator: VisOperator,
163    /// Human-readable description / pattern.
164    pub description: String,
165    /// Estimated output rows (cardinality).
166    pub estimated_rows: Option<u64>,
167    /// Estimated cost (arbitrary units, higher = more expensive).
168    pub estimated_cost: Option<f64>,
169    /// Actual rows (after execution; may be absent before execution).
170    pub actual_rows: Option<u64>,
171    /// Execution time in microseconds (may be absent before execution).
172    pub execution_time_us: Option<u64>,
173    /// Operator-specific properties (e.g. join condition, filter expression).
174    pub properties: HashMap<String, String>,
175    /// Child nodes (0 for leaves, 1 for unary, 2 for binary, etc.).
176    pub children: Vec<VisPlanNode>,
177}
178
179impl VisPlanNode {
180    /// Create a leaf node (no children).
181    pub fn leaf(operator: VisOperator, description: impl Into<String>) -> Self {
182        Self {
183            id: 0,
184            operator,
185            description: description.into(),
186            estimated_rows: None,
187            estimated_cost: None,
188            actual_rows: None,
189            execution_time_us: None,
190            properties: HashMap::new(),
191            children: Vec::new(),
192        }
193    }
194
195    /// Create a unary operator node (one child).
196    pub fn unary(
197        operator: VisOperator,
198        description: impl Into<String>,
199        child: VisPlanNode,
200    ) -> Self {
201        Self {
202            id: 0,
203            operator,
204            description: description.into(),
205            estimated_rows: None,
206            estimated_cost: None,
207            actual_rows: None,
208            execution_time_us: None,
209            properties: HashMap::new(),
210            children: vec![child],
211        }
212    }
213
214    /// Create a binary operator node (two children, e.g. join).
215    pub fn binary(
216        operator: VisOperator,
217        description: impl Into<String>,
218        left: VisPlanNode,
219        right: VisPlanNode,
220    ) -> Self {
221        Self {
222            id: 0,
223            operator,
224            description: description.into(),
225            estimated_rows: None,
226            estimated_cost: None,
227            actual_rows: None,
228            execution_time_us: None,
229            properties: HashMap::new(),
230            children: vec![left, right],
231        }
232    }
233
234    /// Builder: set estimated rows.
235    pub fn with_estimated_rows(mut self, rows: u64) -> Self {
236        self.estimated_rows = Some(rows);
237        self
238    }
239
240    /// Builder: set estimated cost.
241    pub fn with_cost(mut self, cost: f64) -> Self {
242        self.estimated_cost = Some(cost);
243        self
244    }
245
246    /// Builder: set actual rows.
247    pub fn with_actual_rows(mut self, rows: u64) -> Self {
248        self.actual_rows = Some(rows);
249        self
250    }
251
252    /// Builder: set execution time (microseconds).
253    pub fn with_execution_time_us(mut self, us: u64) -> Self {
254        self.execution_time_us = Some(us);
255        self
256    }
257
258    /// Builder: add an arbitrary property.
259    pub fn with_property(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
260        self.properties.insert(key.into(), value.into());
261        self
262    }
263
264    /// Count the total number of nodes in the subtree rooted at this node.
265    pub fn node_count(&self) -> usize {
266        1 + self.children.iter().map(|c| c.node_count()).sum::<usize>()
267    }
268
269    /// Maximum depth of the subtree.
270    pub fn depth(&self) -> usize {
271        if self.children.is_empty() {
272            1
273        } else {
274            1 + self.children.iter().map(|c| c.depth()).max().unwrap_or(0)
275        }
276    }
277
278    /// Collect all filter nodes in the plan.
279    pub fn collect_filters(&self) -> Vec<&VisPlanNode> {
280        let mut result = Vec::new();
281        if self.operator == VisOperator::Filter {
282            result.push(self);
283        }
284        for child in &self.children {
285            result.extend(child.collect_filters());
286        }
287        result
288    }
289
290    /// Collect all join nodes in the plan.
291    pub fn collect_joins(&self) -> Vec<&VisPlanNode> {
292        let mut result = Vec::new();
293        if self.operator.is_join() {
294            result.push(self);
295        }
296        for child in &self.children {
297            result.extend(child.collect_joins());
298        }
299        result
300    }
301
302    /// Total estimated cost of the subtree (sum of all node costs).
303    pub fn total_cost(&self) -> f64 {
304        let self_cost = self.estimated_cost.unwrap_or(0.0);
305        self_cost + self.children.iter().map(|c| c.total_cost()).sum::<f64>()
306    }
307}
308
309// ---------------------------------------------------------------------------
310// Output format
311// ---------------------------------------------------------------------------
312
313/// The output format for the visualiser.
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
315pub enum VisOutputFormat {
316    /// DOT / Graphviz
317    Dot,
318    /// Human-readable text tree
319    TextTree,
320    /// Machine-readable JSON
321    Json,
322}
323
324impl fmt::Display for VisOutputFormat {
325    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326        match self {
327            Self::Dot => write!(f, "DOT"),
328            Self::TextTree => write!(f, "TextTree"),
329            Self::Json => write!(f, "JSON"),
330        }
331    }
332}
333
334// ---------------------------------------------------------------------------
335// Visualiser configuration
336// ---------------------------------------------------------------------------
337
338/// Configuration for the plan visualiser.
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct VisualizerConfig {
341    /// Show estimated rows in labels.
342    pub show_estimated_rows: bool,
343    /// Show cost in labels.
344    pub show_cost: bool,
345    /// Show actual rows (post-execution).
346    pub show_actual_rows: bool,
347    /// Show execution time.
348    pub show_execution_time: bool,
349    /// Show operator properties.
350    pub show_properties: bool,
351    /// Use colour in DOT output.
352    pub use_colour: bool,
353    /// DOT graph orientation: "TB" (top-bottom) or "LR" (left-right).
354    pub dot_orientation: String,
355    /// Indent string for text tree (e.g. "  " or "    ").
356    pub indent: String,
357    /// Pretty-print JSON output.
358    pub json_pretty: bool,
359}
360
361impl Default for VisualizerConfig {
362    fn default() -> Self {
363        Self {
364            show_estimated_rows: true,
365            show_cost: true,
366            show_actual_rows: true,
367            show_execution_time: true,
368            show_properties: true,
369            use_colour: true,
370            dot_orientation: "TB".to_string(),
371            indent: "  ".to_string(),
372            json_pretty: true,
373        }
374    }
375}
376
377// ---------------------------------------------------------------------------
378// Statistics summary
379// ---------------------------------------------------------------------------
380
381/// Summary statistics computed from a plan tree.
382#[derive(Debug, Clone, Serialize, Deserialize)]
383pub struct PlanSummary {
384    /// Total number of operators.
385    pub total_nodes: usize,
386    /// Maximum depth.
387    pub max_depth: usize,
388    /// Number of join operators.
389    pub join_count: usize,
390    /// Number of filter operators.
391    pub filter_count: usize,
392    /// Number of scan operators.
393    pub scan_count: usize,
394    /// Total estimated cost.
395    pub total_estimated_cost: f64,
396    /// Operator type frequency.
397    pub operator_histogram: HashMap<String, usize>,
398}
399
400// ---------------------------------------------------------------------------
401// QueryPlanVisualizer
402// ---------------------------------------------------------------------------
403
404/// The main entry point for rendering query plans.
405pub struct QueryPlanVisualizer {
406    config: VisualizerConfig,
407}
408
409impl Default for QueryPlanVisualizer {
410    fn default() -> Self {
411        Self::new()
412    }
413}
414
415impl QueryPlanVisualizer {
416    /// Create a visualiser with default configuration.
417    pub fn new() -> Self {
418        Self {
419            config: VisualizerConfig::default(),
420        }
421    }
422
423    /// Create a visualiser with custom configuration.
424    pub fn with_config(config: VisualizerConfig) -> Self {
425        Self { config }
426    }
427
428    /// Render the plan tree in the requested format.
429    pub fn render(
430        &self,
431        root: &VisPlanNode,
432        format: VisOutputFormat,
433    ) -> std::result::Result<String, String> {
434        // First assign IDs by traversal order.
435        let mut id_counter = 1_u64;
436        let annotated = self.assign_ids(root, &mut id_counter);
437
438        match format {
439            VisOutputFormat::Dot => Ok(self.render_dot(&annotated)),
440            VisOutputFormat::TextTree => Ok(self.render_text_tree(&annotated)),
441            VisOutputFormat::Json => self.render_json(&annotated),
442        }
443    }
444
445    /// Compute summary statistics from a plan tree.
446    pub fn summarise(&self, root: &VisPlanNode) -> PlanSummary {
447        let mut histogram: HashMap<String, usize> = HashMap::new();
448        Self::count_operators(root, &mut histogram);
449
450        PlanSummary {
451            total_nodes: root.node_count(),
452            max_depth: root.depth(),
453            join_count: root.collect_joins().len(),
454            filter_count: root.collect_filters().len(),
455            scan_count: *histogram.get("Scan").unwrap_or(&0),
456            total_estimated_cost: root.total_cost(),
457            operator_histogram: histogram,
458        }
459    }
460
461    // -----------------------------------------------------------------------
462    // internal helpers
463    // -----------------------------------------------------------------------
464
465    fn count_operators(node: &VisPlanNode, histogram: &mut HashMap<String, usize>) {
466        *histogram.entry(node.operator.to_string()).or_insert(0) += 1;
467        for child in &node.children {
468            Self::count_operators(child, histogram);
469        }
470    }
471
472    /// Recursively clone the tree assigning monotonic IDs.
473    fn assign_ids(&self, node: &VisPlanNode, counter: &mut u64) -> VisPlanNode {
474        let id = *counter;
475        *counter += 1;
476        let children = node
477            .children
478            .iter()
479            .map(|c| self.assign_ids(c, counter))
480            .collect();
481        VisPlanNode {
482            id,
483            operator: node.operator.clone(),
484            description: node.description.clone(),
485            estimated_rows: node.estimated_rows,
486            estimated_cost: node.estimated_cost,
487            actual_rows: node.actual_rows,
488            execution_time_us: node.execution_time_us,
489            properties: node.properties.clone(),
490            children,
491        }
492    }
493
494    // -----------------------------------------------------------------------
495    // DOT rendering
496    // -----------------------------------------------------------------------
497
498    fn render_dot(&self, root: &VisPlanNode) -> String {
499        let mut buf = String::new();
500        buf.push_str("digraph QueryPlan {\n");
501        buf.push_str(&format!("  rankdir={};\n", self.config.dot_orientation));
502        buf.push_str("  node [shape=record, fontname=\"Helvetica\", fontsize=10];\n");
503        buf.push_str("  edge [fontname=\"Helvetica\", fontsize=9];\n\n");
504
505        self.dot_nodes(root, &mut buf);
506        buf.push('\n');
507        self.dot_edges(root, &mut buf);
508
509        buf.push_str("}\n");
510        buf
511    }
512
513    fn dot_nodes(&self, node: &VisPlanNode, buf: &mut String) {
514        let label = self.dot_label(node);
515        let colour = if self.config.use_colour {
516            self.operator_colour(&node.operator)
517        } else {
518            "white"
519        };
520        buf.push_str(&format!(
521            "  n{} [label=\"{}\", style=filled, fillcolor=\"{}\"];\n",
522            node.id, label, colour
523        ));
524        for child in &node.children {
525            self.dot_nodes(child, buf);
526        }
527    }
528
529    fn dot_label(&self, node: &VisPlanNode) -> String {
530        let mut parts = vec![format!(
531            "{}|{}",
532            node.operator.short_label(),
533            Self::escape_dot(&node.description)
534        )];
535        if self.config.show_estimated_rows {
536            if let Some(rows) = node.estimated_rows {
537                parts.push(format!("est: {rows} rows"));
538            }
539        }
540        if self.config.show_cost {
541            if let Some(cost) = node.estimated_cost {
542                parts.push(format!("cost: {cost:.1}"));
543            }
544        }
545        if self.config.show_actual_rows {
546            if let Some(rows) = node.actual_rows {
547                parts.push(format!("actual: {rows} rows"));
548            }
549        }
550        if self.config.show_execution_time {
551            if let Some(us) = node.execution_time_us {
552                parts.push(format!("time: {us} us"));
553            }
554        }
555        if self.config.show_properties {
556            for (k, v) in &node.properties {
557                parts.push(format!("{k}: {}", Self::escape_dot(v)));
558            }
559        }
560        format!("{{{}}}", parts.join("|"))
561    }
562
563    fn dot_edges(&self, node: &VisPlanNode, buf: &mut String) {
564        for (i, child) in node.children.iter().enumerate() {
565            let label = if node.children.len() > 1 {
566                match i {
567                    0 => "left",
568                    1 => "right",
569                    _ => "child",
570                }
571            } else {
572                "input"
573            };
574            buf.push_str(&format!(
575                "  n{} -> n{} [label=\"{}\"];\n",
576                node.id, child.id, label
577            ));
578            self.dot_edges(child, buf);
579        }
580    }
581
582    fn escape_dot(s: &str) -> String {
583        s.replace('\\', "\\\\")
584            .replace('"', "\\\"")
585            .replace('{', "\\{")
586            .replace('}', "\\}")
587            .replace('<', "\\<")
588            .replace('>', "\\>")
589            .replace('|', "\\|")
590    }
591
592    fn operator_colour(&self, op: &VisOperator) -> &'static str {
593        match op {
594            VisOperator::Scan => "#E8F5E9",
595            VisOperator::HashJoin | VisOperator::MergeJoin | VisOperator::NestedLoopJoin => {
596                "#E3F2FD"
597            }
598            VisOperator::LeftJoin | VisOperator::LateralJoin => "#E8EAF6",
599            VisOperator::Filter => "#FFF3E0",
600            VisOperator::Sort | VisOperator::Distinct | VisOperator::Slice => "#F3E5F5",
601            VisOperator::Aggregate => "#FCE4EC",
602            VisOperator::Union => "#E0F7FA",
603            VisOperator::Projection => "#F1F8E9",
604            VisOperator::Service | VisOperator::SubQuery => "#FFF9C4",
605            _ => "#FAFAFA",
606        }
607    }
608
609    // -----------------------------------------------------------------------
610    // Text tree rendering
611    // -----------------------------------------------------------------------
612
613    fn render_text_tree(&self, root: &VisPlanNode) -> String {
614        let mut buf = String::new();
615        self.text_tree_node(root, &mut buf, "", true);
616        buf
617    }
618
619    fn text_tree_node(&self, node: &VisPlanNode, buf: &mut String, prefix: &str, is_last: bool) {
620        let connector = if prefix.is_empty() {
621            ""
622        } else if is_last {
623            "`-- "
624        } else {
625            "|-- "
626        };
627
628        buf.push_str(prefix);
629        buf.push_str(connector);
630        buf.push_str(&format!("[{}] {}", node.operator, node.description));
631
632        // inline stats
633        let mut annotations = Vec::new();
634        if self.config.show_estimated_rows {
635            if let Some(rows) = node.estimated_rows {
636                annotations.push(format!("est={rows}"));
637            }
638        }
639        if self.config.show_cost {
640            if let Some(cost) = node.estimated_cost {
641                annotations.push(format!("cost={cost:.1}"));
642            }
643        }
644        if self.config.show_actual_rows {
645            if let Some(rows) = node.actual_rows {
646                annotations.push(format!("actual={rows}"));
647            }
648        }
649        if self.config.show_execution_time {
650            if let Some(us) = node.execution_time_us {
651                annotations.push(format!("time={us}us"));
652            }
653        }
654        if !annotations.is_empty() {
655            buf.push_str(&format!("  ({})", annotations.join(", ")));
656        }
657        buf.push('\n');
658
659        // properties
660        if self.config.show_properties {
661            let child_prefix = if prefix.is_empty() {
662                self.config.indent.clone()
663            } else if is_last {
664                format!("{prefix}{}", self.config.indent)
665            } else {
666                format!("{prefix}|{}", &self.config.indent[1..])
667            };
668            for (k, v) in &node.properties {
669                buf.push_str(&format!("{child_prefix}  {k}: {v}\n"));
670            }
671        }
672
673        // children
674        let child_prefix = if prefix.is_empty() {
675            String::new()
676        } else if is_last {
677            format!("{prefix}{}", self.config.indent)
678        } else {
679            format!("{prefix}|{}", &self.config.indent[1..])
680        };
681        for (i, child) in node.children.iter().enumerate() {
682            let last = i == node.children.len() - 1;
683            self.text_tree_node(child, buf, &child_prefix, last);
684        }
685    }
686
687    // -----------------------------------------------------------------------
688    // JSON rendering
689    // -----------------------------------------------------------------------
690
691    fn render_json(&self, root: &VisPlanNode) -> std::result::Result<String, String> {
692        if self.config.json_pretty {
693            serde_json::to_string_pretty(root).map_err(|e| e.to_string())
694        } else {
695            serde_json::to_string(root).map_err(|e| e.to_string())
696        }
697    }
698}
699
700// ===========================================================================
701// Tests
702// ===========================================================================
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707
708    // -- helpers ------------------------------------------------------------
709
710    fn simple_scan() -> VisPlanNode {
711        VisPlanNode::leaf(VisOperator::Scan, "?s ?p ?o")
712            .with_estimated_rows(1000)
713            .with_cost(5.0)
714    }
715
716    fn two_scan_join() -> VisPlanNode {
717        let left = VisPlanNode::leaf(VisOperator::Scan, "?s :name ?name")
718            .with_estimated_rows(500)
719            .with_cost(3.0);
720        let right = VisPlanNode::leaf(VisOperator::Scan, "?s :age ?age")
721            .with_estimated_rows(500)
722            .with_cost(3.0);
723        VisPlanNode::binary(VisOperator::HashJoin, "?s", left, right)
724            .with_estimated_rows(200)
725            .with_cost(10.0)
726    }
727
728    fn complex_plan() -> VisPlanNode {
729        let scan1 = VisPlanNode::leaf(VisOperator::Scan, "?s :name ?name")
730            .with_estimated_rows(1000)
731            .with_cost(5.0);
732        let scan2 = VisPlanNode::leaf(VisOperator::Scan, "?s :age ?age")
733            .with_estimated_rows(800)
734            .with_cost(4.0);
735        let join = VisPlanNode::binary(VisOperator::HashJoin, "?s", scan1, scan2)
736            .with_estimated_rows(600)
737            .with_cost(15.0);
738        let filter = VisPlanNode::unary(VisOperator::Filter, "?age > 18", join)
739            .with_estimated_rows(300)
740            .with_cost(1.0);
741        let sort = VisPlanNode::unary(VisOperator::Sort, "ORDER BY ?name", filter)
742            .with_estimated_rows(300)
743            .with_cost(8.0);
744        VisPlanNode::unary(VisOperator::Projection, "?name ?age", sort)
745            .with_estimated_rows(300)
746            .with_cost(0.5)
747    }
748
749    // -- VisOperator tests --------------------------------------------------
750
751    #[test]
752    fn test_operator_display() {
753        assert_eq!(VisOperator::Scan.to_string(), "Scan");
754        assert_eq!(VisOperator::HashJoin.to_string(), "HashJoin");
755        assert_eq!(VisOperator::Custom("MyOp".into()).to_string(), "MyOp");
756    }
757
758    #[test]
759    fn test_operator_short_label() {
760        assert_eq!(VisOperator::Scan.short_label(), "Scan");
761        assert_eq!(VisOperator::HashJoin.short_label(), "HJ");
762        assert_eq!(VisOperator::Projection.short_label(), "Pi");
763    }
764
765    #[test]
766    fn test_operator_is_join() {
767        assert!(VisOperator::HashJoin.is_join());
768        assert!(VisOperator::MergeJoin.is_join());
769        assert!(VisOperator::NestedLoopJoin.is_join());
770        assert!(VisOperator::LeftJoin.is_join());
771        assert!(VisOperator::LateralJoin.is_join());
772        assert!(!VisOperator::Scan.is_join());
773        assert!(!VisOperator::Filter.is_join());
774        assert!(!VisOperator::Projection.is_join());
775    }
776
777    // -- VisPlanNode builder tests ------------------------------------------
778
779    #[test]
780    fn test_leaf_node() {
781        let node = simple_scan();
782        assert_eq!(node.operator, VisOperator::Scan);
783        assert_eq!(node.description, "?s ?p ?o");
784        assert_eq!(node.estimated_rows, Some(1000));
785        assert_eq!(node.estimated_cost, Some(5.0));
786        assert!(node.children.is_empty());
787    }
788
789    #[test]
790    fn test_unary_node() {
791        let child = simple_scan();
792        let node = VisPlanNode::unary(VisOperator::Filter, "?x > 5", child);
793        assert_eq!(node.children.len(), 1);
794        assert_eq!(node.operator, VisOperator::Filter);
795    }
796
797    #[test]
798    fn test_binary_node() {
799        let node = two_scan_join();
800        assert_eq!(node.children.len(), 2);
801        assert_eq!(node.operator, VisOperator::HashJoin);
802    }
803
804    #[test]
805    fn test_with_property() {
806        let node = simple_scan().with_property("index", "spo");
807        assert_eq!(
808            node.properties.get("index").map(|s| s.as_str()),
809            Some("spo")
810        );
811    }
812
813    #[test]
814    fn test_with_actual_rows() {
815        let node = simple_scan().with_actual_rows(950);
816        assert_eq!(node.actual_rows, Some(950));
817    }
818
819    #[test]
820    fn test_with_execution_time() {
821        let node = simple_scan().with_execution_time_us(1234);
822        assert_eq!(node.execution_time_us, Some(1234));
823    }
824
825    // -- tree metrics -------------------------------------------------------
826
827    #[test]
828    fn test_node_count_leaf() {
829        assert_eq!(simple_scan().node_count(), 1);
830    }
831
832    #[test]
833    fn test_node_count_complex() {
834        // Projection -> Sort -> Filter -> HashJoin(Scan, Scan) = 6
835        assert_eq!(complex_plan().node_count(), 6);
836    }
837
838    #[test]
839    fn test_depth_leaf() {
840        assert_eq!(simple_scan().depth(), 1);
841    }
842
843    #[test]
844    fn test_depth_complex() {
845        // depth: Projection -> Sort -> Filter -> HashJoin -> Scan = 5
846        assert_eq!(complex_plan().depth(), 5);
847    }
848
849    #[test]
850    fn test_collect_filters() {
851        let plan = complex_plan();
852        let filters = plan.collect_filters();
853        assert_eq!(filters.len(), 1);
854        assert_eq!(filters[0].description, "?age > 18");
855    }
856
857    #[test]
858    fn test_collect_joins() {
859        let plan = complex_plan();
860        let joins = plan.collect_joins();
861        assert_eq!(joins.len(), 1);
862        assert_eq!(joins[0].operator, VisOperator::HashJoin);
863    }
864
865    #[test]
866    fn test_total_cost() {
867        let plan = complex_plan();
868        // 5.0 + 4.0 + 15.0 + 1.0 + 8.0 + 0.5 = 33.5
869        let cost = plan.total_cost();
870        assert!((cost - 33.5).abs() < 1e-6);
871    }
872
873    // -- DOT output ---------------------------------------------------------
874
875    #[test]
876    fn test_dot_output_contains_digraph() {
877        let viz = QueryPlanVisualizer::new();
878        let dot = viz.render(&simple_scan(), VisOutputFormat::Dot);
879        assert!(dot.is_ok());
880        let dot = dot.unwrap_or_default();
881        assert!(dot.contains("digraph QueryPlan"));
882        assert!(dot.contains("rankdir=TB"));
883    }
884
885    #[test]
886    fn test_dot_output_has_nodes() {
887        let viz = QueryPlanVisualizer::new();
888        let dot = viz.render(&two_scan_join(), VisOutputFormat::Dot);
889        let dot = dot.unwrap_or_default();
890        assert!(dot.contains("n1 "));
891        assert!(dot.contains("n2 "));
892        assert!(dot.contains("n3 "));
893    }
894
895    #[test]
896    fn test_dot_output_has_edges() {
897        let viz = QueryPlanVisualizer::new();
898        let dot = viz.render(&two_scan_join(), VisOutputFormat::Dot);
899        let dot = dot.unwrap_or_default();
900        assert!(dot.contains("n1 -> n2"));
901        assert!(dot.contains("n1 -> n3"));
902        assert!(dot.contains("left"));
903        assert!(dot.contains("right"));
904    }
905
906    #[test]
907    fn test_dot_complex_plan() {
908        let viz = QueryPlanVisualizer::new();
909        let result = viz.render(&complex_plan(), VisOutputFormat::Dot);
910        assert!(result.is_ok());
911        let dot = result.unwrap_or_default();
912        assert!(dot.contains("HJ"));
913        assert!(dot.contains("Sort"));
914    }
915
916    #[test]
917    fn test_dot_orientation_lr() {
918        let config = VisualizerConfig {
919            dot_orientation: "LR".to_string(),
920            ..Default::default()
921        };
922        let viz = QueryPlanVisualizer::with_config(config);
923        let dot = viz
924            .render(&simple_scan(), VisOutputFormat::Dot)
925            .unwrap_or_default();
926        assert!(dot.contains("rankdir=LR"));
927    }
928
929    #[test]
930    fn test_dot_no_colour() {
931        let config = VisualizerConfig {
932            use_colour: false,
933            ..Default::default()
934        };
935        let viz = QueryPlanVisualizer::with_config(config);
936        let dot = viz
937            .render(&simple_scan(), VisOutputFormat::Dot)
938            .unwrap_or_default();
939        assert!(dot.contains("white"));
940    }
941
942    #[test]
943    fn test_dot_escape_special_chars() {
944        let node = VisPlanNode::leaf(VisOperator::Filter, "?x < 10 && ?y > 5");
945        let viz = QueryPlanVisualizer::new();
946        let dot = viz.render(&node, VisOutputFormat::Dot).unwrap_or_default();
947        // < and > should be escaped
948        assert!(dot.contains("\\<") || dot.contains("\\>"));
949    }
950
951    // -- Text tree output ---------------------------------------------------
952
953    #[test]
954    fn test_text_tree_simple() {
955        let viz = QueryPlanVisualizer::new();
956        let tree = viz
957            .render(&simple_scan(), VisOutputFormat::TextTree)
958            .unwrap_or_default();
959        assert!(tree.contains("[Scan]"));
960        assert!(tree.contains("?s ?p ?o"));
961    }
962
963    #[test]
964    fn test_text_tree_shows_estimates() {
965        let viz = QueryPlanVisualizer::new();
966        let tree = viz
967            .render(&simple_scan(), VisOutputFormat::TextTree)
968            .unwrap_or_default();
969        assert!(tree.contains("est=1000"));
970        assert!(tree.contains("cost=5.0"));
971    }
972
973    #[test]
974    fn test_text_tree_join() {
975        let viz = QueryPlanVisualizer::new();
976        let tree = viz
977            .render(&two_scan_join(), VisOutputFormat::TextTree)
978            .unwrap_or_default();
979        assert!(tree.contains("[HashJoin]"));
980        assert!(tree.contains(":name"));
981        assert!(tree.contains(":age"));
982    }
983
984    #[test]
985    fn test_text_tree_complex() {
986        let viz = QueryPlanVisualizer::new();
987        let tree = viz
988            .render(&complex_plan(), VisOutputFormat::TextTree)
989            .unwrap_or_default();
990        assert!(tree.contains("[Projection]"));
991        assert!(tree.contains("[Sort]"));
992        assert!(tree.contains("[Filter]"));
993        assert!(tree.contains("[HashJoin]"));
994        assert!(tree.contains("[Scan]"));
995    }
996
997    #[test]
998    fn test_text_tree_with_properties() {
999        let node =
1000            VisPlanNode::leaf(VisOperator::Scan, "?s ?p ?o").with_property("index_used", "spo");
1001        let viz = QueryPlanVisualizer::new();
1002        let tree = viz
1003            .render(&node, VisOutputFormat::TextTree)
1004            .unwrap_or_default();
1005        assert!(tree.contains("index_used: spo"));
1006    }
1007
1008    #[test]
1009    fn test_text_tree_no_estimates() {
1010        let config = VisualizerConfig {
1011            show_estimated_rows: false,
1012            show_cost: false,
1013            ..Default::default()
1014        };
1015        let viz = QueryPlanVisualizer::with_config(config);
1016        let tree = viz
1017            .render(&simple_scan(), VisOutputFormat::TextTree)
1018            .unwrap_or_default();
1019        assert!(!tree.contains("est="));
1020        assert!(!tree.contains("cost="));
1021    }
1022
1023    #[test]
1024    fn test_text_tree_actual_rows_and_time() {
1025        let node = simple_scan()
1026            .with_actual_rows(950)
1027            .with_execution_time_us(1234);
1028        let viz = QueryPlanVisualizer::new();
1029        let tree = viz
1030            .render(&node, VisOutputFormat::TextTree)
1031            .unwrap_or_default();
1032        assert!(tree.contains("actual=950"));
1033        assert!(tree.contains("time=1234us"));
1034    }
1035
1036    // -- JSON output --------------------------------------------------------
1037
1038    #[test]
1039    fn test_json_output_parses() {
1040        let viz = QueryPlanVisualizer::new();
1041        let json_str = viz
1042            .render(&simple_scan(), VisOutputFormat::Json)
1043            .unwrap_or_default();
1044        let parsed: serde_json::Value = serde_json::from_str(&json_str).expect("valid JSON");
1045        assert!(parsed.is_object());
1046    }
1047
1048    #[test]
1049    fn test_json_contains_operator() {
1050        let viz = QueryPlanVisualizer::new();
1051        let json_str = viz
1052            .render(&simple_scan(), VisOutputFormat::Json)
1053            .unwrap_or_default();
1054        assert!(json_str.contains("\"Scan\""));
1055    }
1056
1057    #[test]
1058    fn test_json_roundtrip() {
1059        let viz = QueryPlanVisualizer::new();
1060        let original = complex_plan();
1061        let json_str = viz
1062            .render(&original, VisOutputFormat::Json)
1063            .unwrap_or_default();
1064        let deserialized: VisPlanNode = serde_json::from_str(&json_str).expect("deserialise");
1065        assert_eq!(deserialized.operator, VisOperator::Projection);
1066        assert_eq!(deserialized.children.len(), 1);
1067    }
1068
1069    #[test]
1070    fn test_json_compact() {
1071        let config = VisualizerConfig {
1072            json_pretty: false,
1073            ..Default::default()
1074        };
1075        let viz = QueryPlanVisualizer::with_config(config);
1076        let json_str = viz
1077            .render(&simple_scan(), VisOutputFormat::Json)
1078            .unwrap_or_default();
1079        // Compact JSON should not contain leading newlines inside object
1080        assert!(!json_str.contains("\n  "));
1081    }
1082
1083    // -- Summary statistics -------------------------------------------------
1084
1085    #[test]
1086    fn test_summary_simple() {
1087        let viz = QueryPlanVisualizer::new();
1088        let summary = viz.summarise(&simple_scan());
1089        assert_eq!(summary.total_nodes, 1);
1090        assert_eq!(summary.max_depth, 1);
1091        assert_eq!(summary.scan_count, 1);
1092        assert_eq!(summary.join_count, 0);
1093        assert_eq!(summary.filter_count, 0);
1094    }
1095
1096    #[test]
1097    fn test_summary_complex() {
1098        let viz = QueryPlanVisualizer::new();
1099        let summary = viz.summarise(&complex_plan());
1100        assert_eq!(summary.total_nodes, 6);
1101        assert_eq!(summary.join_count, 1);
1102        assert_eq!(summary.filter_count, 1);
1103        assert_eq!(summary.scan_count, 2);
1104        assert!((summary.total_estimated_cost - 33.5).abs() < 1e-6);
1105    }
1106
1107    #[test]
1108    fn test_summary_operator_histogram() {
1109        let viz = QueryPlanVisualizer::new();
1110        let summary = viz.summarise(&complex_plan());
1111        assert_eq!(summary.operator_histogram.get("Scan"), Some(&2));
1112        assert_eq!(summary.operator_histogram.get("HashJoin"), Some(&1));
1113        assert_eq!(summary.operator_histogram.get("Filter"), Some(&1));
1114    }
1115
1116    // -- VisOutputFormat display -------------------------------------------
1117
1118    #[test]
1119    fn test_output_format_display() {
1120        assert_eq!(VisOutputFormat::Dot.to_string(), "DOT");
1121        assert_eq!(VisOutputFormat::TextTree.to_string(), "TextTree");
1122        assert_eq!(VisOutputFormat::Json.to_string(), "JSON");
1123    }
1124
1125    // -- Config defaults ---------------------------------------------------
1126
1127    #[test]
1128    fn test_default_config() {
1129        let config = VisualizerConfig::default();
1130        assert!(config.show_estimated_rows);
1131        assert!(config.show_cost);
1132        assert!(config.use_colour);
1133        assert_eq!(config.dot_orientation, "TB");
1134        assert!(config.json_pretty);
1135    }
1136
1137    // -- Edge cases ---------------------------------------------------------
1138
1139    #[test]
1140    fn test_empty_description() {
1141        let node = VisPlanNode::leaf(VisOperator::Scan, "");
1142        let viz = QueryPlanVisualizer::new();
1143        let dot = viz.render(&node, VisOutputFormat::Dot);
1144        assert!(dot.is_ok());
1145    }
1146
1147    #[test]
1148    fn test_deeply_nested_plan() {
1149        let mut current = VisPlanNode::leaf(VisOperator::Scan, "?s ?p ?o");
1150        for i in 0..10 {
1151            current = VisPlanNode::unary(VisOperator::Filter, format!("filter_{i}"), current);
1152        }
1153        let viz = QueryPlanVisualizer::new();
1154        let result = viz.render(&current, VisOutputFormat::TextTree);
1155        assert!(result.is_ok());
1156        assert_eq!(current.depth(), 11);
1157    }
1158
1159    #[test]
1160    fn test_union_three_branches() {
1161        let s1 = VisPlanNode::leaf(VisOperator::Scan, "branch1");
1162        let s2 = VisPlanNode::leaf(VisOperator::Scan, "branch2");
1163        let s3 = VisPlanNode::leaf(VisOperator::Scan, "branch3");
1164        let union = VisPlanNode {
1165            id: 0,
1166            operator: VisOperator::Union,
1167            description: "UNION".into(),
1168            estimated_rows: None,
1169            estimated_cost: None,
1170            actual_rows: None,
1171            execution_time_us: None,
1172            properties: HashMap::new(),
1173            children: vec![s1, s2, s3],
1174        };
1175        assert_eq!(union.node_count(), 4);
1176        let viz = QueryPlanVisualizer::new();
1177        let result = viz.render(&union, VisOutputFormat::Dot);
1178        assert!(result.is_ok());
1179    }
1180
1181    #[test]
1182    fn test_service_node() {
1183        let remote = VisPlanNode::leaf(VisOperator::Scan, "?s ?p ?o")
1184            .with_property("endpoint", "http://dbpedia.org/sparql");
1185        let svc = VisPlanNode::unary(
1186            VisOperator::Service,
1187            "SERVICE <http://dbpedia.org/sparql>",
1188            remote,
1189        );
1190        let viz = QueryPlanVisualizer::new();
1191        let tree = viz
1192            .render(&svc, VisOutputFormat::TextTree)
1193            .unwrap_or_default();
1194        assert!(tree.contains("[Service]"));
1195    }
1196
1197    #[test]
1198    fn test_total_cost_no_costs() {
1199        let node = VisPlanNode::leaf(VisOperator::Scan, "?s ?p ?o");
1200        assert!((node.total_cost() - 0.0).abs() < 1e-6);
1201    }
1202
1203    #[test]
1204    fn test_custom_operator() {
1205        let node = VisPlanNode::leaf(VisOperator::Custom("GeoFilter".into()), "WITHIN polygon");
1206        assert_eq!(node.operator.to_string(), "GeoFilter");
1207        assert!(!node.operator.is_join());
1208    }
1209
1210    #[test]
1211    fn test_all_formats_produce_output() {
1212        let plan = complex_plan();
1213        let viz = QueryPlanVisualizer::new();
1214        for fmt in [
1215            VisOutputFormat::Dot,
1216            VisOutputFormat::TextTree,
1217            VisOutputFormat::Json,
1218        ] {
1219            let result = viz.render(&plan, fmt);
1220            assert!(result.is_ok(), "format {fmt} should succeed");
1221            assert!(!result.unwrap_or_default().is_empty());
1222        }
1223    }
1224
1225    #[test]
1226    fn test_left_join_node() {
1227        let left = VisPlanNode::leaf(VisOperator::Scan, "?s :name ?name");
1228        let right = VisPlanNode::leaf(VisOperator::Scan, "?s :email ?email");
1229        let optional = VisPlanNode::binary(VisOperator::LeftJoin, "OPTIONAL", left, right);
1230        assert!(optional.operator.is_join());
1231        let viz = QueryPlanVisualizer::new();
1232        let tree = viz
1233            .render(&optional, VisOutputFormat::TextTree)
1234            .unwrap_or_default();
1235        assert!(tree.contains("[LeftJoin]"));
1236    }
1237
1238    #[test]
1239    fn test_values_node() {
1240        let vals = VisPlanNode::leaf(VisOperator::Values, "VALUES (?x) { (1) (2) (3) }");
1241        let viz = QueryPlanVisualizer::new();
1242        let dot = viz.render(&vals, VisOutputFormat::Dot).unwrap_or_default();
1243        assert!(dot.contains("Val"));
1244    }
1245
1246    #[test]
1247    fn test_slice_node() {
1248        let scan = simple_scan();
1249        let slice = VisPlanNode::unary(VisOperator::Slice, "LIMIT 10 OFFSET 5", scan)
1250            .with_estimated_rows(10);
1251        let viz = QueryPlanVisualizer::new();
1252        let tree = viz
1253            .render(&slice, VisOutputFormat::TextTree)
1254            .unwrap_or_default();
1255        assert!(tree.contains("[Slice]"));
1256        assert!(tree.contains("LIMIT 10 OFFSET 5"));
1257    }
1258
1259    #[test]
1260    fn test_aggregate_node() {
1261        let scan = simple_scan();
1262        let agg = VisPlanNode::unary(VisOperator::Aggregate, "GROUP BY ?type; COUNT(?s)", scan)
1263            .with_estimated_rows(50);
1264        assert_eq!(agg.depth(), 2);
1265    }
1266
1267    #[test]
1268    fn test_materialise_node() {
1269        let scan = simple_scan();
1270        let mat = VisPlanNode::unary(VisOperator::Materialise, "materialised", scan);
1271        let viz = QueryPlanVisualizer::new();
1272        let dot = viz.render(&mat, VisOutputFormat::Dot).unwrap_or_default();
1273        assert!(dot.contains("Mat"));
1274    }
1275
1276    #[test]
1277    fn test_graph_node() {
1278        let scan = simple_scan();
1279        let graph = VisPlanNode::unary(VisOperator::Graph, "GRAPH <http://example.org/g1>", scan);
1280        let viz = QueryPlanVisualizer::new();
1281        let tree = viz
1282            .render(&graph, VisOutputFormat::TextTree)
1283            .unwrap_or_default();
1284        assert!(tree.contains("[Graph]"));
1285    }
1286
1287    #[test]
1288    fn test_distinct_node() {
1289        let scan = simple_scan();
1290        let distinct = VisPlanNode::unary(VisOperator::Distinct, "DISTINCT", scan);
1291        let viz = QueryPlanVisualizer::new();
1292        let dot = viz
1293            .render(&distinct, VisOutputFormat::Dot)
1294            .unwrap_or_default();
1295        assert!(dot.contains("Dist"));
1296    }
1297
1298    #[test]
1299    fn test_bind_node() {
1300        let scan = simple_scan();
1301        let bind = VisPlanNode::unary(VisOperator::Bind, "BIND(?x + 1 AS ?y)", scan);
1302        assert_eq!(bind.node_count(), 2);
1303    }
1304
1305    #[test]
1306    fn test_subquery_node() {
1307        let inner = VisPlanNode::leaf(VisOperator::Scan, "inner pattern");
1308        let sq = VisPlanNode::unary(VisOperator::SubQuery, "sub-select", inner);
1309        assert_eq!(sq.depth(), 2);
1310    }
1311
1312    #[test]
1313    fn test_multiple_properties() {
1314        let node = VisPlanNode::leaf(VisOperator::Scan, "?s ?p ?o")
1315            .with_property("index", "spo")
1316            .with_property("selectivity", "0.01");
1317        assert_eq!(node.properties.len(), 2);
1318    }
1319}