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