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)] // exhaustive per-table-property diff; extraction would fragment a single conceptual pass.
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                    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                // ---- grant diff ----
125                {
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                // ---- partition_by diff (parent partitioning configuration) ----
193                // Changing PARTITION BY cannot be done in-place; surface as an
194                // UnsupportedDiff so the ordering phase aborts the plan.
195                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                // ---- partition_of diff (child membership in partitioned parent) ----
234                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                        // Source declares partition membership; catalog does not → attach.
239                        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                        // Catalog has partition membership; source dropped it → detach.
250                        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                        // Re-parented: detach from old parent, attach to new.
260                        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                        // Same parent, bounds differ: detach + re-attach.
278                        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                // ---- policy diff (RLS toggles + per-policy changes) ----
297                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                // ---- storage reloptions diff ----
304                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    // ---- partition test helpers ----
456
457    fn qn2(schema: &str, name: &str) -> QualifiedName {
458        QualifiedName::new(id(schema), id(name))
459    }
460
461    /// A plain (non-partitioned) table with the given schema/name.
462    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    /// Construct a `PartitionOf` with `DEFAULT` bounds.
480    fn po_default(schema: &str, parent: &str) -> PartitionOf {
481        PartitionOf {
482            parent: qn2(schema, parent),
483            bounds: PartitionBounds::Default,
484        }
485    }
486
487    /// Construct a `PartitionOf` with a single-column RANGE FROM literal TO MAXVALUE.
488    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    /// Construct a `PartitionBy LIST` on a single column.
500    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    /// Construct a `PartitionBy RANGE` on a single column.
512    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    /// Diff `source_table` against `target_table` (as the only table in each
524    /// catalog) and return the collected changes.
525    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    /// Like `run_diff` but returns `Err` if any `Change::UnsupportedDiff` is
536    /// emitted, or `Ok(changes)` otherwise.
537    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    // ---- partition tests ----
548
549    #[test]
550    fn detects_attach_partition_when_source_declares_it() {
551        // source says partition; catalog says standalone → AttachPartition
552        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}