use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::profile::config::ProfileOptionsSummary;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DatabaseProfile {
pub version: String,
pub profiled_at: String,
pub source_hash: String,
pub seedgen_version: String,
pub options: ProfileOptionsSummary,
pub tables: BTreeMap<String, TableProfile>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TableProfile {
pub row_count: u64,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub parent_ratios: BTreeMap<String, ParentRatio>,
pub columns: BTreeMap<String, ColumnProfile>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ParentRatio {
pub column: String,
pub avg: f64,
pub min: u64,
pub max: u64,
pub median: f64,
pub stddev: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub percentiles: Option<Percentiles>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub zero_count: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub zero_rate: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Percentiles {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub p5: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub p10: Option<f64>,
pub p25: f64,
pub p50: f64,
pub p75: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub p90: Option<f64>,
pub p95: f64,
pub p99: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ColumnProfile {
#[serde(rename = "serial")]
Serial,
#[serde(rename = "skipped_sensitive")]
SkippedSensitive { reason: String },
#[serde(rename = "skipped_excluded")]
SkippedExcluded,
#[serde(rename = "categorical")]
Categorical {
distribution: BTreeMap<String, f64>,
null_rate: f64,
},
#[serde(rename = "string")]
StringStats {
#[serde(default, skip_serializing_if = "Option::is_none")]
semantic: Option<String>,
cardinality: u64,
null_rate: f64,
avg_length: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
min_length: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max_length: Option<u32>,
},
#[serde(rename = "numeric")]
Numeric {
min: f64,
max: f64,
mean: f64,
median: f64,
stddev: f64,
null_rate: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
percentiles: Option<Percentiles>,
},
#[serde(rename = "boolean")]
Boolean { true_rate: f64, null_rate: f64 },
#[serde(rename = "timestamp")]
Timestamp {
range: (String, String),
null_rate: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
weekday_ratio: Option<f64>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
hourly_density: BTreeMap<u8, f64>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
monthly_density: BTreeMap<String, u64>,
},
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_percentiles() -> Percentiles {
Percentiles {
p5: Some(4.99),
p10: Some(9.99),
p25: 19.99,
p50: 42.15,
p75: 79.99,
p90: Some(149.99),
p95: 249.99,
p99: 599.99,
}
}
fn yaml_roundtrip(profile: &ColumnProfile) {
let yaml = serde_yaml::to_string(profile).expect("serialize");
let parsed: ColumnProfile = serde_yaml::from_str(&yaml).expect("deserialize");
assert_eq!(profile, &parsed, "roundtrip mismatch; yaml was:\n{yaml}");
}
#[test]
fn test_column_profile_serde_roundtrip_all_variants() {
let mut distribution = BTreeMap::new();
distribution.insert("paid".to_string(), 61.8);
distribution.insert("shipped".to_string(), 18.3);
let mut hourly = BTreeMap::new();
hourly.insert(0u8, 1.2);
hourly.insert(13u8, 6.8);
let mut monthly = BTreeMap::new();
monthly.insert("2021-04".to_string(), 312u64);
monthly.insert("2021-05".to_string(), 489u64);
let variants = vec![
ColumnProfile::Serial,
ColumnProfile::SkippedSensitive {
reason: "password".into(),
},
ColumnProfile::SkippedExcluded,
ColumnProfile::Categorical {
distribution,
null_rate: 0.0,
},
ColumnProfile::StringStats {
semantic: Some("email".into()),
cardinality: 48392,
null_rate: 0.0,
avg_length: 22.0,
min_length: Some(8),
max_length: Some(64),
},
ColumnProfile::StringStats {
semantic: None,
cardinality: 100,
null_rate: 5.0,
avg_length: 12.0,
min_length: None,
max_length: None,
},
ColumnProfile::Numeric {
min: 0.99,
max: 9847.50,
mean: 67.32,
median: 42.15,
stddev: 89.44,
null_rate: 0.0,
percentiles: Some(sample_percentiles()),
},
ColumnProfile::Numeric {
min: 1.0,
max: 10.0,
mean: 5.0,
median: 5.0,
stddev: 2.0,
null_rate: 1.5,
percentiles: None,
},
ColumnProfile::Boolean {
true_rate: 78.5,
null_rate: 0.0,
},
ColumnProfile::Timestamp {
range: ("2021-03-15T00:00:00".into(), "2026-06-28T23:59:59".into()),
null_rate: 0.0,
weekday_ratio: Some(0.68),
hourly_density: hourly,
monthly_density: monthly,
},
ColumnProfile::Timestamp {
range: ("2024-01-01T00:00:00".into(), "2024-12-31T23:59:59".into()),
null_rate: 2.0,
weekday_ratio: None,
hourly_density: BTreeMap::new(),
monthly_density: BTreeMap::new(),
},
];
for v in &variants {
yaml_roundtrip(v);
}
}
#[test]
fn test_column_profile_type_tag_rendering() {
let yaml = serde_yaml::to_string(&ColumnProfile::Serial).unwrap();
assert!(yaml.contains("type: serial"), "got: {yaml}");
let cat = ColumnProfile::Categorical {
distribution: BTreeMap::new(),
null_rate: 0.0,
};
let yaml = serde_yaml::to_string(&cat).unwrap();
assert!(yaml.contains("type: categorical"), "got: {yaml}");
}
#[test]
fn test_parent_ratio_roundtrip() {
let pr = ParentRatio {
column: "user_id".into(),
avg: 7.3,
min: 0,
max: 89,
median: 5.0,
stddev: 8.2,
percentiles: Some(sample_percentiles()),
zero_count: Some(4201),
zero_rate: Some(8.7),
};
let yaml = serde_yaml::to_string(&pr).unwrap();
let parsed: ParentRatio = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(pr, parsed);
}
#[test]
fn test_database_profile_roundtrip() {
let mut columns = BTreeMap::new();
columns.insert("id".to_string(), ColumnProfile::Serial);
columns.insert(
"role".to_string(),
ColumnProfile::Categorical {
distribution: BTreeMap::from([
("user".to_string(), 89.2),
("admin".to_string(), 10.8),
]),
null_rate: 0.0,
},
);
let mut parent_ratios = BTreeMap::new();
parent_ratios.insert(
"users".to_string(),
ParentRatio {
column: "user_id".into(),
avg: 7.3,
min: 0,
max: 89,
median: 5.0,
stddev: 8.2,
percentiles: None,
zero_count: None,
zero_rate: None,
},
);
let mut tables = BTreeMap::new();
tables.insert(
"orders".to_string(),
TableProfile {
row_count: 354281,
parent_ratios,
columns,
},
);
let profile = DatabaseProfile {
version: "1.0".into(),
profiled_at: "2026-06-29T10:00:00Z".into(),
source_hash: "sha256:abc".into(),
seedgen_version: "0.3.0".into(),
options: ProfileOptionsSummary {
cardinality_threshold: 50,
skipped_sensitive: vec!["users.password_hash".into()],
},
tables,
};
let yaml = serde_yaml::to_string(&profile).unwrap();
let parsed: DatabaseProfile = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(profile, parsed);
}
}