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 tablespace: None,
62 };
63 emit_table_attribute_changes(&empty_target, source_table, managed_roles, out);
64 }
65 }
66
67 for (qname, target_table) in &target_map {
68 match source_map.get(qname) {
69 None => {
70 out.push(
71 Change::DropTable {
72 qname: (*qname).clone(),
73 row_count_estimate: None,
74 },
75 Destructiveness::RequiresApprovalAndDataLossWarning {
76 reason: format!("drops table {qname}"),
77 },
78 );
79 }
80 Some(source_table) => {
81 let mut ops: Vec<TableOpEntry> = Vec::new();
82
83 let either_is_partition =
103 source_table.partition_of.is_some() || target_table.partition_of.is_some();
104 if !either_is_partition {
105 diff_columns(target_table, source_table, &mut ops);
106 diff_constraints(target_table, source_table, &mut ops);
107 }
108
109 if target_table.comment != source_table.comment {
110 ops.push(TableOpEntry {
111 op: super::table_op::TableOp::SetTableComment {
112 comment: source_table.comment.clone(),
113 },
114 destructiveness: Destructiveness::Safe,
115 });
116 }
117
118 if target_table.tablespace != source_table.tablespace {
119 let destructiveness = if source_table.partition_by.is_some() {
120 Destructiveness::Safe
121 } else {
122 Destructiveness::RequiresApproval {
123 reason: "SET TABLESPACE rewrites the table and takes an ACCESS EXCLUSIVE lock".into(),
124 }
125 };
126 ops.push(TableOpEntry {
127 op: super::table_op::TableOp::SetTableSpace {
128 name: source_table.tablespace.clone(),
129 },
130 destructiveness,
131 });
132 }
133
134 if !ops.is_empty() {
135 out.push(
136 Change::AlterTable {
137 qname: (*qname).clone(),
138 ops,
139 },
140 Destructiveness::Safe,
141 );
142 }
143
144 match (&source_table.partition_by, &target_table.partition_by) {
148 (None, None) => {}
149 (Some(s), Some(t)) if s == t => {}
150 (Some(_), Some(_)) => {
151 out.push(
152 Change::UnsupportedDiff {
153 reason: format!(
154 "cannot change PARTITION BY clause on {qname} in-place; \
155 manual migration required"
156 ),
157 },
158 Destructiveness::Safe,
159 );
160 }
161 (None, Some(_)) => {
162 out.push(
163 Change::UnsupportedDiff {
164 reason: format!(
165 "cannot remove PARTITION BY from {qname} in-place; \
166 manual migration required"
167 ),
168 },
169 Destructiveness::Safe,
170 );
171 }
172 (Some(_), None) => {
173 out.push(
174 Change::UnsupportedDiff {
175 reason: format!(
176 "cannot add PARTITION BY to {qname} in-place; \
177 manual migration required"
178 ),
179 },
180 Destructiveness::Safe,
181 );
182 }
183 }
184
185 match (&source_table.partition_of, &target_table.partition_of) {
187 (None, None) => {}
188 (Some(s), Some(t)) if s == t => {}
189 (Some(s), None) => {
190 out.push(
192 Change::Table(TableChange::AttachPartition {
193 parent: s.parent.clone(),
194 child: (*qname).clone(),
195 bounds: s.bounds.clone(),
196 }),
197 Destructiveness::Safe,
198 );
199 }
200 (None, Some(t)) => {
201 out.push(
203 Change::Table(TableChange::DetachPartition {
204 parent: t.parent.clone(),
205 child: (*qname).clone(),
206 }),
207 Destructiveness::Safe,
208 );
209 }
210 (Some(s), Some(t)) if s.parent != t.parent => {
211 out.push(
213 Change::Table(TableChange::DetachPartition {
214 parent: t.parent.clone(),
215 child: (*qname).clone(),
216 }),
217 Destructiveness::Safe,
218 );
219 out.push(
220 Change::Table(TableChange::AttachPartition {
221 parent: s.parent.clone(),
222 child: (*qname).clone(),
223 bounds: s.bounds.clone(),
224 }),
225 Destructiveness::Safe,
226 );
227 }
228 (Some(s), Some(_)) => {
229 out.push(
231 Change::Table(TableChange::DetachPartition {
232 parent: s.parent.clone(),
233 child: (*qname).clone(),
234 }),
235 Destructiveness::Safe,
236 );
237 out.push(
238 Change::Table(TableChange::AttachPartition {
239 parent: s.parent.clone(),
240 child: (*qname).clone(),
241 bounds: s.bounds.clone(),
242 }),
243 Destructiveness::Safe,
244 );
245 }
246 }
247
248 emit_table_attribute_changes(target_table, source_table, managed_roles, out);
250 }
251 }
252 }
253}
254
255fn strip_dropped_columns_from_grants(
271 grants: &[crate::ir::grant::Grant],
272 dropped_columns: &BTreeSet<&Identifier>,
273) -> Vec<crate::ir::grant::Grant> {
274 if dropped_columns.is_empty() {
275 return grants.to_vec();
276 }
277 grants
278 .iter()
279 .filter_map(|g| {
280 g.columns.as_ref().map_or_else(
281 || Some(g.clone()),
282 |cols| {
283 let kept: Vec<Identifier> = cols
284 .iter()
285 .filter(|c| !dropped_columns.contains(c))
286 .cloned()
287 .collect();
288 if kept.is_empty() {
289 None
290 } else {
291 Some(crate::ir::grant::Grant {
292 columns: Some(kept),
293 ..g.clone()
294 })
295 }
296 },
297 )
298 })
299 .collect()
300}
301
302fn emit_table_grant_changes(
305 target_table: &Table,
306 source_table: &Table,
307 managed_roles: &BTreeSet<Identifier>,
308 out: &mut ChangeSet,
309) {
310 let qname = &source_table.qname;
311 let object_label = format!("table {qname}");
312 let dropped_columns: BTreeSet<&Identifier> = {
319 let source_cols: BTreeSet<&Identifier> =
320 source_table.columns.iter().map(|c| &c.name).collect();
321 target_table
322 .columns
323 .iter()
324 .map(|c| &c.name)
325 .filter(|n| !source_cols.contains(*n))
326 .collect()
327 };
328 let adjusted_target = strip_dropped_columns_from_grants(&target_table.grants, &dropped_columns);
329 let (to_add, to_revoke, unmanaged) =
330 diff_grants(&adjusted_target, &source_table.grants, managed_roles);
331 for g in to_revoke {
335 if let Some(source_owner) = &source_table.owner {
336 out.revokes_with_owner.push(RevokeWithOwnerObservation {
337 object_label: object_label.clone(),
338 privilege_label: g.privilege.sql_keyword().into(),
339 grantee: g.grantee.clone(),
340 owner: source_owner.clone(),
341 });
342 }
343 if g.columns.is_some() {
344 out.push(
345 Change::RevokeColumnPrivilege {
346 qname: qname.clone(),
347 grant: g,
348 },
349 Destructiveness::Safe,
350 );
351 } else {
352 out.push(
353 Change::RevokeObjectPrivilege {
354 qname: qname.clone(),
355 kind: OwnerObjectKind::Table,
356 signature: String::new(),
357 grant: g,
358 },
359 Destructiveness::Safe,
360 );
361 }
362 }
363 for g in to_add {
364 if g.columns.is_some() {
365 out.push(
366 Change::GrantColumnPrivilege {
367 qname: qname.clone(),
368 grant: g,
369 },
370 Destructiveness::Safe,
371 );
372 } else {
373 out.push(
374 Change::GrantObjectPrivilege {
375 qname: qname.clone(),
376 kind: OwnerObjectKind::Table,
377 signature: String::new(),
378 grant: g,
379 },
380 Destructiveness::Safe,
381 );
382 }
383 }
384 for g in unmanaged {
385 if let GrantTarget::Role(role_name) = &g.grantee {
386 out.unmanaged_grants.push(UnmanagedGrantObservation {
387 object_label: object_label.clone(),
388 privilege_label: g.privilege.sql_keyword().into(),
389 role_name: role_name.clone(),
390 });
391 }
392 }
393}
394
395fn emit_table_attribute_changes(
396 target_table: &Table,
397 source_table: &Table,
398 managed_roles: &BTreeSet<Identifier>,
399 out: &mut ChangeSet,
400) {
401 let qname = &source_table.qname;
402
403 if let Some(source_owner) = &source_table.owner
405 && target_table.owner.as_ref() != Some(source_owner)
406 {
407 out.push(
408 Change::AlterObjectOwner(AlterObjectOwner {
409 kind: OwnerObjectKind::Table,
410 id: crate::diff::owner_op::OwnedObjectId::Qualified(qname.clone()),
411 signature: String::new(),
412 from: target_table.owner.clone(),
413 to: source_owner.clone(),
414 }),
415 Destructiveness::Safe,
416 );
417 }
418
419 emit_table_grant_changes(target_table, source_table, managed_roles, out);
421
422 let mut policy_changes: Vec<Change> = Vec::new();
424 super::policies::diff_policies(target_table, source_table, &mut policy_changes);
425 for c in policy_changes {
426 out.push(c, Destructiveness::Safe);
427 }
428
429 let delta = crate::diff::reloptions::table_delta(&target_table.storage, &source_table.storage);
431 if !delta.is_empty() {
432 out.push(
433 Change::SetTableStorage {
434 qname: qname.clone(),
435 options: delta,
436 },
437 Destructiveness::Safe,
438 );
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445 use std::collections::BTreeSet;
446
447 use crate::identifier::Identifier;
448 use crate::ir::column::Column;
449 use crate::ir::column_type::ColumnType;
450 use crate::ir::grant::{Grant, GrantTarget, Privilege};
451 use crate::ir::partition::{
452 BoundDatum, PartitionBounds, PartitionBy, PartitionColumn, PartitionColumnKind,
453 PartitionOf, PartitionStrategy,
454 };
455 use crate::ir::policy::{Policy, PolicyCommand};
456 use crate::ir::reloptions::TableStorageOptions;
457
458 fn id(s: &str) -> Identifier {
459 Identifier::from_unquoted(s).unwrap()
460 }
461
462 fn qn(name: &str) -> QualifiedName {
463 QualifiedName::new(id("app"), id(name))
464 }
465
466 fn col(name: &str, ty: ColumnType, nullable: bool) -> Column {
467 Column {
468 name: id(name),
469 ty,
470 nullable,
471 default: None,
472 identity: None,
473 generated: None,
474 collation: None,
475 storage: None,
476 compression: None,
477 comment: None,
478 }
479 }
480
481 fn users() -> Table {
482 Table {
483 qname: qn("users"),
484 columns: vec![col("id", ColumnType::BigInt, false)],
485 constraints: vec![],
486 partition_by: None,
487 partition_of: None,
488 comment: None,
489 owner: None,
490 grants: vec![],
491 rls_enabled: false,
492 rls_forced: false,
493 policies: vec![],
494 storage: crate::ir::reloptions::TableStorageOptions::default(),
495 access_method: None,
496 tablespace: None,
497 }
498 }
499
500 #[test]
511 fn dropped_column_does_not_emit_column_grant_revoke() {
512 let writers = id("writers");
513 let managed_roles: BTreeSet<Identifier> = std::iter::once(writers.clone()).collect();
514
515 let col_grant = |cols: &[&str]| Grant {
516 grantee: GrantTarget::Role(writers.clone()),
517 privilege: Privilege::Select,
518 with_grant_option: false,
519 columns: Some(cols.iter().map(|c| id(c)).collect()),
520 };
521
522 let mut target = Catalog::empty();
523 target.tables.push(Table {
524 columns: vec![
525 col("id", ColumnType::BigInt, false),
526 col("price", ColumnType::BigInt, true),
527 ],
528 grants: vec![col_grant(&["id", "price"])],
529 ..users()
530 });
531
532 let mut source = Catalog::empty();
533 source.tables.push(Table {
534 columns: vec![col("id", ColumnType::BigInt, false)],
535 grants: vec![col_grant(&["id"])],
536 ..users()
537 });
538
539 let mut cs = ChangeSet::new();
540 diff_tables(&target, &source, &mut cs, &managed_roles);
541
542 for entry in &cs.entries {
543 if let Change::RevokeColumnPrivilege { grant, .. } = &entry.change {
544 let cols = grant.columns.as_ref().expect("column grant");
545 assert!(
546 !cols.iter().any(|c| c.as_str() == "price"),
547 "must not revoke the dropped column 'price': {grant:?}"
548 );
549 }
550 assert!(
551 !matches!(&entry.change, Change::GrantColumnPrivilege { .. }),
552 "no column grant needed — surviving column 'id' already has it: {:?}",
553 entry.change
554 );
555 }
556 }
557
558 #[test]
559 fn add_table_emits_create_safe() {
560 let target = Catalog::empty();
561 let mut source = Catalog::empty();
562 source.tables.push(users());
563 let mut cs = ChangeSet::new();
564 diff_tables(&target, &source, &mut cs, &BTreeSet::new());
565 assert_eq!(cs.len(), 1);
566 let entry = &cs.entries[0];
567 assert!(matches!(entry.change, Change::CreateTable(_)));
568 assert_eq!(entry.destructiveness, Destructiveness::Safe);
569 }
570
571 #[test]
572 fn new_table_with_attrs_emits_create_plus_attribute_changes() {
573 let target = Catalog::empty();
577 let mut source = Catalog::empty();
578
579 let app_role = id("app");
580 let reader_role = id("reader");
581 let managed_roles: BTreeSet<Identifier> = [app_role.clone(), reader_role.clone()]
582 .into_iter()
583 .collect();
584
585 let table = Table {
586 qname: qn("users"),
587 columns: vec![col("id", ColumnType::BigInt, false)],
588 constraints: vec![],
589 partition_by: None,
590 partition_of: None,
591 comment: None,
592 owner: Some(app_role),
593 grants: vec![Grant {
594 grantee: GrantTarget::Role(reader_role),
595 privilege: Privilege::Select,
596 with_grant_option: false,
597 columns: None,
598 }],
599 rls_enabled: true,
600 rls_forced: true,
601 policies: vec![Policy {
602 name: id("tenant_isolation"),
603 permissive: true,
604 command: PolicyCommand::All,
605 roles: vec![GrantTarget::Public],
606 using: None,
607 with_check: None,
608 }],
609 storage: TableStorageOptions {
610 fillfactor: Some(70),
611 ..Default::default()
612 },
613 access_method: None,
614 tablespace: None,
615 };
616 source.tables.push(table);
617
618 let mut cs = ChangeSet::new();
619 diff_tables(&target, &source, &mut cs, &managed_roles);
620
621 let changes: Vec<&Change> = cs.entries.iter().map(|e| &e.change).collect();
622
623 assert!(
625 changes.iter().any(|c| matches!(c, Change::CreateTable(_))),
626 "missing CreateTable; got: {changes:?}"
627 );
628 assert!(
630 changes
631 .iter()
632 .any(|c| matches!(c, Change::AlterObjectOwner(_))),
633 "missing AlterObjectOwner; got: {changes:?}"
634 );
635 assert!(
637 changes
638 .iter()
639 .any(|c| matches!(c, Change::GrantObjectPrivilege { .. })),
640 "missing GrantObjectPrivilege; got: {changes:?}"
641 );
642 assert!(
644 changes
645 .iter()
646 .any(|c| matches!(c, Change::CreatePolicy { .. })),
647 "missing CreatePolicy; got: {changes:?}"
648 );
649 assert!(
651 changes
652 .iter()
653 .any(|c| matches!(c, Change::SetTableRowSecurity { enable: true, .. })),
654 "missing SetTableRowSecurity; got: {changes:?}"
655 );
656 assert!(
658 changes
659 .iter()
660 .any(|c| matches!(c, Change::SetTableForceRowSecurity { force: true, .. })),
661 "missing SetTableForceRowSecurity; got: {changes:?}"
662 );
663 assert!(
665 changes
666 .iter()
667 .any(|c| matches!(c, Change::SetTableStorage { .. })),
668 "missing SetTableStorage; got: {changes:?}"
669 );
670 }
671
672 #[test]
673 fn drop_table_emits_data_loss_warning() {
674 let mut target = Catalog::empty();
675 target.tables.push(users());
676 let source = Catalog::empty();
677 let mut cs = ChangeSet::new();
678 diff_tables(&target, &source, &mut cs, &BTreeSet::new());
679 assert_eq!(cs.len(), 1);
680 let entry = &cs.entries[0];
681 match &entry.change {
682 Change::DropTable {
683 qname,
684 row_count_estimate,
685 } => {
686 assert_eq!(qname, &qn("users"));
687 assert!(row_count_estimate.is_none());
688 }
689 other => panic!("expected DropTable, got {other:?}"),
690 }
691 assert!(entry.destructiveness.data_loss_risk());
692 assert!(
693 entry
694 .destructiveness
695 .reason()
696 .unwrap()
697 .contains("app.users")
698 );
699 }
700
701 #[test]
702 fn equal_tables_emit_nothing() {
703 let mut target = Catalog::empty();
704 target.tables.push(users());
705 let mut source = Catalog::empty();
706 source.tables.push(users());
707 let mut cs = ChangeSet::new();
708 diff_tables(&target, &source, &mut cs, &BTreeSet::new());
709 assert!(cs.is_empty());
710 }
711
712 #[test]
713 fn comment_only_change_emits_alter_with_set_comment() {
714 let mut target = Catalog::empty();
715 target.tables.push(users());
716 let mut source = Catalog::empty();
717 source.tables.push(Table {
718 comment: Some("the users table".into()),
719 ..users()
720 });
721 let mut cs = ChangeSet::new();
722 diff_tables(&target, &source, &mut cs, &BTreeSet::new());
723 assert_eq!(cs.len(), 1);
724 let entry = &cs.entries[0];
725 match &entry.change {
726 Change::AlterTable { qname, ops } => {
727 assert_eq!(qname, &qn("users"));
728 assert_eq!(ops.len(), 1);
729 assert!(matches!(
730 ops[0].op,
731 super::super::table_op::TableOp::SetTableComment { .. }
732 ));
733 }
734 other => panic!("expected AlterTable, got {other:?}"),
735 }
736 assert_eq!(entry.destructiveness, Destructiveness::Safe);
737 }
738
739 fn qn2(schema: &str, name: &str) -> QualifiedName {
742 QualifiedName::new(id(schema), id(name))
743 }
744
745 fn sample_table_with_qname(schema: &str, name: &str) -> Table {
747 Table {
748 qname: qn2(schema, name),
749 columns: vec![col("id", ColumnType::BigInt, false)],
750 constraints: vec![],
751 partition_by: None,
752 partition_of: None,
753 comment: None,
754 owner: None,
755 grants: vec![],
756 rls_enabled: false,
757 rls_forced: false,
758 policies: vec![],
759 storage: crate::ir::reloptions::TableStorageOptions::default(),
760 access_method: None,
761 tablespace: None,
762 }
763 }
764
765 fn po_default(schema: &str, parent: &str) -> PartitionOf {
767 PartitionOf {
768 parent: qn2(schema, parent),
769 bounds: PartitionBounds::Default,
770 }
771 }
772
773 fn po_range(schema: &str, parent: &str, from_lit: &str) -> PartitionOf {
775 use crate::ir::default_expr::NormalizedExpr;
776 PartitionOf {
777 parent: qn2(schema, parent),
778 bounds: PartitionBounds::Range {
779 from: vec![BoundDatum::Literal(NormalizedExpr::from_text(from_lit))],
780 to: vec![BoundDatum::MaxValue],
781 },
782 }
783 }
784
785 fn pb_list(col_name: &str) -> PartitionBy {
787 PartitionBy {
788 strategy: PartitionStrategy::List,
789 columns: vec![PartitionColumn {
790 kind: PartitionColumnKind::Column(id(col_name)),
791 collation: None,
792 opclass: None,
793 }],
794 }
795 }
796
797 fn pb_range(col_name: &str) -> PartitionBy {
799 PartitionBy {
800 strategy: PartitionStrategy::Range,
801 columns: vec![PartitionColumn {
802 kind: PartitionColumnKind::Column(id(col_name)),
803 collation: None,
804 opclass: None,
805 }],
806 }
807 }
808
809 fn run_diff(source: &Table, target: &Table) -> Vec<Change> {
812 let mut src_catalog = Catalog::empty();
813 src_catalog.tables.push(source.clone());
814 let mut tgt_catalog = Catalog::empty();
815 tgt_catalog.tables.push(target.clone());
816 let mut cs = ChangeSet::new();
817 diff_tables(&tgt_catalog, &src_catalog, &mut cs, &BTreeSet::new());
818 cs.entries.into_iter().map(|e| e.change).collect()
819 }
820
821 fn try_diff(source: &Table, target: &Table) -> Result<Vec<Change>, String> {
824 let changes = run_diff(source, target);
825 for c in &changes {
826 if let Change::UnsupportedDiff { reason } = c {
827 return Err(reason.clone());
828 }
829 }
830 Ok(changes)
831 }
832
833 #[test]
836 fn detects_attach_partition_when_source_declares_it() {
837 let mut src = sample_table_with_qname("app", "orders_2024");
839 src.partition_of = Some(po_default("app", "orders"));
840 let target = sample_table_with_qname("app", "orders_2024");
841 let changes = run_diff(&src, &target);
842 assert!(
843 changes
844 .iter()
845 .any(|c| matches!(c, Change::Table(TableChange::AttachPartition { .. }))),
846 "got: {changes:?}"
847 );
848 }
849
850 #[test]
851 fn detects_detach_partition_when_source_drops_declaration() {
852 let src = sample_table_with_qname("app", "orders_2024");
853 let mut target = sample_table_with_qname("app", "orders_2024");
854 target.partition_of = Some(po_default("app", "orders"));
855 let changes = run_diff(&src, &target);
856 assert!(
857 changes
858 .iter()
859 .any(|c| matches!(c, Change::Table(TableChange::DetachPartition { .. }))),
860 "got: {changes:?}"
861 );
862 }
863
864 #[test]
865 fn bounds_change_emits_detach_then_attach() {
866 let mut src = sample_table_with_qname("app", "orders_2024");
867 src.partition_of = Some(po_range("app", "orders", "10"));
868 let mut target = sample_table_with_qname("app", "orders_2024");
869 target.partition_of = Some(po_range("app", "orders", "20"));
870 let changes = run_diff(&src, &target);
871 let positions: Vec<_> = changes
872 .iter()
873 .filter_map(|c| match c {
874 Change::Table(TableChange::DetachPartition { .. }) => Some("detach"),
875 Change::Table(TableChange::AttachPartition { .. }) => Some("attach"),
876 _ => None,
877 })
878 .collect();
879 assert_eq!(positions, vec!["detach", "attach"]);
880 }
881
882 #[test]
883 fn parent_partition_by_change_errors() {
884 let mut src = sample_table_with_qname("app", "orders");
885 src.partition_by = Some(pb_list("region"));
886 let mut target = sample_table_with_qname("app", "orders");
887 target.partition_by = Some(pb_range("placed"));
888 let err = try_diff(&src, &target).unwrap_err();
889 assert!(err.contains("PARTITION BY"), "got: {err}");
890 }
891
892 #[test]
899 fn access_method_change_on_existing_table_emits_nothing() {
900 let mut src = sample_table_with_qname("app", "events");
902 src.access_method = Some(Identifier::from_unquoted("columnar").unwrap());
903 let mut target = sample_table_with_qname("app", "events");
904 target.access_method = Some(Identifier::from_unquoted("zheap").unwrap());
905
906 let changes = run_diff(&src, &target);
907 assert!(
908 changes.is_empty(),
909 "access_method change on an existing table must emit no Changes (lint handles it); got: {changes:?}"
910 );
911 }
912
913 fn extract_set_tablespace_op(source: &Table, target: &Table) -> TableOpEntry {
919 let mut src_catalog = Catalog::empty();
920 src_catalog.tables.push(source.clone());
921 let mut tgt_catalog = Catalog::empty();
922 tgt_catalog.tables.push(target.clone());
923 let mut cs = ChangeSet::new();
924 diff_tables(&tgt_catalog, &src_catalog, &mut cs, &BTreeSet::new());
925 assert_eq!(cs.len(), 1, "expected 1 Change entry, got {}", cs.len());
926 let entry = &cs.entries[0];
927 match &entry.change {
928 Change::AlterTable { ops, .. } => {
929 let ts_ops: Vec<&TableOpEntry> = ops
930 .iter()
931 .filter(|o| {
932 matches!(o.op, super::super::table_op::TableOp::SetTableSpace { .. })
933 })
934 .collect();
935 assert_eq!(
936 ts_ops.len(),
937 1,
938 "expected exactly 1 SetTableSpace op, got {}: {ops:?}",
939 ts_ops.len()
940 );
941 (*ts_ops[0]).clone()
942 }
943 other => panic!("expected AlterTable, got {other:?}"),
944 }
945 }
946
947 #[test]
950 fn leaf_table_tablespace_change_requires_approval() {
951 let mut src = sample_table_with_qname("app", "orders");
952 src.tablespace = Some(id("fast_ssd"));
953 let target = sample_table_with_qname("app", "orders");
954 let op_entry = extract_set_tablespace_op(&src, &target);
957 assert!(
958 matches!(
959 op_entry.op,
960 super::super::table_op::TableOp::SetTableSpace { .. }
961 ),
962 "expected SetTableSpace op"
963 );
964 assert!(
965 matches!(
966 op_entry.destructiveness,
967 Destructiveness::RequiresApproval { .. }
968 ),
969 "leaf table tablespace change must be RequiresApproval; got {:?}",
970 op_entry.destructiveness
971 );
972 }
973
974 #[test]
977 fn partition_child_tablespace_change_requires_approval() {
978 let mut src = sample_table_with_qname("app", "orders_2024");
979 src.partition_of = Some(po_default("app", "orders"));
980 src.tablespace = Some(id("fast_ssd"));
981 let mut target = sample_table_with_qname("app", "orders_2024");
982 target.partition_of = Some(po_default("app", "orders"));
983 let op_entry = extract_set_tablespace_op(&src, &target);
986 assert!(
987 matches!(
988 op_entry.op,
989 super::super::table_op::TableOp::SetTableSpace { .. }
990 ),
991 "expected SetTableSpace op"
992 );
993 assert!(
994 matches!(
995 op_entry.destructiveness,
996 Destructiveness::RequiresApproval { .. }
997 ),
998 "partition child tablespace change must be RequiresApproval; got {:?}",
999 op_entry.destructiveness
1000 );
1001 }
1002
1003 #[test]
1006 fn partitioned_parent_tablespace_change_is_safe() {
1007 let mut src = sample_table_with_qname("app", "orders");
1008 src.partition_by = Some(pb_list("region"));
1009 src.tablespace = Some(id("fast_ssd"));
1010 let mut target = sample_table_with_qname("app", "orders");
1011 target.partition_by = Some(pb_list("region"));
1012 let op_entry = extract_set_tablespace_op(&src, &target);
1015 assert!(
1016 matches!(
1017 op_entry.op,
1018 super::super::table_op::TableOp::SetTableSpace { .. }
1019 ),
1020 "expected SetTableSpace op"
1021 );
1022 assert_eq!(
1023 op_entry.destructiveness,
1024 Destructiveness::Safe,
1025 "partitioned parent tablespace change must be Safe; got {:?}",
1026 op_entry.destructiveness
1027 );
1028 }
1029
1030 #[test]
1032 fn equal_tablespace_none_emits_no_op() {
1033 let src = sample_table_with_qname("app", "orders");
1034 let target = sample_table_with_qname("app", "orders");
1035 let changes = run_diff(&src, &target);
1037 assert!(
1038 changes.is_empty(),
1039 "equal tablespace (both None) must emit no Changes; got: {changes:?}"
1040 );
1041 }
1042
1043 #[test]
1045 fn equal_tablespace_some_emits_no_op() {
1046 let mut src = sample_table_with_qname("app", "orders");
1047 src.tablespace = Some(id("fast_ssd"));
1048 let mut target = sample_table_with_qname("app", "orders");
1049 target.tablespace = Some(id("fast_ssd"));
1050
1051 let changes = run_diff(&src, &target);
1052 assert!(
1053 changes.is_empty(),
1054 "equal tablespace (both Some) must emit no Changes; got: {changes:?}"
1055 );
1056 }
1057}