use std::collections::{BTreeMap, BTreeSet};
use crate::diff::change::{Change, StatisticChange};
use crate::diff::changeset::ChangeSet;
use crate::diff::destructiveness::Destructiveness;
use crate::diff::owner_grants::{ColumnGrantMode, diff_owner_and_grants};
use crate::diff::owner_op::CatalogObjectRef;
use crate::identifier::QualifiedName;
use crate::ir::catalog::Catalog;
use crate::ir::statistic::Statistic;
pub fn diff_statistics(target: &Catalog, source: &Catalog, out: &mut ChangeSet) {
let target_map: BTreeMap<&QualifiedName, &Statistic> =
target.statistics.iter().map(|s| (&s.qname, s)).collect();
let source_map: BTreeMap<&QualifiedName, &Statistic> =
source.statistics.iter().map(|s| (&s.qname, s)).collect();
for (qname, src) in &source_map {
if !target_map.contains_key(qname) {
out.push(
Change::Statistic(StatisticChange::Create((*src).clone())),
Destructiveness::Safe,
);
}
}
for (qname, src) in &source_map {
let Some(tgt) = target_map.get(qname) else {
continue;
};
diff_one(tgt, src, out);
}
}
fn diff_one(target: &Statistic, source: &Statistic, out: &mut ChangeSet) {
if target.columns != source.columns
|| target.kinds != source.kinds
|| target.target != source.target
{
out.push(
Change::Statistic(StatisticChange::Replace {
from: target.clone(),
to: source.clone(),
}),
Destructiveness::RequiresApproval {
reason: format!(
"structural change to statistic {} requires DROP + CREATE (PG has no in-place ALTER for columns/kinds/target)",
source.qname
),
},
);
return;
}
if let Some(s_target) = source.statistics_target
&& target.statistics_target != Some(s_target)
{
out.push(
Change::Statistic(StatisticChange::AlterSetTarget {
qname: source.qname.clone(),
value: s_target,
}),
Destructiveness::Safe,
);
}
diff_owner_and_grants(
&CatalogObjectRef::Statistic(source.qname.clone()),
target.owner.as_ref(),
source.owner.as_ref(),
&[],
&[],
&BTreeSet::new(),
ColumnGrantMode::ObjectOnly,
out,
);
if target.comment != source.comment {
out.push(
Change::Statistic(StatisticChange::CommentOn {
qname: source.qname.clone(),
comment: source.comment.clone(),
}),
Destructiveness::Safe,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diff::change::{Change, StatisticChange};
use crate::identifier::{Identifier, QualifiedName};
use crate::ir::catalog::Catalog;
use crate::ir::statistic::{Statistic, StatisticColumn, StatisticKinds};
fn id(s: &str) -> Identifier {
Identifier::from_unquoted(s).unwrap()
}
fn qn(schema: &str, name: &str) -> QualifiedName {
QualifiedName::new(id(schema), id(name))
}
fn basic_statistic(stat_name: &str, table_name: &str) -> Statistic {
Statistic {
qname: qn("app", stat_name),
target: qn("app", table_name),
kinds: StatisticKinds::pg_default(),
columns: vec![
StatisticColumn::Column(id("a")),
StatisticColumn::Column(id("b")),
],
statistics_target: None,
owner: None,
comment: None,
}
}
fn catalog_with(stats: Vec<Statistic>) -> Catalog {
let mut c = Catalog::empty();
c.statistics = stats;
c
}
fn run_diff(target: &Catalog, source: &Catalog) -> ChangeSet {
let mut out = ChangeSet::new();
diff_statistics(target, source, &mut out);
out
}
#[test]
fn create_statistic_when_source_has_it_and_target_doesnt() {
let target = Catalog::empty();
let source = catalog_with(vec![basic_statistic("s", "t")]);
let changes = run_diff(&target, &source);
assert_eq!(changes.len(), 1);
assert!(matches!(
changes.iter().next().unwrap().change,
Change::Statistic(StatisticChange::Create(_))
));
}
#[test]
fn create_statistic_is_safe() {
let target = Catalog::empty();
let source = catalog_with(vec![basic_statistic("s", "t")]);
let changes = run_diff(&target, &source);
let entry = changes.iter().next().unwrap();
assert!(!entry.destructiveness.requires_approval());
}
#[test]
fn no_drop_when_target_has_statistic_but_source_doesnt() {
let target = catalog_with(vec![basic_statistic("s", "t")]);
let source = Catalog::empty();
let changes = run_diff(&target, &source);
assert!(
changes.is_empty(),
"expected no changes (lenient), got {changes:?}"
);
}
#[test]
fn identical_statistics_produce_no_changes() {
let c = catalog_with(vec![basic_statistic("s", "t")]);
let changes = run_diff(&c, &c);
assert!(changes.is_empty());
}
#[test]
fn columns_differ_emits_replace_statistic() {
let mut src_stat = basic_statistic("s", "t");
src_stat.columns = vec![
StatisticColumn::Column(id("a")),
StatisticColumn::Column(id("c")),
];
let target = catalog_with(vec![basic_statistic("s", "t")]);
let source = catalog_with(vec![src_stat]);
let changes = run_diff(&target, &source);
assert_eq!(changes.len(), 1);
let entry = changes.iter().next().unwrap();
assert!(
matches!(
entry.change,
Change::Statistic(StatisticChange::Replace { .. })
),
"expected ReplaceStatistic, got {:?}",
entry.change
);
assert!(
entry.destructiveness.requires_approval(),
"structural change must be RequiresApproval"
);
}
#[test]
fn kinds_differ_emits_replace_statistic() {
let mut src_stat = basic_statistic("s", "t");
src_stat.kinds = StatisticKinds {
ndistinct: true,
dependencies: false,
mcv: false,
};
let target = catalog_with(vec![basic_statistic("s", "t")]);
let source = catalog_with(vec![src_stat]);
let changes = run_diff(&target, &source);
assert_eq!(changes.len(), 1);
assert!(matches!(
changes.iter().next().unwrap().change,
Change::Statistic(StatisticChange::Replace { .. })
));
}
#[test]
fn target_table_differs_emits_replace_statistic() {
let mut src_stat = basic_statistic("s", "t");
src_stat.target = qn("app", "t2");
let target = catalog_with(vec![basic_statistic("s", "t")]);
let source = catalog_with(vec![src_stat]);
let changes = run_diff(&target, &source);
assert_eq!(changes.len(), 1);
assert!(matches!(
changes.iter().next().unwrap().change,
Change::Statistic(StatisticChange::Replace { .. })
));
}
#[test]
fn structural_change_skips_downstream_per_field_checks() {
let mut tgt_stat = basic_statistic("s", "t");
tgt_stat.statistics_target = Some(100);
tgt_stat.owner = Some(id("alice"));
tgt_stat.comment = Some("old".into());
let mut src_stat = basic_statistic("s", "t");
src_stat.columns = vec![StatisticColumn::Column(id("x"))]; src_stat.statistics_target = Some(200);
src_stat.owner = Some(id("bob"));
src_stat.comment = Some("new".into());
let target = catalog_with(vec![tgt_stat]);
let source = catalog_with(vec![src_stat]);
let changes = run_diff(&target, &source);
assert_eq!(
changes.len(),
1,
"only ReplaceStatistic, no downstream diffs"
);
assert!(matches!(
changes.iter().next().unwrap().change,
Change::Statistic(StatisticChange::Replace { .. })
));
}
#[test]
fn only_statistics_target_differs_emits_alter_statistic_set_target() {
let mut src_stat = basic_statistic("s", "t");
src_stat.statistics_target = Some(500);
let target = catalog_with(vec![basic_statistic("s", "t")]); let source = catalog_with(vec![src_stat]);
let changes = run_diff(&target, &source);
assert_eq!(changes.len(), 1);
assert!(matches!(
changes.iter().next().unwrap().change,
Change::Statistic(StatisticChange::AlterSetTarget { value: 500, .. })
));
}
#[test]
fn source_statistics_target_none_does_not_trigger_diff() {
let mut tgt_stat = basic_statistic("s", "t");
tgt_stat.statistics_target = Some(500);
let src_stat = basic_statistic("s", "t"); let target = catalog_with(vec![tgt_stat]);
let source = catalog_with(vec![src_stat]);
let changes = run_diff(&target, &source);
assert!(
changes.is_empty(),
"source statistics_target=None must not trigger diff (lenient)"
);
}
#[test]
fn owner_change_emits_alter_object_owner() {
let mut tgt_stat = basic_statistic("s", "t");
tgt_stat.owner = Some(id("alice"));
let mut src_stat = basic_statistic("s", "t");
src_stat.owner = Some(id("bob"));
let target = catalog_with(vec![tgt_stat]);
let source = catalog_with(vec![src_stat]);
let changes = run_diff(&target, &source);
assert_eq!(changes.len(), 1);
assert!(matches!(
changes.iter().next().unwrap().change,
Change::AlterObjectOwner(_)
));
}
#[test]
fn no_owner_change_when_source_owner_is_none() {
let mut tgt_stat = basic_statistic("s", "t");
tgt_stat.owner = Some(id("alice"));
let src_stat = basic_statistic("s", "t"); let target = catalog_with(vec![tgt_stat]);
let source = catalog_with(vec![src_stat]);
let changes = run_diff(&target, &source);
assert!(
changes.is_empty(),
"source owner None = unmanaged, no change expected"
);
}
#[test]
fn comment_change_emits_comment_on_statistic() {
let src_stat = {
let mut s = basic_statistic("s", "t");
s.comment = Some("my stat".into());
s
};
let target = catalog_with(vec![basic_statistic("s", "t")]);
let source = catalog_with(vec![src_stat]);
let changes = run_diff(&target, &source);
assert_eq!(changes.len(), 1);
assert!(matches!(
changes.iter().next().unwrap().change,
Change::Statistic(StatisticChange::CommentOn { .. })
));
}
#[test]
fn clear_comment_emits_comment_on_statistic_with_none() {
let mut tgt_stat = basic_statistic("s", "t");
tgt_stat.comment = Some("old comment".into());
let src_stat = basic_statistic("s", "t"); let target = catalog_with(vec![tgt_stat]);
let source = catalog_with(vec![src_stat]);
let changes = run_diff(&target, &source);
assert_eq!(changes.len(), 1);
let entry = changes.iter().next().unwrap();
if let Change::Statistic(StatisticChange::CommentOn { comment, .. }) = &entry.change {
assert!(comment.is_none());
} else {
panic!("expected CommentOnStatistic, got {:?}", entry.change);
}
}
#[test]
fn statistics_target_and_comment_both_changed_emit_two_changes() {
let tgt_stat = basic_statistic("s", "t"); let mut src_stat = basic_statistic("s", "t");
src_stat.statistics_target = Some(200);
src_stat.comment = Some("new comment".into());
let target = catalog_with(vec![tgt_stat]);
let source = catalog_with(vec![src_stat]);
let changes = run_diff(&target, &source);
assert_eq!(changes.len(), 2);
assert!(
changes.iter().any(|e| matches!(
&e.change,
Change::Statistic(StatisticChange::AlterSetTarget { .. })
)),
"expected StatisticChange::AlterSetTarget in changes"
);
assert!(
changes.iter().any(|e| matches!(
&e.change,
Change::Statistic(StatisticChange::CommentOn { .. })
)),
"expected StatisticChange::CommentOn in changes"
);
}
}