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