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