1use std::collections::{BTreeMap, BTreeSet};
10
11use crate::identifier::{Identifier, QualifiedName};
12use crate::ir::catalog::Catalog;
13use crate::ir::table::Table;
14
15use super::change::{Change, TableChange};
16use super::changeset::ChangeSet;
17use super::columns::diff_columns;
18use super::constraints::diff_constraints;
19use super::destructiveness::Destructiveness;
20use super::owner_grants::{ColumnGrantMode, diff_owner_and_grants};
21use super::owner_op::CatalogObjectRef;
22use super::table_op::TableOpEntry;
23
24#[allow(clippy::too_many_lines)] pub fn diff_tables(
27 target: &Catalog,
28 source: &Catalog,
29 out: &mut ChangeSet,
30 managed_roles: &BTreeSet<Identifier>,
31) {
32 let target_map: BTreeMap<&QualifiedName, &Table> =
33 target.tables.iter().map(|t| (&t.qname, t)).collect();
34 let source_map: BTreeMap<&QualifiedName, &Table> =
35 source.tables.iter().map(|t| (&t.qname, t)).collect();
36
37 for (qname, source_table) in &source_map {
38 if !target_map.contains_key(qname) {
39 out.push(
40 Change::CreateTable((*source_table).clone()),
41 Destructiveness::Safe,
42 );
43 let empty_target = Table {
47 qname: source_table.qname.clone(),
48 columns: vec![],
49 constraints: vec![],
50 partition_by: None,
51 partition_of: None,
52 comment: None,
53 owner: None,
54 grants: vec![],
55 rls_enabled: false,
56 rls_forced: false,
57 policies: vec![],
58 storage: crate::ir::reloptions::TableStorageOptions::default(),
59 access_method: None,
60 tablespace: 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 target_table.tablespace != source_table.tablespace {
118 let destructiveness = if source_table.partition_by.is_some() {
119 Destructiveness::Safe
120 } else {
121 Destructiveness::RequiresApproval {
122 reason: "SET TABLESPACE rewrites the table and takes an ACCESS EXCLUSIVE lock".into(),
123 }
124 };
125 ops.push(TableOpEntry {
126 op: super::table_op::TableOp::SetTableSpace {
127 name: source_table.tablespace.clone(),
128 },
129 destructiveness,
130 });
131 }
132
133 if !ops.is_empty() {
134 out.push(
135 Change::AlterTable {
136 qname: (*qname).clone(),
137 ops,
138 },
139 Destructiveness::Safe,
140 );
141 }
142
143 match (&source_table.partition_by, &target_table.partition_by) {
147 (None, None) => {}
148 (Some(s), Some(t)) if s == t => {}
149 (Some(_), Some(_)) => {
150 out.push(
151 Change::UnsupportedDiff {
152 reason: format!(
153 "cannot change PARTITION BY clause on {qname} in-place; \
154 manual migration required"
155 ),
156 },
157 Destructiveness::Safe,
158 );
159 }
160 (None, Some(_)) => {
161 out.push(
162 Change::UnsupportedDiff {
163 reason: format!(
164 "cannot remove PARTITION BY from {qname} in-place; \
165 manual migration required"
166 ),
167 },
168 Destructiveness::Safe,
169 );
170 }
171 (Some(_), None) => {
172 out.push(
173 Change::UnsupportedDiff {
174 reason: format!(
175 "cannot add PARTITION BY to {qname} in-place; \
176 manual migration required"
177 ),
178 },
179 Destructiveness::Safe,
180 );
181 }
182 }
183
184 match (&source_table.partition_of, &target_table.partition_of) {
186 (None, None) => {}
187 (Some(s), Some(t)) if s == t => {}
188 (Some(s), None) => {
189 out.push(
191 Change::Table(TableChange::AttachPartition {
192 parent: s.parent.clone(),
193 child: (*qname).clone(),
194 bounds: s.bounds.clone(),
195 }),
196 Destructiveness::Safe,
197 );
198 }
199 (None, Some(t)) => {
200 out.push(
202 Change::Table(TableChange::DetachPartition {
203 parent: t.parent.clone(),
204 child: (*qname).clone(),
205 }),
206 Destructiveness::Safe,
207 );
208 }
209 (Some(s), Some(t)) if s.parent != t.parent => {
210 out.push(
212 Change::Table(TableChange::DetachPartition {
213 parent: t.parent.clone(),
214 child: (*qname).clone(),
215 }),
216 Destructiveness::Safe,
217 );
218 out.push(
219 Change::Table(TableChange::AttachPartition {
220 parent: s.parent.clone(),
221 child: (*qname).clone(),
222 bounds: s.bounds.clone(),
223 }),
224 Destructiveness::Safe,
225 );
226 }
227 (Some(s), Some(_)) => {
228 out.push(
230 Change::Table(TableChange::DetachPartition {
231 parent: s.parent.clone(),
232 child: (*qname).clone(),
233 }),
234 Destructiveness::Safe,
235 );
236 out.push(
237 Change::Table(TableChange::AttachPartition {
238 parent: s.parent.clone(),
239 child: (*qname).clone(),
240 bounds: s.bounds.clone(),
241 }),
242 Destructiveness::Safe,
243 );
244 }
245 }
246
247 emit_table_attribute_changes(target_table, source_table, managed_roles, out);
249 }
250 }
251 }
252}
253
254fn strip_dropped_columns_from_grants(
270 grants: &[crate::ir::grant::Grant],
271 dropped_columns: &BTreeSet<&Identifier>,
272) -> Vec<crate::ir::grant::Grant> {
273 if dropped_columns.is_empty() {
274 return grants.to_vec();
275 }
276 grants
277 .iter()
278 .filter_map(|g| {
279 g.columns.as_ref().map_or_else(
280 || Some(g.clone()),
281 |cols| {
282 let kept: Vec<Identifier> = cols
283 .iter()
284 .filter(|c| !dropped_columns.contains(c))
285 .cloned()
286 .collect();
287 if kept.is_empty() {
288 None
289 } else {
290 Some(crate::ir::grant::Grant {
291 columns: Some(kept),
292 ..g.clone()
293 })
294 }
295 },
296 )
297 })
298 .collect()
299}
300
301fn emit_table_owner_and_grant_changes(
305 target_table: &Table,
306 source_table: &Table,
307 managed_roles: &BTreeSet<Identifier>,
308 out: &mut ChangeSet,
309) {
310 let dropped_columns: BTreeSet<&Identifier> = {
317 let source_cols: BTreeSet<&Identifier> =
318 source_table.columns.iter().map(|c| &c.name).collect();
319 target_table
320 .columns
321 .iter()
322 .map(|c| &c.name)
323 .filter(|n| !source_cols.contains(*n))
324 .collect()
325 };
326 let adjusted_target = strip_dropped_columns_from_grants(&target_table.grants, &dropped_columns);
327 diff_owner_and_grants(
328 &CatalogObjectRef::Table(source_table.qname.clone()),
329 target_table.owner.as_ref(),
330 source_table.owner.as_ref(),
331 &adjusted_target,
332 &source_table.grants,
333 managed_roles,
334 ColumnGrantMode::ColumnAware,
335 out,
336 );
337}
338
339fn emit_table_attribute_changes(
340 target_table: &Table,
341 source_table: &Table,
342 managed_roles: &BTreeSet<Identifier>,
343 out: &mut ChangeSet,
344) {
345 let qname = &source_table.qname;
346
347 emit_table_owner_and_grant_changes(target_table, source_table, managed_roles, out);
349
350 let mut policy_changes: Vec<Change> = Vec::new();
352 super::policies::diff_policies(target_table, source_table, &mut policy_changes);
353 for c in policy_changes {
354 out.push(c, Destructiveness::Safe);
355 }
356
357 let delta = crate::diff::reloptions::table_delta(&target_table.storage, &source_table.storage);
359 if !delta.is_empty() {
360 out.push(
361 Change::SetTableStorage {
362 qname: qname.clone(),
363 options: delta,
364 },
365 Destructiveness::Safe,
366 );
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use std::collections::BTreeSet;
374
375 use crate::identifier::Identifier;
376 use crate::ir::column::Column;
377 use crate::ir::column_type::ColumnType;
378 use crate::ir::grant::{Grant, GrantTarget, Privilege};
379 use crate::ir::partition::{
380 BoundDatum, PartitionBounds, PartitionBy, PartitionColumn, PartitionColumnKind,
381 PartitionOf, PartitionStrategy,
382 };
383 use crate::ir::policy::{Policy, PolicyCommand};
384 use crate::ir::reloptions::TableStorageOptions;
385
386 fn id(s: &str) -> Identifier {
387 Identifier::from_unquoted(s).unwrap()
388 }
389
390 fn qn(name: &str) -> QualifiedName {
391 QualifiedName::new(id("app"), id(name))
392 }
393
394 fn col(name: &str, ty: ColumnType, nullable: bool) -> Column {
395 Column {
396 name: id(name),
397 ty,
398 nullable,
399 default: None,
400 identity: None,
401 generated: None,
402 collation: None,
403 storage: None,
404 compression: None,
405 comment: None,
406 }
407 }
408
409 fn users() -> Table {
410 Table {
411 qname: qn("users"),
412 columns: vec![col("id", ColumnType::BigInt, false)],
413 constraints: vec![],
414 partition_by: None,
415 partition_of: None,
416 comment: None,
417 owner: None,
418 grants: vec![],
419 rls_enabled: false,
420 rls_forced: false,
421 policies: vec![],
422 storage: crate::ir::reloptions::TableStorageOptions::default(),
423 access_method: None,
424 tablespace: None,
425 }
426 }
427
428 #[test]
439 fn dropped_column_does_not_emit_column_grant_revoke() {
440 let writers = id("writers");
441 let managed_roles: BTreeSet<Identifier> = std::iter::once(writers.clone()).collect();
442
443 let col_grant = |cols: &[&str]| Grant {
444 grantee: GrantTarget::Role(writers.clone()),
445 privilege: Privilege::Select,
446 with_grant_option: false,
447 columns: Some(cols.iter().map(|c| id(c)).collect()),
448 };
449
450 let mut target = Catalog::empty();
451 target.tables.push(Table {
452 columns: vec![
453 col("id", ColumnType::BigInt, false),
454 col("price", ColumnType::BigInt, true),
455 ],
456 grants: vec![col_grant(&["id", "price"])],
457 ..users()
458 });
459
460 let mut source = Catalog::empty();
461 source.tables.push(Table {
462 columns: vec![col("id", ColumnType::BigInt, false)],
463 grants: vec![col_grant(&["id"])],
464 ..users()
465 });
466
467 let mut cs = ChangeSet::new();
468 diff_tables(&target, &source, &mut cs, &managed_roles);
469
470 for entry in &cs.entries {
471 if let Change::RevokeColumnPrivilege { grant, .. } = &entry.change {
472 let cols = grant.columns.as_ref().expect("column grant");
473 assert!(
474 !cols.iter().any(|c| c.as_str() == "price"),
475 "must not revoke the dropped column 'price': {grant:?}"
476 );
477 }
478 assert!(
479 !matches!(&entry.change, Change::GrantColumnPrivilege { .. }),
480 "no column grant needed — surviving column 'id' already has it: {:?}",
481 entry.change
482 );
483 }
484 }
485
486 #[test]
487 fn add_table_emits_create_safe() {
488 let target = Catalog::empty();
489 let mut source = Catalog::empty();
490 source.tables.push(users());
491 let mut cs = ChangeSet::new();
492 diff_tables(&target, &source, &mut cs, &BTreeSet::new());
493 assert_eq!(cs.len(), 1);
494 let entry = &cs.entries[0];
495 assert!(matches!(entry.change, Change::CreateTable(_)));
496 assert_eq!(entry.destructiveness, Destructiveness::Safe);
497 }
498
499 #[test]
500 fn new_table_with_attrs_emits_create_plus_attribute_changes() {
501 let target = Catalog::empty();
505 let mut source = Catalog::empty();
506
507 let app_role = id("app");
508 let reader_role = id("reader");
509 let managed_roles: BTreeSet<Identifier> = [app_role.clone(), reader_role.clone()]
510 .into_iter()
511 .collect();
512
513 let table = Table {
514 qname: qn("users"),
515 columns: vec![col("id", ColumnType::BigInt, false)],
516 constraints: vec![],
517 partition_by: None,
518 partition_of: None,
519 comment: None,
520 owner: Some(app_role),
521 grants: vec![Grant {
522 grantee: GrantTarget::Role(reader_role),
523 privilege: Privilege::Select,
524 with_grant_option: false,
525 columns: None,
526 }],
527 rls_enabled: true,
528 rls_forced: true,
529 policies: vec![Policy {
530 name: id("tenant_isolation"),
531 permissive: true,
532 command: PolicyCommand::All,
533 roles: vec![GrantTarget::Public],
534 using: None,
535 with_check: None,
536 }],
537 storage: TableStorageOptions {
538 fillfactor: Some(70),
539 ..Default::default()
540 },
541 access_method: None,
542 tablespace: None,
543 };
544 source.tables.push(table);
545
546 let mut cs = ChangeSet::new();
547 diff_tables(&target, &source, &mut cs, &managed_roles);
548
549 let changes: Vec<&Change> = cs.entries.iter().map(|e| &e.change).collect();
550
551 assert!(
553 changes.iter().any(|c| matches!(c, Change::CreateTable(_))),
554 "missing CreateTable; got: {changes:?}"
555 );
556 assert!(
558 changes
559 .iter()
560 .any(|c| matches!(c, Change::AlterObjectOwner(_))),
561 "missing AlterObjectOwner; got: {changes:?}"
562 );
563 assert!(
565 changes
566 .iter()
567 .any(|c| matches!(c, Change::GrantObjectPrivilege { .. })),
568 "missing GrantObjectPrivilege; got: {changes:?}"
569 );
570 assert!(
572 changes
573 .iter()
574 .any(|c| matches!(c, Change::CreatePolicy { .. })),
575 "missing CreatePolicy; got: {changes:?}"
576 );
577 assert!(
579 changes.iter().any(|c| matches!(
580 c,
581 Change::SetTableRowSecurity {
582 security: crate::diff::change::RowSecurity::Enable,
583 ..
584 }
585 )),
586 "missing SetTableRowSecurity; got: {changes:?}"
587 );
588 assert!(
590 changes.iter().any(|c| matches!(
591 c,
592 Change::SetTableForceRowSecurity {
593 force: crate::diff::change::ForceRowSecurity::Force,
594 ..
595 }
596 )),
597 "missing SetTableForceRowSecurity; got: {changes:?}"
598 );
599 assert!(
601 changes
602 .iter()
603 .any(|c| matches!(c, Change::SetTableStorage { .. })),
604 "missing SetTableStorage; got: {changes:?}"
605 );
606 }
607
608 #[test]
609 fn drop_table_emits_data_loss_warning() {
610 let mut target = Catalog::empty();
611 target.tables.push(users());
612 let source = Catalog::empty();
613 let mut cs = ChangeSet::new();
614 diff_tables(&target, &source, &mut cs, &BTreeSet::new());
615 assert_eq!(cs.len(), 1);
616 let entry = &cs.entries[0];
617 match &entry.change {
618 Change::DropTable {
619 qname,
620 row_count_estimate,
621 } => {
622 assert_eq!(qname, &qn("users"));
623 assert!(row_count_estimate.is_none());
624 }
625 other => panic!("expected DropTable, got {other:?}"),
626 }
627 assert!(entry.destructiveness.data_loss_risk());
628 assert!(
629 entry
630 .destructiveness
631 .reason()
632 .unwrap()
633 .contains("app.users")
634 );
635 }
636
637 #[test]
638 fn equal_tables_emit_nothing() {
639 let mut target = Catalog::empty();
640 target.tables.push(users());
641 let mut source = Catalog::empty();
642 source.tables.push(users());
643 let mut cs = ChangeSet::new();
644 diff_tables(&target, &source, &mut cs, &BTreeSet::new());
645 assert!(cs.is_empty());
646 }
647
648 #[test]
649 fn comment_only_change_emits_alter_with_set_comment() {
650 let mut target = Catalog::empty();
651 target.tables.push(users());
652 let mut source = Catalog::empty();
653 source.tables.push(Table {
654 comment: Some("the users table".into()),
655 ..users()
656 });
657 let mut cs = ChangeSet::new();
658 diff_tables(&target, &source, &mut cs, &BTreeSet::new());
659 assert_eq!(cs.len(), 1);
660 let entry = &cs.entries[0];
661 match &entry.change {
662 Change::AlterTable { qname, ops } => {
663 assert_eq!(qname, &qn("users"));
664 assert_eq!(ops.len(), 1);
665 assert!(matches!(
666 ops[0].op,
667 super::super::table_op::TableOp::SetTableComment { .. }
668 ));
669 }
670 other => panic!("expected AlterTable, got {other:?}"),
671 }
672 assert_eq!(entry.destructiveness, Destructiveness::Safe);
673 }
674
675 fn qn2(schema: &str, name: &str) -> QualifiedName {
678 QualifiedName::new(id(schema), id(name))
679 }
680
681 fn sample_table_with_qname(schema: &str, name: &str) -> Table {
683 Table {
684 qname: qn2(schema, name),
685 columns: vec![col("id", ColumnType::BigInt, false)],
686 constraints: vec![],
687 partition_by: None,
688 partition_of: None,
689 comment: None,
690 owner: None,
691 grants: vec![],
692 rls_enabled: false,
693 rls_forced: false,
694 policies: vec![],
695 storage: crate::ir::reloptions::TableStorageOptions::default(),
696 access_method: None,
697 tablespace: None,
698 }
699 }
700
701 fn po_default(schema: &str, parent: &str) -> PartitionOf {
703 PartitionOf {
704 parent: qn2(schema, parent),
705 bounds: PartitionBounds::Default,
706 }
707 }
708
709 fn po_range(schema: &str, parent: &str, from_lit: &str) -> PartitionOf {
711 use crate::ir::default_expr::NormalizedExpr;
712 PartitionOf {
713 parent: qn2(schema, parent),
714 bounds: PartitionBounds::Range {
715 from: vec![BoundDatum::Literal(NormalizedExpr::from_text(from_lit))],
716 to: vec![BoundDatum::MaxValue],
717 },
718 }
719 }
720
721 fn pb_list(col_name: &str) -> PartitionBy {
723 PartitionBy {
724 strategy: PartitionStrategy::List,
725 columns: vec![PartitionColumn {
726 kind: PartitionColumnKind::Column(id(col_name)),
727 collation: None,
728 opclass: None,
729 }],
730 }
731 }
732
733 fn pb_range(col_name: &str) -> PartitionBy {
735 PartitionBy {
736 strategy: PartitionStrategy::Range,
737 columns: vec![PartitionColumn {
738 kind: PartitionColumnKind::Column(id(col_name)),
739 collation: None,
740 opclass: None,
741 }],
742 }
743 }
744
745 fn run_diff(source: &Table, target: &Table) -> Vec<Change> {
748 let mut src_catalog = Catalog::empty();
749 src_catalog.tables.push(source.clone());
750 let mut tgt_catalog = Catalog::empty();
751 tgt_catalog.tables.push(target.clone());
752 let mut cs = ChangeSet::new();
753 diff_tables(&tgt_catalog, &src_catalog, &mut cs, &BTreeSet::new());
754 cs.entries.into_iter().map(|e| e.change).collect()
755 }
756
757 fn try_diff(source: &Table, target: &Table) -> Result<Vec<Change>, String> {
760 let changes = run_diff(source, target);
761 for c in &changes {
762 if let Change::UnsupportedDiff { reason } = c {
763 return Err(reason.clone());
764 }
765 }
766 Ok(changes)
767 }
768
769 #[test]
772 fn detects_attach_partition_when_source_declares_it() {
773 let mut src = sample_table_with_qname("app", "orders_2024");
775 src.partition_of = Some(po_default("app", "orders"));
776 let target = sample_table_with_qname("app", "orders_2024");
777 let changes = run_diff(&src, &target);
778 assert!(
779 changes
780 .iter()
781 .any(|c| matches!(c, Change::Table(TableChange::AttachPartition { .. }))),
782 "got: {changes:?}"
783 );
784 }
785
786 #[test]
787 fn detects_detach_partition_when_source_drops_declaration() {
788 let src = sample_table_with_qname("app", "orders_2024");
789 let mut target = sample_table_with_qname("app", "orders_2024");
790 target.partition_of = Some(po_default("app", "orders"));
791 let changes = run_diff(&src, &target);
792 assert!(
793 changes
794 .iter()
795 .any(|c| matches!(c, Change::Table(TableChange::DetachPartition { .. }))),
796 "got: {changes:?}"
797 );
798 }
799
800 #[test]
801 fn bounds_change_emits_detach_then_attach() {
802 let mut src = sample_table_with_qname("app", "orders_2024");
803 src.partition_of = Some(po_range("app", "orders", "10"));
804 let mut target = sample_table_with_qname("app", "orders_2024");
805 target.partition_of = Some(po_range("app", "orders", "20"));
806 let changes = run_diff(&src, &target);
807 let positions: Vec<_> = changes
808 .iter()
809 .filter_map(|c| match c {
810 Change::Table(TableChange::DetachPartition { .. }) => Some("detach"),
811 Change::Table(TableChange::AttachPartition { .. }) => Some("attach"),
812 _ => None,
813 })
814 .collect();
815 assert_eq!(positions, vec!["detach", "attach"]);
816 }
817
818 #[test]
819 fn parent_partition_by_change_errors() {
820 let mut src = sample_table_with_qname("app", "orders");
821 src.partition_by = Some(pb_list("region"));
822 let mut target = sample_table_with_qname("app", "orders");
823 target.partition_by = Some(pb_range("placed"));
824 let err = try_diff(&src, &target).unwrap_err();
825 assert!(err.contains("PARTITION BY"), "got: {err}");
826 }
827
828 #[test]
835 fn access_method_change_on_existing_table_emits_nothing() {
836 let mut src = sample_table_with_qname("app", "events");
838 src.access_method = Some(Identifier::from_unquoted("columnar").unwrap());
839 let mut target = sample_table_with_qname("app", "events");
840 target.access_method = Some(Identifier::from_unquoted("zheap").unwrap());
841
842 let changes = run_diff(&src, &target);
843 assert!(
844 changes.is_empty(),
845 "access_method change on an existing table must emit no Changes (lint handles it); got: {changes:?}"
846 );
847 }
848
849 fn extract_set_tablespace_op(source: &Table, target: &Table) -> TableOpEntry {
855 let mut src_catalog = Catalog::empty();
856 src_catalog.tables.push(source.clone());
857 let mut tgt_catalog = Catalog::empty();
858 tgt_catalog.tables.push(target.clone());
859 let mut cs = ChangeSet::new();
860 diff_tables(&tgt_catalog, &src_catalog, &mut cs, &BTreeSet::new());
861 assert_eq!(cs.len(), 1, "expected 1 Change entry, got {}", cs.len());
862 let entry = &cs.entries[0];
863 match &entry.change {
864 Change::AlterTable { ops, .. } => {
865 let ts_ops: Vec<&TableOpEntry> = ops
866 .iter()
867 .filter(|o| {
868 matches!(o.op, super::super::table_op::TableOp::SetTableSpace { .. })
869 })
870 .collect();
871 assert_eq!(
872 ts_ops.len(),
873 1,
874 "expected exactly 1 SetTableSpace op, got {}: {ops:?}",
875 ts_ops.len()
876 );
877 (*ts_ops[0]).clone()
878 }
879 other => panic!("expected AlterTable, got {other:?}"),
880 }
881 }
882
883 #[test]
886 fn leaf_table_tablespace_change_requires_approval() {
887 let mut src = sample_table_with_qname("app", "orders");
888 src.tablespace = Some(id("fast_ssd"));
889 let target = sample_table_with_qname("app", "orders");
890 let op_entry = extract_set_tablespace_op(&src, &target);
893 assert!(
894 matches!(
895 op_entry.op,
896 super::super::table_op::TableOp::SetTableSpace { .. }
897 ),
898 "expected SetTableSpace op"
899 );
900 assert!(
901 matches!(
902 op_entry.destructiveness,
903 Destructiveness::RequiresApproval { .. }
904 ),
905 "leaf table tablespace change must be RequiresApproval; got {:?}",
906 op_entry.destructiveness
907 );
908 }
909
910 #[test]
913 fn partition_child_tablespace_change_requires_approval() {
914 let mut src = sample_table_with_qname("app", "orders_2024");
915 src.partition_of = Some(po_default("app", "orders"));
916 src.tablespace = Some(id("fast_ssd"));
917 let mut target = sample_table_with_qname("app", "orders_2024");
918 target.partition_of = Some(po_default("app", "orders"));
919 let op_entry = extract_set_tablespace_op(&src, &target);
922 assert!(
923 matches!(
924 op_entry.op,
925 super::super::table_op::TableOp::SetTableSpace { .. }
926 ),
927 "expected SetTableSpace op"
928 );
929 assert!(
930 matches!(
931 op_entry.destructiveness,
932 Destructiveness::RequiresApproval { .. }
933 ),
934 "partition child tablespace change must be RequiresApproval; got {:?}",
935 op_entry.destructiveness
936 );
937 }
938
939 #[test]
942 fn partitioned_parent_tablespace_change_is_safe() {
943 let mut src = sample_table_with_qname("app", "orders");
944 src.partition_by = Some(pb_list("region"));
945 src.tablespace = Some(id("fast_ssd"));
946 let mut target = sample_table_with_qname("app", "orders");
947 target.partition_by = Some(pb_list("region"));
948 let op_entry = extract_set_tablespace_op(&src, &target);
951 assert!(
952 matches!(
953 op_entry.op,
954 super::super::table_op::TableOp::SetTableSpace { .. }
955 ),
956 "expected SetTableSpace op"
957 );
958 assert_eq!(
959 op_entry.destructiveness,
960 Destructiveness::Safe,
961 "partitioned parent tablespace change must be Safe; got {:?}",
962 op_entry.destructiveness
963 );
964 }
965
966 #[test]
968 fn equal_tablespace_none_emits_no_op() {
969 let src = sample_table_with_qname("app", "orders");
970 let target = sample_table_with_qname("app", "orders");
971 let changes = run_diff(&src, &target);
973 assert!(
974 changes.is_empty(),
975 "equal tablespace (both None) must emit no Changes; got: {changes:?}"
976 );
977 }
978
979 #[test]
981 fn equal_tablespace_some_emits_no_op() {
982 let mut src = sample_table_with_qname("app", "orders");
983 src.tablespace = Some(id("fast_ssd"));
984 let mut target = sample_table_with_qname("app", "orders");
985 target.tablespace = Some(id("fast_ssd"));
986
987 let changes = run_diff(&src, &target);
988 assert!(
989 changes.is_empty(),
990 "equal tablespace (both Some) must emit no Changes; got: {changes:?}"
991 );
992 }
993}