1use anyhow::{anyhow, Result};
30use serde::{Deserialize, Serialize};
31use std::collections::HashMap;
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct DiffConfig {
36 pub cost_threshold: f64,
38
39 pub include_operator_details: bool,
41
42 pub include_cardinality: bool,
44
45 pub detect_structural_changes: bool,
47
48 pub highlight_regressions: bool,
50
51 pub max_depth: usize,
53
54 pub ignore_formatting: bool,
56}
57
58impl Default for DiffConfig {
59 fn default() -> Self {
60 Self {
61 cost_threshold: 5.0, include_operator_details: true,
63 include_cardinality: true,
64 detect_structural_changes: true,
65 highlight_regressions: true,
66 max_depth: 0, ignore_formatting: true,
68 }
69 }
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct PlanDiff {
75 pub summary: DiffSummary,
77
78 pub structural_changes: Vec<StructuralChange>,
80
81 pub cost_changes: Vec<CostChange>,
83
84 pub operator_changes: Vec<OperatorChange>,
86
87 pub is_regression: bool,
89
90 pub quality_score: f64,
92
93 pub node_diffs: Vec<NodeDiff>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct DiffSummary {
100 pub total_changes: usize,
102
103 pub structural_changes: usize,
105
106 pub cost_changes: usize,
108
109 pub operator_changes: usize,
111
112 pub estimated_impact: f64,
114
115 pub message: String,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct StructuralChange {
122 pub change_type: StructuralChangeType,
124
125 pub path: String,
127
128 pub description: String,
130
131 pub old_node_type: Option<String>,
133
134 pub new_node_type: Option<String>,
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
140pub enum StructuralChangeType {
141 NodeAdded,
143
144 NodeRemoved,
146
147 NodeReplaced,
149
150 SubtreeReordered,
152
153 ChildrenChanged,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct CostChange {
160 pub path: String,
162
163 pub old_cost: f64,
165
166 pub new_cost: f64,
168
169 pub percentage_change: f64,
171
172 pub is_regression: bool,
174
175 pub description: String,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct OperatorChange {
182 pub path: String,
184
185 pub change_type: OperatorChangeType,
187
188 pub old_operator: Option<String>,
190
191 pub new_operator: Option<String>,
193
194 pub description: String,
196
197 pub impact: OperatorImpact,
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
203pub enum OperatorChangeType {
204 TypeChanged,
206
207 ParametersChanged,
209
210 StrategyChanged,
212
213 IndexUsageChanged,
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219pub enum OperatorImpact {
220 Positive,
222
223 Neutral,
225
226 Negative,
228
229 Unknown,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct NodeDiff {
236 pub path: String,
238
239 pub node_type: String,
241
242 pub exists_in_both: bool,
244
245 pub cost_diff: Option<f64>,
247
248 pub cardinality_diff: Option<i64>,
250
251 pub property_changes: Vec<PropertyChange>,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct PropertyChange {
258 pub name: String,
260
261 pub old_value: String,
263
264 pub new_value: String,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct PlanNode {
271 pub id: String,
273
274 pub node_type: String,
276
277 pub cost: f64,
279
280 pub cardinality: usize,
282
283 pub properties: HashMap<String, String>,
285
286 pub children: Vec<PlanNode>,
288}
289
290impl PlanNode {
291 pub fn new(id: String, node_type: String) -> Self {
293 Self {
294 id,
295 node_type,
296 cost: 0.0,
297 cardinality: 0,
298 properties: HashMap::new(),
299 children: Vec::new(),
300 }
301 }
302
303 pub fn with_child(mut self, child: PlanNode) -> Self {
305 self.children.push(child);
306 self
307 }
308
309 pub fn with_cost(mut self, cost: f64) -> Self {
311 self.cost = cost;
312 self
313 }
314
315 pub fn with_cardinality(mut self, cardinality: usize) -> Self {
317 self.cardinality = cardinality;
318 self
319 }
320
321 pub fn with_property(mut self, key: String, value: String) -> Self {
323 self.properties.insert(key, value);
324 self
325 }
326
327 #[allow(dead_code)]
329 fn descendants(&self) -> Vec<&PlanNode> {
330 let mut result = vec![self];
331 for child in &self.children {
332 result.extend(child.descendants());
333 }
334 result
335 }
336
337 pub fn total_cost(&self) -> f64 {
339 self.cost + self.children.iter().map(|c| c.total_cost()).sum::<f64>()
340 }
341}
342
343pub struct PlanDiffer {
345 config: DiffConfig,
346}
347
348impl PlanDiffer {
349 pub fn new(config: DiffConfig) -> Self {
351 Self { config }
352 }
353
354 pub fn compare_plans(&self, old_plan: &PlanNode, new_plan: &PlanNode) -> Result<PlanDiff> {
356 let mut structural_changes = Vec::new();
357 let mut cost_changes = Vec::new();
358 let mut operator_changes = Vec::new();
359 let mut node_diffs = Vec::new();
360
361 self.compare_nodes(
363 old_plan,
364 new_plan,
365 "",
366 &mut structural_changes,
367 &mut cost_changes,
368 &mut operator_changes,
369 &mut node_diffs,
370 0,
371 )?;
372
373 let quality_score = self.calculate_quality_score(&cost_changes, &structural_changes);
375
376 let is_regression = self.detect_regression(&cost_changes, quality_score);
378
379 let estimated_impact = self.estimate_performance_impact(old_plan, new_plan);
381
382 let summary = DiffSummary {
384 total_changes: structural_changes.len() + cost_changes.len() + operator_changes.len(),
385 structural_changes: structural_changes.len(),
386 cost_changes: cost_changes.len(),
387 operator_changes: operator_changes.len(),
388 estimated_impact,
389 message: self.generate_summary_message(
390 &structural_changes,
391 &cost_changes,
392 is_regression,
393 ),
394 };
395
396 Ok(PlanDiff {
397 summary,
398 structural_changes,
399 cost_changes,
400 operator_changes,
401 is_regression,
402 quality_score,
403 node_diffs,
404 })
405 }
406
407 #[allow(clippy::too_many_arguments)]
409 fn compare_nodes(
410 &self,
411 old_node: &PlanNode,
412 new_node: &PlanNode,
413 path: &str,
414 structural_changes: &mut Vec<StructuralChange>,
415 cost_changes: &mut Vec<CostChange>,
416 operator_changes: &mut Vec<OperatorChange>,
417 node_diffs: &mut Vec<NodeDiff>,
418 depth: usize,
419 ) -> Result<()> {
420 if self.config.max_depth > 0 && depth >= self.config.max_depth {
422 return Ok(());
423 }
424
425 let current_path = if path.is_empty() {
426 old_node.id.clone()
427 } else {
428 format!("{}/{}", path, old_node.id)
429 };
430
431 if old_node.node_type != new_node.node_type && self.config.detect_structural_changes {
433 structural_changes.push(StructuralChange {
434 change_type: StructuralChangeType::NodeReplaced,
435 path: current_path.clone(),
436 description: format!(
437 "Node type changed from '{}' to '{}'",
438 old_node.node_type, new_node.node_type
439 ),
440 old_node_type: Some(old_node.node_type.clone()),
441 new_node_type: Some(new_node.node_type.clone()),
442 });
443
444 operator_changes.push(OperatorChange {
445 path: current_path.clone(),
446 change_type: OperatorChangeType::TypeChanged,
447 old_operator: Some(old_node.node_type.clone()),
448 new_operator: Some(new_node.node_type.clone()),
449 description: format!(
450 "Operator changed from {} to {}",
451 old_node.node_type, new_node.node_type
452 ),
453 impact: self.assess_operator_impact(&old_node.node_type, &new_node.node_type),
454 });
455 }
456
457 let cost_diff = new_node.cost - old_node.cost;
459 let cost_pct_change = if old_node.cost > 0.0 {
460 (cost_diff / old_node.cost) * 100.0
461 } else {
462 0.0
463 };
464
465 if cost_pct_change.abs() > self.config.cost_threshold {
466 let is_regression = cost_diff > 0.0; cost_changes.push(CostChange {
469 path: current_path.clone(),
470 old_cost: old_node.cost,
471 new_cost: new_node.cost,
472 percentage_change: cost_pct_change,
473 is_regression,
474 description: format!(
475 "Cost changed by {:.1}% ({:.2} → {:.2})",
476 cost_pct_change, old_node.cost, new_node.cost
477 ),
478 });
479 }
480
481 let mut property_changes = Vec::new();
483 for (key, old_val) in &old_node.properties {
484 if let Some(new_val) = new_node.properties.get(key) {
485 if old_val != new_val && !self.config.ignore_formatting {
486 property_changes.push(PropertyChange {
487 name: key.clone(),
488 old_value: old_val.clone(),
489 new_value: new_val.clone(),
490 });
491 }
492 }
493 }
494
495 node_diffs.push(NodeDiff {
497 path: current_path.clone(),
498 node_type: old_node.node_type.clone(),
499 exists_in_both: true,
500 cost_diff: if cost_pct_change.abs() > self.config.cost_threshold {
501 Some(cost_diff)
502 } else {
503 None
504 },
505 cardinality_diff: if self.config.include_cardinality {
506 Some(new_node.cardinality as i64 - old_node.cardinality as i64)
507 } else {
508 None
509 },
510 property_changes,
511 });
512
513 if old_node.children.len() != new_node.children.len()
515 && self.config.detect_structural_changes
516 {
517 structural_changes.push(StructuralChange {
518 change_type: StructuralChangeType::ChildrenChanged,
519 path: current_path.clone(),
520 description: format!(
521 "Children count changed from {} to {}",
522 old_node.children.len(),
523 new_node.children.len()
524 ),
525 old_node_type: None,
526 new_node_type: None,
527 });
528 }
529
530 let min_children = old_node.children.len().min(new_node.children.len());
532 for i in 0..min_children {
533 self.compare_nodes(
534 &old_node.children[i],
535 &new_node.children[i],
536 ¤t_path,
537 structural_changes,
538 cost_changes,
539 operator_changes,
540 node_diffs,
541 depth + 1,
542 )?;
543 }
544
545 Ok(())
546 }
547
548 fn calculate_quality_score(
550 &self,
551 cost_changes: &[CostChange],
552 structural_changes: &[StructuralChange],
553 ) -> f64 {
554 if cost_changes.is_empty() && structural_changes.is_empty() {
555 return 0.0; }
557
558 let cost_score: f64 = cost_changes
560 .iter()
561 .map(|c| {
562 if c.is_regression {
563 -c.percentage_change / 100.0
564 } else {
565 c.percentage_change.abs() / 100.0
566 }
567 })
568 .sum();
569
570 cost_score.clamp(-1.0, 1.0)
572 }
573
574 fn detect_regression(&self, cost_changes: &[CostChange], quality_score: f64) -> bool {
576 if !self.config.highlight_regressions {
577 return false;
578 }
579
580 if quality_score < -0.1 {
582 return true;
583 }
584
585 cost_changes
587 .iter()
588 .any(|c| c.is_regression && c.percentage_change > 20.0)
589 }
590
591 fn estimate_performance_impact(&self, old_plan: &PlanNode, new_plan: &PlanNode) -> f64 {
593 let old_cost = old_plan.total_cost();
594 let new_cost = new_plan.total_cost();
595
596 if new_cost > 0.0 {
597 old_cost / new_cost } else {
599 1.0
600 }
601 }
602
603 fn assess_operator_impact(&self, old_op: &str, new_op: &str) -> OperatorImpact {
605 match (old_op, new_op) {
607 ("TableScan", "IndexScan") => OperatorImpact::Positive,
609 ("IndexScan", "TableScan") => OperatorImpact::Negative,
610
611 ("NestedLoopJoin", "HashJoin") => OperatorImpact::Positive,
613 ("HashJoin", "NestedLoopJoin") => OperatorImpact::Negative,
614
615 ("NestedLoopJoin", "MergeJoin") => OperatorImpact::Positive,
617
618 _ => OperatorImpact::Unknown,
619 }
620 }
621
622 fn generate_summary_message(
624 &self,
625 structural_changes: &[StructuralChange],
626 cost_changes: &[CostChange],
627 is_regression: bool,
628 ) -> String {
629 if structural_changes.is_empty() && cost_changes.is_empty() {
630 return "Plans are identical".to_string();
631 }
632
633 let mut parts = Vec::new();
634
635 if !structural_changes.is_empty() {
636 parts.push(format!("{} structural change(s)", structural_changes.len()));
637 }
638
639 if !cost_changes.is_empty() {
640 let improvements = cost_changes.iter().filter(|c| !c.is_regression).count();
641 let regressions = cost_changes.iter().filter(|c| c.is_regression).count();
642
643 if improvements > 0 {
644 parts.push(format!("{} cost improvement(s)", improvements));
645 }
646 if regressions > 0 {
647 parts.push(format!("{} cost regression(s)", regressions));
648 }
649 }
650
651 let message = parts.join(", ");
652
653 if is_regression {
654 format!("⚠️ REGRESSION DETECTED: {}", message)
655 } else {
656 message
657 }
658 }
659}
660
661impl PlanDiff {
662 pub fn has_regression(&self) -> bool {
664 self.is_regression
665 }
666
667 pub fn format_summary(&self) -> String {
669 let mut output = String::new();
670
671 output.push_str("# Query Plan Comparison Summary\n\n");
672 output.push_str(&format!("{}\n\n", self.summary.message));
673
674 if self.is_regression {
675 output.push_str("## ⚠️ Performance Regression Detected\n\n");
676 }
677
678 output.push_str(&format!(
679 "**Total Changes**: {}\n",
680 self.summary.total_changes
681 ));
682 output.push_str(&format!(
683 "**Estimated Performance Impact**: {:.2}x\n",
684 self.summary.estimated_impact
685 ));
686 output.push_str(&format!("**Quality Score**: {:.2}\n\n", self.quality_score));
687
688 if !self.cost_changes.is_empty() {
689 output.push_str("## Cost Changes\n\n");
690 for (i, change) in self.cost_changes.iter().enumerate() {
691 let indicator = if change.is_regression { "📈" } else { "📉" };
692 output.push_str(&format!(
693 "{}. {} **{}**: {}\n",
694 i + 1,
695 indicator,
696 change.path,
697 change.description
698 ));
699 }
700 output.push('\n');
701 }
702
703 if !self.structural_changes.is_empty() {
704 output.push_str("## Structural Changes\n\n");
705 for (i, change) in self.structural_changes.iter().enumerate() {
706 output.push_str(&format!(
707 "{}. **{}**: {}\n",
708 i + 1,
709 change.path,
710 change.description
711 ));
712 }
713 output.push('\n');
714 }
715
716 if !self.operator_changes.is_empty() {
717 output.push_str("## Operator Changes\n\n");
718 for (i, change) in self.operator_changes.iter().enumerate() {
719 let impact_icon = match change.impact {
720 OperatorImpact::Positive => "✅",
721 OperatorImpact::Negative => "❌",
722 OperatorImpact::Neutral => "➖",
723 OperatorImpact::Unknown => "❓",
724 };
725 output.push_str(&format!(
726 "{}. {} **{}**: {}\n",
727 i + 1,
728 impact_icon,
729 change.path,
730 change.description
731 ));
732 }
733 }
734
735 output
736 }
737
738 pub fn to_json(&self) -> Result<String> {
740 serde_json::to_string_pretty(self)
741 .map_err(|e| anyhow!("Failed to serialize diff to JSON: {}", e))
742 }
743}
744
745#[cfg(test)]
746mod tests {
747 use super::*;
748
749 fn create_sample_plan() -> PlanNode {
750 PlanNode::new("root".to_string(), "Project".to_string())
751 .with_cost(100.0)
752 .with_cardinality(1000)
753 .with_child(
754 PlanNode::new("join".to_string(), "HashJoin".to_string())
755 .with_cost(80.0)
756 .with_cardinality(500)
757 .with_child(
758 PlanNode::new("scan1".to_string(), "IndexScan".to_string())
759 .with_cost(30.0)
760 .with_cardinality(100),
761 )
762 .with_child(
763 PlanNode::new("scan2".to_string(), "IndexScan".to_string())
764 .with_cost(40.0)
765 .with_cardinality(200),
766 ),
767 )
768 }
769
770 #[test]
771 fn test_identical_plans() {
772 let plan1 = create_sample_plan();
773 let plan2 = create_sample_plan();
774
775 let differ = PlanDiffer::new(DiffConfig::default());
776 let diff = differ.compare_plans(&plan1, &plan2).unwrap();
777
778 assert_eq!(diff.summary.total_changes, 0);
779 assert!(!diff.has_regression());
780 }
781
782 #[test]
783 fn test_cost_increase_regression() {
784 let plan1 = create_sample_plan();
785 let mut plan2 = create_sample_plan();
786 plan2.cost = 150.0; let differ = PlanDiffer::new(DiffConfig::default());
789 let diff = differ.compare_plans(&plan1, &plan2).unwrap();
790
791 assert!(!diff.cost_changes.is_empty());
792 assert!(diff.cost_changes[0].is_regression);
793 assert!(diff.quality_score < 0.0);
794 }
795
796 #[test]
797 fn test_operator_change_detection() {
798 let plan1 = create_sample_plan();
799 let mut plan2 = create_sample_plan();
800 plan2.children[0].node_type = "NestedLoopJoin".to_string();
801
802 let differ = PlanDiffer::new(DiffConfig::default());
803 let diff = differ.compare_plans(&plan1, &plan2).unwrap();
804
805 assert!(!diff.operator_changes.is_empty());
806 assert_eq!(
807 diff.operator_changes[0].change_type,
808 OperatorChangeType::TypeChanged
809 );
810 }
811
812 #[test]
813 fn test_structural_change_children_count() {
814 let plan1 = create_sample_plan();
815 let mut plan2 = create_sample_plan();
816 plan2.children[0]
817 .children
818 .push(PlanNode::new("scan3".to_string(), "TableScan".to_string()).with_cost(10.0));
819
820 let differ = PlanDiffer::new(DiffConfig::default());
821 let diff = differ.compare_plans(&plan1, &plan2).unwrap();
822
823 assert!(!diff.structural_changes.is_empty());
824 }
825
826 #[test]
827 fn test_cost_threshold_filtering() {
828 let plan1 = create_sample_plan();
829 let mut plan2 = create_sample_plan();
830 plan2.cost = 102.0; let differ = PlanDiffer::new(DiffConfig::default());
833 let diff = differ.compare_plans(&plan1, &plan2).unwrap();
834
835 assert!(diff.cost_changes.is_empty());
837 }
838
839 #[test]
840 fn test_performance_impact_calculation() {
841 let plan1 = create_sample_plan();
842 let mut plan2 = create_sample_plan();
843 plan2.cost = 50.0; plan2.children[0].cost = 40.0; let differ = PlanDiffer::new(DiffConfig::default());
848 let diff = differ.compare_plans(&plan1, &plan2).unwrap();
849
850 assert!(diff.summary.estimated_impact > 1.5);
854 }
855
856 #[test]
857 fn test_summary_formatting() {
858 let plan1 = create_sample_plan();
859 let mut plan2 = create_sample_plan();
860 plan2.cost = 150.0;
861
862 let differ = PlanDiffer::new(DiffConfig::default());
863 let diff = differ.compare_plans(&plan1, &plan2).unwrap();
864
865 let summary = diff.format_summary();
866 assert!(summary.contains("Query Plan Comparison Summary"));
867 assert!(summary.contains("Cost Changes"));
868 }
869
870 #[test]
871 fn test_json_export() {
872 let plan1 = create_sample_plan();
873 let plan2 = create_sample_plan();
874
875 let differ = PlanDiffer::new(DiffConfig::default());
876 let diff = differ.compare_plans(&plan1, &plan2).unwrap();
877
878 let json = diff.to_json().unwrap();
879 assert!(json.contains("summary"));
880 assert!(json.contains("quality_score"));
881 }
882
883 #[test]
884 fn test_operator_impact_assessment() {
885 let differ = PlanDiffer::new(DiffConfig::default());
886
887 assert_eq!(
888 differ.assess_operator_impact("TableScan", "IndexScan"),
889 OperatorImpact::Positive
890 );
891
892 assert_eq!(
893 differ.assess_operator_impact("IndexScan", "TableScan"),
894 OperatorImpact::Negative
895 );
896
897 assert_eq!(
898 differ.assess_operator_impact("NestedLoopJoin", "HashJoin"),
899 OperatorImpact::Positive
900 );
901 }
902
903 #[test]
904 fn test_depth_limiting() {
905 let plan1 = create_sample_plan();
906 let plan2 = create_sample_plan();
907
908 let config = DiffConfig {
909 max_depth: 1, ..Default::default()
911 };
912
913 let differ = PlanDiffer::new(config);
914 let diff = differ.compare_plans(&plan1, &plan2).unwrap();
915
916 assert!(diff.node_diffs.len() <= 2);
918 }
919
920 #[test]
921 fn test_total_cost_calculation() {
922 let plan = create_sample_plan();
923 let total = plan.total_cost();
924
925 assert_eq!(total, 250.0);
927 }
928}