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