1use std::collections::BTreeMap;
13
14use crate::diff::change::Change;
15use crate::diff::changeset::ChangeSet;
16use crate::diff::destructiveness::Destructiveness;
17use crate::diff::owner_op::{AlterObjectOwner, OwnerObjectKind};
18use crate::identifier::{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::CreateStatistic((*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::ReplaceStatistic {
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::AlterStatisticSetTarget {
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 let from = target.owner.clone().unwrap_or_else(|| {
92 Identifier::from_unquoted("__unknown_owner__")
93 .expect("literal is always a valid unquoted identifier")
94 });
95 out.push(
96 Change::AlterObjectOwner(AlterObjectOwner {
97 kind: OwnerObjectKind::Statistic,
98 qname: source.qname.clone(),
99 signature: String::new(),
100 from,
101 to: s_owner.clone(),
102 }),
103 Destructiveness::Safe,
104 );
105 }
106
107 if target.comment != source.comment {
109 out.push(
110 Change::CommentOnStatistic {
111 qname: source.qname.clone(),
112 comment: source.comment.clone(),
113 },
114 Destructiveness::Safe,
115 );
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use crate::diff::change::Change;
123 use crate::identifier::{Identifier, QualifiedName};
124 use crate::ir::catalog::Catalog;
125 use crate::ir::statistic::{Statistic, StatisticColumn, StatisticKinds};
126
127 fn id(s: &str) -> Identifier {
128 Identifier::from_unquoted(s).unwrap()
129 }
130
131 fn qn(schema: &str, name: &str) -> QualifiedName {
132 QualifiedName::new(id(schema), id(name))
133 }
134
135 fn basic_statistic(stat_name: &str, table_name: &str) -> Statistic {
136 Statistic {
137 qname: qn("app", stat_name),
138 target: qn("app", table_name),
139 kinds: StatisticKinds::pg_default(),
140 columns: vec![
141 StatisticColumn::Column(id("a")),
142 StatisticColumn::Column(id("b")),
143 ],
144 statistics_target: None,
145 owner: None,
146 comment: None,
147 }
148 }
149
150 fn catalog_with(stats: Vec<Statistic>) -> Catalog {
151 let mut c = Catalog::empty();
152 c.statistics = stats;
153 c
154 }
155
156 fn run_diff(target: &Catalog, source: &Catalog) -> ChangeSet {
157 let mut out = ChangeSet::new();
158 diff_statistics(target, source, &mut out);
159 out
160 }
161
162 #[test]
165 fn create_statistic_when_source_has_it_and_target_doesnt() {
166 let target = Catalog::empty();
167 let source = catalog_with(vec![basic_statistic("s", "t")]);
168 let changes = run_diff(&target, &source);
169 assert_eq!(changes.len(), 1);
170 assert!(matches!(
171 changes.iter().next().unwrap().change,
172 Change::CreateStatistic(_)
173 ));
174 }
175
176 #[test]
177 fn create_statistic_is_safe() {
178 let target = Catalog::empty();
179 let source = catalog_with(vec![basic_statistic("s", "t")]);
180 let changes = run_diff(&target, &source);
181 let entry = changes.iter().next().unwrap();
182 assert!(!entry.destructiveness.requires_approval());
183 }
184
185 #[test]
188 fn no_drop_when_target_has_statistic_but_source_doesnt() {
189 let target = catalog_with(vec![basic_statistic("s", "t")]);
190 let source = Catalog::empty();
191 let changes = run_diff(&target, &source);
192 assert!(
193 changes.is_empty(),
194 "expected no changes (lenient), got {changes:?}"
195 );
196 }
197
198 #[test]
201 fn identical_statistics_produce_no_changes() {
202 let c = catalog_with(vec![basic_statistic("s", "t")]);
203 let changes = run_diff(&c, &c);
204 assert!(changes.is_empty());
205 }
206
207 #[test]
210 fn columns_differ_emits_replace_statistic() {
211 let mut src_stat = basic_statistic("s", "t");
212 src_stat.columns = vec![
213 StatisticColumn::Column(id("a")),
214 StatisticColumn::Column(id("c")),
215 ];
216 let target = catalog_with(vec![basic_statistic("s", "t")]);
217 let source = catalog_with(vec![src_stat]);
218 let changes = run_diff(&target, &source);
219 assert_eq!(changes.len(), 1);
220 let entry = changes.iter().next().unwrap();
221 assert!(
222 matches!(entry.change, Change::ReplaceStatistic { .. }),
223 "expected ReplaceStatistic, got {:?}",
224 entry.change
225 );
226 assert!(
227 entry.destructiveness.requires_approval(),
228 "structural change must be RequiresApproval"
229 );
230 }
231
232 #[test]
233 fn kinds_differ_emits_replace_statistic() {
234 let mut src_stat = basic_statistic("s", "t");
235 src_stat.kinds = StatisticKinds {
236 ndistinct: true,
237 dependencies: false,
238 mcv: false,
239 };
240 let target = catalog_with(vec![basic_statistic("s", "t")]);
241 let source = catalog_with(vec![src_stat]);
242 let changes = run_diff(&target, &source);
243 assert_eq!(changes.len(), 1);
244 assert!(matches!(
245 changes.iter().next().unwrap().change,
246 Change::ReplaceStatistic { .. }
247 ));
248 }
249
250 #[test]
251 fn target_table_differs_emits_replace_statistic() {
252 let mut src_stat = basic_statistic("s", "t");
253 src_stat.target = qn("app", "t2");
254 let target = catalog_with(vec![basic_statistic("s", "t")]);
255 let source = catalog_with(vec![src_stat]);
256 let changes = run_diff(&target, &source);
257 assert_eq!(changes.len(), 1);
258 assert!(matches!(
259 changes.iter().next().unwrap().change,
260 Change::ReplaceStatistic { .. }
261 ));
262 }
263
264 #[test]
265 fn structural_change_skips_downstream_per_field_checks() {
266 let mut tgt_stat = basic_statistic("s", "t");
268 tgt_stat.statistics_target = Some(100);
269 tgt_stat.owner = Some(id("alice"));
270 tgt_stat.comment = Some("old".into());
271
272 let mut src_stat = basic_statistic("s", "t");
273 src_stat.columns = vec![StatisticColumn::Column(id("x"))]; src_stat.statistics_target = Some(200);
275 src_stat.owner = Some(id("bob"));
276 src_stat.comment = Some("new".into());
277
278 let target = catalog_with(vec![tgt_stat]);
279 let source = catalog_with(vec![src_stat]);
280 let changes = run_diff(&target, &source);
281 assert_eq!(
282 changes.len(),
283 1,
284 "only ReplaceStatistic, no downstream diffs"
285 );
286 assert!(matches!(
287 changes.iter().next().unwrap().change,
288 Change::ReplaceStatistic { .. }
289 ));
290 }
291
292 #[test]
295 fn only_statistics_target_differs_emits_alter_statistic_set_target() {
296 let mut src_stat = basic_statistic("s", "t");
297 src_stat.statistics_target = Some(500);
298 let target = catalog_with(vec![basic_statistic("s", "t")]); let source = catalog_with(vec![src_stat]);
300 let changes = run_diff(&target, &source);
301 assert_eq!(changes.len(), 1);
302 assert!(matches!(
303 changes.iter().next().unwrap().change,
304 Change::AlterStatisticSetTarget { value: 500, .. }
305 ));
306 }
307
308 #[test]
309 fn source_statistics_target_none_does_not_trigger_diff() {
310 let mut tgt_stat = basic_statistic("s", "t");
312 tgt_stat.statistics_target = Some(500);
313 let src_stat = basic_statistic("s", "t"); let target = catalog_with(vec![tgt_stat]);
315 let source = catalog_with(vec![src_stat]);
316 let changes = run_diff(&target, &source);
317 assert!(
318 changes.is_empty(),
319 "source statistics_target=None must not trigger diff (lenient)"
320 );
321 }
322
323 #[test]
326 fn owner_change_emits_alter_object_owner() {
327 let mut tgt_stat = basic_statistic("s", "t");
328 tgt_stat.owner = Some(id("alice"));
329 let mut src_stat = basic_statistic("s", "t");
330 src_stat.owner = Some(id("bob"));
331 let target = catalog_with(vec![tgt_stat]);
332 let source = catalog_with(vec![src_stat]);
333 let changes = run_diff(&target, &source);
334 assert_eq!(changes.len(), 1);
335 assert!(matches!(
336 changes.iter().next().unwrap().change,
337 Change::AlterObjectOwner(_)
338 ));
339 }
340
341 #[test]
342 fn no_owner_change_when_source_owner_is_none() {
343 let mut tgt_stat = basic_statistic("s", "t");
345 tgt_stat.owner = Some(id("alice"));
346 let src_stat = basic_statistic("s", "t"); let target = catalog_with(vec![tgt_stat]);
348 let source = catalog_with(vec![src_stat]);
349 let changes = run_diff(&target, &source);
350 assert!(
351 changes.is_empty(),
352 "source owner None = unmanaged, no change expected"
353 );
354 }
355
356 #[test]
359 fn comment_change_emits_comment_on_statistic() {
360 let src_stat = {
361 let mut s = basic_statistic("s", "t");
362 s.comment = Some("my stat".into());
363 s
364 };
365 let target = catalog_with(vec![basic_statistic("s", "t")]);
366 let source = catalog_with(vec![src_stat]);
367 let changes = run_diff(&target, &source);
368 assert_eq!(changes.len(), 1);
369 assert!(matches!(
370 changes.iter().next().unwrap().change,
371 Change::CommentOnStatistic { .. }
372 ));
373 }
374
375 #[test]
376 fn clear_comment_emits_comment_on_statistic_with_none() {
377 let mut tgt_stat = basic_statistic("s", "t");
378 tgt_stat.comment = Some("old comment".into());
379 let src_stat = basic_statistic("s", "t"); let target = catalog_with(vec![tgt_stat]);
381 let source = catalog_with(vec![src_stat]);
382 let changes = run_diff(&target, &source);
383 assert_eq!(changes.len(), 1);
384 let entry = changes.iter().next().unwrap();
385 if let Change::CommentOnStatistic { comment, .. } = &entry.change {
386 assert!(comment.is_none());
387 } else {
388 panic!("expected CommentOnStatistic, got {:?}", entry.change);
389 }
390 }
391
392 #[test]
395 fn statistics_target_and_comment_both_changed_emit_two_changes() {
396 let tgt_stat = basic_statistic("s", "t"); let mut src_stat = basic_statistic("s", "t");
398 src_stat.statistics_target = Some(200);
399 src_stat.comment = Some("new comment".into());
400 let target = catalog_with(vec![tgt_stat]);
401 let source = catalog_with(vec![src_stat]);
402 let changes = run_diff(&target, &source);
403 assert_eq!(changes.len(), 2);
404 let kinds: Vec<_> = changes
405 .iter()
406 .map(|e| std::mem::discriminant(&e.change))
407 .collect();
408 assert!(
409 kinds.iter().any(|d| *d
410 == std::mem::discriminant(&Change::AlterStatisticSetTarget {
411 qname: qn("x", "y"),
412 value: 0
413 })),
414 "expected AlterStatisticSetTarget in changes"
415 );
416 assert!(
417 kinds.iter().any(|d| *d
418 == std::mem::discriminant(&Change::CommentOnStatistic {
419 qname: qn("x", "y"),
420 comment: None
421 })),
422 "expected CommentOnStatistic in changes"
423 );
424 }
425}