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)]
27pub 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 }
45 }
46
47 for (qname, target_table) in &target_map {
48 match source_map.get(qname) {
49 None => {
50 out.push(
51 Change::DropTable {
52 qname: (*qname).clone(),
53 row_count_estimate: None,
54 },
55 Destructiveness::RequiresApprovalAndDataLossWarning {
56 reason: format!("drops table {qname}"),
57 },
58 );
59 }
60 Some(source_table) => {
61 let mut ops: Vec<TableOpEntry> = Vec::new();
62
63 let either_is_partition =
83 source_table.partition_of.is_some() || target_table.partition_of.is_some();
84 if !either_is_partition {
85 diff_columns(target_table, source_table, &mut ops);
86 diff_constraints(target_table, source_table, &mut ops);
87 }
88
89 if target_table.comment != source_table.comment {
90 ops.push(TableOpEntry {
91 op: super::table_op::TableOp::SetTableComment {
92 comment: source_table.comment.clone(),
93 },
94 destructiveness: Destructiveness::Safe,
95 });
96 }
97
98 if !ops.is_empty() {
99 out.push(
100 Change::AlterTable {
101 qname: (*qname).clone(),
102 ops,
103 },
104 Destructiveness::Safe,
105 );
106 }
107
108 if let Some(source_owner) = &source_table.owner
110 && target_table.owner.as_ref() != Some(source_owner)
111 {
112 let from = target_table.owner.clone().unwrap_or_else(|| {
113 Identifier::from_unquoted("__unknown_owner__").expect("literal is valid")
114 });
115 out.push(
116 Change::AlterObjectOwner(AlterObjectOwner {
117 kind: OwnerObjectKind::Table,
118 qname: (*qname).clone(),
119 signature: String::new(),
120 from,
121 to: source_owner.clone(),
122 }),
123 Destructiveness::Safe,
124 );
125 }
126
127 {
129 let object_label = format!("table {qname}");
130 let (to_add, to_revoke, unmanaged) =
131 diff_grants(&target_table.grants, &source_table.grants, managed_roles);
132 for g in to_add {
133 let is_column_level = g.columns.is_some();
134 if is_column_level {
135 out.push(
136 Change::GrantColumnPrivilege {
137 qname: (*qname).clone(),
138 grant: g,
139 },
140 Destructiveness::Safe,
141 );
142 } else {
143 out.push(
144 Change::GrantObjectPrivilege {
145 qname: (*qname).clone(),
146 kind: OwnerObjectKind::Table,
147 signature: String::new(),
148 grant: g,
149 },
150 Destructiveness::Safe,
151 );
152 }
153 }
154 for g in to_revoke {
155 if let Some(source_owner) = &source_table.owner {
156 out.revokes_with_owner.push(RevokeWithOwnerObservation {
157 object_label: object_label.clone(),
158 privilege_label: g.privilege.sql_keyword().into(),
159 grantee: g.grantee.clone(),
160 owner: source_owner.clone(),
161 });
162 }
163 let is_column_level = g.columns.is_some();
164 if is_column_level {
165 out.push(
166 Change::RevokeColumnPrivilege {
167 qname: (*qname).clone(),
168 grant: g,
169 },
170 Destructiveness::Safe,
171 );
172 } else {
173 out.push(
174 Change::RevokeObjectPrivilege {
175 qname: (*qname).clone(),
176 kind: OwnerObjectKind::Table,
177 signature: String::new(),
178 grant: g,
179 },
180 Destructiveness::Safe,
181 );
182 }
183 }
184 for g in unmanaged {
185 if let GrantTarget::Role(role_name) = &g.grantee {
186 out.unmanaged_grants.push(UnmanagedGrantObservation {
187 object_label: object_label.clone(),
188 privilege_label: g.privilege.sql_keyword().into(),
189 role_name: role_name.clone(),
190 });
191 }
192 }
193 }
194
195 match (&source_table.partition_by, &target_table.partition_by) {
199 (None, None) => {}
200 (Some(s), Some(t)) if s == t => {}
201 (Some(_), Some(_)) => {
202 out.push(
203 Change::UnsupportedDiff {
204 reason: format!(
205 "cannot change PARTITION BY clause on {qname} in-place; \
206 manual migration required"
207 ),
208 },
209 Destructiveness::Safe,
210 );
211 }
212 (None, Some(_)) => {
213 out.push(
214 Change::UnsupportedDiff {
215 reason: format!(
216 "cannot remove PARTITION BY from {qname} in-place; \
217 manual migration required"
218 ),
219 },
220 Destructiveness::Safe,
221 );
222 }
223 (Some(_), None) => {
224 out.push(
225 Change::UnsupportedDiff {
226 reason: format!(
227 "cannot add PARTITION BY to {qname} in-place; \
228 manual migration required"
229 ),
230 },
231 Destructiveness::Safe,
232 );
233 }
234 }
235
236 match (&source_table.partition_of, &target_table.partition_of) {
238 (None, None) => {}
239 (Some(s), Some(t)) if s == t => {}
240 (Some(s), None) => {
241 out.push(
243 Change::Table(TableChange::AttachPartition {
244 parent: s.parent.clone(),
245 child: (*qname).clone(),
246 bounds: s.bounds.clone(),
247 }),
248 Destructiveness::Safe,
249 );
250 }
251 (None, Some(t)) => {
252 out.push(
254 Change::Table(TableChange::DetachPartition {
255 parent: t.parent.clone(),
256 child: (*qname).clone(),
257 }),
258 Destructiveness::Safe,
259 );
260 }
261 (Some(s), Some(t)) if s.parent != t.parent => {
262 out.push(
264 Change::Table(TableChange::DetachPartition {
265 parent: t.parent.clone(),
266 child: (*qname).clone(),
267 }),
268 Destructiveness::Safe,
269 );
270 out.push(
271 Change::Table(TableChange::AttachPartition {
272 parent: s.parent.clone(),
273 child: (*qname).clone(),
274 bounds: s.bounds.clone(),
275 }),
276 Destructiveness::Safe,
277 );
278 }
279 (Some(s), Some(_)) => {
280 out.push(
282 Change::Table(TableChange::DetachPartition {
283 parent: s.parent.clone(),
284 child: (*qname).clone(),
285 }),
286 Destructiveness::Safe,
287 );
288 out.push(
289 Change::Table(TableChange::AttachPartition {
290 parent: s.parent.clone(),
291 child: (*qname).clone(),
292 bounds: s.bounds.clone(),
293 }),
294 Destructiveness::Safe,
295 );
296 }
297 }
298
299 let mut policy_changes: Vec<Change> = Vec::new();
301 super::policies::diff_policies(target_table, source_table, &mut policy_changes);
302 for c in policy_changes {
303 out.push(c, Destructiveness::Safe);
304 }
305
306 let delta = crate::diff::reloptions::table_delta(
308 &target_table.storage,
309 &source_table.storage,
310 );
311 if !delta.is_empty() {
312 out.push(
313 Change::SetTableStorage {
314 qname: (*qname).clone(),
315 options: delta,
316 },
317 Destructiveness::Safe,
318 );
319 }
320 }
321 }
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use std::collections::BTreeSet;
329
330 use crate::identifier::Identifier;
331 use crate::ir::column::Column;
332 use crate::ir::column_type::ColumnType;
333 use crate::ir::partition::{
334 BoundDatum, PartitionBounds, PartitionBy, PartitionColumn, PartitionColumnKind,
335 PartitionOf, PartitionStrategy,
336 };
337
338 fn id(s: &str) -> Identifier {
339 Identifier::from_unquoted(s).unwrap()
340 }
341
342 fn qn(name: &str) -> QualifiedName {
343 QualifiedName::new(id("app"), id(name))
344 }
345
346 fn col(name: &str, ty: ColumnType, nullable: bool) -> Column {
347 Column {
348 name: id(name),
349 ty,
350 nullable,
351 default: None,
352 identity: None,
353 generated: None,
354 collation: None,
355 storage: None,
356 compression: None,
357 comment: None,
358 }
359 }
360
361 fn users() -> Table {
362 Table {
363 qname: qn("users"),
364 columns: vec![col("id", ColumnType::BigInt, false)],
365 constraints: vec![],
366 partition_by: None,
367 partition_of: None,
368 comment: None,
369 owner: None,
370 grants: vec![],
371 rls_enabled: false,
372 rls_forced: false,
373 policies: vec![],
374 storage: crate::ir::reloptions::TableStorageOptions::default(),
375 }
376 }
377
378 #[test]
379 fn add_table_emits_create_safe() {
380 let target = Catalog::empty();
381 let mut source = Catalog::empty();
382 source.tables.push(users());
383 let mut cs = ChangeSet::new();
384 diff_tables(&target, &source, &mut cs, &BTreeSet::new());
385 assert_eq!(cs.len(), 1);
386 let entry = &cs.entries[0];
387 assert!(matches!(entry.change, Change::CreateTable(_)));
388 assert_eq!(entry.destructiveness, Destructiveness::Safe);
389 }
390
391 #[test]
392 fn drop_table_emits_data_loss_warning() {
393 let mut target = Catalog::empty();
394 target.tables.push(users());
395 let source = Catalog::empty();
396 let mut cs = ChangeSet::new();
397 diff_tables(&target, &source, &mut cs, &BTreeSet::new());
398 assert_eq!(cs.len(), 1);
399 let entry = &cs.entries[0];
400 match &entry.change {
401 Change::DropTable {
402 qname,
403 row_count_estimate,
404 } => {
405 assert_eq!(qname, &qn("users"));
406 assert!(row_count_estimate.is_none());
407 }
408 other => panic!("expected DropTable, got {other:?}"),
409 }
410 assert!(entry.destructiveness.data_loss_risk());
411 assert!(
412 entry
413 .destructiveness
414 .reason()
415 .unwrap()
416 .contains("app.users")
417 );
418 }
419
420 #[test]
421 fn equal_tables_emit_nothing() {
422 let mut target = Catalog::empty();
423 target.tables.push(users());
424 let mut source = Catalog::empty();
425 source.tables.push(users());
426 let mut cs = ChangeSet::new();
427 diff_tables(&target, &source, &mut cs, &BTreeSet::new());
428 assert!(cs.is_empty());
429 }
430
431 #[test]
432 fn comment_only_change_emits_alter_with_set_comment() {
433 let mut target = Catalog::empty();
434 target.tables.push(users());
435 let mut source = Catalog::empty();
436 source.tables.push(Table {
437 comment: Some("the users table".into()),
438 ..users()
439 });
440 let mut cs = ChangeSet::new();
441 diff_tables(&target, &source, &mut cs, &BTreeSet::new());
442 assert_eq!(cs.len(), 1);
443 let entry = &cs.entries[0];
444 match &entry.change {
445 Change::AlterTable { qname, ops } => {
446 assert_eq!(qname, &qn("users"));
447 assert_eq!(ops.len(), 1);
448 assert!(matches!(
449 ops[0].op,
450 super::super::table_op::TableOp::SetTableComment { .. }
451 ));
452 }
453 other => panic!("expected AlterTable, got {other:?}"),
454 }
455 assert_eq!(entry.destructiveness, Destructiveness::Safe);
456 }
457
458 fn qn2(schema: &str, name: &str) -> QualifiedName {
461 QualifiedName::new(id(schema), id(name))
462 }
463
464 fn sample_table_with_qname(schema: &str, name: &str) -> Table {
466 Table {
467 qname: qn2(schema, name),
468 columns: vec![col("id", ColumnType::BigInt, false)],
469 constraints: vec![],
470 partition_by: None,
471 partition_of: None,
472 comment: None,
473 owner: None,
474 grants: vec![],
475 rls_enabled: false,
476 rls_forced: false,
477 policies: vec![],
478 storage: crate::ir::reloptions::TableStorageOptions::default(),
479 }
480 }
481
482 fn po_default(schema: &str, parent: &str) -> PartitionOf {
484 PartitionOf {
485 parent: qn2(schema, parent),
486 bounds: PartitionBounds::Default,
487 }
488 }
489
490 fn po_range(schema: &str, parent: &str, from_lit: &str) -> PartitionOf {
492 use crate::ir::default_expr::NormalizedExpr;
493 PartitionOf {
494 parent: qn2(schema, parent),
495 bounds: PartitionBounds::Range {
496 from: vec![BoundDatum::Literal(NormalizedExpr::from_text(from_lit))],
497 to: vec![BoundDatum::MaxValue],
498 },
499 }
500 }
501
502 fn pb_list(col_name: &str) -> PartitionBy {
504 PartitionBy {
505 strategy: PartitionStrategy::List,
506 columns: vec![PartitionColumn {
507 kind: PartitionColumnKind::Column(id(col_name)),
508 collation: None,
509 opclass: None,
510 }],
511 }
512 }
513
514 fn pb_range(col_name: &str) -> PartitionBy {
516 PartitionBy {
517 strategy: PartitionStrategy::Range,
518 columns: vec![PartitionColumn {
519 kind: PartitionColumnKind::Column(id(col_name)),
520 collation: None,
521 opclass: None,
522 }],
523 }
524 }
525
526 fn run_diff(source: &Table, target: &Table) -> Vec<Change> {
529 let mut src_catalog = Catalog::empty();
530 src_catalog.tables.push(source.clone());
531 let mut tgt_catalog = Catalog::empty();
532 tgt_catalog.tables.push(target.clone());
533 let mut cs = ChangeSet::new();
534 diff_tables(&tgt_catalog, &src_catalog, &mut cs, &BTreeSet::new());
535 cs.entries.into_iter().map(|e| e.change).collect()
536 }
537
538 fn try_diff(source: &Table, target: &Table) -> Result<Vec<Change>, String> {
541 let changes = run_diff(source, target);
542 for c in &changes {
543 if let Change::UnsupportedDiff { reason } = c {
544 return Err(reason.clone());
545 }
546 }
547 Ok(changes)
548 }
549
550 #[test]
553 fn detects_attach_partition_when_source_declares_it() {
554 let mut src = sample_table_with_qname("app", "orders_2024");
556 src.partition_of = Some(po_default("app", "orders"));
557 let target = sample_table_with_qname("app", "orders_2024");
558 let changes = run_diff(&src, &target);
559 assert!(
560 changes
561 .iter()
562 .any(|c| matches!(c, Change::Table(TableChange::AttachPartition { .. }))),
563 "got: {changes:?}"
564 );
565 }
566
567 #[test]
568 fn detects_detach_partition_when_source_drops_declaration() {
569 let src = sample_table_with_qname("app", "orders_2024");
570 let mut target = sample_table_with_qname("app", "orders_2024");
571 target.partition_of = Some(po_default("app", "orders"));
572 let changes = run_diff(&src, &target);
573 assert!(
574 changes
575 .iter()
576 .any(|c| matches!(c, Change::Table(TableChange::DetachPartition { .. }))),
577 "got: {changes:?}"
578 );
579 }
580
581 #[test]
582 fn bounds_change_emits_detach_then_attach() {
583 let mut src = sample_table_with_qname("app", "orders_2024");
584 src.partition_of = Some(po_range("app", "orders", "10"));
585 let mut target = sample_table_with_qname("app", "orders_2024");
586 target.partition_of = Some(po_range("app", "orders", "20"));
587 let changes = run_diff(&src, &target);
588 let positions: Vec<_> = changes
589 .iter()
590 .filter_map(|c| match c {
591 Change::Table(TableChange::DetachPartition { .. }) => Some("detach"),
592 Change::Table(TableChange::AttachPartition { .. }) => Some("attach"),
593 _ => None,
594 })
595 .collect();
596 assert_eq!(positions, vec!["detach", "attach"]);
597 }
598
599 #[test]
600 fn parent_partition_by_change_errors() {
601 let mut src = sample_table_with_qname("app", "orders");
602 src.partition_by = Some(pb_list("region"));
603 let mut target = sample_table_with_qname("app", "orders");
604 target.partition_by = Some(pb_range("placed"));
605 let err = try_diff(&src, &target).unwrap_err();
606 assert!(err.contains("PARTITION BY"), "got: {err}");
607 }
608}