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