1use 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
20pub 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 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 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 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 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 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 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 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
122fn 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 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 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 let mut value = String::new();
162 if chars.peek() == Some(&'\'') {
163 chars.next(); 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, 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; }
187 out.push((key, value));
188 }
189 out.sort();
195 out
196}
197
198fn 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_slot: None, copy_data: None, 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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
421 fn single_option_changed_emits_set_options() {
422 let tgt = sub_minimal("s"); 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 assert!(options.enabled.is_none());
433 assert!(options.streaming.is_none());
434 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); let src = sub_minimal("s"); 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); 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 #[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"); 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 #[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 #[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}