1use std::collections::HashMap;
32use std::fmt;
33
34use serde::{Deserialize, Serialize};
35
36#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
42pub enum VisOperator {
43 Scan,
45 HashJoin,
47 MergeJoin,
49 NestedLoopJoin,
51 LateralJoin,
53 LeftJoin,
55 Union,
57 Filter,
59 Bind,
61 Sort,
63 Distinct,
65 Slice,
67 Aggregate,
69 Projection,
71 Service,
73 SubQuery,
75 Values,
77 Graph,
79 Materialise,
81 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct VisPlanNode {
159 pub id: u64,
161 pub operator: VisOperator,
163 pub description: String,
165 pub estimated_rows: Option<u64>,
167 pub estimated_cost: Option<f64>,
169 pub actual_rows: Option<u64>,
171 pub execution_time_us: Option<u64>,
173 pub properties: HashMap<String, String>,
175 pub children: Vec<VisPlanNode>,
177}
178
179impl VisPlanNode {
180 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 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 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 pub fn with_estimated_rows(mut self, rows: u64) -> Self {
236 self.estimated_rows = Some(rows);
237 self
238 }
239
240 pub fn with_cost(mut self, cost: f64) -> Self {
242 self.estimated_cost = Some(cost);
243 self
244 }
245
246 pub fn with_actual_rows(mut self, rows: u64) -> Self {
248 self.actual_rows = Some(rows);
249 self
250 }
251
252 pub fn with_execution_time_us(mut self, us: u64) -> Self {
254 self.execution_time_us = Some(us);
255 self
256 }
257
258 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 pub fn node_count(&self) -> usize {
266 1 + self.children.iter().map(|c| c.node_count()).sum::<usize>()
267 }
268
269 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
315pub enum VisOutputFormat {
316 Dot,
318 TextTree,
320 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#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct VisualizerConfig {
341 pub show_estimated_rows: bool,
343 pub show_cost: bool,
345 pub show_actual_rows: bool,
347 pub show_execution_time: bool,
349 pub show_properties: bool,
351 pub use_colour: bool,
353 pub dot_orientation: String,
355 pub indent: String,
357 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#[derive(Debug, Clone, Serialize, Deserialize)]
383pub struct PlanSummary {
384 pub total_nodes: usize,
386 pub max_depth: usize,
388 pub join_count: usize,
390 pub filter_count: usize,
392 pub scan_count: usize,
394 pub total_estimated_cost: f64,
396 pub operator_histogram: HashMap<String, usize>,
398}
399
400pub struct QueryPlanVisualizer {
406 config: VisualizerConfig,
407}
408
409impl Default for QueryPlanVisualizer {
410 fn default() -> Self {
411 Self::new()
412 }
413}
414
415impl QueryPlanVisualizer {
416 pub fn new() -> Self {
418 Self {
419 config: VisualizerConfig::default(),
420 }
421 }
422
423 pub fn with_config(config: VisualizerConfig) -> Self {
425 Self { config }
426 }
427
428 pub fn render(
430 &self,
431 root: &VisPlanNode,
432 format: VisOutputFormat,
433 ) -> std::result::Result<String, String> {
434 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 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 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 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 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 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 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 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 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 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#[cfg(test)]
705mod tests {
706 use super::*;
707
708 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 #[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 #[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 #[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 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 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 let cost = plan.total_cost();
870 assert!((cost - 33.5).abs() < 1e-6);
871 }
872
873 #[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 assert!(dot.contains("\\<") || dot.contains("\\>"));
949 }
950
951 #[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 #[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 assert!(!json_str.contains("\n "));
1081 }
1082
1083 #[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 #[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 #[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 #[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(¤t, 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}