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        connect: None,     // CREATE-only; intentionally never diffed.
219        create_slot: None, // CREATE-only; intentionally never diffed.
220        copy_data: None,   // CREATE-only; intentionally never diffed.
221        synchronous_commit: delta_field!(synchronous_commit),
222        binary: delta_field!(binary),
223        streaming: delta_field!(streaming),
224        two_phase: delta_field!(two_phase),
225        disable_on_error: delta_field!(disable_on_error),
226        password_required: delta_field!(password_required),
227        run_as_owner: delta_field!(run_as_owner),
228        origin: delta_field!(origin),
229        failover: delta_field!(failover),
230    }
231}
232
233const fn options_delta_is_empty(d: &SubscriptionOptions) -> bool {
234    d.enabled.is_none()
235        && d.slot_name.is_none()
236        && d.connect.is_none()
237        && d.create_slot.is_none()
238        && d.copy_data.is_none()
239        && d.synchronous_commit.is_none()
240        && d.binary.is_none()
241        && d.streaming.is_none()
242        && d.two_phase.is_none()
243        && d.disable_on_error.is_none()
244        && d.password_required.is_none()
245        && d.run_as_owner.is_none()
246        && d.origin.is_none()
247        && d.failover.is_none()
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use crate::diff::change::{Change, SubscriptionChange};
254    use crate::identifier::Identifier;
255    use crate::ir::catalog::Catalog;
256    use crate::ir::subscription::{OriginMode, StreamingMode, Subscription, SubscriptionOptions};
257
258    fn id(s: &str) -> Identifier {
259        Identifier::from_unquoted(s).unwrap()
260    }
261
262    fn sub_minimal(name: &str) -> Subscription {
263        Subscription {
264            name: id(name),
265            connection: "host=x".into(),
266            publications: vec![id("p")],
267            options: SubscriptionOptions::default(),
268            owner: None,
269            comment: None,
270        }
271    }
272
273    fn catalog_with(subs: Vec<Subscription>) -> Catalog {
274        let mut c = Catalog::empty();
275        c.subscriptions = subs;
276        c
277    }
278
279    fn run_diff(target: &Catalog, source: &Catalog) -> ChangeSet {
280        let mut out = ChangeSet::new();
281        diff_subscriptions(target, source, &mut out);
282        out
283    }
284
285    // ---- tokenizer tests ----
286
287    #[test]
288    fn tokenize_drops_password() {
289        let a = tokenize_dropping_password("host=x user=u password=secret dbname=app");
290        let b = tokenize_dropping_password("host=x user=u password=different dbname=app");
291        assert_eq!(a, b);
292    }
293
294    #[test]
295    fn tokenize_preserves_other_keys() {
296        let a = tokenize_dropping_password("host=x user=u password=p");
297        let b = tokenize_dropping_password("host=y user=u password=p");
298        assert_ne!(a, b);
299    }
300
301    #[test]
302    fn tokenize_handles_quoted_values() {
303        let a = tokenize_dropping_password("host='db.example.com' password=p");
304        assert_eq!(a, vec![("host".to_string(), "db.example.com".to_string())]);
305    }
306
307    #[test]
308    fn tokenize_handles_escapes_in_quoted_values() {
309        let a = tokenize_dropping_password(r"host='db\'.com' password=p");
310        assert_eq!(a, vec![("host".to_string(), "db'.com".to_string())]);
311    }
312
313    #[test]
314    fn tokenize_case_insensitive_password_key() {
315        let a = tokenize_dropping_password("host=x PASSWORD=secret");
316        let b = tokenize_dropping_password("host=x Password=other");
317        assert_eq!(a, b);
318    }
319
320    // ---- creates ----
321
322    #[test]
323    fn create_subscription_when_source_has_it_and_target_doesnt() {
324        let target = Catalog::empty();
325        let source = catalog_with(vec![sub_minimal("s")]);
326        let changes = run_diff(&target, &source);
327        assert_eq!(changes.len(), 1);
328        assert!(matches!(
329            changes.iter().next().unwrap().change,
330            Change::Subscription(SubscriptionChange::Create(_))
331        ));
332    }
333
334    // ---- lenient: no auto-drop ----
335
336    #[test]
337    fn no_drop_when_target_has_sub_but_source_doesnt() {
338        let target = catalog_with(vec![sub_minimal("s")]);
339        let source = Catalog::empty();
340        let changes = run_diff(&target, &source);
341        assert!(
342            changes.is_empty(),
343            "expected no changes (lenient), got {changes:?}"
344        );
345    }
346
347    // ---- identical subscription: no diff ----
348
349    #[test]
350    fn identical_subscription_produces_no_changes() {
351        let c = catalog_with(vec![sub_minimal("s")]);
352        let changes = run_diff(&c, &c);
353        assert!(changes.is_empty());
354    }
355
356    // ---- connection diff ----
357
358    #[test]
359    fn connection_differs_in_non_password_key_emits_alter_connection() {
360        let mut tgt = sub_minimal("s");
361        tgt.connection = "host=old user=u".into();
362        let mut src = sub_minimal("s");
363        src.connection = "host=new user=u".into();
364        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
365        assert_eq!(changes.len(), 1);
366        assert!(matches!(
367            changes.iter().next().unwrap().change,
368            Change::Subscription(SubscriptionChange::AlterConnection { .. })
369        ));
370    }
371
372    #[test]
373    fn connection_differs_only_in_password_produces_no_change() {
374        let mut tgt = sub_minimal("s");
375        tgt.connection = "host=x password=old".into();
376        let mut src = sub_minimal("s");
377        src.connection = "host=x password=new".into();
378        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
379        assert!(
380            changes.is_empty(),
381            "password-only change must not trigger diff"
382        );
383    }
384
385    // ---- publications ----
386
387    #[test]
388    fn publication_added_emits_add_publication() {
389        let mut tgt = sub_minimal("s");
390        tgt.publications = vec![id("p1")];
391        let mut src = sub_minimal("s");
392        src.publications = vec![id("p1"), id("p2")];
393        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
394        assert_eq!(changes.len(), 1);
395        assert!(matches!(
396            changes.iter().next().unwrap().change,
397            Change::Subscription(SubscriptionChange::AddPublication { .. })
398        ));
399    }
400
401    #[test]
402    fn publication_removed_emits_drop_publication() {
403        let mut tgt = sub_minimal("s");
404        tgt.publications = vec![id("p1"), id("p2")];
405        let mut src = sub_minimal("s");
406        src.publications = vec![id("p1")];
407        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
408        assert_eq!(changes.len(), 1);
409        assert!(matches!(
410            changes.iter().next().unwrap().change,
411            Change::Subscription(SubscriptionChange::DropPublication { .. })
412        ));
413    }
414
415    // ---- options delta ----
416
417    #[test]
418    fn single_option_changed_emits_set_options() {
419        let tgt = sub_minimal("s"); // binary = None (unmanaged)
420        let mut src = sub_minimal("s");
421        src.options.binary = Some(true);
422        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
423        assert_eq!(changes.len(), 1);
424        let entry = changes.iter().next().unwrap();
425        if let Change::Subscription(SubscriptionChange::SetOptions { options, .. }) = &entry.change
426        {
427            assert_eq!(options.binary, Some(true));
428            // Other fields must be None (sparse delta).
429            assert!(options.enabled.is_none());
430            assert!(options.streaming.is_none());
431            // create_slot and copy_data always None.
432            assert!(options.create_slot.is_none());
433            assert!(options.copy_data.is_none());
434        } else {
435            panic!(
436                "expected AlterSubscriptionSetOptions, got {:?}",
437                entry.change
438            );
439        }
440    }
441
442    #[test]
443    fn source_option_none_does_not_trigger_diff() {
444        let mut tgt = sub_minimal("s");
445        tgt.options.enabled = Some(true); // catalog has it set
446        let src = sub_minimal("s"); // source has None (unmanaged)
447        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
448        assert!(
449            changes.is_empty(),
450            "source option=None must not trigger diff (lenient)"
451        );
452    }
453
454    #[test]
455    fn create_slot_never_in_options_delta() {
456        let tgt = sub_minimal("s");
457        let mut src = sub_minimal("s");
458        src.options.create_slot = Some(false); // would be CREATE-only
459        // diff must not produce AlterSubscriptionSetOptions for create_slot
460        let delta = options_delta(&tgt.options, &src.options);
461        assert!(
462            delta.create_slot.is_none(),
463            "create_slot must always be None in options delta"
464        );
465    }
466
467    #[test]
468    fn copy_data_never_in_options_delta() {
469        let tgt = sub_minimal("s");
470        let mut src = sub_minimal("s");
471        src.options.copy_data = Some(false);
472        let delta = options_delta(&tgt.options, &src.options);
473        assert!(
474            delta.copy_data.is_none(),
475            "copy_data must always be None in options delta"
476        );
477    }
478
479    // ---- owner ----
480
481    #[test]
482    fn owner_change_emits_alter_object_owner() {
483        let mut tgt = sub_minimal("s");
484        tgt.owner = Some(id("alice"));
485        let mut src = sub_minimal("s");
486        src.owner = Some(id("bob"));
487        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
488        assert_eq!(changes.len(), 1);
489        assert!(matches!(
490            changes.iter().next().unwrap().change,
491            Change::AlterObjectOwner(_)
492        ));
493    }
494
495    #[test]
496    fn no_owner_change_when_source_owner_is_none() {
497        let mut tgt = sub_minimal("s");
498        tgt.owner = Some(id("alice"));
499        let src = sub_minimal("s"); // owner = None (unmanaged)
500        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
501        assert!(
502            changes.is_empty(),
503            "source owner None = unmanaged, no change expected"
504        );
505    }
506
507    // ---- comment ----
508
509    #[test]
510    fn comment_change_emits_comment_on_subscription() {
511        let tgt = sub_minimal("s");
512        let mut src = sub_minimal("s");
513        src.comment = Some("my sub".into());
514        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
515        assert_eq!(changes.len(), 1);
516        assert!(matches!(
517            changes.iter().next().unwrap().change,
518            Change::Subscription(SubscriptionChange::CommentOn { .. })
519        ));
520    }
521
522    // ---- multiple options changed → single AlterSubscriptionSetOptions ----
523
524    #[test]
525    fn multiple_options_changed_emit_single_set_options() {
526        let tgt = sub_minimal("s");
527        let mut src = sub_minimal("s");
528        src.options.binary = Some(true);
529        src.options.streaming = Some(StreamingMode::On);
530        src.options.origin = Some(OriginMode::None);
531        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
532        assert_eq!(changes.len(), 1);
533        if let Change::Subscription(SubscriptionChange::SetOptions { options, .. }) =
534            &changes.iter().next().unwrap().change
535        {
536            assert_eq!(options.binary, Some(true));
537            assert_eq!(options.streaming, Some(StreamingMode::On));
538            assert_eq!(options.origin, Some(OriginMode::None));
539            assert!(options.create_slot.is_none());
540            assert!(options.copy_data.is_none());
541        } else {
542            panic!("expected AlterSubscriptionSetOptions");
543        }
544    }
545}