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