Skip to main content

pgevolve_core/diff/
indexes.rs

1//! Index-level diffing.
2//!
3//! Pairs indexes by [`QualifiedName`]. Postgres has very limited
4//! `ALTER INDEX` support — column lists, expressions, predicates, opclasses,
5//! sort/nulls order, INCLUDE columns, and the access method are all immutable.
6//! We therefore express any property change as [`Change::ReplaceIndex`]
7//! (drop-then-create), and let the planner decide how to schedule the rebuild.
8//!
9//! The one exception is comment-only differences, which we represent by
10//! emitting `ReplaceIndex` as well — Postgres does support
11//! `COMMENT ON INDEX`, but representing it as a top-level `Change` would mean
12//! a new variant; for v0.1 we keep the surface area small and treat it as a
13//! replace. (We may revisit if comment-only churn becomes noisy.)
14
15use std::collections::BTreeMap;
16
17use crate::identifier::QualifiedName;
18use crate::ir::catalog::Catalog;
19use crate::ir::index::Index;
20
21use super::change::Change;
22use super::changeset::ChangeSet;
23use super::destructiveness::Destructiveness;
24
25/// Diff indexes in `target` against `source`, appending entries to `out`.
26pub fn diff_indexes(target: &Catalog, source: &Catalog, out: &mut ChangeSet) {
27    let target_map: BTreeMap<&QualifiedName, &Index> =
28        target.indexes.iter().map(|i| (&i.qname, i)).collect();
29    let source_map: BTreeMap<&QualifiedName, &Index> =
30        source.indexes.iter().map(|i| (&i.qname, i)).collect();
31
32    for (qname, source_index) in &source_map {
33        if !target_map.contains_key(qname) {
34            out.push(
35                Change::CreateIndex((*source_index).clone()),
36                Destructiveness::Safe,
37            );
38        }
39    }
40
41    for (qname, target_index) in &target_map {
42        match source_map.get(qname) {
43            None => {
44                out.push(
45                    Change::DropIndex((*qname).clone()),
46                    Destructiveness::RequiresApproval {
47                        reason: format!("drops index {qname}"),
48                    },
49                );
50            }
51            Some(source_index) => {
52                // Compute the storage delta first, then decide which change to emit
53                // based on whether the non-storage fields match.
54                let storage_delta = crate::diff::reloptions::index_delta(
55                    &target_index.storage,
56                    &source_index.storage,
57                );
58
59                if !target_index.structurally_eq(source_index) {
60                    // Structural change (columns, method, predicate, …) → DROP + CREATE.
61                    // The new `to` index already carries the desired storage options,
62                    // so no separate SET step is needed.
63                    out.push(
64                        Change::ReplaceIndex {
65                            from: (*target_index).clone(),
66                            to: (*source_index).clone(),
67                        },
68                        Destructiveness::RequiresApproval {
69                            reason: format!(
70                                "replaces index {qname} (drop + create — Postgres cannot ALTER index properties in place)"
71                            ),
72                        },
73                    );
74                } else if !storage_delta.is_empty() {
75                    // Only storage reloptions differ → emit ALTER INDEX SET (…).
76                    out.push(
77                        Change::SetIndexStorage {
78                            qname: (*qname).clone(),
79                            options: storage_delta,
80                        },
81                        Destructiveness::Safe,
82                    );
83                }
84                // else: fully equal — no-op.
85            }
86        }
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::identifier::Identifier;
94    use crate::ir::index::{
95        IndexColumn, IndexColumnExpr, IndexMethod, IndexParent, NullsOrder, SortOrder,
96    };
97
98    fn id(s: &str) -> Identifier {
99        Identifier::from_unquoted(s).unwrap()
100    }
101
102    fn qn(name: &str) -> QualifiedName {
103        QualifiedName::new(id("app"), id(name))
104    }
105
106    fn col(name: &str) -> IndexColumn {
107        IndexColumn {
108            expr: IndexColumnExpr::Column(id(name)),
109            collation: None,
110            opclass: None,
111            sort_order: SortOrder::Asc,
112            nulls_order: NullsOrder::NullsLast,
113        }
114    }
115
116    fn ix(name: &str, cols: Vec<IndexColumn>, unique: bool) -> Index {
117        Index {
118            qname: qn(name),
119            on: IndexParent::Table(qn("users")),
120            method: IndexMethod::BTree,
121            columns: cols,
122            include: vec![],
123            unique,
124            nulls_not_distinct: false,
125            predicate: None,
126            tablespace: None,
127            comment: None,
128            storage: crate::ir::reloptions::IndexStorageOptions::default(),
129        }
130    }
131
132    #[test]
133    fn add_index_is_safe() {
134        let target = Catalog::empty();
135        let mut source = Catalog::empty();
136        source
137            .indexes
138            .push(ix("users_email_idx", vec![col("email")], true));
139        let mut cs = ChangeSet::new();
140        diff_indexes(&target, &source, &mut cs);
141        assert_eq!(cs.len(), 1);
142        let entry = &cs.entries[0];
143        assert!(matches!(entry.change, Change::CreateIndex(_)));
144        assert_eq!(entry.destructiveness, Destructiveness::Safe);
145    }
146
147    #[test]
148    fn drop_index_requires_approval() {
149        let mut target = Catalog::empty();
150        target
151            .indexes
152            .push(ix("users_email_idx", vec![col("email")], true));
153        let source = Catalog::empty();
154        let mut cs = ChangeSet::new();
155        diff_indexes(&target, &source, &mut cs);
156        assert_eq!(cs.len(), 1);
157        let entry = &cs.entries[0];
158        assert!(matches!(entry.change, Change::DropIndex(_)));
159        assert!(entry.destructiveness.requires_approval());
160        assert!(!entry.destructiveness.data_loss_risk());
161    }
162
163    #[test]
164    fn unique_change_emits_replace() {
165        let mut target = Catalog::empty();
166        target.indexes.push(ix("ix1", vec![col("email")], false));
167        let mut source = Catalog::empty();
168        source.indexes.push(ix("ix1", vec![col("email")], true));
169        let mut cs = ChangeSet::new();
170        diff_indexes(&target, &source, &mut cs);
171        assert_eq!(cs.len(), 1);
172        let entry = &cs.entries[0];
173        match &entry.change {
174            Change::ReplaceIndex { from, to } => {
175                assert!(!from.unique);
176                assert!(to.unique);
177            }
178            other => panic!("expected ReplaceIndex, got {other:?}"),
179        }
180        assert!(entry.destructiveness.requires_approval());
181    }
182
183    #[test]
184    fn column_list_change_emits_replace() {
185        let mut target = Catalog::empty();
186        target.indexes.push(ix("ix1", vec![col("a")], false));
187        let mut source = Catalog::empty();
188        source
189            .indexes
190            .push(ix("ix1", vec![col("a"), col("b")], false));
191        let mut cs = ChangeSet::new();
192        diff_indexes(&target, &source, &mut cs);
193        assert_eq!(cs.len(), 1);
194        assert!(matches!(cs.entries[0].change, Change::ReplaceIndex { .. }));
195    }
196
197    #[test]
198    fn equal_indexes_emit_nothing() {
199        let mut target = Catalog::empty();
200        target.indexes.push(ix("ix1", vec![col("email")], true));
201        let mut source = Catalog::empty();
202        source.indexes.push(ix("ix1", vec![col("email")], true));
203        let mut cs = ChangeSet::new();
204        diff_indexes(&target, &source, &mut cs);
205        assert!(cs.is_empty());
206    }
207
208    #[test]
209    fn storage_only_change_emits_set_not_replace() {
210        // Indexes that differ ONLY in a reloption (fillfactor) must emit
211        // SetIndexStorage, not ReplaceIndex.
212        let base = ix("ix1", vec![col("email")], true);
213        let mut target_cat = Catalog::empty();
214        target_cat.indexes.push(Index {
215            storage: crate::ir::reloptions::IndexStorageOptions {
216                fillfactor: Some(70),
217                ..Default::default()
218            },
219            ..base.clone()
220        });
221        let mut source_cat = Catalog::empty();
222        source_cat.indexes.push(Index {
223            storage: crate::ir::reloptions::IndexStorageOptions {
224                fillfactor: Some(80),
225                ..Default::default()
226            },
227            ..base
228        });
229        let mut cs = ChangeSet::new();
230        diff_indexes(&target_cat, &source_cat, &mut cs);
231        assert_eq!(cs.len(), 1, "expected exactly one change");
232        match &cs.entries[0].change {
233            Change::SetIndexStorage { options, .. } => {
234                assert_eq!(options.fillfactor, Some(80));
235            }
236            other => panic!("expected SetIndexStorage, got {other:?}"),
237        }
238        assert_eq!(
239            cs.entries[0].destructiveness,
240            Destructiveness::Safe,
241            "storage-only change should be safe"
242        );
243    }
244
245    #[test]
246    fn structural_change_emits_replace_not_set_storage() {
247        // Indexes that differ structurally (different column list) must emit
248        // ReplaceIndex, regardless of whether storage also differs.
249        let base = ix("ix1", vec![col("a")], false);
250        let mut target_cat = Catalog::empty();
251        target_cat.indexes.push(Index {
252            storage: crate::ir::reloptions::IndexStorageOptions {
253                fillfactor: Some(70),
254                ..Default::default()
255            },
256            ..base.clone()
257        });
258        let mut source_cat = Catalog::empty();
259        source_cat.indexes.push(Index {
260            columns: vec![col("a"), col("b")],
261            storage: crate::ir::reloptions::IndexStorageOptions {
262                fillfactor: Some(80),
263                ..Default::default()
264            },
265            ..base
266        });
267        let mut cs = ChangeSet::new();
268        diff_indexes(&target_cat, &source_cat, &mut cs);
269        assert_eq!(cs.len(), 1, "expected exactly one change");
270        assert!(
271            matches!(cs.entries[0].change, Change::ReplaceIndex { .. }),
272            "structural change must emit ReplaceIndex, got {:?}",
273            cs.entries[0].change
274        );
275        assert!(
276            cs.entries[0].destructiveness.requires_approval(),
277            "ReplaceIndex must require approval"
278        );
279    }
280}