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