1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::classification::{DataType, TypeHomogeneity};
6use crate::pattern::Pattern;
7
8#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
10pub struct ColumnProfile {
11 pub name: String,
12 pub data_type: DataType,
13 pub null_count: usize,
14 pub total_count: usize,
15 pub unique_count: Option<usize>,
16 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub unique_count_is_approximate: Option<bool>,
27 #[serde(skip)]
37 #[schemars(skip)]
38 pub unique_count_lower_bound: Option<usize>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub invalid_count: Option<usize>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub type_homogeneity: Option<TypeHomogeneity>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub locale_number_count: Option<usize>,
86 pub stats: ColumnStats,
87 pub patterns: Option<Vec<Pattern>>,
94}
95
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
98pub struct Quartiles {
99 pub q1: f64,
100 pub q2: f64,
101 pub q3: f64,
102 #[serde(deserialize_with = "crate::serde_helpers::required_nullable")]
106 pub iqr: Option<f64>,
107}
108
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
111pub struct FrequencyItem {
112 pub value: String,
113 pub count: usize,
114 #[serde(serialize_with = "crate::serde_helpers::round_2")]
115 pub percentage: f64,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
120pub struct NumericStats {
121 #[serde(serialize_with = "crate::serde_helpers::round_4")]
125 pub min: f64,
126 #[serde(serialize_with = "crate::serde_helpers::round_4")]
127 pub max: f64,
128 #[serde(serialize_with = "crate::serde_helpers::round_4")]
129 pub mean: f64,
130 #[serde(
133 serialize_with = "crate::serde_helpers::round_4_opt",
134 deserialize_with = "crate::serde_helpers::required_nullable"
135 )]
136 pub std_dev: Option<f64>,
137 #[serde(
140 serialize_with = "crate::serde_helpers::round_4_opt",
141 deserialize_with = "crate::serde_helpers::required_nullable"
142 )]
143 pub variance: Option<f64>,
144 #[serde(
145 skip_serializing_if = "Option::is_none",
146 serialize_with = "crate::serde_helpers::round_4_opt"
147 )]
148 pub median: Option<f64>,
149 #[serde(
150 skip_serializing_if = "Option::is_none",
151 serialize_with = "crate::serde_helpers::quartiles::serialize"
152 )]
153 pub quartiles: Option<Quartiles>,
154 #[serde(
155 skip_serializing_if = "Option::is_none",
156 serialize_with = "crate::serde_helpers::round_4_opt"
157 )]
158 pub mode: Option<f64>,
159 #[serde(
160 skip_serializing_if = "Option::is_none",
161 serialize_with = "crate::serde_helpers::round_2_opt"
162 )]
163 pub coefficient_of_variation: Option<f64>,
164 #[serde(
165 skip_serializing_if = "Option::is_none",
166 serialize_with = "crate::serde_helpers::round_4_opt"
167 )]
168 pub skewness: Option<f64>,
169 #[serde(
170 skip_serializing_if = "Option::is_none",
171 serialize_with = "crate::serde_helpers::round_4_opt"
172 )]
173 pub kurtosis: Option<f64>,
174 #[serde(skip_serializing_if = "Option::is_none")]
175 pub is_approximate: Option<bool>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub outlier_count: Option<usize>,
184}
185
186impl NumericStats {
187 pub fn empty() -> Self {
188 Self {
189 min: 0.0,
190 max: 0.0,
191 mean: 0.0,
192 std_dev: Some(0.0),
193 variance: Some(0.0),
194 median: None,
195 quartiles: None,
196 mode: None,
197 coefficient_of_variation: None,
198 skewness: None,
199 kurtosis: None,
200 is_approximate: None,
201 outlier_count: None,
202 }
203 }
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
214pub struct TextStats {
215 pub min_length: usize,
217 pub max_length: usize,
219 #[serde(serialize_with = "crate::serde_helpers::round_4")]
222 pub avg_length: f64,
223 #[serde(skip_serializing_if = "Option::is_none")]
224 pub most_frequent: Option<Vec<FrequencyItem>>,
225 #[serde(skip_serializing_if = "Option::is_none")]
226 pub least_frequent: Option<Vec<FrequencyItem>>,
227}
228
229impl TextStats {
230 pub fn empty() -> Self {
231 Self {
232 min_length: 0,
233 max_length: 0,
234 avg_length: 0.0,
235 most_frequent: None,
236 least_frequent: None,
237 }
238 }
239
240 pub fn from_lengths(min_length: usize, max_length: usize, avg_length: f64) -> Self {
241 Self {
242 min_length: if min_length == usize::MAX {
243 0
244 } else {
245 min_length
246 },
247 max_length,
248 avg_length,
249 most_frequent: None,
250 least_frequent: None,
251 }
252 }
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
257pub struct DateTimeStats {
258 pub min_datetime: String,
259 pub max_datetime: String,
260 #[serde(serialize_with = "crate::serde_helpers::round_2")]
261 pub duration_days: f64,
262 pub year_distribution: HashMap<i32, usize>,
263 pub month_distribution: HashMap<u32, usize>,
264 pub day_of_week_distribution: HashMap<String, usize>,
265 #[serde(skip_serializing_if = "Option::is_none")]
266 pub hour_distribution: Option<HashMap<u32, usize>>,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub slash_date_order: Option<SlashDateOrder>,
274}
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
278#[serde(rename_all = "snake_case")]
279pub enum SlashDateOrder {
280 DayFirst,
283 MonthFirst,
286 AssumedDayFirst,
289 Mixed,
294}
295
296impl DateTimeStats {
297 pub fn empty() -> Self {
298 Self {
299 min_datetime: String::new(),
300 max_datetime: String::new(),
301 duration_days: 0.0,
302 year_distribution: HashMap::new(),
303 month_distribution: HashMap::new(),
304 day_of_week_distribution: HashMap::new(),
305 hour_distribution: None,
306 slash_date_order: None,
307 }
308 }
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
313pub struct BooleanStats {
314 pub true_count: usize,
315 pub false_count: usize,
316 #[serde(serialize_with = "crate::serde_helpers::round_4")]
317 pub true_ratio: f64,
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
322pub enum ColumnStats {
323 Numeric(NumericStats),
324 Text(TextStats),
325 DateTime(DateTimeStats),
326 Boolean(BooleanStats),
327 None,
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 #[test]
338 fn nullable_spread_fields_are_still_required() {
339 let stats = NumericStats {
340 std_dev: None,
341 variance: None,
342 quartiles: Some(Quartiles {
343 q1: 1.0,
344 q2: 2.0,
345 q3: 3.0,
346 iqr: None,
347 }),
348 ..NumericStats::empty()
349 };
350 let document = serde_json::to_value(&stats).unwrap();
351 let restored: NumericStats = serde_json::from_value(document.clone()).unwrap();
352 assert_eq!(serde_json::to_value(restored).unwrap(), document);
353
354 for (parent, key) in [
355 (None, "std_dev"),
356 (None, "variance"),
357 (Some("quartiles"), "iqr"),
358 ] {
359 let mut malformed = document.clone();
360 let object = match parent {
361 Some(parent) => &mut malformed[parent],
362 None => &mut malformed,
363 };
364 object.as_object_mut().unwrap().remove(key).unwrap();
365 let error = serde_json::from_value::<NumericStats>(malformed).unwrap_err();
366 assert!(
367 error
368 .to_string()
369 .contains(&format!("missing field `{key}`")),
370 "{key}: {error}"
371 );
372 }
373 }
374
375 #[test]
376 fn test_column_profile_json_roundtrip() {
377 let profile = ColumnProfile {
378 name: "test_col".to_string(),
379 data_type: DataType::Integer,
380 null_count: 2,
381 total_count: 10,
382 unique_count: Some(8),
383 unique_count_is_approximate: Some(false),
384 unique_count_lower_bound: None,
385 invalid_count: Some(0),
386 type_homogeneity: None,
387 locale_number_count: None,
388 stats: ColumnStats::Numeric(NumericStats {
389 min: 1.0,
390 max: 100.0,
391 mean: 50.5,
392 std_dev: Some(28.87),
393 variance: Some(833.25),
394 median: Some(50.0),
395 quartiles: Some(Quartiles {
396 q1: 25.0,
397 q2: 50.0,
398 q3: 75.0,
399 iqr: Some(50.0),
400 }),
401 mode: Some(42.0),
402 coefficient_of_variation: Some(57.17),
403 skewness: Some(0.0),
404 kurtosis: Some(-1.2),
405 is_approximate: Some(false),
406 outlier_count: Some(0),
407 }),
408 patterns: Some(vec![]),
409 };
410
411 let json = serde_json::to_string(&profile).unwrap();
412 let deserialized: ColumnProfile = serde_json::from_str(&json).unwrap();
413
414 assert_eq!(deserialized.name, "test_col");
415 assert_eq!(deserialized.data_type, DataType::Integer);
416 assert_eq!(deserialized.total_count, 10);
417 assert_eq!(deserialized.null_count, 2);
418 assert_eq!(deserialized.unique_count_is_approximate, Some(false));
419
420 if let ColumnStats::Numeric(n) = &deserialized.stats {
421 assert!((n.min - 1.0).abs() < 0.01);
422 assert!((n.max - 100.0).abs() < 0.01);
423 assert!((n.mean - 50.5).abs() < 0.01);
424 assert!(n.median.is_some());
425 assert!(n.quartiles.is_some());
426 } else {
427 panic!("Expected Numeric stats after roundtrip");
428 }
429 }
430
431 #[test]
432 fn test_text_stats_json_roundtrip() {
433 let profile = ColumnProfile {
434 name: "name".to_string(),
435 data_type: DataType::String,
436 null_count: 0,
437 total_count: 3,
438 unique_count: Some(3),
439 unique_count_is_approximate: Some(false),
440 unique_count_lower_bound: None,
441 invalid_count: None,
442 type_homogeneity: None,
443 locale_number_count: None,
444 stats: ColumnStats::Text(TextStats {
445 min_length: 3,
446 max_length: 7,
447 avg_length: 5.0,
448 most_frequent: None,
449 least_frequent: None,
450 }),
451 patterns: Some(vec![]),
452 };
453
454 let json = serde_json::to_string(&profile).unwrap();
455 let deserialized: ColumnProfile = serde_json::from_str(&json).unwrap();
456
457 assert_eq!(deserialized.data_type, DataType::String);
458 if let ColumnStats::Text(t) = &deserialized.stats {
459 assert_eq!(t.min_length, 3);
460 assert_eq!(t.max_length, 7);
461 } else {
462 panic!("Expected Text stats after roundtrip");
463 }
464 }
465}