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            access_method: None,
143            tablespace: None,
144        }
145    }
146
147    fn pk(name: &str, cols: &[&str]) -> Constraint {
148        Constraint {
149            qname: qn(name),
150            kind: ConstraintKind::PrimaryKey {
151                columns: cols.iter().map(|c| id(c)).collect(),
152                include: vec![],
153            },
154            deferrable: Deferrable::NotDeferrable,
155            comment: None,
156        }
157    }
158
159    fn fk(name: &str, on_delete: ReferentialAction) -> Constraint {
160        Constraint {
161            qname: qn(name),
162            kind: ConstraintKind::ForeignKey(ForeignKey {
163                columns: vec![id("org_id")],
164                referenced_table: qn("orgs"),
165                referenced_columns: vec![id("id")],
166                on_update: ReferentialAction::NoAction,
167                on_delete,
168                match_type: FkMatchType::Simple,
169            }),
170            deferrable: Deferrable::NotDeferrable,
171            comment: None,
172        }
173    }
174
175    fn run(target: &Table, source: &Table) -> Vec<TableOpEntry> {
176        let mut out = Vec::new();
177        diff_constraints(target, source, &mut out);
178        out
179    }
180
181    #[test]
182    fn add_pk_is_safe() {
183        let target = tbl_with(vec![]);
184        let source = tbl_with(vec![pk("users_pkey", &["id"])]);
185        let ops = run(&target, &source);
186        assert_eq!(ops.len(), 1);
187        assert!(matches!(ops[0].op, TableOp::AddConstraint(_)));
188        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
189    }
190
191    #[test]
192    fn add_fk_is_safe() {
193        let target = tbl_with(vec![]);
194        let source = tbl_with(vec![fk("users_org_fkey", ReferentialAction::NoAction)]);
195        let ops = run(&target, &source);
196        assert_eq!(ops.len(), 1);
197        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
198    }
199
200    #[test]
201    fn drop_pk_requires_approval() {
202        let target = tbl_with(vec![pk("users_pkey", &["id"])]);
203        let source = tbl_with(vec![]);
204        let ops = run(&target, &source);
205        assert_eq!(ops.len(), 1);
206        assert!(matches!(ops[0].op, TableOp::DropConstraint { .. }));
207        assert!(ops[0].destructiveness.requires_approval());
208        assert!(
209            ops[0]
210                .destructiveness
211                .reason()
212                .unwrap()
213                .contains("primary key")
214        );
215    }
216
217    #[test]
218    fn drop_fk_requires_approval() {
219        let target = tbl_with(vec![fk("users_org_fkey", ReferentialAction::NoAction)]);
220        let source = tbl_with(vec![]);
221        let ops = run(&target, &source);
222        assert_eq!(ops.len(), 1);
223        assert!(ops[0].destructiveness.requires_approval());
224        assert!(
225            ops[0]
226                .destructiveness
227                .reason()
228                .unwrap()
229                .contains("foreign key")
230        );
231    }
232
233    #[test]
234    fn pk_column_change_emits_drop_then_add() {
235        let target = tbl_with(vec![pk("users_pkey", &["id"])]);
236        let source = tbl_with(vec![pk("users_pkey", &["id", "org_id"])]);
237        let ops = run(&target, &source);
238        assert_eq!(ops.len(), 2);
239        assert!(matches!(ops[0].op, TableOp::DropConstraint { .. }));
240        assert!(matches!(ops[1].op, TableOp::AddConstraint(_)));
241    }
242
243    #[test]
244    fn fk_on_delete_change_emits_drop_then_add() {
245        let target = tbl_with(vec![fk("fk1", ReferentialAction::NoAction)]);
246        let source = tbl_with(vec![fk("fk1", ReferentialAction::Cascade)]);
247        let ops = run(&target, &source);
248        assert_eq!(ops.len(), 2);
249        assert!(matches!(ops[0].op, TableOp::DropConstraint { .. }));
250        assert!(matches!(ops[1].op, TableOp::AddConstraint(_)));
251    }
252
253    #[test]
254    fn comment_only_change_emits_set_constraint_comment() {
255        let mut a = pk("users_pkey", &["id"]);
256        a.comment = Some("v1".into());
257        let mut b = pk("users_pkey", &["id"]);
258        b.comment = Some("v2".into());
259        let ops = run(&tbl_with(vec![a]), &tbl_with(vec![b]));
260        assert_eq!(ops.len(), 1);
261        match &ops[0].op {
262            TableOp::SetConstraintComment { name, comment } => {
263                assert_eq!(name, &id("users_pkey"));
264                assert_eq!(comment.as_deref(), Some("v2"));
265            }
266            other => panic!("got {other:?}"),
267        }
268        assert_eq!(ops[0].destructiveness, Destructiveness::Safe);
269    }
270
271    #[test]
272    fn deferrable_change_emits_drop_then_add() {
273        let a = pk("users_pkey", &["id"]);
274        let b = Constraint {
275            deferrable: Deferrable::Deferrable {
276                initially_deferred: true,
277            },
278            ..a.clone()
279        };
280        let ops = run(&tbl_with(vec![a]), &tbl_with(vec![b]));
281        assert_eq!(ops.len(), 2);
282    }
283
284    #[test]
285    fn equal_constraints_emit_nothing() {
286        let a = pk("users_pkey", &["id"]);
287        let b = pk("users_pkey", &["id"]);
288        let ops = run(&tbl_with(vec![a]), &tbl_with(vec![b]));
289        assert!(ops.is_empty());
290    }
291}