Skip to main content

oxirs_arq/
query_plan_diff.rs

1//! Query Plan Comparison and Diff Utilities
2//!
3//! Provides tools for comparing SPARQL query execution plans to understand
4//! optimization changes, performance regressions, and structural differences.
5//!
6//! ## Features
7//!
8//! - **Structural diff**: Compare plan tree structures
9//! - **Cost comparison**: Analyze cost estimate changes
10//! - **Operator changes**: Track added/removed/modified operators
11//! - **Visual diff**: Generate readable diff reports
12//! - **Regression detection**: Identify performance regressions
13//! - **SciRS2 integration**: Statistical analysis of plan differences
14//!
15//! ## Example
16//!
17//! ```rust,ignore
18//! use oxirs_arq::query_plan_diff::{PlanDiffer, DiffConfig};
19//!
20//! let differ = PlanDiffer::new(DiffConfig::default());
21//! let diff = differ.compare_plans(&plan_v1, &plan_v2)?;
22//!
23//! if diff.has_regression() {
24//!     println!("WARNING: Performance regression detected!");
25//!     println!("{}", diff.format_summary());
26//! }
27//! ```
28
29use anyhow::{anyhow, Result};
30use serde::{Deserialize, Serialize};
31use std::collections::HashMap;
32
33/// Configuration for query plan comparison
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct DiffConfig {
36    /// Threshold for cost change to be considered significant (percentage)
37    pub cost_threshold: f64,
38
39    /// Include operator details in diff
40    pub include_operator_details: bool,
41
42    /// Include cardinality estimates in diff
43    pub include_cardinality: bool,
44
45    /// Detect structural changes (added/removed nodes)
46    pub detect_structural_changes: bool,
47
48    /// Highlight performance regressions
49    pub highlight_regressions: bool,
50
51    /// Maximum diff depth (0 = unlimited)
52    pub max_depth: usize,
53
54    /// Ignore minor formatting changes
55    pub ignore_formatting: bool,
56}
57
58impl Default for DiffConfig {
59    fn default() -> Self {
60        Self {
61            cost_threshold: 5.0, // 5% threshold
62            include_operator_details: true,
63            include_cardinality: true,
64            detect_structural_changes: true,
65            highlight_regressions: true,
66            max_depth: 0, // unlimited
67            ignore_formatting: true,
68        }
69    }
70}
71
72/// Result of comparing two query plans
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct PlanDiff {
75    /// Summary of changes
76    pub summary: DiffSummary,
77
78    /// Structural changes detected
79    pub structural_changes: Vec<StructuralChange>,
80
81    /// Cost changes detected
82    pub cost_changes: Vec<CostChange>,
83
84    /// Operator changes
85    pub operator_changes: Vec<OperatorChange>,
86
87    /// Whether this represents a regression
88    pub is_regression: bool,
89
90    /// Overall quality score (-1.0 = worse, 0.0 = same, 1.0 = better)
91    pub quality_score: f64,
92
93    /// Detailed node-by-node comparison
94    pub node_diffs: Vec<NodeDiff>,
95}
96
97/// Summary of plan differences
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct DiffSummary {
100    /// Total number of changes
101    pub total_changes: usize,
102
103    /// Number of structural changes
104    pub structural_changes: usize,
105
106    /// Number of cost changes
107    pub cost_changes: usize,
108
109    /// Number of operator changes
110    pub operator_changes: usize,
111
112    /// Estimated performance impact (multiplier: >1.0 = faster)
113    pub estimated_impact: f64,
114
115    /// Summary message
116    pub message: String,
117}
118
119/// Structural change in the query plan
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct StructuralChange {
122    /// Type of structural change
123    pub change_type: StructuralChangeType,
124
125    /// Path to the changed node
126    pub path: String,
127
128    /// Description of the change
129    pub description: String,
130
131    /// Old node type (if applicable)
132    pub old_node_type: Option<String>,
133
134    /// New node type (if applicable)
135    pub new_node_type: Option<String>,
136}
137
138/// Type of structural change
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
140pub enum StructuralChangeType {
141    /// Node was added
142    NodeAdded,
143
144    /// Node was removed
145    NodeRemoved,
146
147    /// Node was replaced with different type
148    NodeReplaced,
149
150    /// Subtree was reordered
151    SubtreeReordered,
152
153    /// Children count changed
154    ChildrenChanged,
155}
156
157/// Cost change in the query plan
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct CostChange {
160    /// Path to the node
161    pub path: String,
162
163    /// Old cost estimate
164    pub old_cost: f64,
165
166    /// New cost estimate
167    pub new_cost: f64,
168
169    /// Percentage change
170    pub percentage_change: f64,
171
172    /// Whether this is a regression
173    pub is_regression: bool,
174
175    /// Description
176    pub description: String,
177}
178
179/// Operator change in the query plan
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct OperatorChange {
182    /// Path to the operator
183    pub path: String,
184
185    /// Type of change
186    pub change_type: OperatorChangeType,
187
188    /// Old operator name
189    pub old_operator: Option<String>,
190
191    /// New operator name
192    pub new_operator: Option<String>,
193
194    /// Description
195    pub description: String,
196
197    /// Performance impact
198    pub impact: OperatorImpact,
199}
200
201/// Type of operator change
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
203pub enum OperatorChangeType {
204    /// Operator type changed
205    TypeChanged,
206
207    /// Operator parameters changed
208    ParametersChanged,
209
210    /// Execution strategy changed
211    StrategyChanged,
212
213    /// Index usage changed
214    IndexUsageChanged,
215}
216
217/// Performance impact of operator change
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219pub enum OperatorImpact {
220    /// Positive impact (improvement)
221    Positive,
222
223    /// Neutral impact
224    Neutral,
225
226    /// Negative impact (regression)
227    Negative,
228
229    /// Unknown impact
230    Unknown,
231}
232
233/// Node-level diff information
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct NodeDiff {
236    /// Path to this node
237    pub path: String,
238
239    /// Node type
240    pub node_type: String,
241
242    /// Whether this node exists in both plans
243    pub exists_in_both: bool,
244
245    /// Cost comparison
246    pub cost_diff: Option<f64>,
247
248    /// Cardinality comparison
249    pub cardinality_diff: Option<i64>,
250
251    /// Property changes
252    pub property_changes: Vec<PropertyChange>,
253}
254
255/// Change in a node property
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct PropertyChange {
258    /// Property name
259    pub name: String,
260
261    /// Old value
262    pub old_value: String,
263
264    /// New value
265    pub new_value: String,
266}
267
268/// Simplified plan node for comparison
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct PlanNode {
271    /// Node identifier/path
272    pub id: String,
273
274    /// Node type (e.g., "Scan", "Join", "Filter")
275    pub node_type: String,
276
277    /// Estimated cost
278    pub cost: f64,
279
280    /// Estimated cardinality
281    pub cardinality: usize,
282
283    /// Operator-specific properties
284    pub properties: HashMap<String, String>,
285
286    /// Child nodes
287    pub children: Vec<PlanNode>,
288}
289
290impl PlanNode {
291    /// Create a new plan node
292    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    /// Add a child node
304    pub fn with_child(mut self, child: PlanNode) -> Self {
305        self.children.push(child);
306        self
307    }
308
309    /// Set cost estimate
310    pub fn with_cost(mut self, cost: f64) -> Self {
311        self.cost = cost;
312        self
313    }
314
315    /// Set cardinality estimate
316    pub fn with_cardinality(mut self, cardinality: usize) -> Self {
317        self.cardinality = cardinality;
318        self
319    }
320
321    /// Add a property
322    pub fn with_property(mut self, key: String, value: String) -> Self {
323        self.properties.insert(key, value);
324        self
325    }
326
327    /// Get all descendant nodes
328    #[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    /// Calculate total cost including children
338    pub fn total_cost(&self) -> f64 {
339        self.cost + self.children.iter().map(|c| c.total_cost()).sum::<f64>()
340    }
341}
342
343/// Query plan differ
344pub struct PlanDiffer {
345    config: DiffConfig,
346}
347
348impl PlanDiffer {
349    /// Create a new plan differ
350    pub fn new(config: DiffConfig) -> Self {
351        Self { config }
352    }
353
354    /// Compare two query plans
355    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        // Perform recursive comparison
362        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        // Calculate quality score
374        let quality_score = self.calculate_quality_score(&cost_changes, &structural_changes);
375
376        // Determine if this is a regression
377        let is_regression = self.detect_regression(&cost_changes, quality_score);
378
379        // Calculate estimated performance impact
380        let estimated_impact = self.estimate_performance_impact(old_plan, new_plan);
381
382        // Build summary
383        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    /// Recursively compare plan nodes
408    #[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        // Check depth limit
421        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        // Compare node types
432        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        // Compare costs
458        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; // Higher cost = regression
467
468            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        // Compare properties
482        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        // Add node diff
496        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        // Compare children count
514        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        // Recursively compare children
531        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                &current_path,
537                structural_changes,
538                cost_changes,
539                operator_changes,
540                node_diffs,
541                depth + 1,
542            )?;
543        }
544
545        Ok(())
546    }
547
548    /// Calculate quality score based on changes
549    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; // No changes
556        }
557
558        // Calculate cost-based score
559        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        // Normalize to -1.0 to 1.0 range
571        cost_score.clamp(-1.0, 1.0)
572    }
573
574    /// Detect if changes represent a performance regression
575    fn detect_regression(&self, cost_changes: &[CostChange], quality_score: f64) -> bool {
576        if !self.config.highlight_regressions {
577            return false;
578        }
579
580        // Regression if quality score is significantly negative
581        if quality_score < -0.1 {
582            return true;
583        }
584
585        // Regression if any critical cost increases
586        cost_changes
587            .iter()
588            .any(|c| c.is_regression && c.percentage_change > 20.0)
589    }
590
591    /// Estimate performance impact
592    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 // >1.0 = faster, <1.0 = slower
598        } else {
599            1.0
600        }
601    }
602
603    /// Assess impact of operator change
604    fn assess_operator_impact(&self, old_op: &str, new_op: &str) -> OperatorImpact {
605        // Simple heuristic-based assessment
606        match (old_op, new_op) {
607            // Index scan is usually better than table scan
608            ("TableScan", "IndexScan") => OperatorImpact::Positive,
609            ("IndexScan", "TableScan") => OperatorImpact::Negative,
610
611            // Hash join often better than nested loop for large datasets
612            ("NestedLoopJoin", "HashJoin") => OperatorImpact::Positive,
613            ("HashJoin", "NestedLoopJoin") => OperatorImpact::Negative,
614
615            // Merge join can be better for sorted data
616            ("NestedLoopJoin", "MergeJoin") => OperatorImpact::Positive,
617
618            _ => OperatorImpact::Unknown,
619        }
620    }
621
622    /// Generate summary message
623    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    /// Check if this diff indicates a regression
663    pub fn has_regression(&self) -> bool {
664        self.is_regression
665    }
666
667    /// Format a human-readable summary
668    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    /// Export diff as JSON
739    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; // 50% increase
787
788        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; // Only 2% increase, below 5% threshold
831
832        let differ = PlanDiffer::new(DiffConfig::default());
833        let diff = differ.compare_plans(&plan1, &plan2).unwrap();
834
835        // Should not report cost change due to threshold
836        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; // Half the root cost
844                           // Also reduce child costs for more dramatic impact
845        plan2.children[0].cost = 40.0; // Was 80.0
846
847        let differ = PlanDiffer::new(DiffConfig::default());
848        let diff = differ.compare_plans(&plan1, &plan2).unwrap();
849
850        // Performance impact should be positive (>1.0 = faster)
851        // old_total = 250, new_total = 50 + 40 + 30 + 40 = 160
852        // impact = 250 / 160 = 1.56
853        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, // Only compare top level
910            ..Default::default()
911        };
912
913        let differ = PlanDiffer::new(config);
914        let diff = differ.compare_plans(&plan1, &plan2).unwrap();
915
916        // Should have limited node_diffs due to depth restriction
917        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        // Should sum all nodes: 100 + 80 + 30 + 40 = 250
926        assert_eq!(total, 250.0);
927    }
928}