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