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