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 connect: None, create_slot: None, copy_data: None, 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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
418 fn single_option_changed_emits_set_options() {
419 let tgt = sub_minimal("s"); 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 assert!(options.enabled.is_none());
430 assert!(options.streaming.is_none());
431 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); let src = sub_minimal("s"); 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); 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 #[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"); 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 #[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 #[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}