1use 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
25pub 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 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 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 out.push(
77 Change::SetIndexStorage {
78 qname: (*qname).clone(),
79 options: storage_delta,
80 },
81 Destructiveness::Safe,
82 );
83 }
84 }
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 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 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}