1use std::collections::{BTreeMap, BTreeSet};
10
11use crate::identifier::{Identifier, QualifiedName};
12use crate::ir::catalog::Catalog;
13use crate::ir::grant::GrantTarget;
14use crate::ir::table::Table;
15
16use super::change::{Change, TableChange};
17use super::changeset::{ChangeSet, RevokeWithOwnerObservation, UnmanagedGrantObservation};
18use super::columns::diff_columns;
19use super::constraints::diff_constraints;
20use super::destructiveness::Destructiveness;
21use super::grants::diff_grants;
22use super::owner_op::{AlterObjectOwner, OwnerObjectKind};
23use super::table_op::TableOpEntry;
24
25#[allow(clippy::too_many_lines)] pub fn diff_tables(
28 target: &Catalog,
29 source: &Catalog,
30 out: &mut ChangeSet,
31 managed_roles: &BTreeSet<Identifier>,
32) {
33 let target_map: BTreeMap<&QualifiedName, &Table> =
34 target.tables.iter().map(|t| (&t.qname, t)).collect();
35 let source_map: BTreeMap<&QualifiedName, &Table> =
36 source.tables.iter().map(|t| (&t.qname, t)).collect();
37
38 for (qname, source_table) in &source_map {
39 if !target_map.contains_key(qname) {
40 out.push(
41 Change::CreateTable((*source_table).clone()),
42 Destructiveness::Safe,
43 );
44 let empty_target = Table {
48 qname: source_table.qname.clone(),
49 columns: vec![],
50 constraints: vec![],
51 partition_by: None,
52 partition_of: None,
53 comment: None,
54 owner: None,
55 grants: vec![],
56 rls_enabled: false,
57 rls_forced: false,
58 policies: vec![],
59 storage: crate::ir::reloptions::TableStorageOptions::default(),
60 access_method: None,
61 };
62 emit_table_attribute_changes(&empty_target, source_table, managed_roles, out);
63 }
64 }
65
66 for (qname, target_table) in &target_map {
67 match source_map.get(qname) {
68 None => {
69 out.push(
70 Change::DropTable {
71 qname: (*qname).clone(),
72 row_count_estimate: None,
73 },
74 Destructiveness::RequiresApprovalAndDataLossWarning {
75 reason: format!("drops table {qname}"),
76 },
77 );
78 }
79 Some(source_table) => {
80 let mut ops: Vec<TableOpEntry> = Vec::new();
81
82 let either_is_partition =
102 source_table.partition_of.is_some() || target_table.partition_of.is_some();
103 if !either_is_partition {
104 diff_columns(target_table, source_table, &mut ops);
105 diff_constraints(target_table, source_table, &mut ops);
106 }
107
108 if target_table.comment != source_table.comment {
109 ops.push(TableOpEntry {
110 op: super::table_op::TableOp::SetTableComment {
111 comment: source_table.comment.clone(),
112 },
113 destructiveness: Destructiveness::Safe,
114 });
115 }
116
117 if !ops.is_empty() {
118 out.push(
119 Change::AlterTable {
120 qname: (*qname).clone(),
121 ops,
122 },
123 Destructiveness::Safe,
124 );
125 }
126
127 match (&source_table.partition_by, &target_table.partition_by) {
131 (None, None) => {}
132 (Some(s), Some(t)) if s == t => {}
133 (Some(_), Some(_)) => {
134 out.push(
135 Change::UnsupportedDiff {
136 reason: format!(
137 "cannot change PARTITION BY clause on {qname} in-place; \
138 manual migration required"
139 ),
140 },
141 Destructiveness::Safe,
142 );
143 }
144 (None, Some(_)) => {
145 out.push(
146 Change::UnsupportedDiff {
147 reason: format!(
148 "cannot remove PARTITION BY from {qname} in-place; \
149 manual migration required"
150 ),
151 },
152 Destructiveness::Safe,
153 );
154 }
155 (Some(_), None) => {
156 out.push(
157 Change::UnsupportedDiff {
158 reason: format!(
159 "cannot add PARTITION BY to {qname} in-place; \
160 manual migration required"
161 ),
162 },
163 Destructiveness::Safe,
164 );
165 }
166 }
167
168 match (&source_table.partition_of, &target_table.partition_of) {
170 (None, None) => {}
171 (Some(s), Some(t)) if s == t => {}
172 (Some(s), None) => {
173 out.push(
175 Change::Table(TableChange::AttachPartition {
176 parent: s.parent.clone(),
177 child: (*qname).clone(),
178 bounds: s.bounds.clone(),
179 }),
180 Destructiveness::Safe,
181 );
182 }
183 (None, Some(t)) => {
184 out.push(
186 Change::Table(TableChange::DetachPartition {
187 parent: t.parent.clone(),
188 child: (*qname).clone(),
189 }),
190 Destructiveness::Safe,
191 );
192 }
193 (Some(s), Some(t)) if s.parent != t.parent => {
194 out.push(
196 Change::Table(TableChange::DetachPartition {
197 parent: t.parent.clone(),
198 child: (*qname).clone(),
199 }),
200 Destructiveness::Safe,
201 );
202 out.push(
203 Change::Table(TableChange::AttachPartition {
204 parent: s.parent.clone(),
205 child: (*qname).clone(),
206 bounds: s.bounds.clone(),
207 }),
208 Destructiveness::Safe,
209 );
210 }
211 (Some(s), Some(_)) => {
212 out.push(
214 Change::Table(TableChange::DetachPartition {
215 parent: s.parent.clone(),
216 child: (*qname).clone(),
217 }),
218 Destructiveness::Safe,
219 );
220 out.push(
221 Change::Table(TableChange::AttachPartition {
222 parent: s.parent.clone(),
223 child: (*qname).clone(),
224 bounds: s.bounds.clone(),
225 }),
226 Destructiveness::Safe,
227 );
228 }
229 }
230
231 emit_table_attribute_changes(target_table, source_table, managed_roles, out);
233 }
234 }
235 }
236}
237
238fn strip_dropped_columns_from_grants(
254 grants: &[crate::ir::grant::Grant],
255 dropped_columns: &BTreeSet<&Identifier>,
256) -> Vec<crate::ir::grant::Grant> {
257 if dropped_columns.is_empty() {
258 return grants.to_vec();
259 }
260 grants
261 .iter()
262 .filter_map(|g| {
263 g.columns.as_ref().map_or_else(
264 || Some(g.clone()),
265 |cols| {
266 let kept: Vec<Identifier> = cols
267 .iter()
268 .filter(|c| !dropped_columns.contains(c))
269 .cloned()
270 .collect();
271 if kept.is_empty() {
272 None
273 } else {
274 Some(crate::ir::grant::Grant {
275 columns: Some(kept),
276 ..g.clone()
277 })
278 }
279 },
280 )
281 })
282 .collect()
283}
284
285fn emit_table_grant_changes(
288 target_table: &Table,
289 source_table: &Table,
290 managed_roles: &BTreeSet<Identifier>,
291 out: &mut ChangeSet,
292) {
293 let qname = &source_table.qname;
294 let object_label = format!("table {qname}");
295 let dropped_columns: BTreeSet<&Identifier> = {
302 let source_cols: BTreeSet<&Identifier> =
303 source_table.columns.iter().map(|c| &c.name).collect();
304 target_table
305 .columns
306 .iter()
307 .map(|c| &c.name)
308 .filter(|n| !source_cols.contains(*n))
309 .collect()
310 };
311 let adjusted_target = strip_dropped_columns_from_grants(&target_table.grants, &dropped_columns);
312 let (to_add, to_revoke, unmanaged) =
313 diff_grants(&adjusted_target, &source_table.grants, managed_roles);
314 for g in to_revoke {
318 if let Some(source_owner) = &source_table.owner {
319 out.revokes_with_owner.push(RevokeWithOwnerObservation {
320 object_label: object_label.clone(),
321 privilege_label: g.privilege.sql_keyword().into(),
322 grantee: g.grantee.clone(),
323 owner: source_owner.clone(),
324 });
325 }
326 if g.columns.is_some() {
327 out.push(
328 Change::RevokeColumnPrivilege {
329 qname: qname.clone(),
330 grant: g,
331 },
332 Destructiveness::Safe,
333 );
334 } else {
335 out.push(
336 Change::RevokeObjectPrivilege {
337 qname: qname.clone(),
338 kind: OwnerObjectKind::Table,
339 signature: String::new(),
340 grant: g,
341 },
342 Destructiveness::Safe,
343 );
344 }
345 }
346 for g in to_add {
347 if g.columns.is_some() {
348 out.push(
349 Change::GrantColumnPrivilege {
350 qname: qname.clone(),
351 grant: g,
352 },
353 Destructiveness::Safe,
354 );
355 } else {
356 out.push(
357 Change::GrantObjectPrivilege {
358 qname: qname.clone(),
359 kind: OwnerObjectKind::Table,
360 signature: String::new(),
361 grant: g,
362 },
363 Destructiveness::Safe,
364 );
365 }
366 }
367 for g in unmanaged {
368 if let GrantTarget::Role(role_name) = &g.grantee {
369 out.unmanaged_grants.push(UnmanagedGrantObservation {
370 object_label: object_label.clone(),
371 privilege_label: g.privilege.sql_keyword().into(),
372 role_name: role_name.clone(),
373 });
374 }
375 }
376}
377
378fn emit_table_attribute_changes(
379 target_table: &Table,
380 source_table: &Table,
381 managed_roles: &BTreeSet<Identifier>,
382 out: &mut ChangeSet,
383) {
384 let qname = &source_table.qname;
385
386 if let Some(source_owner) = &source_table.owner
388 && target_table.owner.as_ref() != Some(source_owner)
389 {
390 out.push(
391 Change::AlterObjectOwner(AlterObjectOwner {
392 kind: OwnerObjectKind::Table,
393 id: crate::diff::owner_op::OwnedObjectId::Qualified(qname.clone()),
394 signature: String::new(),
395 from: target_table.owner.clone(),
396 to: source_owner.clone(),
397 }),
398 Destructiveness::Safe,
399 );
400 }
401
402 emit_table_grant_changes(target_table, source_table, managed_roles, out);
404
405 let mut policy_changes: Vec<Change> = Vec::new();
407 super::policies::diff_policies(target_table, source_table, &mut policy_changes);
408 for c in policy_changes {
409 out.push(c, Destructiveness::Safe);
410 }
411
412 let delta = crate::diff::reloptions::table_delta(&target_table.storage, &source_table.storage);
414 if !delta.is_empty() {
415 out.push(
416 Change::SetTableStorage {
417 qname: qname.clone(),
418 options: delta,
419 },
420 Destructiveness::Safe,
421 );
422 }
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428 use std::collections::BTreeSet;
429
430 use crate::identifier::Identifier;
431 use crate::ir::column::Column;
432 use crate::ir::column_type::ColumnType;
433 use crate::ir::grant::{Grant, GrantTarget, Privilege};
434 use crate::ir::partition::{
435 BoundDatum, PartitionBounds, PartitionBy, PartitionColumn, PartitionColumnKind,
436 PartitionOf, PartitionStrategy,
437 };
438 use crate::ir::policy::{Policy, PolicyCommand};
439 use crate::ir::reloptions::TableStorageOptions;
440
441 fn id(s: &str) -> Identifier {
442 Identifier::from_unquoted(s).unwrap()
443 }
444
445 fn qn(name: &str) -> QualifiedName {
446 QualifiedName::new(id("app"), id(name))
447 }
448
449 fn col(name: &str, ty: ColumnType, nullable: bool) -> Column {
450 Column {
451 name: id(name),
452 ty,
453 nullable,
454 default: None,
455 identity: None,
456 generated: None,
457 collation: None,
458 storage: None,
459 compression: None,
460 comment: None,
461 }
462 }
463
464 fn users() -> Table {
465 Table {
466 qname: qn("users"),
467 columns: vec![col("id", ColumnType::BigInt, false)],
468 constraints: vec![],
469 partition_by: None,
470 partition_of: None,
471 comment: None,
472 owner: None,
473 grants: vec![],
474 rls_enabled: false,
475 rls_forced: false,
476 policies: vec![],
477 storage: crate::ir::reloptions::TableStorageOptions::default(),
478 access_method: None,
479 }
480 }
481
482 #[test]
493 fn dropped_column_does_not_emit_column_grant_revoke() {
494 let writers = id("writers");
495 let managed_roles: BTreeSet<Identifier> = std::iter::once(writers.clone()).collect();
496
497 let col_grant = |cols: &[&str]| Grant {
498 grantee: GrantTarget::Role(writers.clone()),
499 privilege: Privilege::Select,
500 with_grant_option: false,
501 columns: Some(cols.iter().map(|c| id(c)).collect()),
502 };
503
504 let mut target = Catalog::empty();
505 target.tables.push(Table {
506 columns: vec![
507 col("id", ColumnType::BigInt, false),
508 col("price", ColumnType::BigInt, true),
509 ],
510 grants: vec![col_grant(&["id", "price"])],
511 ..users()
512 });
513
514 let mut source = Catalog::empty();
515 source.tables.push(Table {
516 columns: vec![col("id", ColumnType::BigInt, false)],
517 grants: vec![col_grant(&["id"])],
518 ..users()
519 });
520
521 let mut cs = ChangeSet::new();
522 diff_tables(&target, &source, &mut cs, &managed_roles);
523
524 for entry in &cs.entries {
525 if let Change::RevokeColumnPrivilege { grant, .. } = &entry.change {
526 let cols = grant.columns.as_ref().expect("column grant");
527 assert!(
528 !cols.iter().any(|c| c.as_str() == "price"),
529 "must not revoke the dropped column 'price': {grant:?}"
530 );
531 }
532 assert!(
533 !matches!(&entry.change, Change::GrantColumnPrivilege { .. }),
534 "no column grant needed — surviving column 'id' already has it: {:?}",
535 entry.change
536 );
537 }
538 }
539
540 #[test]
541 fn add_table_emits_create_safe() {
542 let target = Catalog::empty();
543 let mut source = Catalog::empty();
544 source.tables.push(users());
545 let mut cs = ChangeSet::new();
546 diff_tables(&target, &source, &mut cs, &BTreeSet::new());
547 assert_eq!(cs.len(), 1);
548 let entry = &cs.entries[0];
549 assert!(matches!(entry.change, Change::CreateTable(_)));
550 assert_eq!(entry.destructiveness, Destructiveness::Safe);
551 }
552
553 #[test]
554 fn new_table_with_attrs_emits_create_plus_attribute_changes() {
555 let target = Catalog::empty();
559 let mut source = Catalog::empty();
560
561 let app_role = id("app");
562 let reader_role = id("reader");
563 let managed_roles: BTreeSet<Identifier> = [app_role.clone(), reader_role.clone()]
564 .into_iter()
565 .collect();
566
567 let table = Table {
568 qname: qn("users"),
569 columns: vec![col("id", ColumnType::BigInt, false)],
570 constraints: vec![],
571 partition_by: None,
572 partition_of: None,
573 comment: None,
574 owner: Some(app_role),
575 grants: vec![Grant {
576 grantee: GrantTarget::Role(reader_role),
577 privilege: Privilege::Select,
578 with_grant_option: false,
579 columns: None,
580 }],
581 rls_enabled: true,
582 rls_forced: true,
583 policies: vec![Policy {
584 name: id("tenant_isolation"),
585 permissive: true,
586 command: PolicyCommand::All,
587 roles: vec![GrantTarget::Public],
588 using: None,
589 with_check: None,
590 }],
591 storage: TableStorageOptions {
592 fillfactor: Some(70),
593 ..Default::default()
594 },
595 access_method: None,
596 };
597 source.tables.push(table);
598
599 let mut cs = ChangeSet::new();
600 diff_tables(&target, &source, &mut cs, &managed_roles);
601
602 let changes: Vec<&Change> = cs.entries.iter().map(|e| &e.change).collect();
603
604 assert!(
606 changes.iter().any(|c| matches!(c, Change::CreateTable(_))),
607 "missing CreateTable; got: {changes:?}"
608 );
609 assert!(
611 changes
612 .iter()
613 .any(|c| matches!(c, Change::AlterObjectOwner(_))),
614 "missing AlterObjectOwner; got: {changes:?}"
615 );
616 assert!(
618 changes
619 .iter()
620 .any(|c| matches!(c, Change::GrantObjectPrivilege { .. })),
621 "missing GrantObjectPrivilege; got: {changes:?}"
622 );
623 assert!(
625 changes
626 .iter()
627 .any(|c| matches!(c, Change::CreatePolicy { .. })),
628 "missing CreatePolicy; got: {changes:?}"
629 );
630 assert!(
632 changes
633 .iter()
634 .any(|c| matches!(c, Change::SetTableRowSecurity { enable: true, .. })),
635 "missing SetTableRowSecurity; got: {changes:?}"
636 );
637 assert!(
639 changes
640 .iter()
641 .any(|c| matches!(c, Change::SetTableForceRowSecurity { force: true, .. })),
642 "missing SetTableForceRowSecurity; got: {changes:?}"
643 );
644 assert!(
646 changes
647 .iter()
648 .any(|c| matches!(c, Change::SetTableStorage { .. })),
649 "missing SetTableStorage; got: {changes:?}"
650 );
651 }
652
653 #[test]
654 fn drop_table_emits_data_loss_warning() {
655 let mut target = Catalog::empty();
656 target.tables.push(users());
657 let source = Catalog::empty();
658 let mut cs = ChangeSet::new();
659 diff_tables(&target, &source, &mut cs, &BTreeSet::new());
660 assert_eq!(cs.len(), 1);
661 let entry = &cs.entries[0];
662 match &entry.change {
663 Change::DropTable {
664 qname,
665 row_count_estimate,
666 } => {
667 assert_eq!(qname, &qn("users"));
668 assert!(row_count_estimate.is_none());
669 }
670 other => panic!("expected DropTable, got {other:?}"),
671 }
672 assert!(entry.destructiveness.data_loss_risk());
673 assert!(
674 entry
675 .destructiveness
676 .reason()
677 .unwrap()
678 .contains("app.users")
679 );
680 }
681
682 #[test]
683 fn equal_tables_emit_nothing() {
684 let mut target = Catalog::empty();
685 target.tables.push(users());
686 let mut source = Catalog::empty();
687 source.tables.push(users());
688 let mut cs = ChangeSet::new();
689 diff_tables(&target, &source, &mut cs, &BTreeSet::new());
690 assert!(cs.is_empty());
691 }
692
693 #[test]
694 fn comment_only_change_emits_alter_with_set_comment() {
695 let mut target = Catalog::empty();
696 target.tables.push(users());
697 let mut source = Catalog::empty();
698 source.tables.push(Table {
699 comment: Some("the users table".into()),
700 ..users()
701 });
702 let mut cs = ChangeSet::new();
703 diff_tables(&target, &source, &mut cs, &BTreeSet::new());
704 assert_eq!(cs.len(), 1);
705 let entry = &cs.entries[0];
706 match &entry.change {
707 Change::AlterTable { qname, ops } => {
708 assert_eq!(qname, &qn("users"));
709 assert_eq!(ops.len(), 1);
710 assert!(matches!(
711 ops[0].op,
712 super::super::table_op::TableOp::SetTableComment { .. }
713 ));
714 }
715 other => panic!("expected AlterTable, got {other:?}"),
716 }
717 assert_eq!(entry.destructiveness, Destructiveness::Safe);
718 }
719
720 fn qn2(schema: &str, name: &str) -> QualifiedName {
723 QualifiedName::new(id(schema), id(name))
724 }
725
726 fn sample_table_with_qname(schema: &str, name: &str) -> Table {
728 Table {
729 qname: qn2(schema, name),
730 columns: vec![col("id", ColumnType::BigInt, false)],
731 constraints: vec![],
732 partition_by: None,
733 partition_of: None,
734 comment: None,
735 owner: None,
736 grants: vec![],
737 rls_enabled: false,
738 rls_forced: false,
739 policies: vec![],
740 storage: crate::ir::reloptions::TableStorageOptions::default(),
741 access_method: None,
742 }
743 }
744
745 fn po_default(schema: &str, parent: &str) -> PartitionOf {
747 PartitionOf {
748 parent: qn2(schema, parent),
749 bounds: PartitionBounds::Default,
750 }
751 }
752
753 fn po_range(schema: &str, parent: &str, from_lit: &str) -> PartitionOf {
755 use crate::ir::default_expr::NormalizedExpr;
756 PartitionOf {
757 parent: qn2(schema, parent),
758 bounds: PartitionBounds::Range {
759 from: vec![BoundDatum::Literal(NormalizedExpr::from_text(from_lit))],
760 to: vec![BoundDatum::MaxValue],
761 },
762 }
763 }
764
765 fn pb_list(col_name: &str) -> PartitionBy {
767 PartitionBy {
768 strategy: PartitionStrategy::List,
769 columns: vec![PartitionColumn {
770 kind: PartitionColumnKind::Column(id(col_name)),
771 collation: None,
772 opclass: None,
773 }],
774 }
775 }
776
777 fn pb_range(col_name: &str) -> PartitionBy {
779 PartitionBy {
780 strategy: PartitionStrategy::Range,
781 columns: vec![PartitionColumn {
782 kind: PartitionColumnKind::Column(id(col_name)),
783 collation: None,
784 opclass: None,
785 }],
786 }
787 }
788
789 fn run_diff(source: &Table, target: &Table) -> Vec<Change> {
792 let mut src_catalog = Catalog::empty();
793 src_catalog.tables.push(source.clone());
794 let mut tgt_catalog = Catalog::empty();
795 tgt_catalog.tables.push(target.clone());
796 let mut cs = ChangeSet::new();
797 diff_tables(&tgt_catalog, &src_catalog, &mut cs, &BTreeSet::new());
798 cs.entries.into_iter().map(|e| e.change).collect()
799 }
800
801 fn try_diff(source: &Table, target: &Table) -> Result<Vec<Change>, String> {
804 let changes = run_diff(source, target);
805 for c in &changes {
806 if let Change::UnsupportedDiff { reason } = c {
807 return Err(reason.clone());
808 }
809 }
810 Ok(changes)
811 }
812
813 #[test]
816 fn detects_attach_partition_when_source_declares_it() {
817 let mut src = sample_table_with_qname("app", "orders_2024");
819 src.partition_of = Some(po_default("app", "orders"));
820 let target = sample_table_with_qname("app", "orders_2024");
821 let changes = run_diff(&src, &target);
822 assert!(
823 changes
824 .iter()
825 .any(|c| matches!(c, Change::Table(TableChange::AttachPartition { .. }))),
826 "got: {changes:?}"
827 );
828 }
829
830 #[test]
831 fn detects_detach_partition_when_source_drops_declaration() {
832 let src = sample_table_with_qname("app", "orders_2024");
833 let mut target = sample_table_with_qname("app", "orders_2024");
834 target.partition_of = Some(po_default("app", "orders"));
835 let changes = run_diff(&src, &target);
836 assert!(
837 changes
838 .iter()
839 .any(|c| matches!(c, Change::Table(TableChange::DetachPartition { .. }))),
840 "got: {changes:?}"
841 );
842 }
843
844 #[test]
845 fn bounds_change_emits_detach_then_attach() {
846 let mut src = sample_table_with_qname("app", "orders_2024");
847 src.partition_of = Some(po_range("app", "orders", "10"));
848 let mut target = sample_table_with_qname("app", "orders_2024");
849 target.partition_of = Some(po_range("app", "orders", "20"));
850 let changes = run_diff(&src, &target);
851 let positions: Vec<_> = changes
852 .iter()
853 .filter_map(|c| match c {
854 Change::Table(TableChange::DetachPartition { .. }) => Some("detach"),
855 Change::Table(TableChange::AttachPartition { .. }) => Some("attach"),
856 _ => None,
857 })
858 .collect();
859 assert_eq!(positions, vec!["detach", "attach"]);
860 }
861
862 #[test]
863 fn parent_partition_by_change_errors() {
864 let mut src = sample_table_with_qname("app", "orders");
865 src.partition_by = Some(pb_list("region"));
866 let mut target = sample_table_with_qname("app", "orders");
867 target.partition_by = Some(pb_range("placed"));
868 let err = try_diff(&src, &target).unwrap_err();
869 assert!(err.contains("PARTITION BY"), "got: {err}");
870 }
871
872 #[test]
879 fn access_method_change_on_existing_table_emits_nothing() {
880 let mut src = sample_table_with_qname("app", "events");
882 src.access_method = Some(Identifier::from_unquoted("columnar").unwrap());
883 let mut target = sample_table_with_qname("app", "events");
884 target.access_method = Some(Identifier::from_unquoted("zheap").unwrap());
885
886 let changes = run_diff(&src, &target);
887 assert!(
888 changes.is_empty(),
889 "access_method change on an existing table must emit no Changes (lint handles it); got: {changes:?}"
890 );
891 }
892}