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