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