1use std::collections::HashMap;
34use std::fmt;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub enum ExportFormat {
39 Json,
41 Dot,
43 Mermaid,
45 Text,
47 Yaml,
49 Html,
51}
52
53impl fmt::Display for ExportFormat {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 match self {
56 Self::Json => write!(f, "JSON"),
57 Self::Dot => write!(f, "DOT"),
58 Self::Mermaid => write!(f, "Mermaid"),
59 Self::Text => write!(f, "Text"),
60 Self::Yaml => write!(f, "YAML"),
61 Self::Html => write!(f, "HTML"),
62 }
63 }
64}
65
66impl ExportFormat {
67 pub fn extension(&self) -> &'static str {
69 match self {
70 Self::Json => "json",
71 Self::Dot => "dot",
72 Self::Mermaid => "md",
73 Self::Text => "txt",
74 Self::Yaml => "yaml",
75 Self::Html => "html",
76 }
77 }
78
79 pub fn mime_type(&self) -> &'static str {
81 match self {
82 Self::Json => "application/json",
83 Self::Dot => "text/vnd.graphviz",
84 Self::Mermaid => "text/markdown",
85 Self::Text => "text/plain",
86 Self::Yaml => "application/x-yaml",
87 Self::Html => "text/html",
88 }
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Hash)]
94pub enum OperatorType {
95 Scan,
97 HashJoin,
99 MergeJoin,
101 NestedLoopJoin,
103 IndexJoin,
105 Filter,
107 Project,
109 Distinct,
111 OrderBy,
113 Limit,
115 Offset,
117 GroupBy,
119 Aggregate,
121 Union,
123 Optional,
125 Minus,
127 Service,
129 Graph,
131 Bind,
133 Values,
135 PropertyPath,
137 Subquery,
139 Custom(String),
141}
142
143impl fmt::Display for OperatorType {
144 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145 match self {
146 Self::Scan => write!(f, "Scan"),
147 Self::HashJoin => write!(f, "HashJoin"),
148 Self::MergeJoin => write!(f, "MergeJoin"),
149 Self::NestedLoopJoin => write!(f, "NestedLoopJoin"),
150 Self::IndexJoin => write!(f, "IndexJoin"),
151 Self::Filter => write!(f, "Filter"),
152 Self::Project => write!(f, "Project"),
153 Self::Distinct => write!(f, "Distinct"),
154 Self::OrderBy => write!(f, "OrderBy"),
155 Self::Limit => write!(f, "Limit"),
156 Self::Offset => write!(f, "Offset"),
157 Self::GroupBy => write!(f, "GroupBy"),
158 Self::Aggregate => write!(f, "Aggregate"),
159 Self::Union => write!(f, "Union"),
160 Self::Optional => write!(f, "Optional"),
161 Self::Minus => write!(f, "Minus"),
162 Self::Service => write!(f, "Service"),
163 Self::Graph => write!(f, "Graph"),
164 Self::Bind => write!(f, "Bind"),
165 Self::Values => write!(f, "Values"),
166 Self::PropertyPath => write!(f, "PropertyPath"),
167 Self::Subquery => write!(f, "Subquery"),
168 Self::Custom(name) => write!(f, "{}", name),
169 }
170 }
171}
172
173#[derive(Debug, Clone, Default)]
175pub struct CostEstimate {
176 pub estimated_rows: f64,
178 pub estimated_cost: f64,
180 pub estimated_memory: usize,
182 pub estimated_io: usize,
184}
185
186#[derive(Debug, Clone, Default)]
188pub struct ExecutionStats {
189 pub actual_rows: usize,
191 pub execution_time_ms: f64,
193 pub memory_used: usize,
195 pub iterations: usize,
197}
198
199#[derive(Debug, Clone)]
201pub struct PlanNode {
202 pub id: String,
204 pub operator: OperatorType,
206 pub description: String,
208 pub variables: Vec<String>,
210 pub children: Vec<PlanNode>,
212 pub cost: Option<CostEstimate>,
214 pub stats: Option<ExecutionStats>,
216 pub properties: HashMap<String, String>,
218}
219
220impl PlanNode {
221 pub fn new(operator: OperatorType, description: impl Into<String>) -> Self {
223 Self {
224 id: Self::generate_id(),
225 operator,
226 description: description.into(),
227 variables: Vec::new(),
228 children: Vec::new(),
229 cost: None,
230 stats: None,
231 properties: HashMap::new(),
232 }
233 }
234
235 pub fn scan(pattern: impl Into<String>) -> Self {
237 Self::new(OperatorType::Scan, pattern)
238 }
239
240 pub fn hash_join(description: impl Into<String>) -> Self {
242 Self::new(OperatorType::HashJoin, description)
243 }
244
245 pub fn filter(condition: impl Into<String>) -> Self {
247 Self::new(OperatorType::Filter, condition)
248 }
249
250 pub fn project(vars: impl Into<String>) -> Self {
252 Self::new(OperatorType::Project, vars)
253 }
254
255 pub fn distinct() -> Self {
257 Self::new(OperatorType::Distinct, "DISTINCT")
258 }
259
260 pub fn order_by(ordering: impl Into<String>) -> Self {
262 Self::new(OperatorType::OrderBy, ordering)
263 }
264
265 pub fn limit(n: usize) -> Self {
267 Self::new(OperatorType::Limit, format!("LIMIT {}", n))
268 }
269
270 pub fn offset(n: usize) -> Self {
272 Self::new(OperatorType::Offset, format!("OFFSET {}", n))
273 }
274
275 pub fn union() -> Self {
277 Self::new(OperatorType::Union, "UNION")
278 }
279
280 pub fn optional() -> Self {
282 Self::new(OperatorType::Optional, "OPTIONAL")
283 }
284
285 pub fn group_by(vars: impl Into<String>) -> Self {
287 Self::new(OperatorType::GroupBy, vars)
288 }
289
290 pub fn aggregate(agg: impl Into<String>) -> Self {
292 Self::new(OperatorType::Aggregate, agg)
293 }
294
295 pub fn with_child(mut self, child: PlanNode) -> Self {
297 self.children.push(child);
298 self
299 }
300
301 pub fn with_children(mut self, children: Vec<PlanNode>) -> Self {
303 self.children.extend(children);
304 self
305 }
306
307 pub fn with_variables(mut self, vars: Vec<String>) -> Self {
309 self.variables = vars;
310 self
311 }
312
313 pub fn with_cost(mut self, cost: CostEstimate) -> Self {
315 self.cost = Some(cost);
316 self
317 }
318
319 pub fn with_stats(mut self, stats: ExecutionStats) -> Self {
321 self.stats = Some(stats);
322 self
323 }
324
325 pub fn with_property(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
327 self.properties.insert(key.into(), value.into());
328 self
329 }
330
331 pub fn node_count(&self) -> usize {
333 1 + self.children.iter().map(|c| c.node_count()).sum::<usize>()
334 }
335
336 pub fn depth(&self) -> usize {
338 if self.children.is_empty() {
339 1
340 } else {
341 1 + self.children.iter().map(|c| c.depth()).max().unwrap_or(0)
342 }
343 }
344
345 fn generate_id() -> String {
346 use std::sync::atomic::{AtomicU64, Ordering};
347 static COUNTER: AtomicU64 = AtomicU64::new(0);
348 format!("node_{}", COUNTER.fetch_add(1, Ordering::Relaxed))
349 }
350}
351
352#[derive(Debug, Clone)]
354pub struct ExportConfig {
355 pub include_costs: bool,
357 pub include_stats: bool,
359 pub include_properties: bool,
361 pub include_variables: bool,
363 pub pretty_print: bool,
365 pub indent: String,
367 pub include_metadata: bool,
369 pub graph_direction: String,
371}
372
373impl Default for ExportConfig {
374 fn default() -> Self {
375 Self {
376 include_costs: true,
377 include_stats: true,
378 include_properties: true,
379 include_variables: true,
380 pretty_print: true,
381 indent: " ".to_string(),
382 include_metadata: true,
383 graph_direction: "TB".to_string(),
384 }
385 }
386}
387
388impl ExportConfig {
389 pub fn minimal() -> Self {
391 Self {
392 include_costs: false,
393 include_stats: false,
394 include_properties: false,
395 include_variables: false,
396 pretty_print: false,
397 indent: " ".to_string(),
398 include_metadata: false,
399 graph_direction: "TB".to_string(),
400 }
401 }
402
403 pub fn full() -> Self {
405 Self {
406 include_costs: true,
407 include_stats: true,
408 include_properties: true,
409 include_variables: true,
410 pretty_print: true,
411 indent: " ".to_string(),
412 include_metadata: true,
413 graph_direction: "TB".to_string(),
414 }
415 }
416}
417
418#[derive(Debug)]
420pub struct QueryPlanExporter {
421 config: ExportConfig,
423 stats: ExporterStats,
425}
426
427#[derive(Debug, Clone, Default)]
429pub struct ExporterStats {
430 pub total_exports: usize,
432 pub exports_by_format: HashMap<String, usize>,
434 pub total_nodes_exported: usize,
436}
437
438impl QueryPlanExporter {
439 pub fn new() -> Self {
441 Self {
442 config: ExportConfig::default(),
443 stats: ExporterStats::default(),
444 }
445 }
446
447 pub fn with_config(config: ExportConfig) -> Self {
449 Self {
450 config,
451 stats: ExporterStats::default(),
452 }
453 }
454
455 pub fn export(&mut self, plan: &PlanNode, format: ExportFormat) -> Result<String, ExportError> {
457 self.stats.total_exports += 1;
458 *self
459 .stats
460 .exports_by_format
461 .entry(format.to_string())
462 .or_insert(0) += 1;
463 self.stats.total_nodes_exported += plan.node_count();
464
465 match format {
466 ExportFormat::Json => self.export_json(plan),
467 ExportFormat::Dot => self.export_dot(plan),
468 ExportFormat::Mermaid => self.export_mermaid(plan),
469 ExportFormat::Text => self.export_text(plan),
470 ExportFormat::Yaml => self.export_yaml(plan),
471 ExportFormat::Html => self.export_html(plan),
472 }
473 }
474
475 pub fn statistics(&self) -> &ExporterStats {
477 &self.stats
478 }
479
480 pub fn config(&self) -> &ExportConfig {
482 &self.config
483 }
484
485 fn export_json(&self, plan: &PlanNode) -> Result<String, ExportError> {
488 let mut output = String::new();
489
490 if self.config.pretty_print {
491 self.json_node_pretty(&mut output, plan, 0);
492 } else {
493 self.json_node(&mut output, plan);
494 }
495
496 Ok(output)
497 }
498
499 fn json_node(&self, output: &mut String, node: &PlanNode) {
500 output.push('{');
501 output.push_str(&format!("\"id\":\"{}\"", node.id));
502 output.push_str(&format!(",\"operator\":\"{}\"", node.operator));
503 output.push_str(&format!(
504 ",\"description\":\"{}\"",
505 Self::escape_json(&node.description)
506 ));
507
508 if self.config.include_variables && !node.variables.is_empty() {
509 output.push_str(",\"variables\":[");
510 for (i, var) in node.variables.iter().enumerate() {
511 if i > 0 {
512 output.push(',');
513 }
514 output.push_str(&format!("\"{}\"", var));
515 }
516 output.push(']');
517 }
518
519 if self.config.include_costs {
520 if let Some(ref cost) = node.cost {
521 output.push_str(&format!(
522 ",\"cost\":{{\"estimated_rows\":{},\"estimated_cost\":{}}}",
523 cost.estimated_rows, cost.estimated_cost
524 ));
525 }
526 }
527
528 if self.config.include_stats {
529 if let Some(ref stats) = node.stats {
530 output.push_str(&format!(
531 ",\"stats\":{{\"actual_rows\":{},\"execution_time_ms\":{}}}",
532 stats.actual_rows, stats.execution_time_ms
533 ));
534 }
535 }
536
537 if !node.children.is_empty() {
538 output.push_str(",\"children\":[");
539 for (i, child) in node.children.iter().enumerate() {
540 if i > 0 {
541 output.push(',');
542 }
543 self.json_node(output, child);
544 }
545 output.push(']');
546 }
547
548 output.push('}');
549 }
550
551 fn json_node_pretty(&self, output: &mut String, node: &PlanNode, depth: usize) {
552 let indent = self.config.indent.repeat(depth);
553 let child_indent = self.config.indent.repeat(depth + 1);
554
555 output.push_str(&format!("{}{{\n", indent));
556 output.push_str(&format!("{}\"id\": \"{}\",\n", child_indent, node.id));
557 output.push_str(&format!(
558 "{}\"operator\": \"{}\",\n",
559 child_indent, node.operator
560 ));
561 output.push_str(&format!(
562 "{}\"description\": \"{}\"",
563 child_indent,
564 Self::escape_json(&node.description)
565 ));
566
567 if self.config.include_variables && !node.variables.is_empty() {
568 output.push_str(&format!(
569 ",\n{}\"variables\": {:?}",
570 child_indent, node.variables
571 ));
572 }
573
574 if self.config.include_costs {
575 if let Some(ref cost) = node.cost {
576 output.push_str(&format!(
577 ",\n{}\"cost\": {{\n{}\"estimated_rows\": {},\n{}\"estimated_cost\": {}\n{}}}",
578 child_indent,
579 self.config.indent.repeat(depth + 2),
580 cost.estimated_rows,
581 self.config.indent.repeat(depth + 2),
582 cost.estimated_cost,
583 child_indent
584 ));
585 }
586 }
587
588 if !node.children.is_empty() {
589 output.push_str(&format!(",\n{}\"children\": [\n", child_indent));
590 for (i, child) in node.children.iter().enumerate() {
591 if i > 0 {
592 output.push_str(",\n");
593 }
594 self.json_node_pretty(output, child, depth + 2);
595 }
596 output.push_str(&format!("\n{}]", child_indent));
597 }
598
599 output.push_str(&format!("\n{}}}", indent));
600 }
601
602 fn export_dot(&self, plan: &PlanNode) -> Result<String, ExportError> {
603 let mut output = String::new();
604
605 output.push_str("digraph QueryPlan {\n");
606 output.push_str(&format!(
607 "{}rankdir={};\n",
608 self.config.indent, self.config.graph_direction
609 ));
610 output.push_str(&format!(
611 "{}node [shape=box, style=rounded];\n",
612 self.config.indent
613 ));
614
615 self.dot_node(&mut output, plan);
616
617 output.push_str("}\n");
618 Ok(output)
619 }
620
621 fn dot_node(&self, output: &mut String, node: &PlanNode) {
622 let label = self.dot_label(node);
623 output.push_str(&format!(
624 "{}\"{}\" [label=\"{}\"];\n",
625 self.config.indent, node.id, label
626 ));
627
628 for child in &node.children {
629 output.push_str(&format!(
630 "{}\"{}\" -> \"{}\";\n",
631 self.config.indent, node.id, child.id
632 ));
633 self.dot_node(output, child);
634 }
635 }
636
637 fn dot_label(&self, node: &PlanNode) -> String {
638 let mut label = format!("{}\\n{}", node.operator, node.description);
639
640 if self.config.include_costs {
641 if let Some(ref cost) = node.cost {
642 label.push_str(&format!(
643 "\\n[rows: {:.0}, cost: {:.2}]",
644 cost.estimated_rows, cost.estimated_cost
645 ));
646 }
647 }
648
649 if self.config.include_stats {
650 if let Some(ref stats) = node.stats {
651 label.push_str(&format!(
652 "\\n(actual: {} rows, {:.2}ms)",
653 stats.actual_rows, stats.execution_time_ms
654 ));
655 }
656 }
657
658 label
659 }
660
661 fn export_mermaid(&self, plan: &PlanNode) -> Result<String, ExportError> {
662 let mut output = String::new();
663
664 output.push_str("```mermaid\n");
665 output.push_str(&format!("graph {}\n", self.config.graph_direction));
666
667 self.mermaid_node(&mut output, plan);
668
669 output.push_str("```\n");
670 Ok(output)
671 }
672
673 fn mermaid_node(&self, output: &mut String, node: &PlanNode) {
674 let label = self.mermaid_label(node);
675 output.push_str(&format!(
676 "{}{}[\"{}\"]\n",
677 self.config.indent, node.id, label
678 ));
679
680 for child in &node.children {
681 output.push_str(&format!(
682 "{}{} --> {}\n",
683 self.config.indent, node.id, child.id
684 ));
685 self.mermaid_node(output, child);
686 }
687 }
688
689 fn mermaid_label(&self, node: &PlanNode) -> String {
690 let mut label = format!("{}: {}", node.operator, node.description);
691
692 if self.config.include_costs {
693 if let Some(ref cost) = node.cost {
694 label.push_str(&format!(" [rows: {:.0}]", cost.estimated_rows));
695 }
696 }
697
698 label
699 }
700
701 fn export_text(&self, plan: &PlanNode) -> Result<String, ExportError> {
702 let mut output = String::new();
703
704 if self.config.include_metadata {
705 output.push_str("Query Plan\n");
706 output.push_str(&format!("Nodes: {}\n", plan.node_count()));
707 output.push_str(&format!("Depth: {}\n", plan.depth()));
708 output.push_str("─".repeat(40).as_str());
709 output.push('\n');
710 }
711
712 self.text_node(&mut output, plan, 0);
713 Ok(output)
714 }
715
716 fn text_node(&self, output: &mut String, node: &PlanNode, depth: usize) {
717 let prefix = if depth == 0 {
718 "".to_string()
719 } else {
720 format!("{}├── ", "│ ".repeat(depth - 1))
721 };
722
723 output.push_str(&format!(
724 "{}{}: {}",
725 prefix, node.operator, node.description
726 ));
727
728 if self.config.include_costs {
729 if let Some(ref cost) = node.cost {
730 output.push_str(&format!(
731 " (rows: {:.0}, cost: {:.2})",
732 cost.estimated_rows, cost.estimated_cost
733 ));
734 }
735 }
736
737 if self.config.include_stats {
738 if let Some(ref stats) = node.stats {
739 output.push_str(&format!(
740 " [actual: {} rows, {:.2}ms]",
741 stats.actual_rows, stats.execution_time_ms
742 ));
743 }
744 }
745
746 output.push('\n');
747
748 for child in &node.children {
749 self.text_node(output, child, depth + 1);
750 }
751 }
752
753 fn export_yaml(&self, plan: &PlanNode) -> Result<String, ExportError> {
754 let mut output = String::new();
755
756 if self.config.include_metadata {
757 output.push_str("# Query Plan Export\n");
758 output.push_str(&format!("# Nodes: {}\n", plan.node_count()));
759 output.push_str(&format!("# Depth: {}\n\n", plan.depth()));
760 }
761
762 self.yaml_node(&mut output, plan, 0);
763 Ok(output)
764 }
765
766 fn yaml_node(&self, output: &mut String, node: &PlanNode, depth: usize) {
767 let indent = self.config.indent.repeat(depth);
768
769 output.push_str(&format!("{}id: {}\n", indent, node.id));
770 output.push_str(&format!("{}operator: {}\n", indent, node.operator));
771 output.push_str(&format!(
772 "{}description: \"{}\"\n",
773 indent, node.description
774 ));
775
776 if self.config.include_variables && !node.variables.is_empty() {
777 output.push_str(&format!("{}variables:\n", indent));
778 for var in &node.variables {
779 output.push_str(&format!(
780 "{}- {}\n",
781 self.config.indent.repeat(depth + 1),
782 var
783 ));
784 }
785 }
786
787 if self.config.include_costs {
788 if let Some(ref cost) = node.cost {
789 output.push_str(&format!("{}cost:\n", indent));
790 output.push_str(&format!(
791 "{}estimated_rows: {}\n",
792 self.config.indent.repeat(depth + 1),
793 cost.estimated_rows
794 ));
795 output.push_str(&format!(
796 "{}estimated_cost: {}\n",
797 self.config.indent.repeat(depth + 1),
798 cost.estimated_cost
799 ));
800 }
801 }
802
803 if !node.children.is_empty() {
804 output.push_str(&format!("{}children:\n", indent));
805 for child in &node.children {
806 output.push_str(&format!("{}- \n", self.config.indent.repeat(depth + 1)));
807 self.yaml_node(output, child, depth + 2);
808 }
809 }
810 }
811
812 fn export_html(&self, plan: &PlanNode) -> Result<String, ExportError> {
813 let mut output = String::new();
814
815 output.push_str("<!DOCTYPE html>\n<html>\n<head>\n");
816 output.push_str("<title>Query Plan</title>\n");
817 output.push_str("<style>\n");
818 output.push_str(
819 ".node { border: 1px solid #ccc; border-radius: 8px; padding: 10px; margin: 5px; background: #f9f9f9; }\n",
820 );
821 output.push_str(".operator { font-weight: bold; color: #333; }\n");
822 output.push_str(".description { color: #666; font-family: monospace; }\n");
823 output.push_str(".cost { color: #999; font-size: 0.9em; }\n");
824 output.push_str(".children { margin-left: 20px; border-left: 2px solid #ddd; }\n");
825 output.push_str("</style>\n</head>\n<body>\n");
826
827 if self.config.include_metadata {
828 output.push_str("<h1>Query Plan</h1>\n");
829 output.push_str(&format!(
830 "<p>Nodes: {} | Depth: {}</p>\n",
831 plan.node_count(),
832 plan.depth()
833 ));
834 }
835
836 self.html_node(&mut output, plan);
837
838 output.push_str("</body>\n</html>\n");
839 Ok(output)
840 }
841
842 fn html_node(&self, output: &mut String, node: &PlanNode) {
843 output.push_str("<div class=\"node\">\n");
844 output.push_str(&format!(
845 "<span class=\"operator\">{}</span>: ",
846 node.operator
847 ));
848 output.push_str(&format!(
849 "<span class=\"description\">{}</span>\n",
850 Self::escape_html(&node.description)
851 ));
852
853 if self.config.include_costs {
854 if let Some(ref cost) = node.cost {
855 output.push_str(&format!(
856 "<div class=\"cost\">Est. rows: {:.0}, Cost: {:.2}</div>\n",
857 cost.estimated_rows, cost.estimated_cost
858 ));
859 }
860 }
861
862 if !node.children.is_empty() {
863 output.push_str("<div class=\"children\">\n");
864 for child in &node.children {
865 self.html_node(output, child);
866 }
867 output.push_str("</div>\n");
868 }
869
870 output.push_str("</div>\n");
871 }
872
873 fn escape_json(s: &str) -> String {
874 s.replace('\\', "\\\\")
875 .replace('"', "\\\"")
876 .replace('\n', "\\n")
877 .replace('\r', "\\r")
878 .replace('\t', "\\t")
879 }
880
881 fn escape_html(s: &str) -> String {
882 s.replace('&', "&")
883 .replace('<', "<")
884 .replace('>', ">")
885 .replace('"', """)
886 }
887}
888
889impl Default for QueryPlanExporter {
890 fn default() -> Self {
891 Self::new()
892 }
893}
894
895#[derive(Debug, Clone)]
897pub struct ExportError {
898 pub message: String,
900 pub format: ExportFormat,
902}
903
904impl fmt::Display for ExportError {
905 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
906 write!(f, "Export error ({}): {}", self.format, self.message)
907 }
908}
909
910impl std::error::Error for ExportError {}
911
912#[cfg(test)]
913mod tests {
914 use super::*;
915
916 #[test]
917 fn test_plan_node_creation() {
918 let node = PlanNode::scan("?s ?p ?o");
919 assert_eq!(node.operator, OperatorType::Scan);
920 assert_eq!(node.description, "?s ?p ?o");
921 }
922
923 #[test]
924 fn test_plan_node_with_children() {
925 let plan = PlanNode::hash_join("?s = ?s")
926 .with_child(PlanNode::scan("?s :knows ?o"))
927 .with_child(PlanNode::scan("?s :name ?n"));
928
929 assert_eq!(plan.children.len(), 2);
930 assert_eq!(plan.node_count(), 3);
931 assert_eq!(plan.depth(), 2);
932 }
933
934 #[test]
935 fn test_plan_node_with_cost() {
936 let cost = CostEstimate {
937 estimated_rows: 100.0,
938 estimated_cost: 50.0,
939 ..Default::default()
940 };
941 let node = PlanNode::scan("?s ?p ?o").with_cost(cost);
942
943 assert!(node.cost.is_some());
944 assert_eq!(node.cost.as_ref().unwrap().estimated_rows, 100.0);
945 }
946
947 #[test]
948 fn test_export_json() {
949 let plan = PlanNode::scan("?s ?p ?o");
950 let mut exporter = QueryPlanExporter::new();
951
952 let json = exporter.export(&plan, ExportFormat::Json).unwrap();
953 assert!(json.contains("\"operator\""));
955 assert!(json.contains("Scan"));
956 assert!(json.contains("?s ?p ?o"));
957 }
958
959 #[test]
960 fn test_export_dot() {
961 let plan = PlanNode::hash_join("join").with_child(PlanNode::scan("?s ?p ?o"));
962
963 let mut exporter = QueryPlanExporter::new();
964 let dot = exporter.export(&plan, ExportFormat::Dot).unwrap();
965
966 assert!(dot.contains("digraph QueryPlan"));
967 assert!(dot.contains("->"));
968 }
969
970 #[test]
971 fn test_export_mermaid() {
972 let plan = PlanNode::scan("?s ?p ?o");
973 let mut exporter = QueryPlanExporter::new();
974
975 let mermaid = exporter.export(&plan, ExportFormat::Mermaid).unwrap();
976 assert!(mermaid.contains("```mermaid"));
977 assert!(mermaid.contains("graph TB"));
978 }
979
980 #[test]
981 fn test_export_text() {
982 let plan = PlanNode::project("?s ?o").with_child(PlanNode::scan("?s ?p ?o"));
983
984 let mut exporter = QueryPlanExporter::new();
985 let text = exporter.export(&plan, ExportFormat::Text).unwrap();
986
987 assert!(text.contains("Project"));
988 assert!(text.contains("Scan"));
989 }
990
991 #[test]
992 fn test_export_yaml() {
993 let plan = PlanNode::scan("?s ?p ?o");
994 let mut exporter = QueryPlanExporter::new();
995
996 let yaml = exporter.export(&plan, ExportFormat::Yaml).unwrap();
997 assert!(yaml.contains("operator: Scan"));
998 }
999
1000 #[test]
1001 fn test_export_html() {
1002 let plan = PlanNode::scan("?s ?p ?o");
1003 let mut exporter = QueryPlanExporter::new();
1004
1005 let html = exporter.export(&plan, ExportFormat::Html).unwrap();
1006 assert!(html.contains("<!DOCTYPE html>"));
1007 assert!(html.contains("Scan"));
1008 }
1009
1010 #[test]
1011 fn test_export_format_extension() {
1012 assert_eq!(ExportFormat::Json.extension(), "json");
1013 assert_eq!(ExportFormat::Dot.extension(), "dot");
1014 assert_eq!(ExportFormat::Html.extension(), "html");
1015 }
1016
1017 #[test]
1018 fn test_export_format_mime_type() {
1019 assert_eq!(ExportFormat::Json.mime_type(), "application/json");
1020 assert_eq!(ExportFormat::Html.mime_type(), "text/html");
1021 }
1022
1023 #[test]
1024 fn test_exporter_statistics() {
1025 let mut exporter = QueryPlanExporter::new();
1026 let plan = PlanNode::scan("?s ?p ?o");
1027
1028 exporter.export(&plan, ExportFormat::Json).unwrap();
1029 exporter.export(&plan, ExportFormat::Dot).unwrap();
1030
1031 assert_eq!(exporter.statistics().total_exports, 2);
1032 assert_eq!(exporter.statistics().total_nodes_exported, 2);
1033 }
1034
1035 #[test]
1036 fn test_config_presets() {
1037 let minimal = ExportConfig::minimal();
1038 assert!(!minimal.include_costs);
1039 assert!(!minimal.include_stats);
1040
1041 let full = ExportConfig::full();
1042 assert!(full.include_costs);
1043 assert!(full.include_stats);
1044 }
1045
1046 #[test]
1047 fn test_operator_types() {
1048 assert_eq!(format!("{}", OperatorType::Scan), "Scan");
1049 assert_eq!(format!("{}", OperatorType::HashJoin), "HashJoin");
1050 assert_eq!(
1051 format!("{}", OperatorType::Custom("MyOp".to_string())),
1052 "MyOp"
1053 );
1054 }
1055
1056 #[test]
1057 fn test_complex_plan() {
1058 let plan = PlanNode::project("?name ?age")
1059 .with_child(
1060 PlanNode::filter("?age > 18").with_child(
1061 PlanNode::hash_join("?person = ?person")
1062 .with_child(PlanNode::scan("?person :name ?name"))
1063 .with_child(PlanNode::scan("?person :age ?age")),
1064 ),
1065 )
1066 .with_cost(CostEstimate {
1067 estimated_rows: 50.0,
1068 estimated_cost: 120.0,
1069 ..Default::default()
1070 });
1071
1072 assert_eq!(plan.node_count(), 5);
1073 assert_eq!(plan.depth(), 4);
1074
1075 let mut exporter = QueryPlanExporter::new();
1076 let json = exporter.export(&plan, ExportFormat::Json).unwrap();
1077 assert!(json.contains("Project"));
1078 assert!(json.contains("Filter"));
1079 assert!(json.contains("HashJoin"));
1080 }
1081}