1use 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
19pub 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 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 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 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 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 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 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 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
124fn 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 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 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 let mut value = String::new();
164 if chars.peek() == Some(&'\'') {
165 chars.next(); 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, 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; }
189 out.push((key, value));
190 }
191 out.sort();
192 out
193}
194
195fn 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, copy_data: None, 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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
416 fn single_option_changed_emits_set_options() {
417 let tgt = sub_minimal("s"); 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 assert!(options.enabled.is_none());
428 assert!(options.streaming.is_none());
429 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); let src = sub_minimal("s"); 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); 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 #[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"); 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 #[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 #[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}