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