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;
15use crate::diff::changeset::ChangeSet;
16use crate::diff::destructiveness::Destructiveness;
17use crate::diff::owner_op::{AlterObjectOwner, OwnerObjectKind};
18use crate::identifier::{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::CreateStatistic((*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::ReplaceStatistic {
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::AlterStatisticSetTarget {
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        let from = target.owner.clone().unwrap_or_else(|| {
92            Identifier::from_unquoted("__unknown_owner__")
93                .expect("literal is always a valid unquoted identifier")
94        });
95        out.push(
96            Change::AlterObjectOwner(AlterObjectOwner {
97                kind: OwnerObjectKind::Statistic,
98                qname: source.qname.clone(),
99                signature: String::new(),
100                from,
101                to: s_owner.clone(),
102            }),
103            Destructiveness::Safe,
104        );
105    }
106
107    // Comment.
108    if target.comment != source.comment {
109        out.push(
110            Change::CommentOnStatistic {
111                qname: source.qname.clone(),
112                comment: source.comment.clone(),
113            },
114            Destructiveness::Safe,
115        );
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::diff::change::Change;
123    use crate::identifier::{Identifier, QualifiedName};
124    use crate::ir::catalog::Catalog;
125    use crate::ir::statistic::{Statistic, StatisticColumn, StatisticKinds};
126
127    fn id(s: &str) -> Identifier {
128        Identifier::from_unquoted(s).unwrap()
129    }
130
131    fn qn(schema: &str, name: &str) -> QualifiedName {
132        QualifiedName::new(id(schema), id(name))
133    }
134
135    fn basic_statistic(stat_name: &str, table_name: &str) -> Statistic {
136        Statistic {
137            qname: qn("app", stat_name),
138            target: qn("app", table_name),
139            kinds: StatisticKinds::pg_default(),
140            columns: vec![
141                StatisticColumn::Column(id("a")),
142                StatisticColumn::Column(id("b")),
143            ],
144            statistics_target: None,
145            owner: None,
146            comment: None,
147        }
148    }
149
150    fn catalog_with(stats: Vec<Statistic>) -> Catalog {
151        let mut c = Catalog::empty();
152        c.statistics = stats;
153        c
154    }
155
156    fn run_diff(target: &Catalog, source: &Catalog) -> ChangeSet {
157        let mut out = ChangeSet::new();
158        diff_statistics(target, source, &mut out);
159        out
160    }
161
162    // ---- creates ----
163
164    #[test]
165    fn create_statistic_when_source_has_it_and_target_doesnt() {
166        let target = Catalog::empty();
167        let source = catalog_with(vec![basic_statistic("s", "t")]);
168        let changes = run_diff(&target, &source);
169        assert_eq!(changes.len(), 1);
170        assert!(matches!(
171            changes.iter().next().unwrap().change,
172            Change::CreateStatistic(_)
173        ));
174    }
175
176    #[test]
177    fn create_statistic_is_safe() {
178        let target = Catalog::empty();
179        let source = catalog_with(vec![basic_statistic("s", "t")]);
180        let changes = run_diff(&target, &source);
181        let entry = changes.iter().next().unwrap();
182        assert!(!entry.destructiveness.requires_approval());
183    }
184
185    // ---- lenient: no auto-drop ----
186
187    #[test]
188    fn no_drop_when_target_has_statistic_but_source_doesnt() {
189        let target = catalog_with(vec![basic_statistic("s", "t")]);
190        let source = Catalog::empty();
191        let changes = run_diff(&target, &source);
192        assert!(
193            changes.is_empty(),
194            "expected no changes (lenient), got {changes:?}"
195        );
196    }
197
198    // ---- identical: no diff ----
199
200    #[test]
201    fn identical_statistics_produce_no_changes() {
202        let c = catalog_with(vec![basic_statistic("s", "t")]);
203        let changes = run_diff(&c, &c);
204        assert!(changes.is_empty());
205    }
206
207    // ---- structural changes → ReplaceStatistic ----
208
209    #[test]
210    fn columns_differ_emits_replace_statistic() {
211        let mut src_stat = basic_statistic("s", "t");
212        src_stat.columns = vec![
213            StatisticColumn::Column(id("a")),
214            StatisticColumn::Column(id("c")),
215        ];
216        let target = catalog_with(vec![basic_statistic("s", "t")]);
217        let source = catalog_with(vec![src_stat]);
218        let changes = run_diff(&target, &source);
219        assert_eq!(changes.len(), 1);
220        let entry = changes.iter().next().unwrap();
221        assert!(
222            matches!(entry.change, Change::ReplaceStatistic { .. }),
223            "expected ReplaceStatistic, got {:?}",
224            entry.change
225        );
226        assert!(
227            entry.destructiveness.requires_approval(),
228            "structural change must be RequiresApproval"
229        );
230    }
231
232    #[test]
233    fn kinds_differ_emits_replace_statistic() {
234        let mut src_stat = basic_statistic("s", "t");
235        src_stat.kinds = StatisticKinds {
236            ndistinct: true,
237            dependencies: false,
238            mcv: false,
239        };
240        let target = catalog_with(vec![basic_statistic("s", "t")]);
241        let source = catalog_with(vec![src_stat]);
242        let changes = run_diff(&target, &source);
243        assert_eq!(changes.len(), 1);
244        assert!(matches!(
245            changes.iter().next().unwrap().change,
246            Change::ReplaceStatistic { .. }
247        ));
248    }
249
250    #[test]
251    fn target_table_differs_emits_replace_statistic() {
252        let mut src_stat = basic_statistic("s", "t");
253        src_stat.target = qn("app", "t2");
254        let target = catalog_with(vec![basic_statistic("s", "t")]);
255        let source = catalog_with(vec![src_stat]);
256        let changes = run_diff(&target, &source);
257        assert_eq!(changes.len(), 1);
258        assert!(matches!(
259            changes.iter().next().unwrap().change,
260            Change::ReplaceStatistic { .. }
261        ));
262    }
263
264    #[test]
265    fn structural_change_skips_downstream_per_field_checks() {
266        // Even if statistics_target and owner also differ, only ReplaceStatistic is emitted.
267        let mut tgt_stat = basic_statistic("s", "t");
268        tgt_stat.statistics_target = Some(100);
269        tgt_stat.owner = Some(id("alice"));
270        tgt_stat.comment = Some("old".into());
271
272        let mut src_stat = basic_statistic("s", "t");
273        src_stat.columns = vec![StatisticColumn::Column(id("x"))]; // structural diff
274        src_stat.statistics_target = Some(200);
275        src_stat.owner = Some(id("bob"));
276        src_stat.comment = Some("new".into());
277
278        let target = catalog_with(vec![tgt_stat]);
279        let source = catalog_with(vec![src_stat]);
280        let changes = run_diff(&target, &source);
281        assert_eq!(
282            changes.len(),
283            1,
284            "only ReplaceStatistic, no downstream diffs"
285        );
286        assert!(matches!(
287            changes.iter().next().unwrap().change,
288            Change::ReplaceStatistic { .. }
289        ));
290    }
291
292    // ---- statistics_target diff ----
293
294    #[test]
295    fn only_statistics_target_differs_emits_alter_statistic_set_target() {
296        let mut src_stat = basic_statistic("s", "t");
297        src_stat.statistics_target = Some(500);
298        let target = catalog_with(vec![basic_statistic("s", "t")]); // None
299        let source = catalog_with(vec![src_stat]);
300        let changes = run_diff(&target, &source);
301        assert_eq!(changes.len(), 1);
302        assert!(matches!(
303            changes.iter().next().unwrap().change,
304            Change::AlterStatisticSetTarget { value: 500, .. }
305        ));
306    }
307
308    #[test]
309    fn source_statistics_target_none_does_not_trigger_diff() {
310        // Source `None` = unmanaged; no change emitted even if target has a value.
311        let mut tgt_stat = basic_statistic("s", "t");
312        tgt_stat.statistics_target = Some(500);
313        let src_stat = basic_statistic("s", "t"); // statistics_target = None
314        let target = catalog_with(vec![tgt_stat]);
315        let source = catalog_with(vec![src_stat]);
316        let changes = run_diff(&target, &source);
317        assert!(
318            changes.is_empty(),
319            "source statistics_target=None must not trigger diff (lenient)"
320        );
321    }
322
323    // ---- owner diff ----
324
325    #[test]
326    fn owner_change_emits_alter_object_owner() {
327        let mut tgt_stat = basic_statistic("s", "t");
328        tgt_stat.owner = Some(id("alice"));
329        let mut src_stat = basic_statistic("s", "t");
330        src_stat.owner = Some(id("bob"));
331        let target = catalog_with(vec![tgt_stat]);
332        let source = catalog_with(vec![src_stat]);
333        let changes = run_diff(&target, &source);
334        assert_eq!(changes.len(), 1);
335        assert!(matches!(
336            changes.iter().next().unwrap().change,
337            Change::AlterObjectOwner(_)
338        ));
339    }
340
341    #[test]
342    fn no_owner_change_when_source_owner_is_none() {
343        // Source `None` = unmanaged; no change emitted.
344        let mut tgt_stat = basic_statistic("s", "t");
345        tgt_stat.owner = Some(id("alice"));
346        let src_stat = basic_statistic("s", "t"); // owner = None
347        let target = catalog_with(vec![tgt_stat]);
348        let source = catalog_with(vec![src_stat]);
349        let changes = run_diff(&target, &source);
350        assert!(
351            changes.is_empty(),
352            "source owner None = unmanaged, no change expected"
353        );
354    }
355
356    // ---- comment diff ----
357
358    #[test]
359    fn comment_change_emits_comment_on_statistic() {
360        let src_stat = {
361            let mut s = basic_statistic("s", "t");
362            s.comment = Some("my stat".into());
363            s
364        };
365        let target = catalog_with(vec![basic_statistic("s", "t")]);
366        let source = catalog_with(vec![src_stat]);
367        let changes = run_diff(&target, &source);
368        assert_eq!(changes.len(), 1);
369        assert!(matches!(
370            changes.iter().next().unwrap().change,
371            Change::CommentOnStatistic { .. }
372        ));
373    }
374
375    #[test]
376    fn clear_comment_emits_comment_on_statistic_with_none() {
377        let mut tgt_stat = basic_statistic("s", "t");
378        tgt_stat.comment = Some("old comment".into());
379        let src_stat = basic_statistic("s", "t"); // comment = None
380        let target = catalog_with(vec![tgt_stat]);
381        let source = catalog_with(vec![src_stat]);
382        let changes = run_diff(&target, &source);
383        assert_eq!(changes.len(), 1);
384        let entry = changes.iter().next().unwrap();
385        if let Change::CommentOnStatistic { comment, .. } = &entry.change {
386            assert!(comment.is_none());
387        } else {
388            panic!("expected CommentOnStatistic, got {:?}", entry.change);
389        }
390    }
391
392    // ---- multiple independent fields changed ----
393
394    #[test]
395    fn statistics_target_and_comment_both_changed_emit_two_changes() {
396        let tgt_stat = basic_statistic("s", "t"); // no target, no comment
397        let mut src_stat = basic_statistic("s", "t");
398        src_stat.statistics_target = Some(200);
399        src_stat.comment = Some("new comment".into());
400        let target = catalog_with(vec![tgt_stat]);
401        let source = catalog_with(vec![src_stat]);
402        let changes = run_diff(&target, &source);
403        assert_eq!(changes.len(), 2);
404        let kinds: Vec<_> = changes
405            .iter()
406            .map(|e| std::mem::discriminant(&e.change))
407            .collect();
408        assert!(
409            kinds.iter().any(|d| *d
410                == std::mem::discriminant(&Change::AlterStatisticSetTarget {
411                    qname: qn("x", "y"),
412                    value: 0
413                })),
414            "expected AlterStatisticSetTarget in changes"
415        );
416        assert!(
417            kinds.iter().any(|d| *d
418                == std::mem::discriminant(&Change::CommentOnStatistic {
419                    qname: qn("x", "y"),
420                    comment: None
421                })),
422            "expected CommentOnStatistic in changes"
423        );
424    }
425}