1use std::collections::BTreeMap;
15
16use crate::diff::change::Change;
17use crate::diff::changeset::ChangeSet;
18use crate::diff::destructiveness::Destructiveness;
19use crate::diff::owner_op::{AlterObjectOwner, OwnerObjectKind};
20use crate::identifier::Identifier;
21use crate::ir::catalog::Catalog;
22use crate::ir::publication::{Publication, PublicationScope, PublishedTable};
23
24pub fn diff_publications(target: &Catalog, source: &Catalog, out: &mut ChangeSet) {
27 let target_map: BTreeMap<&Identifier, &Publication> =
28 target.publications.iter().map(|p| (&p.name, p)).collect();
29 let source_map: BTreeMap<&Identifier, &Publication> =
30 source.publications.iter().map(|p| (&p.name, p)).collect();
31
32 for (name, src) in &source_map {
34 if !target_map.contains_key(name) {
35 out.push(
36 Change::CreatePublication((*src).clone()),
37 Destructiveness::Safe,
38 );
39 }
40 }
41
42 for _name in target_map.keys() {
45 }
47
48 for (name, src) in &source_map {
50 let Some(tgt) = target_map.get(name) else {
51 continue;
52 };
53 diff_one_publication(tgt, src, out);
54 }
55}
56
57fn diff_one_publication(target: &Publication, source: &Publication, out: &mut ChangeSet) {
58 let target_mode = std::mem::discriminant(&target.scope);
63 let source_mode = std::mem::discriminant(&source.scope);
64 if target_mode != source_mode {
65 out.push(
66 Change::ReplacePublication {
67 from: target.clone(),
68 to: source.clone(),
69 },
70 Destructiveness::RequiresApproval {
71 reason: format!(
72 "publication {} mode swap (AllTables ↔ Selective)",
73 source.name
74 ),
75 },
76 );
77 return;
79 }
80
81 if let (
83 PublicationScope::Selective {
84 schemas: t_schemas,
85 tables: t_tables,
86 },
87 PublicationScope::Selective {
88 schemas: s_schemas,
89 tables: s_tables,
90 },
91 ) = (&target.scope, &source.scope)
92 {
93 diff_selective_tables(&source.name, t_tables, s_tables, out);
94 diff_selective_schemas(&source.name, t_schemas, s_schemas, out);
95 }
96 if target.publish != source.publish {
100 out.push(
101 Change::AlterPublicationSetPublish {
102 publication: source.name.clone(),
103 kinds: source.publish,
104 },
105 Destructiveness::Safe,
106 );
107 }
108 if target.publish_via_partition_root != source.publish_via_partition_root {
109 out.push(
110 Change::AlterPublicationSetViaRoot {
111 publication: source.name.clone(),
112 value: source.publish_via_partition_root,
113 },
114 Destructiveness::Safe,
115 );
116 }
117 if target.comment != source.comment {
118 out.push(
119 Change::CommentOnPublication {
120 name: source.name.clone(),
121 comment: source.comment.clone(),
122 },
123 Destructiveness::Safe,
124 );
125 }
126
127 if let Some(s_owner) = &source.owner
130 && target.owner.as_ref() != Some(s_owner)
131 {
132 let from = target.owner.clone().unwrap_or_else(|| {
133 Identifier::from_unquoted("__unknown_owner__")
134 .expect("literal is always a valid unquoted identifier")
135 });
136 out.push(
137 Change::AlterObjectOwner(AlterObjectOwner {
138 kind: OwnerObjectKind::Publication,
139 qname: crate::identifier::QualifiedName::new(
144 Identifier::from_unquoted("__cluster__")
145 .expect("literal is always a valid unquoted identifier"),
146 source.name.clone(),
147 ),
148 signature: String::new(),
149 from,
150 to: s_owner.clone(),
151 }),
152 Destructiveness::Safe,
153 );
154 }
155}
156
157fn diff_selective_tables(
158 pub_name: &Identifier,
159 target_tables: &[PublishedTable],
160 source_tables: &[PublishedTable],
161 out: &mut ChangeSet,
162) {
163 let t_map: BTreeMap<_, _> = target_tables.iter().map(|t| (&t.qname, t)).collect();
164 let s_map: BTreeMap<_, _> = source_tables.iter().map(|t| (&t.qname, t)).collect();
165
166 for (qname, t) in &s_map {
168 if !t_map.contains_key(qname) {
169 out.push(
170 Change::AlterPublicationAddTable {
171 publication: pub_name.clone(),
172 table: (*t).clone(),
173 },
174 Destructiveness::Safe,
175 );
176 }
177 }
178
179 for qname in t_map.keys() {
181 if !s_map.contains_key(qname) {
182 out.push(
183 Change::AlterPublicationDropTable {
184 publication: pub_name.clone(),
185 qname: (*qname).clone(),
186 },
187 Destructiveness::Safe,
188 );
189 }
190 }
191
192 for (qname, src_table) in &s_map {
194 let Some(tgt_table) = t_map.get(qname) else {
195 continue;
196 };
197 if tgt_table.row_filter != src_table.row_filter || tgt_table.columns != src_table.columns {
198 out.push(
199 Change::AlterPublicationSetTable {
200 publication: pub_name.clone(),
201 table: (*src_table).clone(),
202 },
203 Destructiveness::Safe,
204 );
205 }
206 }
207}
208
209fn diff_selective_schemas(
210 pub_name: &Identifier,
211 target_schemas: &std::collections::BTreeSet<Identifier>,
212 source_schemas: &std::collections::BTreeSet<Identifier>,
213 out: &mut ChangeSet,
214) {
215 for s in source_schemas.difference(target_schemas) {
217 out.push(
218 Change::AlterPublicationAddSchema {
219 publication: pub_name.clone(),
220 schema: s.clone(),
221 },
222 Destructiveness::Safe,
223 );
224 }
225
226 for s in target_schemas.difference(source_schemas) {
228 out.push(
229 Change::AlterPublicationDropSchema {
230 publication: pub_name.clone(),
231 schema: s.clone(),
232 },
233 Destructiveness::Safe,
234 );
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use crate::identifier::{Identifier, QualifiedName};
242 use crate::ir::catalog::Catalog;
243 use crate::ir::publication::{Publication, PublicationScope, PublishKinds, PublishedTable};
244 use std::collections::BTreeSet;
245
246 fn id(s: &str) -> Identifier {
247 Identifier::from_unquoted(s).unwrap()
248 }
249
250 fn qn(schema: &str, name: &str) -> QualifiedName {
251 QualifiedName::new(id(schema), id(name))
252 }
253
254 fn pub_all_tables(name: &str) -> Publication {
255 Publication {
256 name: id(name),
257 scope: PublicationScope::AllTables,
258 publish: PublishKinds::pg_default(),
259 publish_via_partition_root: false,
260 owner: None,
261 comment: None,
262 }
263 }
264
265 fn pub_selective(name: &str, tables: Vec<PublishedTable>) -> Publication {
266 Publication {
267 name: id(name),
268 scope: PublicationScope::Selective {
269 schemas: BTreeSet::new(),
270 tables,
271 },
272 publish: PublishKinds::pg_default(),
273 publish_via_partition_root: false,
274 owner: None,
275 comment: None,
276 }
277 }
278
279 fn table_entry(schema: &str, name: &str) -> PublishedTable {
280 PublishedTable {
281 qname: qn(schema, name),
282 row_filter: None,
283 columns: None,
284 }
285 }
286
287 fn catalog_with(pubs: Vec<Publication>) -> Catalog {
288 let mut c = Catalog::empty();
289 c.publications = pubs;
290 c
291 }
292
293 fn run_diff(target: &Catalog, source: &Catalog) -> ChangeSet {
294 let mut out = ChangeSet::new();
295 diff_publications(target, source, &mut out);
296 out
297 }
298
299 #[test]
302 fn create_pub_when_source_has_it_and_target_doesnt() {
303 let target = Catalog::empty();
304 let source = catalog_with(vec![pub_all_tables("p")]);
305 let changes = run_diff(&target, &source);
306 assert_eq!(changes.len(), 1);
307 assert!(matches!(
308 changes.iter().next().unwrap().change,
309 Change::CreatePublication(_)
310 ));
311 }
312
313 #[test]
316 fn no_drop_when_target_has_pub_but_source_doesnt() {
317 let target = catalog_with(vec![pub_all_tables("p")]);
318 let source = Catalog::empty();
319 let changes = run_diff(&target, &source);
320 assert!(
321 changes.is_empty(),
322 "expected no changes (lenient), got {changes:?}"
323 );
324 }
325
326 #[test]
329 fn mode_mismatch_emits_replace_publication() {
330 let target = catalog_with(vec![pub_all_tables("p")]);
331 let source = catalog_with(vec![pub_selective("p", vec![table_entry("app", "t")])]);
332 let changes = run_diff(&target, &source);
333 assert_eq!(changes.len(), 1);
334 let entry = changes.iter().next().unwrap();
335 assert!(
336 matches!(entry.change, Change::ReplacePublication { .. }),
337 "expected ReplacePublication, got {:?}",
338 entry.change
339 );
340 assert!(
341 entry.destructiveness.requires_approval(),
342 "mode swap must be RequiresApproval"
343 );
344 }
345
346 #[test]
347 fn mode_mismatch_emits_no_per_field_diffs() {
348 let target = catalog_with(vec![pub_all_tables("p")]);
350 let mut src_pub = pub_selective("p", vec![table_entry("app", "t")]);
351 src_pub.publish = PublishKinds {
352 insert: true,
353 update: false,
354 delete: false,
355 truncate: false,
356 };
357 let source = catalog_with(vec![src_pub]);
358 let changes = run_diff(&target, &source);
359 assert_eq!(changes.len(), 1, "only ReplacePublication, no scalar diffs");
360 }
361
362 #[test]
365 fn add_table_when_source_has_it_and_target_doesnt() {
366 let target = catalog_with(vec![pub_selective("p", vec![])]);
367 let source = catalog_with(vec![pub_selective("p", vec![table_entry("app", "t")])]);
368 let changes = run_diff(&target, &source);
369 assert_eq!(changes.len(), 1);
370 assert!(matches!(
371 changes.iter().next().unwrap().change,
372 Change::AlterPublicationAddTable { .. }
373 ));
374 }
375
376 #[test]
377 fn drop_table_when_target_has_it_and_source_doesnt() {
378 let target = catalog_with(vec![pub_selective("p", vec![table_entry("app", "t")])]);
379 let source = catalog_with(vec![pub_selective("p", vec![])]);
380 let changes = run_diff(&target, &source);
381 assert_eq!(changes.len(), 1);
382 assert!(matches!(
383 changes.iter().next().unwrap().change,
384 Change::AlterPublicationDropTable { .. }
385 ));
386 }
387
388 #[test]
389 fn set_table_when_columns_differ() {
390 let mut src_table = table_entry("app", "t");
391 src_table.columns = Some(vec![id("id"), id("name")]);
392 let target = catalog_with(vec![pub_selective("p", vec![table_entry("app", "t")])]);
393 let source = catalog_with(vec![pub_selective("p", vec![src_table])]);
394 let changes = run_diff(&target, &source);
395 assert_eq!(changes.len(), 1);
396 assert!(matches!(
397 changes.iter().next().unwrap().change,
398 Change::AlterPublicationSetTable { .. }
399 ));
400 }
401
402 #[test]
403 fn no_change_when_table_identical() {
404 let t = table_entry("app", "orders");
405 let target = catalog_with(vec![pub_selective("p", vec![t.clone()])]);
406 let source = catalog_with(vec![pub_selective("p", vec![t])]);
407 let changes = run_diff(&target, &source);
408 assert!(changes.is_empty());
409 }
410
411 #[test]
414 fn add_schema_when_source_has_it_and_target_doesnt() {
415 let tgt_pub = Publication {
416 name: id("p"),
417 scope: PublicationScope::Selective {
418 schemas: BTreeSet::new(),
419 tables: vec![table_entry("app", "t")],
420 },
421 publish: PublishKinds::pg_default(),
422 publish_via_partition_root: false,
423 owner: None,
424 comment: None,
425 };
426 let src_pub = Publication {
427 name: id("p"),
428 scope: PublicationScope::Selective {
429 schemas: BTreeSet::from([id("app")]),
430 tables: vec![table_entry("app", "t")],
431 },
432 publish: PublishKinds::pg_default(),
433 publish_via_partition_root: false,
434 owner: None,
435 comment: None,
436 };
437 let target = catalog_with(vec![tgt_pub]);
438 let source = catalog_with(vec![src_pub]);
439 let changes = run_diff(&target, &source);
440 assert_eq!(changes.len(), 1);
441 assert!(matches!(
442 changes.iter().next().unwrap().change,
443 Change::AlterPublicationAddSchema { .. }
444 ));
445 }
446
447 #[test]
448 fn drop_schema_when_target_has_it_and_source_doesnt() {
449 let tgt_pub = Publication {
450 name: id("p"),
451 scope: PublicationScope::Selective {
452 schemas: BTreeSet::from([id("app")]),
453 tables: vec![table_entry("app", "t")],
454 },
455 publish: PublishKinds::pg_default(),
456 publish_via_partition_root: false,
457 owner: None,
458 comment: None,
459 };
460 let src_pub = Publication {
461 name: id("p"),
462 scope: PublicationScope::Selective {
463 schemas: BTreeSet::new(),
464 tables: vec![table_entry("app", "t")],
465 },
466 publish: PublishKinds::pg_default(),
467 publish_via_partition_root: false,
468 owner: None,
469 comment: None,
470 };
471 let target = catalog_with(vec![tgt_pub]);
472 let source = catalog_with(vec![src_pub]);
473 let changes = run_diff(&target, &source);
474 assert_eq!(changes.len(), 1);
475 assert!(matches!(
476 changes.iter().next().unwrap().change,
477 Change::AlterPublicationDropSchema { .. }
478 ));
479 }
480
481 #[test]
484 fn set_publish_when_publish_kinds_differ() {
485 let target = catalog_with(vec![pub_all_tables("p")]);
486 let mut src_pub = pub_all_tables("p");
487 src_pub.publish = PublishKinds {
488 insert: true,
489 update: false,
490 delete: false,
491 truncate: false,
492 };
493 let source = catalog_with(vec![src_pub]);
494 let changes = run_diff(&target, &source);
495 assert_eq!(changes.len(), 1);
496 assert!(matches!(
497 changes.iter().next().unwrap().change,
498 Change::AlterPublicationSetPublish { .. }
499 ));
500 }
501
502 #[test]
503 fn set_via_root_when_differs() {
504 let target = catalog_with(vec![pub_all_tables("p")]);
505 let mut src_pub = pub_all_tables("p");
506 src_pub.publish_via_partition_root = true;
507 let source = catalog_with(vec![src_pub]);
508 let changes = run_diff(&target, &source);
509 assert_eq!(changes.len(), 1);
510 assert!(matches!(
511 changes.iter().next().unwrap().change,
512 Change::AlterPublicationSetViaRoot { value: true, .. }
513 ));
514 }
515
516 #[test]
517 fn comment_on_publication_when_comment_differs() {
518 let target = catalog_with(vec![pub_all_tables("p")]);
519 let mut src_pub = pub_all_tables("p");
520 src_pub.comment = Some("my pub".into());
521 let source = catalog_with(vec![src_pub]);
522 let changes = run_diff(&target, &source);
523 assert_eq!(changes.len(), 1);
524 assert!(matches!(
525 changes.iter().next().unwrap().change,
526 Change::CommentOnPublication { .. }
527 ));
528 }
529
530 #[test]
533 fn owner_change_emits_alter_object_owner() {
534 let mut tgt_pub = pub_all_tables("p");
535 tgt_pub.owner = Some(id("alice"));
536 let mut src_pub = pub_all_tables("p");
537 src_pub.owner = Some(id("bob"));
538 let target = catalog_with(vec![tgt_pub]);
539 let source = catalog_with(vec![src_pub]);
540 let changes = run_diff(&target, &source);
541 assert_eq!(changes.len(), 1);
542 assert!(matches!(
543 changes.iter().next().unwrap().change,
544 Change::AlterObjectOwner(_)
545 ));
546 }
547
548 #[test]
549 fn no_owner_change_when_source_owner_is_none() {
550 let mut tgt_pub = pub_all_tables("p");
552 tgt_pub.owner = Some(id("alice"));
553 let src_pub = pub_all_tables("p"); let target = catalog_with(vec![tgt_pub]);
555 let source = catalog_with(vec![src_pub]);
556 let changes = run_diff(&target, &source);
557 assert!(
558 changes.is_empty(),
559 "source owner None = unmanaged, no change expected"
560 );
561 }
562
563 #[test]
566 fn diff_against_self_is_empty_all_tables() {
567 let c = catalog_with(vec![pub_all_tables("p")]);
568 let changes = run_diff(&c, &c);
569 assert!(changes.is_empty());
570 }
571
572 #[test]
573 fn diff_against_self_is_empty_selective() {
574 let c = catalog_with(vec![pub_selective("p", vec![table_entry("app", "users")])]);
575 let changes = run_diff(&c, &c);
576 assert!(changes.is_empty());
577 }
578}