1use std::collections::BTreeSet;
2
3use serde::Serialize;
4
5use plexus_engine::required_capabilities;
6use plexus_serde::Plan;
7
8use crate::explain::op_kind;
9
10#[derive(Debug, Clone, Serialize)]
11pub struct PlanDiffReport {
12 pub old_version: String,
13 pub new_version: String,
14 pub root_changed: bool,
15 pub op_count_old: usize,
16 pub op_count_new: usize,
17 pub changed_ops: Vec<ChangedOp>,
18 pub added_required_ops: Vec<String>,
19 pub removed_required_ops: Vec<String>,
20 pub added_required_exprs: Vec<String>,
21 pub removed_required_exprs: Vec<String>,
22 pub risk_flags: Vec<String>,
23}
24
25#[derive(Debug, Clone, Serialize)]
26pub struct ChangedOp {
27 pub index: usize,
28 pub old_kind: Option<String>,
29 pub new_kind: Option<String>,
30}
31
32pub fn diff_plans(old: &Plan, new: &Plan) -> PlanDiffReport {
33 let old_caps = required_capabilities(old);
34 let new_caps = required_capabilities(new);
35
36 let old_ops: BTreeSet<_> = old_caps
37 .required_ops
38 .iter()
39 .map(|k| k.as_str().to_string())
40 .collect();
41 let new_ops: BTreeSet<_> = new_caps
42 .required_ops
43 .iter()
44 .map(|k| k.as_str().to_string())
45 .collect();
46 let old_exprs: BTreeSet<_> = old_caps
47 .required_exprs
48 .iter()
49 .map(|k| k.as_str().to_string())
50 .collect();
51 let new_exprs: BTreeSet<_> = new_caps
52 .required_exprs
53 .iter()
54 .map(|k| k.as_str().to_string())
55 .collect();
56
57 let changed_ops = diff_ops(old, new);
58 let mut risk_flags = Vec::new();
59 if count_kind(new, "Sort") < count_kind(old, "Sort")
60 && count_kind(new, "Limit") == count_kind(old, "Limit")
61 {
62 risk_flags.push("sort_count_dropped_with_limit_present".to_string());
63 }
64 if old.root_op != new.root_op {
65 risk_flags.push("root_op_changed".to_string());
66 }
67
68 PlanDiffReport {
69 old_version: format_version(old),
70 new_version: format_version(new),
71 root_changed: old.root_op != new.root_op,
72 op_count_old: old.ops.len(),
73 op_count_new: new.ops.len(),
74 changed_ops,
75 added_required_ops: new_ops.difference(&old_ops).cloned().collect(),
76 removed_required_ops: old_ops.difference(&new_ops).cloned().collect(),
77 added_required_exprs: new_exprs.difference(&old_exprs).cloned().collect(),
78 removed_required_exprs: old_exprs.difference(&new_exprs).cloned().collect(),
79 risk_flags,
80 }
81}
82
83fn diff_ops(old: &Plan, new: &Plan) -> Vec<ChangedOp> {
84 let max = old.ops.len().max(new.ops.len());
85 let mut out = Vec::new();
86
87 for index in 0..max {
88 let o = old.ops.get(index);
89 let n = new.ops.get(index);
90 if o == n {
91 continue;
92 }
93
94 out.push(ChangedOp {
95 index,
96 old_kind: o.map(|op| op_kind(op).as_str().to_string()),
97 new_kind: n.map(|op| op_kind(op).as_str().to_string()),
98 });
99 }
100
101 out
102}
103
104fn count_kind(plan: &Plan, kind: &str) -> usize {
105 plan.ops
106 .iter()
107 .filter(|op| op_kind(op).as_str() == kind)
108 .count()
109}
110
111fn format_version(plan: &Plan) -> String {
112 format!(
113 "{}.{}.{} ({})",
114 plan.version.major, plan.version.minor, plan.version.patch, plan.version.producer
115 )
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121 use plexus_serde::{current_plan_version, ColDef, ColKind, Op, Plan, SortDir};
122
123 #[test]
124 fn detects_changed_ops() {
125 let v = current_plan_version("test");
126 let old = Plan {
127 version: v.clone(),
128 ops: vec![
129 Op::ScanNodes {
130 labels: Vec::new(),
131 schema: vec![ColDef {
132 name: "n".to_string(),
133 kind: ColKind::Node,
134 logical_type: plexus_serde::LogicalType::Unknown,
135 }],
136 must_labels: Vec::new(),
137 forbidden_labels: Vec::new(),
138 est_rows: 1,
139 selectivity: 1.0,
140 graph_ref: None,
141 },
142 Op::Sort {
143 input: 0,
144 keys: vec![0],
145 dirs: vec![SortDir::Asc],
146 },
147 Op::Limit {
148 input: 1,
149 count: 10,
150 skip: 0,
151 cursor: None,
152 emit_cursor: false,
153 },
154 Op::Return { input: 2 },
155 ],
156 root_op: 3,
157 };
158
159 let new = Plan {
160 version: v,
161 ops: vec![
162 old.ops[0].clone(),
163 Op::Limit {
164 input: 0,
165 count: 10,
166 skip: 0,
167 cursor: None,
168 emit_cursor: false,
169 },
170 Op::Return { input: 1 },
171 ],
172 root_op: 2,
173 };
174
175 let report = diff_plans(&old, &new);
176 assert!(report.root_changed);
177 assert!(report
178 .risk_flags
179 .iter()
180 .any(|x| x == "sort_count_dropped_with_limit_present"));
181 assert!(!report.changed_ops.is_empty());
182 }
183}