Skip to main content

pgevolve_core/diff/
aggregates.rs

1//! Differ for `Catalog::aggregates`.
2//!
3//! Aggregates are **managed** (schema-scoped): a live aggregate that is absent
4//! from source IS auto-dropped — unlike lenient objects such as event triggers
5//! or statistics.
6//!
7//! Identity is `(qname, arg_types)`. Because [`ColumnType`](crate::ir::column_type::ColumnType) does not implement
8//! [`Ord`], the `BTreeMap` key is `(QualifiedName, Vec<String>)` where each
9//! `String` is `ColumnType::render_sql()` — a stable, canonical representation.
10//!
11//! Logic summary:
12//! - source-only → `Create` (Safe).
13//! - target-only → `Drop` (Safe — aggregates carry no data).
14//! - both present, structural diff (`state_type` / `sfunc` / `finalfunc` /
15//!   `initcond`) → `Replace` (Safe, subsumes owner/comment).
16//! - else: owner lenient (only when source declares one) → `AlterOwner` (Safe).
17//! - comment differs → `CommentOn` (Safe).
18
19use std::collections::BTreeMap;
20
21use crate::diff::change::{AggregateChange, Change};
22use crate::diff::changeset::ChangeSet;
23use crate::diff::destructiveness::Destructiveness;
24use crate::identifier::QualifiedName;
25use crate::ir::aggregate::Aggregate;
26use crate::ir::catalog::Catalog;
27
28/// The `BTreeMap` key for an aggregate: canonical `(qname, rendered arg types)`.
29type AggKey = (QualifiedName, Vec<String>);
30
31fn agg_key(a: &Aggregate) -> AggKey {
32    (
33        a.qname.clone(),
34        a.arg_types
35            .iter()
36            .map(crate::ir::column_type::ColumnType::render_sql)
37            .collect(),
38    )
39}
40
41/// Compute aggregate changes needed to converge `target` (live) toward `source`.
42///
43/// Appends all emitted changes to `out`. Aggregates are **managed**: a
44/// target-only aggregate (absent from source) IS auto-dropped.
45pub fn diff_aggregates(target: &Catalog, source: &Catalog, out: &mut ChangeSet) {
46    let target_map: BTreeMap<AggKey, &Aggregate> =
47        target.aggregates.iter().map(|a| (agg_key(a), a)).collect();
48    let source_map: BTreeMap<AggKey, &Aggregate> =
49        source.aggregates.iter().map(|a| (agg_key(a), a)).collect();
50
51    // Source-only → Create.
52    for (key, src) in &source_map {
53        if !target_map.contains_key(key) {
54            out.push(
55                Change::Aggregate(AggregateChange::Create((*src).clone())),
56                Destructiveness::Safe,
57            );
58        }
59    }
60
61    // Target-only → Drop (managed, not lenient).
62    for (key, tgt) in &target_map {
63        if !source_map.contains_key(key) {
64            out.push(
65                Change::Aggregate(AggregateChange::Drop {
66                    qname: tgt.qname.clone(),
67                    arg_types: tgt.arg_types.clone(),
68                }),
69                Destructiveness::Safe,
70            );
71        }
72    }
73
74    // Both present → granular diff.
75    for (key, src) in &source_map {
76        let Some(tgt) = target_map.get(key) else {
77            continue;
78        };
79        emit_modify(tgt, src, out);
80    }
81}
82
83fn structural_differs(t: &Aggregate, s: &Aggregate) -> bool {
84    t.state_type != s.state_type
85        || t.sfunc != s.sfunc
86        || t.finalfunc != s.finalfunc
87        || t.initcond != s.initcond
88}
89
90fn emit_modify(t: &Aggregate, s: &Aggregate, out: &mut ChangeSet) {
91    if structural_differs(t, s) {
92        // Replace subsumes owner/comment — the recreate carries them.
93        out.push(
94            Change::Aggregate(AggregateChange::Replace {
95                from: t.clone(),
96                to: s.clone(),
97            }),
98            Destructiveness::Safe,
99        );
100        return;
101    }
102
103    // Owner is lenient: only when source declares one and it differs.
104    if let Some(src_owner) = &s.owner
105        && t.owner.as_ref() != Some(src_owner)
106    {
107        out.push(
108            Change::Aggregate(AggregateChange::AlterOwner {
109                qname: s.qname.clone(),
110                arg_types: s.arg_types.clone(),
111                owner: src_owner.clone(),
112            }),
113            Destructiveness::Safe,
114        );
115    }
116
117    // Comment differs → CommentOn (both directions: set or clear).
118    if t.comment != s.comment {
119        out.push(
120            Change::Aggregate(AggregateChange::CommentOn {
121                qname: s.qname.clone(),
122                arg_types: s.arg_types.clone(),
123                comment: s.comment.clone(),
124            }),
125            Destructiveness::Safe,
126        );
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::diff::change::{AggregateChange, Change};
134    use crate::identifier::{Identifier, QualifiedName};
135    use crate::ir::catalog::Catalog;
136    use crate::ir::column_type::ColumnType;
137
138    fn id(s: &str) -> Identifier {
139        Identifier::from_unquoted(s).unwrap()
140    }
141
142    fn qn(schema: &str, name: &str) -> QualifiedName {
143        QualifiedName::new(id(schema), id(name))
144    }
145
146    /// Build a minimal aggregate with one `INTEGER` argument.
147    fn basic_agg(name: &str) -> Aggregate {
148        Aggregate {
149            qname: qn("app", name),
150            arg_types: vec![ColumnType::Integer],
151            state_type: ColumnType::BigInt,
152            sfunc: qn("app", "my_sfunc"),
153            finalfunc: None,
154            initcond: None,
155            owner: None,
156            comment: None,
157        }
158    }
159
160    fn cat(aggs: Vec<Aggregate>) -> Catalog {
161        let mut c = Catalog::empty();
162        c.aggregates = aggs;
163        c
164    }
165
166    fn run(target: &Catalog, source: &Catalog) -> ChangeSet {
167        let mut out = ChangeSet::new();
168        diff_aggregates(target, source, &mut out);
169        out
170    }
171
172    // ---- source-only → Create ----
173
174    #[test]
175    fn source_only_creates() {
176        let changes = run(&cat(vec![]), &cat(vec![basic_agg("my_sum")]));
177        assert_eq!(changes.len(), 1);
178        assert!(matches!(
179            changes.iter().next().unwrap().change,
180            Change::Aggregate(AggregateChange::Create(_))
181        ));
182    }
183
184    // ---- target-only → Drop (managed, NOT lenient) ----
185
186    #[test]
187    fn target_only_drops() {
188        let changes = run(&cat(vec![basic_agg("my_sum")]), &cat(vec![]));
189        assert_eq!(
190            changes.len(),
191            1,
192            "managed aggregate must emit Drop when absent from source"
193        );
194        assert!(
195            matches!(
196                changes.iter().next().unwrap().change,
197                Change::Aggregate(AggregateChange::Drop { .. })
198            ),
199            "expected Drop, got {:?}",
200            changes.iter().next().unwrap().change
201        );
202    }
203
204    // ---- structural change → Replace ----
205
206    #[test]
207    fn different_sfunc_replaces() {
208        let t = basic_agg("my_sum");
209        let mut s = basic_agg("my_sum");
210        s.sfunc = qn("app", "other_sfunc");
211        let changes = run(&cat(vec![t]), &cat(vec![s]));
212        assert_eq!(changes.len(), 1);
213        assert!(matches!(
214            changes.iter().next().unwrap().change,
215            Change::Aggregate(AggregateChange::Replace { .. })
216        ));
217    }
218
219    #[test]
220    fn different_state_type_replaces() {
221        let t = basic_agg("my_sum");
222        let mut s = basic_agg("my_sum");
223        s.state_type = ColumnType::Numeric {
224            precision: None,
225            scale: None,
226        };
227        let changes = run(&cat(vec![t]), &cat(vec![s]));
228        assert_eq!(changes.len(), 1);
229        assert!(matches!(
230            changes.iter().next().unwrap().change,
231            Change::Aggregate(AggregateChange::Replace { .. })
232        ));
233    }
234
235    #[test]
236    fn different_finalfunc_replaces() {
237        let t = basic_agg("my_sum");
238        let mut s = basic_agg("my_sum");
239        s.finalfunc = Some(qn("app", "my_final"));
240        let changes = run(&cat(vec![t]), &cat(vec![s]));
241        assert_eq!(changes.len(), 1);
242        assert!(matches!(
243            changes.iter().next().unwrap().change,
244            Change::Aggregate(AggregateChange::Replace { .. })
245        ));
246    }
247
248    #[test]
249    fn different_initcond_replaces() {
250        let t = basic_agg("my_sum");
251        let mut s = basic_agg("my_sum");
252        s.initcond = Some("0".into());
253        let changes = run(&cat(vec![t]), &cat(vec![s]));
254        assert_eq!(changes.len(), 1);
255        assert!(matches!(
256            changes.iter().next().unwrap().change,
257            Change::Aggregate(AggregateChange::Replace { .. })
258        ));
259    }
260
261    // ---- Replace subsumes owner/comment ----
262
263    #[test]
264    fn replace_subsumes_owner_and_comment() {
265        let t = basic_agg("my_sum");
266        let mut s = basic_agg("my_sum");
267        s.sfunc = qn("app", "other_sfunc"); // structural
268        s.owner = Some(id("alice"));
269        s.comment = Some("new".into());
270        let changes = run(&cat(vec![t]), &cat(vec![s]));
271        assert_eq!(changes.len(), 1, "Replace must subsume owner + comment");
272        assert!(matches!(
273            changes.iter().next().unwrap().change,
274            Change::Aggregate(AggregateChange::Replace { .. })
275        ));
276    }
277
278    // ---- owner change → AlterOwner (lenient) ----
279
280    #[test]
281    fn owner_change_emits_alter_owner() {
282        let mut t = basic_agg("my_sum");
283        t.owner = Some(id("alice"));
284        let mut s = basic_agg("my_sum");
285        s.owner = Some(id("bob"));
286        let changes = run(&cat(vec![t]), &cat(vec![s]));
287        assert_eq!(changes.len(), 1);
288        assert!(matches!(
289            changes.iter().next().unwrap().change,
290            Change::Aggregate(AggregateChange::AlterOwner { .. })
291        ));
292    }
293
294    #[test]
295    fn source_owner_none_no_alter_owner() {
296        let mut t = basic_agg("my_sum");
297        t.owner = Some(id("alice"));
298        let s = basic_agg("my_sum"); // owner = None
299        let changes = run(&cat(vec![t]), &cat(vec![s]));
300        assert!(
301            changes.is_empty(),
302            "source owner None = unmanaged, no change expected"
303        );
304    }
305
306    // ---- comment change → CommentOn ----
307
308    #[test]
309    fn comment_change_emits_comment_on() {
310        let t = basic_agg("my_sum");
311        let mut s = basic_agg("my_sum");
312        s.comment = Some("aggregates integers".into());
313        let changes = run(&cat(vec![t]), &cat(vec![s]));
314        assert_eq!(changes.len(), 1);
315        assert!(matches!(
316            changes.iter().next().unwrap().change,
317            Change::Aggregate(AggregateChange::CommentOn { .. })
318        ));
319    }
320
321    // ---- different arg_types = distinct identities → Create + Drop ----
322
323    #[test]
324    fn different_arg_types_are_distinct_identities() {
325        // target has my_sum(integer), source has my_sum(bigint) → Drop + Create.
326        let mut t = basic_agg("my_sum"); // arg_types = [Integer]
327        t.arg_types = vec![ColumnType::Integer];
328        let mut s = basic_agg("my_sum");
329        s.arg_types = vec![ColumnType::BigInt];
330        let changes = run(&cat(vec![t]), &cat(vec![s]));
331        assert_eq!(
332            changes.len(),
333            2,
334            "different arg_types are distinct: Drop + Create"
335        );
336        assert!(
337            changes
338                .iter()
339                .any(|e| matches!(&e.change, Change::Aggregate(AggregateChange::Create(_)))),
340            "expected Create"
341        );
342        assert!(
343            changes
344                .iter()
345                .any(|e| matches!(&e.change, Change::Aggregate(AggregateChange::Drop { .. }))),
346            "expected Drop"
347        );
348    }
349
350    // ---- identical → no changes ----
351
352    #[test]
353    fn identical_aggregates_produce_no_changes() {
354        let agg = basic_agg("my_sum");
355        let c = cat(vec![agg]);
356        let changes = run(&c, &c);
357        assert!(changes.is_empty());
358    }
359}