use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffConfig {
pub cost_threshold: f64,
pub include_operator_details: bool,
pub include_cardinality: bool,
pub detect_structural_changes: bool,
pub highlight_regressions: bool,
pub max_depth: usize,
pub ignore_formatting: bool,
}
impl Default for DiffConfig {
fn default() -> Self {
Self {
cost_threshold: 5.0, include_operator_details: true,
include_cardinality: true,
detect_structural_changes: true,
highlight_regressions: true,
max_depth: 0, ignore_formatting: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanDiff {
pub summary: DiffSummary,
pub structural_changes: Vec<StructuralChange>,
pub cost_changes: Vec<CostChange>,
pub operator_changes: Vec<OperatorChange>,
pub is_regression: bool,
pub quality_score: f64,
pub node_diffs: Vec<NodeDiff>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffSummary {
pub total_changes: usize,
pub structural_changes: usize,
pub cost_changes: usize,
pub operator_changes: usize,
pub estimated_impact: f64,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StructuralChange {
pub change_type: StructuralChangeType,
pub path: String,
pub description: String,
pub old_node_type: Option<String>,
pub new_node_type: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum StructuralChangeType {
NodeAdded,
NodeRemoved,
NodeReplaced,
SubtreeReordered,
ChildrenChanged,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostChange {
pub path: String,
pub old_cost: f64,
pub new_cost: f64,
pub percentage_change: f64,
pub is_regression: bool,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OperatorChange {
pub path: String,
pub change_type: OperatorChangeType,
pub old_operator: Option<String>,
pub new_operator: Option<String>,
pub description: String,
pub impact: OperatorImpact,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum OperatorChangeType {
TypeChanged,
ParametersChanged,
StrategyChanged,
IndexUsageChanged,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum OperatorImpact {
Positive,
Neutral,
Negative,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeDiff {
pub path: String,
pub node_type: String,
pub exists_in_both: bool,
pub cost_diff: Option<f64>,
pub cardinality_diff: Option<i64>,
pub property_changes: Vec<PropertyChange>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PropertyChange {
pub name: String,
pub old_value: String,
pub new_value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanNode {
pub id: String,
pub node_type: String,
pub cost: f64,
pub cardinality: usize,
pub properties: HashMap<String, String>,
pub children: Vec<PlanNode>,
}
impl PlanNode {
pub fn new(id: String, node_type: String) -> Self {
Self {
id,
node_type,
cost: 0.0,
cardinality: 0,
properties: HashMap::new(),
children: Vec::new(),
}
}
pub fn with_child(mut self, child: PlanNode) -> Self {
self.children.push(child);
self
}
pub fn with_cost(mut self, cost: f64) -> Self {
self.cost = cost;
self
}
pub fn with_cardinality(mut self, cardinality: usize) -> Self {
self.cardinality = cardinality;
self
}
pub fn with_property(mut self, key: String, value: String) -> Self {
self.properties.insert(key, value);
self
}
#[allow(dead_code)]
fn descendants(&self) -> Vec<&PlanNode> {
let mut result = vec![self];
for child in &self.children {
result.extend(child.descendants());
}
result
}
pub fn total_cost(&self) -> f64 {
self.cost + self.children.iter().map(|c| c.total_cost()).sum::<f64>()
}
}
pub struct PlanDiffer {
config: DiffConfig,
}
impl PlanDiffer {
pub fn new(config: DiffConfig) -> Self {
Self { config }
}
pub fn compare_plans(&self, old_plan: &PlanNode, new_plan: &PlanNode) -> Result<PlanDiff> {
let mut structural_changes = Vec::new();
let mut cost_changes = Vec::new();
let mut operator_changes = Vec::new();
let mut node_diffs = Vec::new();
self.compare_nodes(
old_plan,
new_plan,
"",
&mut structural_changes,
&mut cost_changes,
&mut operator_changes,
&mut node_diffs,
0,
)?;
let quality_score = self.calculate_quality_score(&cost_changes, &structural_changes);
let is_regression = self.detect_regression(&cost_changes, quality_score);
let estimated_impact = self.estimate_performance_impact(old_plan, new_plan);
let summary = DiffSummary {
total_changes: structural_changes.len() + cost_changes.len() + operator_changes.len(),
structural_changes: structural_changes.len(),
cost_changes: cost_changes.len(),
operator_changes: operator_changes.len(),
estimated_impact,
message: self.generate_summary_message(
&structural_changes,
&cost_changes,
is_regression,
),
};
Ok(PlanDiff {
summary,
structural_changes,
cost_changes,
operator_changes,
is_regression,
quality_score,
node_diffs,
})
}
#[allow(clippy::too_many_arguments)]
fn compare_nodes(
&self,
old_node: &PlanNode,
new_node: &PlanNode,
path: &str,
structural_changes: &mut Vec<StructuralChange>,
cost_changes: &mut Vec<CostChange>,
operator_changes: &mut Vec<OperatorChange>,
node_diffs: &mut Vec<NodeDiff>,
depth: usize,
) -> Result<()> {
if self.config.max_depth > 0 && depth >= self.config.max_depth {
return Ok(());
}
let current_path = if path.is_empty() {
old_node.id.clone()
} else {
format!("{}/{}", path, old_node.id)
};
if old_node.node_type != new_node.node_type && self.config.detect_structural_changes {
structural_changes.push(StructuralChange {
change_type: StructuralChangeType::NodeReplaced,
path: current_path.clone(),
description: format!(
"Node type changed from '{}' to '{}'",
old_node.node_type, new_node.node_type
),
old_node_type: Some(old_node.node_type.clone()),
new_node_type: Some(new_node.node_type.clone()),
});
operator_changes.push(OperatorChange {
path: current_path.clone(),
change_type: OperatorChangeType::TypeChanged,
old_operator: Some(old_node.node_type.clone()),
new_operator: Some(new_node.node_type.clone()),
description: format!(
"Operator changed from {} to {}",
old_node.node_type, new_node.node_type
),
impact: self.assess_operator_impact(&old_node.node_type, &new_node.node_type),
});
}
let cost_diff = new_node.cost - old_node.cost;
let cost_pct_change = if old_node.cost > 0.0 {
(cost_diff / old_node.cost) * 100.0
} else {
0.0
};
if cost_pct_change.abs() > self.config.cost_threshold {
let is_regression = cost_diff > 0.0;
cost_changes.push(CostChange {
path: current_path.clone(),
old_cost: old_node.cost,
new_cost: new_node.cost,
percentage_change: cost_pct_change,
is_regression,
description: format!(
"Cost changed by {:.1}% ({:.2} → {:.2})",
cost_pct_change, old_node.cost, new_node.cost
),
});
}
let mut property_changes = Vec::new();
for (key, old_val) in &old_node.properties {
if let Some(new_val) = new_node.properties.get(key) {
if old_val != new_val && !self.config.ignore_formatting {
property_changes.push(PropertyChange {
name: key.clone(),
old_value: old_val.clone(),
new_value: new_val.clone(),
});
}
}
}
node_diffs.push(NodeDiff {
path: current_path.clone(),
node_type: old_node.node_type.clone(),
exists_in_both: true,
cost_diff: if cost_pct_change.abs() > self.config.cost_threshold {
Some(cost_diff)
} else {
None
},
cardinality_diff: if self.config.include_cardinality {
Some(new_node.cardinality as i64 - old_node.cardinality as i64)
} else {
None
},
property_changes,
});
if old_node.children.len() != new_node.children.len()
&& self.config.detect_structural_changes
{
structural_changes.push(StructuralChange {
change_type: StructuralChangeType::ChildrenChanged,
path: current_path.clone(),
description: format!(
"Children count changed from {} to {}",
old_node.children.len(),
new_node.children.len()
),
old_node_type: None,
new_node_type: None,
});
}
let min_children = old_node.children.len().min(new_node.children.len());
for i in 0..min_children {
self.compare_nodes(
&old_node.children[i],
&new_node.children[i],
¤t_path,
structural_changes,
cost_changes,
operator_changes,
node_diffs,
depth + 1,
)?;
}
Ok(())
}
fn calculate_quality_score(
&self,
cost_changes: &[CostChange],
structural_changes: &[StructuralChange],
) -> f64 {
if cost_changes.is_empty() && structural_changes.is_empty() {
return 0.0; }
let cost_score: f64 = cost_changes
.iter()
.map(|c| {
if c.is_regression {
-c.percentage_change / 100.0
} else {
c.percentage_change.abs() / 100.0
}
})
.sum();
cost_score.clamp(-1.0, 1.0)
}
fn detect_regression(&self, cost_changes: &[CostChange], quality_score: f64) -> bool {
if !self.config.highlight_regressions {
return false;
}
if quality_score < -0.1 {
return true;
}
cost_changes
.iter()
.any(|c| c.is_regression && c.percentage_change > 20.0)
}
fn estimate_performance_impact(&self, old_plan: &PlanNode, new_plan: &PlanNode) -> f64 {
let old_cost = old_plan.total_cost();
let new_cost = new_plan.total_cost();
if new_cost > 0.0 {
old_cost / new_cost } else {
1.0
}
}
fn assess_operator_impact(&self, old_op: &str, new_op: &str) -> OperatorImpact {
match (old_op, new_op) {
("TableScan", "IndexScan") => OperatorImpact::Positive,
("IndexScan", "TableScan") => OperatorImpact::Negative,
("NestedLoopJoin", "HashJoin") => OperatorImpact::Positive,
("HashJoin", "NestedLoopJoin") => OperatorImpact::Negative,
("NestedLoopJoin", "MergeJoin") => OperatorImpact::Positive,
_ => OperatorImpact::Unknown,
}
}
fn generate_summary_message(
&self,
structural_changes: &[StructuralChange],
cost_changes: &[CostChange],
is_regression: bool,
) -> String {
if structural_changes.is_empty() && cost_changes.is_empty() {
return "Plans are identical".to_string();
}
let mut parts = Vec::new();
if !structural_changes.is_empty() {
parts.push(format!("{} structural change(s)", structural_changes.len()));
}
if !cost_changes.is_empty() {
let improvements = cost_changes.iter().filter(|c| !c.is_regression).count();
let regressions = cost_changes.iter().filter(|c| c.is_regression).count();
if improvements > 0 {
parts.push(format!("{} cost improvement(s)", improvements));
}
if regressions > 0 {
parts.push(format!("{} cost regression(s)", regressions));
}
}
let message = parts.join(", ");
if is_regression {
format!("⚠️ REGRESSION DETECTED: {}", message)
} else {
message
}
}
}
impl PlanDiff {
pub fn has_regression(&self) -> bool {
self.is_regression
}
pub fn format_summary(&self) -> String {
let mut output = String::new();
output.push_str("# Query Plan Comparison Summary\n\n");
output.push_str(&format!("{}\n\n", self.summary.message));
if self.is_regression {
output.push_str("## ⚠️ Performance Regression Detected\n\n");
}
output.push_str(&format!(
"**Total Changes**: {}\n",
self.summary.total_changes
));
output.push_str(&format!(
"**Estimated Performance Impact**: {:.2}x\n",
self.summary.estimated_impact
));
output.push_str(&format!("**Quality Score**: {:.2}\n\n", self.quality_score));
if !self.cost_changes.is_empty() {
output.push_str("## Cost Changes\n\n");
for (i, change) in self.cost_changes.iter().enumerate() {
let indicator = if change.is_regression { "📈" } else { "📉" };
output.push_str(&format!(
"{}. {} **{}**: {}\n",
i + 1,
indicator,
change.path,
change.description
));
}
output.push('\n');
}
if !self.structural_changes.is_empty() {
output.push_str("## Structural Changes\n\n");
for (i, change) in self.structural_changes.iter().enumerate() {
output.push_str(&format!(
"{}. **{}**: {}\n",
i + 1,
change.path,
change.description
));
}
output.push('\n');
}
if !self.operator_changes.is_empty() {
output.push_str("## Operator Changes\n\n");
for (i, change) in self.operator_changes.iter().enumerate() {
let impact_icon = match change.impact {
OperatorImpact::Positive => "✅",
OperatorImpact::Negative => "❌",
OperatorImpact::Neutral => "➖",
OperatorImpact::Unknown => "❓",
};
output.push_str(&format!(
"{}. {} **{}**: {}\n",
i + 1,
impact_icon,
change.path,
change.description
));
}
}
output
}
pub fn to_json(&self) -> Result<String> {
serde_json::to_string_pretty(self)
.map_err(|e| anyhow!("Failed to serialize diff to JSON: {}", e))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_sample_plan() -> PlanNode {
PlanNode::new("root".to_string(), "Project".to_string())
.with_cost(100.0)
.with_cardinality(1000)
.with_child(
PlanNode::new("join".to_string(), "HashJoin".to_string())
.with_cost(80.0)
.with_cardinality(500)
.with_child(
PlanNode::new("scan1".to_string(), "IndexScan".to_string())
.with_cost(30.0)
.with_cardinality(100),
)
.with_child(
PlanNode::new("scan2".to_string(), "IndexScan".to_string())
.with_cost(40.0)
.with_cardinality(200),
),
)
}
#[test]
fn test_identical_plans() {
let plan1 = create_sample_plan();
let plan2 = create_sample_plan();
let differ = PlanDiffer::new(DiffConfig::default());
let diff = differ.compare_plans(&plan1, &plan2).unwrap();
assert_eq!(diff.summary.total_changes, 0);
assert!(!diff.has_regression());
}
#[test]
fn test_cost_increase_regression() {
let plan1 = create_sample_plan();
let mut plan2 = create_sample_plan();
plan2.cost = 150.0;
let differ = PlanDiffer::new(DiffConfig::default());
let diff = differ.compare_plans(&plan1, &plan2).unwrap();
assert!(!diff.cost_changes.is_empty());
assert!(diff.cost_changes[0].is_regression);
assert!(diff.quality_score < 0.0);
}
#[test]
fn test_operator_change_detection() {
let plan1 = create_sample_plan();
let mut plan2 = create_sample_plan();
plan2.children[0].node_type = "NestedLoopJoin".to_string();
let differ = PlanDiffer::new(DiffConfig::default());
let diff = differ.compare_plans(&plan1, &plan2).unwrap();
assert!(!diff.operator_changes.is_empty());
assert_eq!(
diff.operator_changes[0].change_type,
OperatorChangeType::TypeChanged
);
}
#[test]
fn test_structural_change_children_count() {
let plan1 = create_sample_plan();
let mut plan2 = create_sample_plan();
plan2.children[0]
.children
.push(PlanNode::new("scan3".to_string(), "TableScan".to_string()).with_cost(10.0));
let differ = PlanDiffer::new(DiffConfig::default());
let diff = differ.compare_plans(&plan1, &plan2).unwrap();
assert!(!diff.structural_changes.is_empty());
}
#[test]
fn test_cost_threshold_filtering() {
let plan1 = create_sample_plan();
let mut plan2 = create_sample_plan();
plan2.cost = 102.0;
let differ = PlanDiffer::new(DiffConfig::default());
let diff = differ.compare_plans(&plan1, &plan2).unwrap();
assert!(diff.cost_changes.is_empty());
}
#[test]
fn test_performance_impact_calculation() {
let plan1 = create_sample_plan();
let mut plan2 = create_sample_plan();
plan2.cost = 50.0; plan2.children[0].cost = 40.0;
let differ = PlanDiffer::new(DiffConfig::default());
let diff = differ.compare_plans(&plan1, &plan2).unwrap();
assert!(diff.summary.estimated_impact > 1.5);
}
#[test]
fn test_summary_formatting() {
let plan1 = create_sample_plan();
let mut plan2 = create_sample_plan();
plan2.cost = 150.0;
let differ = PlanDiffer::new(DiffConfig::default());
let diff = differ.compare_plans(&plan1, &plan2).unwrap();
let summary = diff.format_summary();
assert!(summary.contains("Query Plan Comparison Summary"));
assert!(summary.contains("Cost Changes"));
}
#[test]
fn test_json_export() {
let plan1 = create_sample_plan();
let plan2 = create_sample_plan();
let differ = PlanDiffer::new(DiffConfig::default());
let diff = differ.compare_plans(&plan1, &plan2).unwrap();
let json = diff.to_json().unwrap();
assert!(json.contains("summary"));
assert!(json.contains("quality_score"));
}
#[test]
fn test_operator_impact_assessment() {
let differ = PlanDiffer::new(DiffConfig::default());
assert_eq!(
differ.assess_operator_impact("TableScan", "IndexScan"),
OperatorImpact::Positive
);
assert_eq!(
differ.assess_operator_impact("IndexScan", "TableScan"),
OperatorImpact::Negative
);
assert_eq!(
differ.assess_operator_impact("NestedLoopJoin", "HashJoin"),
OperatorImpact::Positive
);
}
#[test]
fn test_depth_limiting() {
let plan1 = create_sample_plan();
let plan2 = create_sample_plan();
let config = DiffConfig {
max_depth: 1, ..Default::default()
};
let differ = PlanDiffer::new(config);
let diff = differ.compare_plans(&plan1, &plan2).unwrap();
assert!(diff.node_diffs.len() <= 2);
}
#[test]
fn test_total_cost_calculation() {
let plan = create_sample_plan();
let total = plan.total_cost();
assert_eq!(total, 250.0);
}
}