1use 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
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::CreateSubscription((*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::AlterSubscriptionConnection {
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::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 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 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 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 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
135fn 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 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 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 let mut value = String::new();
175 if chars.peek() == Some(&'\'') {
176 chars.next(); 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, 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; }
200 out.push((key, value));
201 }
202 out.sort();
203 out
204}
205
206fn 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, copy_data: None, 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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
427 fn single_option_changed_emits_set_options() {
428 let tgt = sub_minimal("s"); 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 assert!(options.enabled.is_none());
438 assert!(options.streaming.is_none());
439 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); let src = sub_minimal("s"); 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); 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 #[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"); 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 #[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 #[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}