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