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;
12use crate::diff::changeset::ChangeSet;
13use crate::diff::destructiveness::Destructiveness;
14use crate::diff::owner_op::{AlterObjectOwner, OwnerObjectKind};
15use crate::identifier::{Identifier, QualifiedName};
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::CreateSubscription((*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::AlterSubscriptionConnection {
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::AlterSubscriptionAddPublication {
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::AlterSubscriptionDropPublication {
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::AlterSubscriptionSetOptions {
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        let from = target.owner.clone().unwrap_or_else(|| {
101            Identifier::from_unquoted("__unknown_owner__")
102                .expect("literal is always a valid unquoted identifier")
103        });
104        out.push(
105            Change::AlterObjectOwner(AlterObjectOwner {
106                kind: OwnerObjectKind::Subscription,
107                // Subscriptions are not schema-qualified. Use a synthetic
108                // QualifiedName with `__cluster__` as the schema component to
109                // satisfy the `QualifiedName` type (same convention as publications).
110                qname: QualifiedName::new(
111                    Identifier::from_unquoted("__cluster__")
112                        .expect("literal is always a valid unquoted identifier"),
113                    source.name.clone(),
114                ),
115                signature: String::new(),
116                from,
117                to: s_owner.clone(),
118            }),
119            Destructiveness::Safe,
120        );
121    }
122
123    // Comment.
124    if target.comment != source.comment {
125        out.push(
126            Change::CommentOnSubscription {
127                name: source.name.clone(),
128                comment: source.comment.clone(),
129            },
130            Destructiveness::Safe,
131        );
132    }
133}
134
135/// Compare two libpq connection strings ignoring the `password` key.
136///
137/// Tokenizes by `key=value` pairs separated by whitespace. Values may be
138/// single-quoted with backslash-escaping for embedded quotes/backslashes
139/// (libpq's documented syntax).
140///
141/// `${VAR}` placeholders are compared literally — a change in the env-var
142/// name DOES trigger a diff (legitimate config change; operator should
143/// approve via plan review).
144fn connection_differs_ignoring_password(a: &str, b: &str) -> bool {
145    tokenize_dropping_password(a) != tokenize_dropping_password(b)
146}
147
148fn tokenize_dropping_password(s: &str) -> Vec<(String, String)> {
149    let mut out = Vec::new();
150    let mut chars = s.chars().peekable();
151    loop {
152        // Skip whitespace.
153        while let Some(&c) = chars.peek() {
154            if c.is_whitespace() {
155                chars.next();
156            } else {
157                break;
158            }
159        }
160        if chars.peek().is_none() {
161            break;
162        }
163        // Read key (everything up to '=').
164        let mut key = String::new();
165        while let Some(&c) = chars.peek() {
166            if c == '=' {
167                chars.next();
168                break;
169            }
170            key.push(c);
171            chars.next();
172        }
173        // Read value: either single-quoted or unquoted.
174        let mut value = String::new();
175        if chars.peek() == Some(&'\'') {
176            chars.next(); // consume opening quote
177            while let Some(c) = chars.next() {
178                match c {
179                    '\\' => {
180                        if let Some(esc) = chars.next() {
181                            value.push(esc);
182                        }
183                    }
184                    '\'' => break, // closing quote
185                    other => value.push(other),
186                }
187            }
188        } else {
189            while let Some(&c) = chars.peek() {
190                if c.is_whitespace() {
191                    break;
192                }
193                value.push(c);
194                chars.next();
195            }
196        }
197        if key.eq_ignore_ascii_case("password") {
198            continue; // drop the password key/value pair
199        }
200        out.push((key, value));
201    }
202    out.sort();
203    out
204}
205
206/// Compute the sparse options delta: only fields where `source` is `Some` and
207/// differs from `target` are included.
208///
209/// `create_slot` and `copy_data` are CREATE-only PG options — no
210/// `ALTER SUBSCRIPTION s SET (create_slot = …)` form exists. They are
211/// hard-coded to `None` in the delta to prevent generating SQL PG would
212/// reject.
213fn options_delta(
214    target: &SubscriptionOptions,
215    source: &SubscriptionOptions,
216) -> SubscriptionOptions {
217    macro_rules! delta_field {
218        ($field:ident) => {{
219            if source.$field.is_some() && target.$field != source.$field {
220                source.$field.clone()
221            } else {
222                None
223            }
224        }};
225    }
226    SubscriptionOptions {
227        enabled: delta_field!(enabled),
228        slot_name: delta_field!(slot_name),
229        create_slot: None, // CREATE-only; intentionally never diffed.
230        copy_data: None,   // CREATE-only; intentionally never diffed.
231        synchronous_commit: delta_field!(synchronous_commit),
232        binary: delta_field!(binary),
233        streaming: delta_field!(streaming),
234        two_phase: delta_field!(two_phase),
235        disable_on_error: delta_field!(disable_on_error),
236        password_required: delta_field!(password_required),
237        run_as_owner: delta_field!(run_as_owner),
238        origin: delta_field!(origin),
239        failover: delta_field!(failover),
240    }
241}
242
243const fn options_delta_is_empty(d: &SubscriptionOptions) -> bool {
244    d.enabled.is_none()
245        && d.slot_name.is_none()
246        && d.create_slot.is_none()
247        && d.copy_data.is_none()
248        && d.synchronous_commit.is_none()
249        && d.binary.is_none()
250        && d.streaming.is_none()
251        && d.two_phase.is_none()
252        && d.disable_on_error.is_none()
253        && d.password_required.is_none()
254        && d.run_as_owner.is_none()
255        && d.origin.is_none()
256        && d.failover.is_none()
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use crate::diff::change::Change;
263    use crate::identifier::Identifier;
264    use crate::ir::catalog::Catalog;
265    use crate::ir::subscription::{OriginMode, StreamingMode, Subscription, SubscriptionOptions};
266
267    fn id(s: &str) -> Identifier {
268        Identifier::from_unquoted(s).unwrap()
269    }
270
271    fn sub_minimal(name: &str) -> Subscription {
272        Subscription {
273            name: id(name),
274            connection: "host=x".into(),
275            publications: vec![id("p")],
276            options: SubscriptionOptions::default(),
277            owner: None,
278            comment: None,
279        }
280    }
281
282    fn catalog_with(subs: Vec<Subscription>) -> Catalog {
283        let mut c = Catalog::empty();
284        c.subscriptions = subs;
285        c
286    }
287
288    fn run_diff(target: &Catalog, source: &Catalog) -> ChangeSet {
289        let mut out = ChangeSet::new();
290        diff_subscriptions(target, source, &mut out);
291        out
292    }
293
294    // ---- tokenizer tests ----
295
296    #[test]
297    fn tokenize_drops_password() {
298        let a = tokenize_dropping_password("host=x user=u password=secret dbname=app");
299        let b = tokenize_dropping_password("host=x user=u password=different dbname=app");
300        assert_eq!(a, b);
301    }
302
303    #[test]
304    fn tokenize_preserves_other_keys() {
305        let a = tokenize_dropping_password("host=x user=u password=p");
306        let b = tokenize_dropping_password("host=y user=u password=p");
307        assert_ne!(a, b);
308    }
309
310    #[test]
311    fn tokenize_handles_quoted_values() {
312        let a = tokenize_dropping_password("host='db.example.com' password=p");
313        assert_eq!(a, vec![("host".to_string(), "db.example.com".to_string())]);
314    }
315
316    #[test]
317    fn tokenize_handles_escapes_in_quoted_values() {
318        let a = tokenize_dropping_password(r"host='db\'.com' password=p");
319        assert_eq!(a, vec![("host".to_string(), "db'.com".to_string())]);
320    }
321
322    #[test]
323    fn tokenize_case_insensitive_password_key() {
324        let a = tokenize_dropping_password("host=x PASSWORD=secret");
325        let b = tokenize_dropping_password("host=x Password=other");
326        assert_eq!(a, b);
327    }
328
329    // ---- creates ----
330
331    #[test]
332    fn create_subscription_when_source_has_it_and_target_doesnt() {
333        let target = Catalog::empty();
334        let source = catalog_with(vec![sub_minimal("s")]);
335        let changes = run_diff(&target, &source);
336        assert_eq!(changes.len(), 1);
337        assert!(matches!(
338            changes.iter().next().unwrap().change,
339            Change::CreateSubscription(_)
340        ));
341    }
342
343    // ---- lenient: no auto-drop ----
344
345    #[test]
346    fn no_drop_when_target_has_sub_but_source_doesnt() {
347        let target = catalog_with(vec![sub_minimal("s")]);
348        let source = Catalog::empty();
349        let changes = run_diff(&target, &source);
350        assert!(
351            changes.is_empty(),
352            "expected no changes (lenient), got {changes:?}"
353        );
354    }
355
356    // ---- identical subscription: no diff ----
357
358    #[test]
359    fn identical_subscription_produces_no_changes() {
360        let c = catalog_with(vec![sub_minimal("s")]);
361        let changes = run_diff(&c, &c);
362        assert!(changes.is_empty());
363    }
364
365    // ---- connection diff ----
366
367    #[test]
368    fn connection_differs_in_non_password_key_emits_alter_connection() {
369        let mut tgt = sub_minimal("s");
370        tgt.connection = "host=old user=u".into();
371        let mut src = sub_minimal("s");
372        src.connection = "host=new user=u".into();
373        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
374        assert_eq!(changes.len(), 1);
375        assert!(matches!(
376            changes.iter().next().unwrap().change,
377            Change::AlterSubscriptionConnection { .. }
378        ));
379    }
380
381    #[test]
382    fn connection_differs_only_in_password_produces_no_change() {
383        let mut tgt = sub_minimal("s");
384        tgt.connection = "host=x password=old".into();
385        let mut src = sub_minimal("s");
386        src.connection = "host=x password=new".into();
387        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
388        assert!(
389            changes.is_empty(),
390            "password-only change must not trigger diff"
391        );
392    }
393
394    // ---- publications ----
395
396    #[test]
397    fn publication_added_emits_add_publication() {
398        let mut tgt = sub_minimal("s");
399        tgt.publications = vec![id("p1")];
400        let mut src = sub_minimal("s");
401        src.publications = vec![id("p1"), id("p2")];
402        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
403        assert_eq!(changes.len(), 1);
404        assert!(matches!(
405            changes.iter().next().unwrap().change,
406            Change::AlterSubscriptionAddPublication { .. }
407        ));
408    }
409
410    #[test]
411    fn publication_removed_emits_drop_publication() {
412        let mut tgt = sub_minimal("s");
413        tgt.publications = vec![id("p1"), id("p2")];
414        let mut src = sub_minimal("s");
415        src.publications = vec![id("p1")];
416        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
417        assert_eq!(changes.len(), 1);
418        assert!(matches!(
419            changes.iter().next().unwrap().change,
420            Change::AlterSubscriptionDropPublication { .. }
421        ));
422    }
423
424    // ---- options delta ----
425
426    #[test]
427    fn single_option_changed_emits_set_options() {
428        let tgt = sub_minimal("s"); // binary = None (unmanaged)
429        let mut src = sub_minimal("s");
430        src.options.binary = Some(true);
431        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
432        assert_eq!(changes.len(), 1);
433        let entry = changes.iter().next().unwrap();
434        if let Change::AlterSubscriptionSetOptions { options, .. } = &entry.change {
435            assert_eq!(options.binary, Some(true));
436            // Other fields must be None (sparse delta).
437            assert!(options.enabled.is_none());
438            assert!(options.streaming.is_none());
439            // create_slot and copy_data always None.
440            assert!(options.create_slot.is_none());
441            assert!(options.copy_data.is_none());
442        } else {
443            panic!(
444                "expected AlterSubscriptionSetOptions, got {:?}",
445                entry.change
446            );
447        }
448    }
449
450    #[test]
451    fn source_option_none_does_not_trigger_diff() {
452        let mut tgt = sub_minimal("s");
453        tgt.options.enabled = Some(true); // catalog has it set
454        let src = sub_minimal("s"); // source has None (unmanaged)
455        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
456        assert!(
457            changes.is_empty(),
458            "source option=None must not trigger diff (lenient)"
459        );
460    }
461
462    #[test]
463    fn create_slot_never_in_options_delta() {
464        let tgt = sub_minimal("s");
465        let mut src = sub_minimal("s");
466        src.options.create_slot = Some(false); // would be CREATE-only
467        // diff must not produce AlterSubscriptionSetOptions for create_slot
468        let delta = options_delta(&tgt.options, &src.options);
469        assert!(
470            delta.create_slot.is_none(),
471            "create_slot must always be None in options delta"
472        );
473    }
474
475    #[test]
476    fn copy_data_never_in_options_delta() {
477        let tgt = sub_minimal("s");
478        let mut src = sub_minimal("s");
479        src.options.copy_data = Some(false);
480        let delta = options_delta(&tgt.options, &src.options);
481        assert!(
482            delta.copy_data.is_none(),
483            "copy_data must always be None in options delta"
484        );
485    }
486
487    // ---- owner ----
488
489    #[test]
490    fn owner_change_emits_alter_object_owner() {
491        let mut tgt = sub_minimal("s");
492        tgt.owner = Some(id("alice"));
493        let mut src = sub_minimal("s");
494        src.owner = Some(id("bob"));
495        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
496        assert_eq!(changes.len(), 1);
497        assert!(matches!(
498            changes.iter().next().unwrap().change,
499            Change::AlterObjectOwner(_)
500        ));
501    }
502
503    #[test]
504    fn no_owner_change_when_source_owner_is_none() {
505        let mut tgt = sub_minimal("s");
506        tgt.owner = Some(id("alice"));
507        let src = sub_minimal("s"); // owner = None (unmanaged)
508        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
509        assert!(
510            changes.is_empty(),
511            "source owner None = unmanaged, no change expected"
512        );
513    }
514
515    // ---- comment ----
516
517    #[test]
518    fn comment_change_emits_comment_on_subscription() {
519        let tgt = sub_minimal("s");
520        let mut src = sub_minimal("s");
521        src.comment = Some("my sub".into());
522        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
523        assert_eq!(changes.len(), 1);
524        assert!(matches!(
525            changes.iter().next().unwrap().change,
526            Change::CommentOnSubscription { .. }
527        ));
528    }
529
530    // ---- multiple options changed → single AlterSubscriptionSetOptions ----
531
532    #[test]
533    fn multiple_options_changed_emit_single_set_options() {
534        let tgt = sub_minimal("s");
535        let mut src = sub_minimal("s");
536        src.options.binary = Some(true);
537        src.options.streaming = Some(StreamingMode::On);
538        src.options.origin = Some(OriginMode::None);
539        let changes = run_diff(&catalog_with(vec![tgt]), &catalog_with(vec![src]));
540        assert_eq!(changes.len(), 1);
541        if let Change::AlterSubscriptionSetOptions { options, .. } =
542            &changes.iter().next().unwrap().change
543        {
544            assert_eq!(options.binary, Some(true));
545            assert_eq!(options.streaming, Some(StreamingMode::On));
546            assert_eq!(options.origin, Some(OriginMode::None));
547            assert!(options.create_slot.is_none());
548            assert!(options.copy_data.is_none());
549        } else {
550            panic!("expected AlterSubscriptionSetOptions");
551        }
552    }
553}