Skip to main content

pgevolve_core/diff/
constraints.rs

1//! Constraint-level diffing inside an `AlterTable`.
2//!
3//! Pairs constraints by [`QualifiedName`]. v0.1 emits *only* `AddConstraint`,
4//! `DropConstraint`, and `SetConstraintComment`: any change to the definition
5//! of an existing constraint is expressed as a drop-then-add pair, since
6//! Postgres does not support most `ALTER ... CONSTRAINT` operations.
7//!
8//! ## Destructiveness
9//!
10//! - `AddConstraint(...)`: `Safe`. The rewrite pass converts FK adds to
11//!   `NOT VALID + VALIDATE` for online behavior.
12//! - `DropConstraint`: conservatively `RequiresApproval` for every kind. We may
13//!   relax CHECK / UNIQUE drops to `Safe` in a future pass once we have
14//!   experience with users.
15//! - `SetConstraintComment`: `Safe`.
16
17use std::collections::BTreeMap;
18
19use crate::identifier::QualifiedName;
20use crate::ir::constraint::{Constraint, ConstraintKind};
21use crate::ir::table::Table;
22
23use super::destructiveness::Destructiveness;
24use super::table_op::{TableOp, TableOpEntry};
25
26/// Diff constraints in `target` against `source`, appending entries to `out`.
27pub fn diff_constraints(target: &Table, source: &Table, out: &mut Vec<TableOpEntry>) {
28    let target_map: BTreeMap<&QualifiedName, &Constraint> =
29        target.constraints.iter().map(|c| (&c.qname, c)).collect();
30    let source_map: BTreeMap<&QualifiedName, &Constraint> =
31        source.constraints.iter().map(|c| (&c.qname, c)).collect();
32
33    // Adds: present in source but not in target.
34    for (qname, source_constraint) in &source_map {
35        if !target_map.contains_key(qname) {
36            out.push(add_constraint_entry((*source_constraint).clone()));
37        }
38    }
39
40    for (qname, target_constraint) in &target_map {
41        match source_map.get(qname) {
42            None => {
43                // Pure drop.
44                out.push(drop_constraint_entry(target_constraint));
45            }
46            Some(source_constraint) => {
47                if definition_changed(target_constraint, source_constraint) {
48                    // Express definition changes as drop + add.
49                    out.push(drop_constraint_entry(target_constraint));
50                    out.push(add_constraint_entry((*source_constraint).clone()));
51                } else if target_constraint.comment != source_constraint.comment {
52                    out.push(TableOpEntry {
53                        op: TableOp::SetConstraintComment {
54                            name: qname.name.clone(),
55                            comment: source_constraint.comment.clone(),
56                        },
57                        destructiveness: Destructiveness::Safe,
58                    });
59                }
60            }
61        }
62    }
63}
64
65const fn add_constraint_entry(c: Constraint) -> TableOpEntry {
66    TableOpEntry {
67        op: TableOp::AddConstraint(c),
68        destructiveness: Destructiveness::Safe,
69    }
70}
71
72fn drop_constraint_entry(c: &Constraint) -> TableOpEntry {
73    TableOpEntry {
74        op: TableOp::DropConstraint {
75            name: c.qname.name.clone(),
76        },
77        destructiveness: Destructiveness::RequiresApproval {
78            reason: format!("drops {} constraint {}", kind_label(&c.kind), c.qname.name),
79        },
80    }
81}
82
83const fn kind_label(k: &ConstraintKind) -> &'static str {
84    match k {
85        ConstraintKind::PrimaryKey { .. } => "primary key",
86        ConstraintKind::Unique { .. } => "unique",
87        ConstraintKind::ForeignKey(_) => "foreign key",
88        ConstraintKind::Check { .. } => "check",
89    }
90}
91
92/// True iff anything other than `comment` changed.
93fn definition_changed(a: &Constraint, b: &Constraint) -> bool {
94    a.kind != b.kind || a.deferrable != b.deferrable
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::identifier::Identifier;
101    use crate::ir::column::Column;
102    use crate::ir::column_type::ColumnType;
103    use crate::ir::constraint::{Deferrable, FkMatchType, ForeignKey, ReferentialAction};
104
105    fn id(s: &str) -> Identifier {
106        Identifier::from_unquoted(s).unwrap()
107    }
108
109    fn qn(name: &str) -> QualifiedName {
110        QualifiedName::new(id("app"), id(name))
111    }
112
113    fn col(name: &str) -> Column {
114        Column {
115            name: id(name),
116            ty: ColumnType::BigInt,
117            nullable: false,
118            default: None,
119            identity: None,
120            generated: None,
121            collation: None,
122            storage: None,
123            compression: None,
124            comment: None,
125        }
126    }
127
128    fn tbl_with(constraints: Vec<Constraint>) -> Table {
129        Table {
130            qname: qn("users"),
131            columns: vec![col("id"), col("org_id")],
132            constraints,
133            partition_by: None,
134            partition_of: None,
135            comment: None,
136            owner: None,
137            grants: vec![],
138            rls_enabled: false,
139            rls_forced: false,
140            policies: vec![],
141            storage: crate::ir::reloptions::TableStorageOptions::default(),
142        }
143    }
144
145    fn pk(name: &str, cols: &[&str]) -> Constraint {
146        Constraint {
147            qname: qn(name),
148            kind: ConstraintKind::PrimaryKey {
149                columns: cols.iter().map(|c| id(c)).collect(),
150                include: vec![],
151            },
152            deferrable: Deferrable::NotDeferrable,
153            comment: None,
154        }
155    }
156
157    fn fk(name: &str, on_delete: ReferentialAction) -> Constraint {
158        Constraint {
159            qname: qn(name),
160            kind: ConstraintKind::ForeignKey(ForeignKey {
161                columns: vec![id("org_id")],
162                referenced_table: qn("orgs"),
163                referenced_columns: vec![id("id")],
164                on_update: ReferentialAction::NoAction,
165                on_delete,
166                match_type: FkMatchType::Simple,
167            }),
168            deferrable: Deferrable::NotDeferrable,
169            comment: None,
170        }
171    }
172
173    fn run(target: &Table, source: &Table) -> Vec<TableOpEntry> {
174        let mut out = Vec::new();
175        diff_constraints(target, source, &mut out);
176        out
177    }
178
179    #[test]
180    fn add_pk_is_safe() {
181        let target = tbl_with(vec![]);
182        let source = tbl_with(vec![pk("users_pkey", &["id"])]);
183        let ops = run(&target, &source);
184        assert_eq!(ops.len(), 1);
185        assert!(matches!(ops[0].op, TableOp::AddConstraint(_)));
186        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
187    }
188
189    #[test]
190    fn add_fk_is_safe() {
191        let target = tbl_with(vec![]);
192        let source = tbl_with(vec![fk("users_org_fkey", ReferentialAction::NoAction)]);
193        let ops = run(&target, &source);
194        assert_eq!(ops.len(), 1);
195        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
196    }
197
198    #[test]
199    fn drop_pk_requires_approval() {
200        let target = tbl_with(vec![pk("users_pkey", &["id"])]);
201        let source = tbl_with(vec![]);
202        let ops = run(&target, &source);
203        assert_eq!(ops.len(), 1);
204        assert!(matches!(ops[0].op, TableOp::DropConstraint { .. }));
205        assert!(ops[0].destructiveness.requires_approval());
206        assert!(
207            ops[0]
208                .destructiveness
209                .reason()
210                .unwrap()
211                .contains("primary key")
212        );
213    }
214
215    #[test]
216    fn drop_fk_requires_approval() {
217        let target = tbl_with(vec![fk("users_org_fkey", ReferentialAction::NoAction)]);
218        let source = tbl_with(vec![]);
219        let ops = run(&target, &source);
220        assert_eq!(ops.len(), 1);
221        assert!(ops[0].destructiveness.requires_approval());
222        assert!(
223            ops[0]
224                .destructiveness
225                .reason()
226                .unwrap()
227                .contains("foreign key")
228        );
229    }
230
231    #[test]
232    fn pk_column_change_emits_drop_then_add() {
233        let target = tbl_with(vec![pk("users_pkey", &["id"])]);
234        let source = tbl_with(vec![pk("users_pkey", &["id", "org_id"])]);
235        let ops = run(&target, &source);
236        assert_eq!(ops.len(), 2);
237        assert!(matches!(ops[0].op, TableOp::DropConstraint { .. }));
238        assert!(matches!(ops[1].op, TableOp::AddConstraint(_)));
239    }
240
241    #[test]
242    fn fk_on_delete_change_emits_drop_then_add() {
243        let target = tbl_with(vec![fk("fk1", ReferentialAction::NoAction)]);
244        let source = tbl_with(vec![fk("fk1", ReferentialAction::Cascade)]);
245        let ops = run(&target, &source);
246        assert_eq!(ops.len(), 2);
247        assert!(matches!(ops[0].op, TableOp::DropConstraint { .. }));
248        assert!(matches!(ops[1].op, TableOp::AddConstraint(_)));
249    }
250
251    #[test]
252    fn comment_only_change_emits_set_constraint_comment() {
253        let mut a = pk("users_pkey", &["id"]);
254        a.comment = Some("v1".into());
255        let mut b = pk("users_pkey", &["id"]);
256        b.comment = Some("v2".into());
257        let ops = run(&tbl_with(vec![a]), &tbl_with(vec![b]));
258        assert_eq!(ops.len(), 1);
259        match &ops[0].op {
260            TableOp::SetConstraintComment { name, comment } => {
261                assert_eq!(name, &id("users_pkey"));
262                assert_eq!(comment.as_deref(), Some("v2"));
263            }
264            other => panic!("got {other:?}"),
265        }
266        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
267    }
268
269    #[test]
270    fn deferrable_change_emits_drop_then_add() {
271        let a = pk("users_pkey", &["id"]);
272        let b = Constraint {
273            deferrable: Deferrable::Deferrable {
274                initially_deferred: true,
275            },
276            ..a.clone()
277        };
278        let ops = run(&tbl_with(vec![a]), &tbl_with(vec![b]));
279        assert_eq!(ops.len(), 2);
280    }
281
282    #[test]
283    fn equal_constraints_emit_nothing() {
284        let a = pk("users_pkey", &["id"]);
285        let b = pk("users_pkey", &["id"]);
286        let ops = run(&tbl_with(vec![a]), &tbl_with(vec![b]));
287        assert!(ops.is_empty());
288    }
289}