Skip to main content

pgevolve_core/diff/
statistics.rs

1//! Differ for statistics. Per-statistic granular diff:
2//! - Structural change (columns / kinds / target) → `ReplaceStatistic` (skip the rest).
3//! - `statistics_target` differs → `AlterStatisticSetTarget`.
4//! - owner differs (lenient) → `AlterObjectOwner`.
5//! - comment differs → `CommentOnStatistic`.
6//!
7//! Lenient: target-only statistics do NOT emit `DropStatistic` (surfaces via
8//! unmanaged-statistic lint in Stage 9).
9//!
10//! Spec: `docs/superpowers/specs/2026-05-27-statistics-and-check-option-design.md`.
11
12use std::collections::BTreeMap;
13
14use crate::diff::change::{Change, StatisticChange};
15use crate::diff::changeset::ChangeSet;
16use crate::diff::destructiveness::Destructiveness;
17use crate::diff::owner_op::{AlterObjectOwner, OwnerObjectKind};
18use crate::identifier::QualifiedName;
19use crate::ir::catalog::Catalog;
20use crate::ir::statistic::Statistic;
21
22/// Compute granular statistic changes needed to converge `target` toward
23/// `source`. Appends all emitted changes to `out`.
24pub fn diff_statistics(target: &Catalog, source: &Catalog, out: &mut ChangeSet) {
25    let target_map: BTreeMap<&QualifiedName, &Statistic> =
26        target.statistics.iter().map(|s| (&s.qname, s)).collect();
27    let source_map: BTreeMap<&QualifiedName, &Statistic> =
28        source.statistics.iter().map(|s| (&s.qname, s)).collect();
29
30    // Creates: in source but not in target.
31    for (qname, src) in &source_map {
32        if !target_map.contains_key(qname) {
33            out.push(
34                Change::Statistic(StatisticChange::Create((*src).clone())),
35                Destructiveness::Safe,
36            );
37        }
38    }
39
40    // Target-only: lenient — no auto-drop. Surfaces via unmanaged-statistic lint.
41    // Intentionally no-op; Stage 9 adds the unmanaged-statistic lint rule.
42
43    // Modifies: in both.
44    for (qname, src) in &source_map {
45        let Some(tgt) = target_map.get(qname) else {
46            continue;
47        };
48        diff_one(tgt, src, out);
49    }
50}
51
52fn diff_one(target: &Statistic, source: &Statistic, out: &mut ChangeSet) {
53    // Structural change → ReplaceStatistic; skip the rest for this statistic.
54    if target.columns != source.columns
55        || target.kinds != source.kinds
56        || target.target != source.target
57    {
58        out.push(
59            Change::Statistic(StatisticChange::Replace {
60                from: target.clone(),
61                to: source.clone(),
62            }),
63            Destructiveness::RequiresApproval {
64                reason: format!(
65                    "structural change to statistic {} requires DROP + CREATE (PG has no in-place ALTER for columns/kinds/target)",
66                    source.qname
67                ),
68            },
69        );
70        return;
71    }
72
73    // statistics_target diff — lenient: only emit when source declares a value.
74    if let Some(s_target) = source.statistics_target
75        && target.statistics_target != Some(s_target)
76    {
77        out.push(
78            Change::Statistic(StatisticChange::AlterSetTarget {
79                qname: source.qname.clone(),
80                value: s_target,
81            }),
82            Destructiveness::Safe,
83        );
84    }
85
86    // Owner: v0.3.1 lenient — only emit when source declares an owner and it
87    // differs from target. Source `None` = unmanaged, no change emitted.
88    if let Some(s_owner) = &source.owner
89        && target.owner.as_ref() != Some(s_owner)
90    {
91        out.push(
92            Change::AlterObjectOwner(AlterObjectOwner {
93                kind: OwnerObjectKind::Statistic,
94                id: crate::diff::owner_op::OwnedObjectId::Qualified(source.qname.clone()),
95                signature: String::new(),
96                from: target.owner.clone(),
97                to: s_owner.clone(),
98            }),
99            Destructiveness::Safe,
100        );
101    }
102
103    // Comment.
104    if target.comment != source.comment {
105        out.push(
106            Change::Statistic(StatisticChange::CommentOn {
107                qname: source.qname.clone(),
108                comment: source.comment.clone(),
109            }),
110            Destructiveness::Safe,
111        );
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::diff::change::{Change, StatisticChange};
119    use crate::identifier::{Identifier, QualifiedName};
120    use crate::ir::catalog::Catalog;
121    use crate::ir::statistic::{Statistic, StatisticColumn, StatisticKinds};
122
123    fn id(s: &str) -> Identifier {
124        Identifier::from_unquoted(s).unwrap()
125    }
126
127    fn qn(schema: &str, name: &str) -> QualifiedName {
128        QualifiedName::new(id(schema), id(name))
129    }
130
131    fn basic_statistic(stat_name: &str, table_name: &str) -> Statistic {
132        Statistic {
133            qname: qn("app", stat_name),
134            target: qn("app", table_name),
135            kinds: StatisticKinds::pg_default(),
136            columns: vec![
137                StatisticColumn::Column(id("a")),
138                StatisticColumn::Column(id("b")),
139            ],
140            statistics_target: None,
141            owner: None,
142            comment: None,
143        }
144    }
145
146    fn catalog_with(stats: Vec<Statistic>) -> Catalog {
147        let mut c = Catalog::empty();
148        c.statistics = stats;
149        c
150    }
151
152    fn run_diff(target: &Catalog, source: &Catalog) -> ChangeSet {
153        let mut out = ChangeSet::new();
154        diff_statistics(target, source, &mut out);
155        out
156    }
157
158    // ---- creates ----
159
160    #[test]
161    fn create_statistic_when_source_has_it_and_target_doesnt() {
162        let target = Catalog::empty();
163        let source = catalog_with(vec![basic_statistic("s", "t")]);
164        let changes = run_diff(&target, &source);
165        assert_eq!(changes.len(), 1);
166        assert!(matches!(
167            changes.iter().next().unwrap().change,
168            Change::Statistic(StatisticChange::Create(_))
169        ));
170    }
171
172    #[test]
173    fn create_statistic_is_safe() {
174        let target = Catalog::empty();
175        let source = catalog_with(vec![basic_statistic("s", "t")]);
176        let changes = run_diff(&target, &source);
177        let entry = changes.iter().next().unwrap();
178        assert!(!entry.destructiveness.requires_approval());
179    }
180
181    // ---- lenient: no auto-drop ----
182
183    #[test]
184    fn no_drop_when_target_has_statistic_but_source_doesnt() {
185        let target = catalog_with(vec![basic_statistic("s", "t")]);
186        let source = Catalog::empty();
187        let changes = run_diff(&target, &source);
188        assert!(
189            changes.is_empty(),
190            "expected no changes (lenient), got {changes:?}"
191        );
192    }
193
194    // ---- identical: no diff ----
195
196    #[test]
197    fn identical_statistics_produce_no_changes() {
198        let c = catalog_with(vec![basic_statistic("s", "t")]);
199        let changes = run_diff(&c, &c);
200        assert!(changes.is_empty());
201    }
202
203    // ---- structural changes → ReplaceStatistic ----
204
205    #[test]
206    fn columns_differ_emits_replace_statistic() {
207        let mut src_stat = basic_statistic("s", "t");
208        src_stat.columns = vec![
209            StatisticColumn::Column(id("a")),
210            StatisticColumn::Column(id("c")),
211        ];
212        let target = catalog_with(vec![basic_statistic("s", "t")]);
213        let source = catalog_with(vec![src_stat]);
214        let changes = run_diff(&target, &source);
215        assert_eq!(changes.len(), 1);
216        let entry = changes.iter().next().unwrap();
217        assert!(
218            matches!(
219                entry.change,
220                Change::Statistic(StatisticChange::Replace { .. })
221            ),
222            "expected ReplaceStatistic, got {:?}",
223            entry.change
224        );
225        assert!(
226            entry.destructiveness.requires_approval(),
227            "structural change must be RequiresApproval"
228        );
229    }
230
231    #[test]
232    fn kinds_differ_emits_replace_statistic() {
233        let mut src_stat = basic_statistic("s", "t");
234        src_stat.kinds = StatisticKinds {
235            ndistinct: true,
236            dependencies: false,
237            mcv: false,
238        };
239        let target = catalog_with(vec![basic_statistic("s", "t")]);
240        let source = catalog_with(vec![src_stat]);
241        let changes = run_diff(&target, &source);
242        assert_eq!(changes.len(), 1);
243        assert!(matches!(
244            changes.iter().next().unwrap().change,
245            Change::Statistic(StatisticChange::Replace { .. })
246        ));
247    }
248
249    #[test]
250    fn target_table_differs_emits_replace_statistic() {
251        let mut src_stat = basic_statistic("s", "t");
252        src_stat.target = qn("app", "t2");
253        let target = catalog_with(vec![basic_statistic("s", "t")]);
254        let source = catalog_with(vec![src_stat]);
255        let changes = run_diff(&target, &source);
256        assert_eq!(changes.len(), 1);
257        assert!(matches!(
258            changes.iter().next().unwrap().change,
259            Change::Statistic(StatisticChange::Replace { .. })
260        ));
261    }
262
263    #[test]
264    fn structural_change_skips_downstream_per_field_checks() {
265        // Even if statistics_target and owner also differ, only ReplaceStatistic is emitted.
266        let mut tgt_stat = basic_statistic("s", "t");
267        tgt_stat.statistics_target = Some(100);
268        tgt_stat.owner = Some(id("alice"));
269        tgt_stat.comment = Some("old".into());
270
271        let mut src_stat = basic_statistic("s", "t");
272        src_stat.columns = vec![StatisticColumn::Column(id("x"))]; // structural diff
273        src_stat.statistics_target = Some(200);
274        src_stat.owner = Some(id("bob"));
275        src_stat.comment = Some("new".into());
276
277        let target = catalog_with(vec![tgt_stat]);
278        let source = catalog_with(vec![src_stat]);
279        let changes = run_diff(&target, &source);
280        assert_eq!(
281            changes.len(),
282            1,
283            "only ReplaceStatistic, no downstream diffs"
284        );
285        assert!(matches!(
286            changes.iter().next().unwrap().change,
287            Change::Statistic(StatisticChange::Replace { .. })
288        ));
289    }
290
291    // ---- statistics_target diff ----
292
293    #[test]
294    fn only_statistics_target_differs_emits_alter_statistic_set_target() {
295        let mut src_stat = basic_statistic("s", "t");
296        src_stat.statistics_target = Some(500);
297        let target = catalog_with(vec![basic_statistic("s", "t")]); // None
298        let source = catalog_with(vec![src_stat]);
299        let changes = run_diff(&target, &source);
300        assert_eq!(changes.len(), 1);
301        assert!(matches!(
302            changes.iter().next().unwrap().change,
303            Change::Statistic(StatisticChange::AlterSetTarget { value: 500, .. })
304        ));
305    }
306
307    #[test]
308    fn source_statistics_target_none_does_not_trigger_diff() {
309        // Source `None` = unmanaged; no change emitted even if target has a value.
310        let mut tgt_stat = basic_statistic("s", "t");
311        tgt_stat.statistics_target = Some(500);
312        let src_stat = basic_statistic("s", "t"); // statistics_target = None
313        let target = catalog_with(vec![tgt_stat]);
314        let source = catalog_with(vec![src_stat]);
315        let changes = run_diff(&target, &source);
316        assert!(
317            changes.is_empty(),
318            "source statistics_target=None must not trigger diff (lenient)"
319        );
320    }
321
322    // ---- owner diff ----
323
324    #[test]
325    fn owner_change_emits_alter_object_owner() {
326        let mut tgt_stat = basic_statistic("s", "t");
327        tgt_stat.owner = Some(id("alice"));
328        let mut src_stat = basic_statistic("s", "t");
329        src_stat.owner = Some(id("bob"));
330        let target = catalog_with(vec![tgt_stat]);
331        let source = catalog_with(vec![src_stat]);
332        let changes = run_diff(&target, &source);
333        assert_eq!(changes.len(), 1);
334        assert!(matches!(
335            changes.iter().next().unwrap().change,
336            Change::AlterObjectOwner(_)
337        ));
338    }
339
340    #[test]
341    fn no_owner_change_when_source_owner_is_none() {
342        // Source `None` = unmanaged; no change emitted.
343        let mut tgt_stat = basic_statistic("s", "t");
344        tgt_stat.owner = Some(id("alice"));
345        let src_stat = basic_statistic("s", "t"); // owner = None
346        let target = catalog_with(vec![tgt_stat]);
347        let source = catalog_with(vec![src_stat]);
348        let changes = run_diff(&target, &source);
349        assert!(
350            changes.is_empty(),
351            "source owner None = unmanaged, no change expected"
352        );
353    }
354
355    // ---- comment diff ----
356
357    #[test]
358    fn comment_change_emits_comment_on_statistic() {
359        let src_stat = {
360            let mut s = basic_statistic("s", "t");
361            s.comment = Some("my stat".into());
362            s
363        };
364        let target = catalog_with(vec![basic_statistic("s", "t")]);
365        let source = catalog_with(vec![src_stat]);
366        let changes = run_diff(&target, &source);
367        assert_eq!(changes.len(), 1);
368        assert!(matches!(
369            changes.iter().next().unwrap().change,
370            Change::Statistic(StatisticChange::CommentOn { .. })
371        ));
372    }
373
374    #[test]
375    fn clear_comment_emits_comment_on_statistic_with_none() {
376        let mut tgt_stat = basic_statistic("s", "t");
377        tgt_stat.comment = Some("old comment".into());
378        let src_stat = basic_statistic("s", "t"); // comment = None
379        let target = catalog_with(vec![tgt_stat]);
380        let source = catalog_with(vec![src_stat]);
381        let changes = run_diff(&target, &source);
382        assert_eq!(changes.len(), 1);
383        let entry = changes.iter().next().unwrap();
384        if let Change::Statistic(StatisticChange::CommentOn { comment, .. }) = &entry.change {
385            assert!(comment.is_none());
386        } else {
387            panic!("expected CommentOnStatistic, got {:?}", entry.change);
388        }
389    }
390
391    // ---- multiple independent fields changed ----
392
393    #[test]
394    fn statistics_target_and_comment_both_changed_emit_two_changes() {
395        let tgt_stat = basic_statistic("s", "t"); // no target, no comment
396        let mut src_stat = basic_statistic("s", "t");
397        src_stat.statistics_target = Some(200);
398        src_stat.comment = Some("new comment".into());
399        let target = catalog_with(vec![tgt_stat]);
400        let source = catalog_with(vec![src_stat]);
401        let changes = run_diff(&target, &source);
402        assert_eq!(changes.len(), 2);
403        assert!(
404            changes.iter().any(|e| matches!(
405                &e.change,
406                Change::Statistic(StatisticChange::AlterSetTarget { .. })
407            )),
408            "expected StatisticChange::AlterSetTarget in changes"
409        );
410        assert!(
411            changes.iter().any(|e| matches!(
412                &e.change,
413                Change::Statistic(StatisticChange::CommentOn { .. })
414            )),
415            "expected StatisticChange::CommentOn in changes"
416        );
417    }
418}