Skip to main content

pgevolve_core/diff/
tables.rs

1//! Table-level diffing.
2//!
3//! Pairs tables by [`QualifiedName`]. Existence differences emit
4//! [`Change::CreateTable`] / [`Change::DropTable`]; pairs that are present in
5//! both catalogs dispatch to [`super::columns::diff_columns`] and
6//! [`super::constraints::diff_constraints`] and emit a single
7//! [`Change::AlterTable`] containing every per-table operation.
8
9use 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/// Diff tables in `target` against `source`, appending entries to `out`.
26#[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                // Skip column and constraint diffs when either side is a
64                // partition child (`partition_of.is_some()`).
65                //
66                // A partition child's column list is always inherited from the
67                // parent; the canonical source form (`PARTITION OF …`) never
68                // includes explicit columns. Diffing them would produce spurious
69                // ADD/DROP COLUMN steps. Three sub-cases:
70                //
71                //   1. partition_of is changing (None → Some / Some → None):
72                //      ATTACH / DETACH handles column inheritance atomically.
73                //      - ATTACH: child columns must already match parent.
74                //      - DETACH: inherited columns become explicit automatically.
75                //
76                //   2. partition_of is stable (both Some): e.g. Form 3 parses
77                //      a standalone CREATE TABLE + ALTER TABLE ATTACH, giving the
78                //      child an explicit column list; Form 2 gives an empty list.
79                //      Skip to avoid spurious DROP COLUMN steps.
80                //
81                //   3. partition_of is None in both: ordinary table → run diff.
82                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                // ---- owner diff ----
109                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                // ---- grant diff ----
128                {
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                // ---- partition_by diff (parent partitioning configuration) ----
196                // Changing PARTITION BY cannot be done in-place; surface as an
197                // UnsupportedDiff so the ordering phase aborts the plan.
198                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                // ---- partition_of diff (child membership in partitioned parent) ----
237                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                        // Source declares partition membership; catalog does not → attach.
242                        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                        // Catalog has partition membership; source dropped it → detach.
253                        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                        // Re-parented: detach from old parent, attach to new.
263                        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                        // Same parent, bounds differ: detach + re-attach.
281                        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                // ---- policy diff (RLS toggles + per-policy changes) ----
300                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                // ---- storage reloptions diff ----
307                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    // ---- partition test helpers ----
459
460    fn qn2(schema: &str, name: &str) -> QualifiedName {
461        QualifiedName::new(id(schema), id(name))
462    }
463
464    /// A plain (non-partitioned) table with the given schema/name.
465    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    /// Construct a `PartitionOf` with `DEFAULT` bounds.
483    fn po_default(schema: &str, parent: &str) -> PartitionOf {
484        PartitionOf {
485            parent: qn2(schema, parent),
486            bounds: PartitionBounds::Default,
487        }
488    }
489
490    /// Construct a `PartitionOf` with a single-column RANGE FROM literal TO MAXVALUE.
491    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    /// Construct a `PartitionBy LIST` on a single column.
503    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    /// Construct a `PartitionBy RANGE` on a single column.
515    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    /// Diff `source_table` against `target_table` (as the only table in each
527    /// catalog) and return the collected changes.
528    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    /// Like `run_diff` but returns `Err` if any `Change::UnsupportedDiff` is
539    /// emitted, or `Ok(changes)` otherwise.
540    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    // ---- partition tests ----
551
552    #[test]
553    fn detects_attach_partition_when_source_declares_it() {
554        // source says partition; catalog says standalone → AttachPartition
555        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}