1use std::collections::BTreeMap;
13
14use crate::diff::change::{Change, StatisticChange};
15use crate::diff::changeset::ChangeSet;
16use crate::diff::destructiveness::Destructiveness;
17use crate::diff::owner_op::{AlterObjectOwner, OwnerObjectKind};
18use crate::identifier::QualifiedName;
19use crate::ir::catalog::Catalog;
20use crate::ir::statistic::Statistic;
21
22pub fn diff_statistics(target: &Catalog, source: &Catalog, out: &mut ChangeSet) {
25 let target_map: BTreeMap<&QualifiedName, &Statistic> =
26 target.statistics.iter().map(|s| (&s.qname, s)).collect();
27 let source_map: BTreeMap<&QualifiedName, &Statistic> =
28 source.statistics.iter().map(|s| (&s.qname, s)).collect();
29
30 for (qname, src) in &source_map {
32 if !target_map.contains_key(qname) {
33 out.push(
34 Change::Statistic(StatisticChange::Create((*src).clone())),
35 Destructiveness::Safe,
36 );
37 }
38 }
39
40 for (qname, src) in &source_map {
45 let Some(tgt) = target_map.get(qname) else {
46 continue;
47 };
48 diff_one(tgt, src, out);
49 }
50}
51
52fn diff_one(target: &Statistic, source: &Statistic, out: &mut ChangeSet) {
53 if target.columns != source.columns
55 || target.kinds != source.kinds
56 || target.target != source.target
57 {
58 out.push(
59 Change::Statistic(StatisticChange::Replace {
60 from: target.clone(),
61 to: source.clone(),
62 }),
63 Destructiveness::RequiresApproval {
64 reason: format!(
65 "structural change to statistic {} requires DROP + CREATE (PG has no in-place ALTER for columns/kinds/target)",
66 source.qname
67 ),
68 },
69 );
70 return;
71 }
72
73 if let Some(s_target) = source.statistics_target
75 && target.statistics_target != Some(s_target)
76 {
77 out.push(
78 Change::Statistic(StatisticChange::AlterSetTarget {
79 qname: source.qname.clone(),
80 value: s_target,
81 }),
82 Destructiveness::Safe,
83 );
84 }
85
86 if let Some(s_owner) = &source.owner
89 && target.owner.as_ref() != Some(s_owner)
90 {
91 out.push(
92 Change::AlterObjectOwner(AlterObjectOwner {
93 kind: OwnerObjectKind::Statistic,
94 id: crate::diff::owner_op::OwnedObjectId::Qualified(source.qname.clone()),
95 signature: String::new(),
96 from: target.owner.clone(),
97 to: s_owner.clone(),
98 }),
99 Destructiveness::Safe,
100 );
101 }
102
103 if target.comment != source.comment {
105 out.push(
106 Change::Statistic(StatisticChange::CommentOn {
107 qname: source.qname.clone(),
108 comment: source.comment.clone(),
109 }),
110 Destructiveness::Safe,
111 );
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 use crate::diff::change::{Change, StatisticChange};
119 use crate::identifier::{Identifier, QualifiedName};
120 use crate::ir::catalog::Catalog;
121 use crate::ir::statistic::{Statistic, StatisticColumn, StatisticKinds};
122
123 fn id(s: &str) -> Identifier {
124 Identifier::from_unquoted(s).unwrap()
125 }
126
127 fn qn(schema: &str, name: &str) -> QualifiedName {
128 QualifiedName::new(id(schema), id(name))
129 }
130
131 fn basic_statistic(stat_name: &str, table_name: &str) -> Statistic {
132 Statistic {
133 qname: qn("app", stat_name),
134 target: qn("app", table_name),
135 kinds: StatisticKinds::pg_default(),
136 columns: vec![
137 StatisticColumn::Column(id("a")),
138 StatisticColumn::Column(id("b")),
139 ],
140 statistics_target: None,
141 owner: None,
142 comment: None,
143 }
144 }
145
146 fn catalog_with(stats: Vec<Statistic>) -> Catalog {
147 let mut c = Catalog::empty();
148 c.statistics = stats;
149 c
150 }
151
152 fn run_diff(target: &Catalog, source: &Catalog) -> ChangeSet {
153 let mut out = ChangeSet::new();
154 diff_statistics(target, source, &mut out);
155 out
156 }
157
158 #[test]
161 fn create_statistic_when_source_has_it_and_target_doesnt() {
162 let target = Catalog::empty();
163 let source = catalog_with(vec![basic_statistic("s", "t")]);
164 let changes = run_diff(&target, &source);
165 assert_eq!(changes.len(), 1);
166 assert!(matches!(
167 changes.iter().next().unwrap().change,
168 Change::Statistic(StatisticChange::Create(_))
169 ));
170 }
171
172 #[test]
173 fn create_statistic_is_safe() {
174 let target = Catalog::empty();
175 let source = catalog_with(vec![basic_statistic("s", "t")]);
176 let changes = run_diff(&target, &source);
177 let entry = changes.iter().next().unwrap();
178 assert!(!entry.destructiveness.requires_approval());
179 }
180
181 #[test]
184 fn no_drop_when_target_has_statistic_but_source_doesnt() {
185 let target = catalog_with(vec![basic_statistic("s", "t")]);
186 let source = Catalog::empty();
187 let changes = run_diff(&target, &source);
188 assert!(
189 changes.is_empty(),
190 "expected no changes (lenient), got {changes:?}"
191 );
192 }
193
194 #[test]
197 fn identical_statistics_produce_no_changes() {
198 let c = catalog_with(vec![basic_statistic("s", "t")]);
199 let changes = run_diff(&c, &c);
200 assert!(changes.is_empty());
201 }
202
203 #[test]
206 fn columns_differ_emits_replace_statistic() {
207 let mut src_stat = basic_statistic("s", "t");
208 src_stat.columns = vec![
209 StatisticColumn::Column(id("a")),
210 StatisticColumn::Column(id("c")),
211 ];
212 let target = catalog_with(vec![basic_statistic("s", "t")]);
213 let source = catalog_with(vec![src_stat]);
214 let changes = run_diff(&target, &source);
215 assert_eq!(changes.len(), 1);
216 let entry = changes.iter().next().unwrap();
217 assert!(
218 matches!(
219 entry.change,
220 Change::Statistic(StatisticChange::Replace { .. })
221 ),
222 "expected ReplaceStatistic, got {:?}",
223 entry.change
224 );
225 assert!(
226 entry.destructiveness.requires_approval(),
227 "structural change must be RequiresApproval"
228 );
229 }
230
231 #[test]
232 fn kinds_differ_emits_replace_statistic() {
233 let mut src_stat = basic_statistic("s", "t");
234 src_stat.kinds = StatisticKinds {
235 ndistinct: true,
236 dependencies: false,
237 mcv: false,
238 };
239 let target = catalog_with(vec![basic_statistic("s", "t")]);
240 let source = catalog_with(vec![src_stat]);
241 let changes = run_diff(&target, &source);
242 assert_eq!(changes.len(), 1);
243 assert!(matches!(
244 changes.iter().next().unwrap().change,
245 Change::Statistic(StatisticChange::Replace { .. })
246 ));
247 }
248
249 #[test]
250 fn target_table_differs_emits_replace_statistic() {
251 let mut src_stat = basic_statistic("s", "t");
252 src_stat.target = qn("app", "t2");
253 let target = catalog_with(vec![basic_statistic("s", "t")]);
254 let source = catalog_with(vec![src_stat]);
255 let changes = run_diff(&target, &source);
256 assert_eq!(changes.len(), 1);
257 assert!(matches!(
258 changes.iter().next().unwrap().change,
259 Change::Statistic(StatisticChange::Replace { .. })
260 ));
261 }
262
263 #[test]
264 fn structural_change_skips_downstream_per_field_checks() {
265 let mut tgt_stat = basic_statistic("s", "t");
267 tgt_stat.statistics_target = Some(100);
268 tgt_stat.owner = Some(id("alice"));
269 tgt_stat.comment = Some("old".into());
270
271 let mut src_stat = basic_statistic("s", "t");
272 src_stat.columns = vec![StatisticColumn::Column(id("x"))]; src_stat.statistics_target = Some(200);
274 src_stat.owner = Some(id("bob"));
275 src_stat.comment = Some("new".into());
276
277 let target = catalog_with(vec![tgt_stat]);
278 let source = catalog_with(vec![src_stat]);
279 let changes = run_diff(&target, &source);
280 assert_eq!(
281 changes.len(),
282 1,
283 "only ReplaceStatistic, no downstream diffs"
284 );
285 assert!(matches!(
286 changes.iter().next().unwrap().change,
287 Change::Statistic(StatisticChange::Replace { .. })
288 ));
289 }
290
291 #[test]
294 fn only_statistics_target_differs_emits_alter_statistic_set_target() {
295 let mut src_stat = basic_statistic("s", "t");
296 src_stat.statistics_target = Some(500);
297 let target = catalog_with(vec![basic_statistic("s", "t")]); let source = catalog_with(vec![src_stat]);
299 let changes = run_diff(&target, &source);
300 assert_eq!(changes.len(), 1);
301 assert!(matches!(
302 changes.iter().next().unwrap().change,
303 Change::Statistic(StatisticChange::AlterSetTarget { value: 500, .. })
304 ));
305 }
306
307 #[test]
308 fn source_statistics_target_none_does_not_trigger_diff() {
309 let mut tgt_stat = basic_statistic("s", "t");
311 tgt_stat.statistics_target = Some(500);
312 let src_stat = basic_statistic("s", "t"); let target = catalog_with(vec![tgt_stat]);
314 let source = catalog_with(vec![src_stat]);
315 let changes = run_diff(&target, &source);
316 assert!(
317 changes.is_empty(),
318 "source statistics_target=None must not trigger diff (lenient)"
319 );
320 }
321
322 #[test]
325 fn owner_change_emits_alter_object_owner() {
326 let mut tgt_stat = basic_statistic("s", "t");
327 tgt_stat.owner = Some(id("alice"));
328 let mut src_stat = basic_statistic("s", "t");
329 src_stat.owner = Some(id("bob"));
330 let target = catalog_with(vec![tgt_stat]);
331 let source = catalog_with(vec![src_stat]);
332 let changes = run_diff(&target, &source);
333 assert_eq!(changes.len(), 1);
334 assert!(matches!(
335 changes.iter().next().unwrap().change,
336 Change::AlterObjectOwner(_)
337 ));
338 }
339
340 #[test]
341 fn no_owner_change_when_source_owner_is_none() {
342 let mut tgt_stat = basic_statistic("s", "t");
344 tgt_stat.owner = Some(id("alice"));
345 let src_stat = basic_statistic("s", "t"); let target = catalog_with(vec![tgt_stat]);
347 let source = catalog_with(vec![src_stat]);
348 let changes = run_diff(&target, &source);
349 assert!(
350 changes.is_empty(),
351 "source owner None = unmanaged, no change expected"
352 );
353 }
354
355 #[test]
358 fn comment_change_emits_comment_on_statistic() {
359 let src_stat = {
360 let mut s = basic_statistic("s", "t");
361 s.comment = Some("my stat".into());
362 s
363 };
364 let target = catalog_with(vec![basic_statistic("s", "t")]);
365 let source = catalog_with(vec![src_stat]);
366 let changes = run_diff(&target, &source);
367 assert_eq!(changes.len(), 1);
368 assert!(matches!(
369 changes.iter().next().unwrap().change,
370 Change::Statistic(StatisticChange::CommentOn { .. })
371 ));
372 }
373
374 #[test]
375 fn clear_comment_emits_comment_on_statistic_with_none() {
376 let mut tgt_stat = basic_statistic("s", "t");
377 tgt_stat.comment = Some("old comment".into());
378 let src_stat = basic_statistic("s", "t"); let target = catalog_with(vec![tgt_stat]);
380 let source = catalog_with(vec![src_stat]);
381 let changes = run_diff(&target, &source);
382 assert_eq!(changes.len(), 1);
383 let entry = changes.iter().next().unwrap();
384 if let Change::Statistic(StatisticChange::CommentOn { comment, .. }) = &entry.change {
385 assert!(comment.is_none());
386 } else {
387 panic!("expected CommentOnStatistic, got {:?}", entry.change);
388 }
389 }
390
391 #[test]
394 fn statistics_target_and_comment_both_changed_emit_two_changes() {
395 let tgt_stat = basic_statistic("s", "t"); let mut src_stat = basic_statistic("s", "t");
397 src_stat.statistics_target = Some(200);
398 src_stat.comment = Some("new comment".into());
399 let target = catalog_with(vec![tgt_stat]);
400 let source = catalog_with(vec![src_stat]);
401 let changes = run_diff(&target, &source);
402 assert_eq!(changes.len(), 2);
403 assert!(
404 changes.iter().any(|e| matches!(
405 &e.change,
406 Change::Statistic(StatisticChange::AlterSetTarget { .. })
407 )),
408 "expected StatisticChange::AlterSetTarget in changes"
409 );
410 assert!(
411 changes.iter().any(|e| matches!(
412 &e.change,
413 Change::Statistic(StatisticChange::CommentOn { .. })
414 )),
415 "expected StatisticChange::CommentOn in changes"
416 );
417 }
418}