Skip to main content

plexus_devtools/
diff.rs

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}