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