Skip to main content

pgevolve_core/diff/
mod.rs

1//! Pure-function diff over the IR.
2//!
3//! `diff(target, source)` returns a [`ChangeSet`] describing every structural
4//! difference between two [`Catalog`] snapshots.
5//! No SQL is generated here — that is the job of phases 5+ (planner, rewrites).
6//!
7//! ## Direction
8//!
9//! `diff(target, source)` describes the changes needed to take the *target*
10//! catalog and turn it into the *source* catalog. In pgevolve terminology, the
11//! "target" is the live database and the "source" is the desired state declared
12//! in `*.sql` files; running the resulting plan converges target to source.
13
14pub mod aggregates;
15pub mod change;
16pub mod changeset;
17pub mod cluster;
18pub mod collations;
19pub mod columns;
20pub mod constraints;
21pub mod default_privileges;
22pub mod destructiveness;
23pub mod event_triggers;
24pub mod extensions;
25pub mod grants;
26pub mod indexes;
27pub mod owner_op;
28pub mod policies;
29pub mod publications;
30pub mod reloptions;
31pub mod routines;
32pub mod schemas;
33pub mod sequence_op;
34pub mod sequences;
35pub mod statistics;
36pub mod subscriptions;
37pub mod table_op;
38pub mod tables;
39pub mod triggers;
40pub mod types;
41pub mod views;
42
43pub use change::{
44    AggregateChange, Change, ChangeEntry, CollationChange, EventTriggerChange, ExtensionChange,
45    FunctionChange, MvChange, ProcedureChange, TableChange, TriggerChange, UserTypeChange,
46    ViewChange,
47};
48pub use changeset::{ChangeSet, RevokeWithOwnerObservation, UnmanagedGrantObservation};
49pub use cluster::{ClusterChange, ClusterChangeEntry, ClusterChangeSet, diff_cluster};
50pub use destructiveness::Destructiveness;
51pub use owner_op::{AlterObjectOwner, OwnerObjectKind};
52pub use routines::{diff_functions, diff_procedures};
53pub use sequence_op::{SequenceOp, SequenceOpEntry};
54pub use table_op::{TableOp, TableOpEntry};
55
56use crate::catalog::DriftReport;
57use crate::ir::catalog::Catalog;
58
59/// Compute the changes needed to converge `target` toward `source`.
60///
61/// `drift` captures any NOT VALID constraints or INVALID indexes found in the
62/// live database (from [`crate::catalog::read_catalog`]). Pass
63/// `&DriftReport::default()` when diffing two source-side catalogs (no live
64/// database involved).
65///
66/// The returned [`ChangeSet`] is unordered; ordering / dependency analysis is
67/// the planner's responsibility (phase 5).
68pub fn diff(target: &Catalog, source: &Catalog, drift: &DriftReport) -> ChangeSet {
69    let mut out = ChangeSet::new();
70
71    // Build the managed-roles set once from the source catalog.
72    // This is used throughout all per-family differs for the lenient
73    // drift policy: grants to roles not in this set are never silently revoked.
74    let managed_roles = grants::collect_managed_roles(source);
75
76    // Emit recovery changes for any drift in the live database before the
77    // structural diff, so the planner can schedule them appropriately.
78    for (table_qname, constraint_name) in &drift.pending_validation {
79        out.push(
80            Change::ValidateConstraint {
81                table: table_qname.clone(),
82                constraint: constraint_name.clone(),
83            },
84            Destructiveness::Safe,
85        );
86    }
87    for qname in &drift.invalid_indexes {
88        out.push(
89            Change::RecreateIndex {
90                qname: qname.clone(),
91            },
92            Destructiveness::Safe,
93        );
94    }
95
96    schemas::diff_schemas(target, source, &mut out, &managed_roles);
97    extensions::diff_extensions(&target.extensions, &source.extensions, &mut out);
98    tables::diff_tables(target, source, &mut out, &managed_roles);
99    indexes::diff_indexes(target, source, &mut out);
100    sequences::diff_sequences(target, source, &mut out, &managed_roles);
101    views::diff_views(&target.views, &source.views, &mut out, &managed_roles);
102    views::diff_materialized_views(
103        &target.materialized_views,
104        &source.materialized_views,
105        &mut out,
106        &managed_roles,
107    );
108    types::diff_user_types(&target.types, &source.types, &mut out, &managed_roles);
109    routines::diff_functions(
110        &target.functions,
111        &source.functions,
112        &mut out,
113        &managed_roles,
114    );
115    routines::diff_procedures(
116        &target.procedures,
117        &source.procedures,
118        &mut out,
119        &managed_roles,
120    );
121    triggers::diff_triggers(&target.triggers, &source.triggers, &mut out);
122    publications::diff_publications(target, source, &mut out);
123    subscriptions::diff_subscriptions(target, source, &mut out);
124    event_triggers::diff_event_triggers(target, source, &mut out);
125    aggregates::diff_aggregates(target, source, &mut out);
126    statistics::diff_statistics(target, source, &mut out);
127    collations::diff_collations(target, source, &mut out);
128
129    // ---- default-privileges diff ----
130    let dp_changes = default_privileges::diff_default_privileges(
131        &target.default_privileges,
132        &source.default_privileges,
133        &managed_roles,
134    );
135    for dp in dp_changes {
136        out.push(
137            Change::AlterDefaultPrivileges {
138                target_role: dp.target_role,
139                schema: dp.schema,
140                object_type: dp.object_type,
141                is_grant: dp.is_grant,
142                grant: dp.grant,
143            },
144            Destructiveness::Safe,
145        );
146    }
147
148    out
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::catalog::DriftReport;
155    use crate::identifier::{Identifier, QualifiedName};
156    use crate::ir::column::Column;
157    use crate::ir::column_type::ColumnType;
158    use crate::ir::constraint::{
159        Constraint, ConstraintKind, Deferrable, FkMatchType, ForeignKey, ReferentialAction,
160    };
161    use crate::ir::index::{
162        Index, IndexColumn, IndexColumnExpr, IndexMethod, IndexParent, NullsOrder, SortOrder,
163    };
164    use crate::ir::schema::Schema;
165    use crate::ir::sequence::Sequence;
166    use crate::ir::table::Table;
167
168    fn id(s: &str) -> Identifier {
169        Identifier::from_unquoted(s).unwrap()
170    }
171
172    fn qn(schema: &str, name: &str) -> QualifiedName {
173        QualifiedName::new(id(schema), id(name))
174    }
175
176    fn col(name: &str, ty: ColumnType, nullable: bool) -> Column {
177        Column {
178            name: id(name),
179            ty,
180            nullable,
181            default: None,
182            identity: None,
183            generated: None,
184            collation: None,
185            storage: None,
186            compression: None,
187            comment: None,
188        }
189    }
190
191    fn catalog_empty() -> Catalog {
192        Catalog::empty()
193    }
194
195    fn catalog_with_one_table() -> Catalog {
196        let mut c = Catalog::empty();
197        c.schemas.push(Schema::new(id("app")));
198        c.tables.push(Table {
199            qname: qn("app", "users"),
200            columns: vec![col("id", ColumnType::BigInt, false)],
201            constraints: vec![Constraint {
202                qname: qn("app", "users_pkey"),
203                kind: ConstraintKind::PrimaryKey {
204                    columns: vec![id("id")],
205                    include: vec![],
206                },
207                deferrable: Deferrable::NotDeferrable,
208                comment: None,
209            }],
210            partition_by: None,
211            partition_of: None,
212            comment: Some("user accounts".into()),
213            owner: None,
214            grants: vec![],
215            rls_enabled: false,
216            rls_forced: false,
217            policies: vec![],
218            storage: crate::ir::reloptions::TableStorageOptions::default(),
219            access_method: None,
220        });
221        c
222    }
223
224    fn table_orgs() -> Table {
225        Table {
226            qname: qn("app", "orgs"),
227            columns: vec![col("id", ColumnType::BigInt, false)],
228            constraints: vec![Constraint {
229                qname: qn("app", "orgs_pkey"),
230                kind: ConstraintKind::PrimaryKey {
231                    columns: vec![id("id")],
232                    include: vec![],
233                },
234                deferrable: Deferrable::NotDeferrable,
235                comment: None,
236            }],
237            partition_by: None,
238            partition_of: None,
239            comment: None,
240            owner: None,
241            grants: vec![],
242            rls_enabled: false,
243            rls_forced: false,
244            policies: vec![],
245            storage: crate::ir::reloptions::TableStorageOptions::default(),
246            access_method: None,
247        }
248    }
249
250    fn catalog_with_indexes_and_fks() -> Catalog {
251        let mut c = Catalog::empty();
252        c.schemas.push(Schema::new(id("app")));
253        c.tables.push(table_orgs());
254        c.tables.push(Table {
255            qname: qn("app", "users"),
256            columns: vec![
257                col("id", ColumnType::BigInt, false),
258                col("org_id", ColumnType::BigInt, false),
259                col("email", ColumnType::Varchar { len: Some(255) }, true),
260            ],
261            constraints: vec![
262                Constraint {
263                    qname: qn("app", "users_pkey"),
264                    kind: ConstraintKind::PrimaryKey {
265                        columns: vec![id("id")],
266                        include: vec![],
267                    },
268                    deferrable: Deferrable::NotDeferrable,
269                    comment: None,
270                },
271                Constraint {
272                    qname: qn("app", "users_org_fkey"),
273                    kind: ConstraintKind::ForeignKey(ForeignKey {
274                        columns: vec![id("org_id")],
275                        referenced_table: qn("app", "orgs"),
276                        referenced_columns: vec![id("id")],
277                        on_update: ReferentialAction::NoAction,
278                        on_delete: ReferentialAction::Cascade,
279                        match_type: FkMatchType::Simple,
280                    }),
281                    deferrable: Deferrable::NotDeferrable,
282                    comment: None,
283                },
284            ],
285            partition_by: None,
286            partition_of: None,
287            comment: None,
288            owner: None,
289            grants: vec![],
290            rls_enabled: false,
291            rls_forced: false,
292            policies: vec![],
293            storage: crate::ir::reloptions::TableStorageOptions::default(),
294            access_method: None,
295        });
296        c.indexes.push(Index {
297            qname: qn("app", "users_email_idx"),
298            on: IndexParent::Table(qn("app", "users")),
299            method: IndexMethod::BTree,
300            columns: vec![IndexColumn {
301                expr: IndexColumnExpr::Column(id("email")),
302                collation: None,
303                opclass: None,
304                sort_order: SortOrder::Asc,
305                nulls_order: NullsOrder::NullsLast,
306            }],
307            include: vec![],
308            unique: true,
309            nulls_not_distinct: false,
310            predicate: None,
311            tablespace: None,
312            comment: None,
313            storage: crate::ir::reloptions::IndexStorageOptions::default(),
314        });
315        c.sequences.push(Sequence {
316            qname: qn("app", "global_counter"),
317            data_type: ColumnType::BigInt,
318            start: 1,
319            increment: 1,
320            min_value: None,
321            max_value: None,
322            cache: 1,
323            cycle: false,
324            owned_by: None,
325            comment: None,
326            owner: None,
327            grants: vec![],
328        });
329        c
330    }
331
332    #[test]
333    fn diff_against_empty_self_is_empty() {
334        let c = Catalog::empty();
335        assert!(diff(&c, &c, &DriftReport::default()).is_empty());
336    }
337
338    #[test]
339    fn diff_against_single_table_self_is_empty() {
340        assert!(
341            diff(
342                &catalog_with_one_table(),
343                &catalog_with_one_table(),
344                &DriftReport::default()
345            )
346            .is_empty()
347        );
348    }
349
350    /// Property-style: `diff(c, c)` is empty for every hand-built catalog.
351    /// Replace with a real `proptest` once the testkit `IRGenerator` lands in phase 11.
352    #[test]
353    fn diff_against_self_is_empty() {
354        let catalogs = vec![
355            catalog_empty(),
356            catalog_with_one_table(),
357            catalog_with_indexes_and_fks(),
358        ];
359        for c in &catalogs {
360            assert!(
361                diff(c, c, &DriftReport::default()).is_empty(),
362                "diff(c, c) was not empty for catalog: {c:?}"
363            );
364        }
365    }
366}