Skip to main content

pgevolve_core/diff/
subscriptions.rs

1//! Differ for subscriptions. Pair by name; per-subscription granular diff.
2//!
3//! CONNECTION strings compare *modulo password*: a tiny libpq-style
4//! tokenizer strips `password=…` from both sides before text-compare.
5//! All other connstr keys participate in diff normally.
6//!
7//! Spec: `docs/superpowers/specs/2026-05-26-subscriptions-design.md`.
8
9use std::collections::BTreeMap;
10
11use crate::diff::change::{Change, SubscriptionChange};
12use crate::diff::changeset::ChangeSet;
13use crate::diff::destructiveness::Destructiveness;
14use crate::diff::owner_op::{AlterObjectOwner, OwnerObjectKind};
15use crate::identifier::Identifier;
16use crate::ir::catalog::Catalog;
17use crate::ir::subscription::{Subscription, SubscriptionOptions};
18
19/// Compute granular subscription changes needed to converge `target` toward
20/// `source`. Appends all emitted changes to `out`.
21pub fn diff_subscriptions(target: &Catalog, source: &Catalog, out: &mut ChangeSet) {
22    let target_map: BTreeMap<&Identifier, &Subscription> =
23        target.subscriptions.iter().map(|s| (&s.name, s)).collect();
24    let source_map: BTreeMap<&Identifier, &Subscription> =
25        source.subscriptions.iter().map(|s| (&s.name, s)).collect();
26
27    // Creates: in source but not in target.
28    for (name, src) in &source_map {
29        if !target_map.contains_key(name) {
30            out.push(
31                Change::Subscription(SubscriptionChange::Create((*src).clone())),
32                Destructiveness::Safe,
33            );
34        }
35    }
36
37    // Target-only: lenient — no auto-drop. Surfaces via unmanaged-subscription lint.
38    // Intentionally no-op; Stage 9 adds the unmanaged-subscription lint rule.
39
40    // Modifies: in both.
41    for (name, src) in &source_map {
42        let Some(tgt) = target_map.get(name) else {
43            continue;
44        };
45        diff_one(tgt, src, out);
46    }
47}
48
49fn diff_one(target: &Subscription, source: &Subscription, out: &mut ChangeSet) {
50    // CONNECTION (modulo password).
51    if connection_differs_ignoring_password(&target.connection, &source.connection) {
52        out.push(
53            Change::Subscription(SubscriptionChange::AlterConnection {
54                name: source.name.clone(),
55                new_connection: source.connection.clone(),
56            }),
57            Destructiveness::Safe,
58        );
59    }
60
61    // Publications: granular ADD/DROP.
62    let t_pubs: std::collections::BTreeSet<_> = target.publications.iter().collect();
63    let s_pubs: std::collections::BTreeSet<_> = source.publications.iter().collect();
64    for added in s_pubs.difference(&t_pubs) {
65        out.push(
66            Change::Subscription(SubscriptionChange::AddPublication {
67                name: source.name.clone(),
68                publication: (*added).clone(),
69            }),
70            Destructiveness::Safe,
71        );
72    }
73    for dropped in t_pubs.difference(&s_pubs) {
74        out.push(
75            Change::Subscription(SubscriptionChange::DropPublication {
76                name: source.name.clone(),
77                publication: (*dropped).clone(),
78            }),
79            Destructiveness::Safe,
80        );
81    }
82
83    // Options: sparse delta.
84    let opts_delta = options_delta(&target.options, &source.options);
85    if !options_delta_is_empty(&opts_delta) {
86        out.push(
87            Change::Subscription(SubscriptionChange::SetOptions {
88                name: source.name.clone(),
89                options: opts_delta,
90            }),
91            Destructiveness::Safe,
92        );
93    }
94
95    // Owner (v0.3.1 lenient pattern — only emit when source declares an owner
96    // and it differs from target; source `None` = unmanaged, no change emitted).
97    if let Some(s_owner) = &source.owner
98        && target.owner.as_ref() != Some(s_owner)
99    {
100        out.push(
101            Change::AlterObjectOwner(AlterObjectOwner {
102                kind: OwnerObjectKind::Subscription,
103                id: crate::diff::owner_op::OwnedObjectId::Cluster(source.name.clone()),
104                signature: String::new(),
105                from: target.owner.clone(),
106                to: s_owner.clone(),
107            }),
108            Destructiveness::Safe,
109        );
110    }
111
112    // Comment.
113    if target.comment != source.comment {
114        out.push(
115            Change::Subscription(SubscriptionChange::CommentOn {
116                name: source.name.clone(),
117                comment: source.comment.clone(),
118            }),
119            Destructiveness::Safe,
120        );
121    }
122}
123
124/// Compare two libpq connection strings ignoring the `password` key.
125///
126/// Tokenizes by `key=value` pairs separated by whitespace. Values may be
127/// single-quoted with backslash-escaping for embedded quotes/backslashes
128/// (libpq's documented syntax).
129///
130/// `${VAR}` placeholders are compared literally — a change in the env-var
131/// name DOES trigger a diff (legitimate config change; operator should
132/// approve via plan review).
133fn connection_differs_ignoring_password(a: &str, b: &str) -> bool {
134    tokenize_dropping_password(a) != tokenize_dropping_password(b)
135}
136
137fn tokenize_dropping_password(s: &str) -> Vec<(String, String)> {
138    let mut out = Vec::new();
139    let mut chars = s.chars().peekable();
140    loop {
141        // Skip whitespace.
142        while let Some(&c) = chars.peek() {
143            if c.is_whitespace() {
144                chars.next();
145            } else {
146                break;
147            }
148        }
149        if chars.peek().is_none() {
150            break;
151        }
152        // Read key (everything up to '=').
153        let mut key = String::new();
154        while let Some(&c) = chars.peek() {
155            if c == '=' {
156                chars.next();
157                break;
158            }
159            key.push(c);
160            chars.next();
161        }
162        // Read value: either single-quoted or unquoted.
163        let mut value = String::new();
164        if chars.peek() == Some(&'\'') {
165            chars.next(); // consume opening quote
166            while let Some(c) = chars.next() {
167                match c {
168                    '\\' => {
169                        if let Some(esc) = chars.next() {
170                            value.push(esc);
171                        }
172                    }
173                    '\'' => break, // closing quote
174                    other => value.push(other),
175                }
176            }
177        } else {
178            while let Some(&c) = chars.peek() {
179                if c.is_whitespace() {
180                    break;
181                }
182                value.push(c);
183                chars.next();
184            }
185        }
186        if key.eq_ignore_ascii_case("password") {
187            continue; // drop the password key/value pair
188        }
189        out.push((key, value));
190    }
191    out.sort();
192    out
193}
194
195/// Compute the sparse options delta: only fields where `source` is `Some` and
196/// differs from `target` are included.
197///
198/// `create_slot` and `copy_data` are CREATE-only PG options — no
199/// `ALTER SUBSCRIPTION s SET (create_slot = …)` form exists. They are
200/// hard-coded to `None` in the delta to prevent generating SQL PG would
201/// reject.
202fn options_delta(
203    target: &SubscriptionOptions,
204    source: &SubscriptionOptions,
205) -> SubscriptionOptions {
206    macro_rules! delta_field {
207        ($field:ident) => {{
208            if source.$field.is_some() && target.$field != source.$field {
209                source.$field.clone()
210            } else {
211                None
212            }
213        }};
214    }
215    SubscriptionOptions {
216        enabled: delta_field!(enabled),
217        slot_name: delta_field!(slot_name),
218        create_slot: None, // CREATE-only; intentionally never diffed.
219        copy_data: None,   // CREATE-only; intentionally never diffed.
220        synchronous_commit: delta_field!(synchronous_commit),
221        binary: delta_field!(binary),
222        streaming: delta_field!(streaming),
223        two_phase: delta_field!(two_phase),
224        disable_on_error: delta_field!(disable_on_error),
225        password_required: delta_field!(password_required),
226        run_as_owner: delta_field!(run_as_owner),
227        origin: delta_field!(origin),
228        failover: delta_field!(failover),
229    }
230}
231
232const fn options_delta_is_empty(d: &SubscriptionOptions) -> bool {
233    d.enabled.is_none()
234        && d.slot_name.is_none()
235        && d.create_slot.is_none()
236        && d.copy_data.is_none()
237        && d.synchronous_commit.is_none()
238        && d.binary.is_none()
239        && d.streaming.is_none()
240        && d.two_phase.is_none()
241        && d.disable_on_error.is_none()
242        && d.password_required.is_none()
243        && d.run_as_owner.is_none()
244        && d.origin.is_none()
245        && d.failover.is_none()
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::diff::change::{Change, SubscriptionChange};
252    use crate::identifier::Identifier;
253    use crate::ir::catalog::Catalog;
254    use crate::ir::subscription::{OriginMode, StreamingMode, Subscription, SubscriptionOptions};
255
256    fn id(s: &str) -> Identifier {
257        Identifier::from_unquoted(s).unwrap()
258    }
259
260    fn sub_minimal(name: &str) -> Subscription {
261        Subscription {
262            name: id(name),
263            connection: "host=x".into(),
264            publications: vec![id("p")],
265            options: SubscriptionOptions::default(),
266            owner: None,
267            comment: None,
268        }
269    }
270
271    fn catalog_with(subs: Vec<Subscription>) -> Catalog {
272        let mut c = Catalog::empty();
273        c.subscriptions = subs;
274        c
275    }
276
277    fn run_diff(target: &Catalog, source: &Catalog) -> ChangeSet {
278        let mut out = ChangeSet::new();
279        diff_subscriptions(target, source, &mut out);
280        out
281    }
282
283    // ---- tokenizer tests ----
284
285    #[test]
286    fn tokenize_drops_password() {
287        let a = tokenize_dropping_password("host=x user=u password=secret dbname=app");
288        let b = tokenize_dropping_password("host=x user=u password=different dbname=app");
289        assert_eq!(a, b);
290    }
291
292    #[test]
293    fn tokenize_preserves_other_keys() {
294        let a = tokenize_dropping_password("host=x user=u password=p");
295        let b = tokenize_dropping_password("host=y user=u password=p");
296        assert_ne!(a, b);
297    }
298
299    #[test]
300    fn tokenize_handles_quoted_values() {
301        let a = tokenize_dropping_password("host='db.example.com' password=p");
302        assert_eq!(a, vec![("host".to_string(), "db.example.com".to_string())]);
303    }
304
305    #[test]
306    fn tokenize_handles_escapes_in_quoted_values() {
307        let a = tokenize_dropping_password(r"host='db\'.com' password=p");
308        assert_eq!(a, vec![("host".to_string(), "db'.com".to_string())]);
309    }
310
311    #[test]
312    fn tokenize_case_insensitive_password_key() {
313        let a = tokenize_dropping_password("host=x PASSWORD=secret");
314        let b = tokenize_dropping_password("host=x Password=other");
315        assert_eq!(a, b);
316    }
317
318    // ---- creates ----
319
320    #[test]
321    fn create_subscription_when_source_has_it_and_target_doesnt() {
322        let target = Catalog::empty();
323        let source = catalog_with(vec![sub_minimal("s")]);
324        let changes = run_diff(&target, &source);
325        assert_eq!(changes.len(), 1);
326        assert!(matches!(
327            changes.iter().next().unwrap().change,
328            Change::Subscription(SubscriptionChange::Create(_))
329        ));
330    }
331
332    // ---- lenient: no auto-drop ----
333
334    #[test]
335    fn no_drop_when_target_has_sub_but_source_doesnt() {
336        let target = catalog_with(vec![sub_minimal("s")]);
337        let source = Catalog::empty();
338        let changes = run_diff(&target, &source);
339        assert!(
340            changes.is_empty(),
341            "expected no changes (lenient), got {changes:?}"
342        );
343    }
344
345    // ---- identical subscription: no diff ----
346
347    #[test]
348    fn identical_subscription_produces_no_changes() {
349        let c = catalog_with(vec![sub_minimal("s")]);
350        let changes = run_diff(&c, &c);
351        assert!(changes.is_empty());
352    }
353
354    // ---- connection diff ----
355
356    #[test]
357    fn connection_differs_in_non_password_key_emits_alter_connection() {
358        let mut tgt = sub_minimal("s");
359        tgt.connection = "host=old user=u".into();
360        let mut src = sub_minimal("s");
361        src.connection = "host=new user=u".into();
362        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
363        assert_eq!(changes.len(), 1);
364        assert!(matches!(
365            changes.iter().next().unwrap().change,
366            Change::Subscription(SubscriptionChange::AlterConnection { .. })
367        ));
368    }
369
370    #[test]
371    fn connection_differs_only_in_password_produces_no_change() {
372        let mut tgt = sub_minimal("s");
373        tgt.connection = "host=x password=old".into();
374        let mut src = sub_minimal("s");
375        src.connection = "host=x password=new".into();
376        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
377        assert!(
378            changes.is_empty(),
379            "password-only change must not trigger diff"
380        );
381    }
382
383    // ---- publications ----
384
385    #[test]
386    fn publication_added_emits_add_publication() {
387        let mut tgt = sub_minimal("s");
388        tgt.publications = vec![id("p1")];
389        let mut src = sub_minimal("s");
390        src.publications = vec![id("p1"), id("p2")];
391        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
392        assert_eq!(changes.len(), 1);
393        assert!(matches!(
394            changes.iter().next().unwrap().change,
395            Change::Subscription(SubscriptionChange::AddPublication { .. })
396        ));
397    }
398
399    #[test]
400    fn publication_removed_emits_drop_publication() {
401        let mut tgt = sub_minimal("s");
402        tgt.publications = vec![id("p1"), id("p2")];
403        let mut src = sub_minimal("s");
404        src.publications = vec![id("p1")];
405        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
406        assert_eq!(changes.len(), 1);
407        assert!(matches!(
408            changes.iter().next().unwrap().change,
409            Change::Subscription(SubscriptionChange::DropPublication { .. })
410        ));
411    }
412
413    // ---- options delta ----
414
415    #[test]
416    fn single_option_changed_emits_set_options() {
417        let tgt = sub_minimal("s"); // binary = None (unmanaged)
418        let mut src = sub_minimal("s");
419        src.options.binary = Some(true);
420        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
421        assert_eq!(changes.len(), 1);
422        let entry = changes.iter().next().unwrap();
423        if let Change::Subscription(SubscriptionChange::SetOptions { options, .. }) = &entry.change
424        {
425            assert_eq!(options.binary, Some(true));
426            // Other fields must be None (sparse delta).
427            assert!(options.enabled.is_none());
428            assert!(options.streaming.is_none());
429            // create_slot and copy_data always None.
430            assert!(options.create_slot.is_none());
431            assert!(options.copy_data.is_none());
432        } else {
433            panic!(
434                "expected AlterSubscriptionSetOptions, got {:?}",
435                entry.change
436            );
437        }
438    }
439
440    #[test]
441    fn source_option_none_does_not_trigger_diff() {
442        let mut tgt = sub_minimal("s");
443        tgt.options.enabled = Some(true); // catalog has it set
444        let src = sub_minimal("s"); // source has None (unmanaged)
445        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
446        assert!(
447            changes.is_empty(),
448            "source option=None must not trigger diff (lenient)"
449        );
450    }
451
452    #[test]
453    fn create_slot_never_in_options_delta() {
454        let tgt = sub_minimal("s");
455        let mut src = sub_minimal("s");
456        src.options.create_slot = Some(false); // would be CREATE-only
457        // diff must not produce AlterSubscriptionSetOptions for create_slot
458        let delta = options_delta(&tgt.options, &src.options);
459        assert!(
460            delta.create_slot.is_none(),
461            "create_slot must always be None in options delta"
462        );
463    }
464
465    #[test]
466    fn copy_data_never_in_options_delta() {
467        let tgt = sub_minimal("s");
468        let mut src = sub_minimal("s");
469        src.options.copy_data = Some(false);
470        let delta = options_delta(&tgt.options, &src.options);
471        assert!(
472            delta.copy_data.is_none(),
473            "copy_data must always be None in options delta"
474        );
475    }
476
477    // ---- owner ----
478
479    #[test]
480    fn owner_change_emits_alter_object_owner() {
481        let mut tgt = sub_minimal("s");
482        tgt.owner = Some(id("alice"));
483        let mut src = sub_minimal("s");
484        src.owner = Some(id("bob"));
485        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
486        assert_eq!(changes.len(), 1);
487        assert!(matches!(
488            changes.iter().next().unwrap().change,
489            Change::AlterObjectOwner(_)
490        ));
491    }
492
493    #[test]
494    fn no_owner_change_when_source_owner_is_none() {
495        let mut tgt = sub_minimal("s");
496        tgt.owner = Some(id("alice"));
497        let src = sub_minimal("s"); // owner = None (unmanaged)
498        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
499        assert!(
500            changes.is_empty(),
501            "source owner None = unmanaged, no change expected"
502        );
503    }
504
505    // ---- comment ----
506
507    #[test]
508    fn comment_change_emits_comment_on_subscription() {
509        let tgt = sub_minimal("s");
510        let mut src = sub_minimal("s");
511        src.comment = Some("my sub".into());
512        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
513        assert_eq!(changes.len(), 1);
514        assert!(matches!(
515            changes.iter().next().unwrap().change,
516            Change::Subscription(SubscriptionChange::CommentOn { .. })
517        ));
518    }
519
520    // ---- multiple options changed → single AlterSubscriptionSetOptions ----
521
522    #[test]
523    fn multiple_options_changed_emit_single_set_options() {
524        let tgt = sub_minimal("s");
525        let mut src = sub_minimal("s");
526        src.options.binary = Some(true);
527        src.options.streaming = Some(StreamingMode::On);
528        src.options.origin = Some(OriginMode::None);
529        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
530        assert_eq!(changes.len(), 1);
531        if let Change::Subscription(SubscriptionChange::SetOptions { options, .. }) =
532            &changes.iter().next().unwrap().change
533        {
534            assert_eq!(options.binary, Some(true));
535            assert_eq!(options.streaming, Some(StreamingMode::On));
536            assert_eq!(options.origin, Some(OriginMode::None));
537            assert!(options.create_slot.is_none());
538            assert!(options.copy_data.is_none());
539        } else {
540            panic!("expected AlterSubscriptionSetOptions");
541        }
542    }
543}