1use std::collections::HashMap;
9
10use dataprof_core::{
11 BooleanStats, ColumnProfile, ColumnStats, DataType, DateTimeStats, SemanticHints, TextStats,
12};
13use dataprof_metrics::{
14 analysis::inference::{is_null_like_token, parse_strict_boolean_token},
15 analysis::patterns::looks_like_date,
16 calculate_datetime_stats, calculate_text_stats, detect_patterns,
17 stats::numeric::{calculate_coefficient_of_variation, compute_numeric_stats_with_parsed_count},
18};
19
20use crate::streaming_stats::{StreamingColumnCollection, StreamingStatistics};
21
22pub struct ColumnProfileInput<'a> {
24 pub name: String,
25 pub data_type: DataType,
26 pub total_count: usize,
27 pub null_count: usize,
28 pub unique_count: Option<usize>,
29 pub unique_count_is_approximate: Option<bool>,
33 pub sample_values: &'a [String],
34 pub text_lengths: Option<TextLengths>,
37 pub boolean_counts: Option<(usize, usize)>,
39 pub skip_statistics: bool,
41 pub skip_patterns: bool,
43 pub locale: Option<&'a str>,
45 pub exact_numeric: Option<ExactNumericAggregates>,
53 pub exact_date_matches: Option<usize>,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq)]
62pub struct ExactNumericAggregates {
63 pub min: f64,
64 pub max: f64,
65 pub mean: f64,
66 pub std_dev: f64,
68 pub variance: f64,
70 pub count: usize,
72}
73
74pub struct TextLengths {
76 pub min_length: usize,
77 pub max_length: usize,
78 pub avg_length: f64,
79}
80
81pub fn build_column_profile(input: ColumnProfileInput<'_>) -> ColumnProfile {
88 let mut invalid_count = None;
89 let stats = if input.skip_statistics {
90 ColumnStats::None
91 } else {
92 match input.data_type {
93 DataType::Integer | DataType::Float => {
94 let (mut numeric, sampled_numeric) =
95 compute_numeric_stats_with_parsed_count(input.sample_values);
96 let parsed_total = match &input.exact_numeric {
100 Some(exact) => exact.count,
101 None => sampled_numeric,
102 };
103 invalid_count = Some(
104 input
105 .total_count
106 .saturating_sub(input.null_count)
107 .saturating_sub(parsed_total),
108 );
109 if let Some(exact) = &input.exact_numeric {
110 numeric.min = exact.min;
111 numeric.max = exact.max;
112 numeric.mean = exact.mean;
113 numeric.std_dev = exact.std_dev;
114 numeric.variance = exact.variance;
115 numeric.coefficient_of_variation =
116 calculate_coefficient_of_variation(exact.std_dev, exact.mean);
117 if exact.count > sampled_numeric {
122 numeric.is_approximate = Some(true);
123 }
124 }
125 ColumnStats::Numeric(numeric)
126 }
127 DataType::Date => {
128 let parsed_dates = input.exact_date_matches.unwrap_or_else(|| {
129 input
130 .sample_values
131 .iter()
132 .filter(|value| {
133 dataprof_metrics::value_matches_hint(
134 value,
135 dataprof_core::SemanticHintKind::Temporal,
136 )
137 })
138 .count()
139 });
140 invalid_count = Some(
141 input
142 .total_count
143 .saturating_sub(input.null_count)
144 .saturating_sub(parsed_dates),
145 );
146 if !input.sample_values.is_empty() {
147 calculate_datetime_stats(input.sample_values)
148 } else if let Some(tl) = &input.text_lengths {
149 ColumnStats::Text(TextStats::from_lengths(
150 tl.min_length,
151 tl.max_length,
152 tl.avg_length,
153 ))
154 } else {
155 ColumnStats::DateTime(DateTimeStats::empty())
156 }
157 }
158 DataType::Boolean => {
159 let (true_count, false_count) = input.boolean_counts.unwrap_or_else(|| {
160 let tc = input
161 .sample_values
162 .iter()
163 .filter(|v| parse_strict_boolean_token(v.trim()) == Some(true))
164 .count();
165 let fc = input
166 .sample_values
167 .iter()
168 .filter(|v| parse_strict_boolean_token(v.trim()) == Some(false))
169 .count();
170 (tc, fc)
171 });
172 let total = true_count + false_count;
173 let true_ratio = if total > 0 {
174 true_count as f64 / total as f64
175 } else {
176 0.0
177 };
178 ColumnStats::Boolean(BooleanStats {
179 true_count,
180 false_count,
181 true_ratio,
182 })
183 }
184 DataType::String | DataType::Identifier => {
185 if let Some(tl) = &input.text_lengths {
186 ColumnStats::Text(TextStats::from_lengths(
187 tl.min_length,
188 tl.max_length,
189 tl.avg_length,
190 ))
191 } else {
192 calculate_text_stats(input.sample_values)
193 }
194 }
195 }
196 };
197
198 let patterns = if input.skip_patterns {
199 None
200 } else {
201 Some(detect_patterns(input.sample_values, input.locale))
202 };
203
204 ColumnProfile {
205 name: input.name,
206 data_type: input.data_type,
207 null_count: input.null_count,
208 total_count: input.total_count,
209 unique_count: input.unique_count,
210 unique_count_is_approximate: input.unique_count_is_approximate,
211 invalid_count,
212 stats,
213 patterns,
214 }
215}
216
217pub fn profiles_from_streaming(
219 column_stats: &StreamingColumnCollection,
220 skip_statistics: bool,
221 skip_patterns: bool,
222 locale: Option<&str>,
223) -> Vec<ColumnProfile> {
224 profiles_from_streaming_with_hints(
225 column_stats,
226 skip_statistics,
227 skip_patterns,
228 locale,
229 &SemanticHints::default(),
230 )
231}
232
233pub fn profiles_from_streaming_with_hints(
235 column_stats: &StreamingColumnCollection,
236 skip_statistics: bool,
237 skip_patterns: bool,
238 locale: Option<&str>,
239 semantic_hints: &SemanticHints,
240) -> Vec<ColumnProfile> {
241 let mut profiles = Vec::new();
242
243 for column_name in column_stats.column_names() {
244 if let Some(stats) = column_stats.get_column_stats(&column_name) {
245 let profile = profile_from_stats_with_hints(
246 &column_name,
247 stats,
248 skip_statistics,
249 skip_patterns,
250 locale,
251 semantic_hints,
252 );
253 profiles.push(profile);
254 }
255 }
256
257 profiles
258}
259
260pub fn profile_from_stats(
262 name: &str,
263 stats: &StreamingStatistics,
264 skip_statistics: bool,
265 skip_patterns: bool,
266 locale: Option<&str>,
267) -> ColumnProfile {
268 profile_from_stats_with_hints(
269 name,
270 stats,
271 skip_statistics,
272 skip_patterns,
273 locale,
274 &SemanticHints::default(),
275 )
276}
277
278pub fn profile_from_stats_with_hints(
280 name: &str,
281 stats: &StreamingStatistics,
282 skip_statistics: bool,
283 skip_patterns: bool,
284 locale: Option<&str>,
285 semantic_hints: &SemanticHints,
286) -> ColumnProfile {
287 let data_type = if semantic_hints.is_identifier_column(name) {
288 DataType::Identifier
289 } else {
290 infer_data_type_streaming(stats)
291 };
292 let text_stats = stats.text_length_stats();
293
294 build_column_profile(ColumnProfileInput {
295 name: name.to_string(),
296 data_type,
297 total_count: stats.count,
298 null_count: stats.null_count,
299 unique_count: Some(stats.unique_count()),
300 unique_count_is_approximate: Some(stats.unique_count_is_approximate()),
301 sample_values: stats.sample_values(),
302 text_lengths: Some(TextLengths {
303 min_length: text_stats.min_length,
304 max_length: text_stats.max_length,
305 avg_length: text_stats.avg_length,
306 }),
307 boolean_counts: None,
308 skip_statistics,
309 skip_patterns,
310 locale,
311 exact_numeric: stats.exact_numeric_aggregates(),
312 exact_date_matches: Some(stats.date_match_count()),
313 })
314}
315
316pub fn infer_data_type_streaming(stats: &StreamingStatistics) -> DataType {
318 if stats.min.is_finite() && stats.max.is_finite() {
319 let sample_values = stats.sample_values();
320 let non_empty: Vec<&String> = sample_values
321 .iter()
322 .filter(|s| !is_null_like_token(s.trim()))
323 .collect();
324
325 if !non_empty.is_empty() {
326 let all_integers = non_empty.iter().all(|s| s.parse::<i64>().is_ok());
327 if all_integers {
328 return DataType::Integer;
329 }
330
331 let numeric_count = non_empty
332 .iter()
333 .filter(|s| s.parse::<f64>().is_ok())
334 .count();
335 if numeric_count as f64 / non_empty.len() as f64 > 0.8 {
336 return DataType::Float;
337 }
338 }
339 }
340
341 let sample_values = stats.sample_values();
342 let non_empty: Vec<&String> = sample_values
343 .iter()
344 .filter(|s| !is_null_like_token(s.trim()))
345 .collect();
346
347 if !non_empty.is_empty() {
348 let date_like_count = non_empty
349 .iter()
350 .take(100)
351 .filter(|s| looks_like_date(s))
352 .count();
353
354 if date_like_count as f64 / non_empty.len().min(100) as f64 > 0.7 {
355 return DataType::Date;
356 }
357
358 let bool_count = non_empty
359 .iter()
360 .filter(|s| parse_strict_boolean_token(s.trim()).is_some())
361 .count();
362
363 if bool_count as f64 / non_empty.len() as f64 >= 0.9 {
364 return DataType::Boolean;
365 }
366 }
367
368 DataType::String
369}
370
371pub fn quality_check_samples(
374 column_stats: &StreamingColumnCollection,
375) -> HashMap<String, Vec<String>> {
376 let mut samples = HashMap::new();
377
378 for column_name in column_stats.column_names() {
379 if let Some(stats) = column_stats.get_column_stats(&column_name) {
380 let sample_values: Vec<String> = stats.sample_values().to_vec();
381 samples.insert(column_name, sample_values);
382 }
383 }
384
385 samples
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use crate::streaming_stats::StreamingColumnCollection;
392
393 #[test]
394 fn test_profiles_from_streaming() {
395 let mut collection = StreamingColumnCollection::new();
396 let headers = vec!["name".to_string(), "age".to_string()];
397
398 collection.process_record(&headers, vec!["Alice".to_string(), "30".to_string()]);
399 collection.process_record(&headers, vec!["Bob".to_string(), "25".to_string()]);
400 collection.process_record(&headers, vec!["Charlie".to_string(), "35".to_string()]);
401
402 let profiles = profiles_from_streaming(&collection, false, false, None);
403 assert_eq!(profiles.len(), 2);
404
405 let age = profiles.iter().find(|p| p.name == "age").unwrap();
406 assert_eq!(age.data_type, DataType::Integer);
407 assert_eq!(age.total_count, 3);
408 }
409
410 #[test]
411 fn test_quality_check_samples() {
412 let mut collection = StreamingColumnCollection::new();
413 let headers = vec!["col".to_string()];
414
415 collection.process_record(&headers, vec!["val1".to_string()]);
416 collection.process_record(&headers, vec!["val2".to_string()]);
417
418 let samples = quality_check_samples(&collection);
419 assert!(samples.contains_key("col"));
420 assert_eq!(samples["col"].len(), 2);
421 }
422
423 #[test]
424 fn test_boolean_stats_with_counts() {
425 let samples = vec!["True".to_string(), "False".to_string(), "True".to_string()];
426 let profile = build_column_profile(ColumnProfileInput {
427 name: "flag".to_string(),
428 data_type: DataType::Boolean,
429 total_count: 3,
430 null_count: 0,
431 unique_count: Some(2),
432 unique_count_is_approximate: Some(false),
433 sample_values: &samples,
434 text_lengths: None,
435 boolean_counts: Some((2, 1)),
436 skip_statistics: false,
437 skip_patterns: false,
438 exact_numeric: None,
439 exact_date_matches: None,
440 locale: None,
441 });
442
443 match &profile.stats {
444 ColumnStats::Boolean(b) => {
445 assert_eq!(b.true_count, 2);
446 assert_eq!(b.false_count, 1);
447 assert!((b.true_ratio - 2.0 / 3.0).abs() < 0.001);
448 }
449 other => panic!("expected Boolean stats, got {:?}", other),
450 }
451 }
452
453 #[test]
454 fn test_boolean_stats_fallback_case_insensitive() {
455 let samples = vec![
456 "true".to_string(),
457 "FALSE".to_string(),
458 " True ".to_string(),
459 ];
460 let profile = build_column_profile(ColumnProfileInput {
461 name: "flag".to_string(),
462 data_type: DataType::Boolean,
463 total_count: 3,
464 null_count: 0,
465 unique_count: Some(2),
466 unique_count_is_approximate: Some(false),
467 sample_values: &samples,
468 text_lengths: None,
469 boolean_counts: None,
470 skip_statistics: false,
471 skip_patterns: false,
472 exact_numeric: None,
473 exact_date_matches: None,
474 locale: None,
475 });
476
477 match &profile.stats {
478 ColumnStats::Boolean(b) => {
479 assert_eq!(b.true_count, 2);
480 assert_eq!(b.false_count, 1);
481 assert!((b.true_ratio - 2.0 / 3.0).abs() < 0.001);
482 }
483 other => panic!("expected Boolean stats, got {:?}", other),
484 }
485 }
486
487 #[test]
488 fn test_skip_statistics() {
489 let samples = vec!["10".to_string(), "20".to_string(), "30".to_string()];
490 let profile = build_column_profile(ColumnProfileInput {
491 name: "num".to_string(),
492 data_type: DataType::Integer,
493 total_count: 3,
494 null_count: 0,
495 unique_count: Some(3),
496 unique_count_is_approximate: Some(false),
497 sample_values: &samples,
498 text_lengths: None,
499 boolean_counts: None,
500 skip_statistics: true,
501 skip_patterns: false,
502 exact_numeric: None,
503 exact_date_matches: None,
504 locale: None,
505 });
506
507 assert!(matches!(profile.stats, ColumnStats::None));
508 assert_eq!(profile.data_type, DataType::Integer);
509 }
510
511 #[test]
512 fn test_skip_patterns() {
513 let samples = vec!["hello".to_string(), "world".to_string()];
514 let profile = build_column_profile(ColumnProfileInput {
515 name: "text".to_string(),
516 data_type: DataType::String,
517 total_count: 2,
518 null_count: 0,
519 unique_count: Some(2),
520 unique_count_is_approximate: Some(false),
521 sample_values: &samples,
522 text_lengths: None,
523 boolean_counts: None,
524 skip_statistics: false,
525 skip_patterns: true,
526 exact_numeric: None,
527 exact_date_matches: None,
528 locale: None,
529 });
530
531 assert!(profile.patterns.is_none());
534 assert!(matches!(profile.stats, ColumnStats::Text(_)));
535 }
536
537 #[test]
538 fn test_scanned_without_matches_is_not_none() {
539 let samples = vec!["hello".to_string(), "world".to_string()];
543 let profile = build_column_profile(ColumnProfileInput {
544 name: "text".to_string(),
545 data_type: DataType::String,
546 total_count: 2,
547 null_count: 0,
548 unique_count: Some(2),
549 unique_count_is_approximate: Some(false),
550 sample_values: &samples,
551 text_lengths: None,
552 boolean_counts: None,
553 skip_statistics: false,
554 skip_patterns: false,
555 exact_numeric: None,
556 exact_date_matches: None,
557 locale: None,
558 });
559
560 assert!(profile.patterns.is_some_and(|p| p.is_empty()));
561 }
562
563 #[test]
564 fn test_all_packs_default() {
565 let samples = vec!["42".to_string(), "99".to_string()];
566 let profile = build_column_profile(ColumnProfileInput {
567 name: "val".to_string(),
568 data_type: DataType::Integer,
569 total_count: 2,
570 null_count: 0,
571 unique_count: Some(2),
572 unique_count_is_approximate: Some(false),
573 sample_values: &samples,
574 text_lengths: None,
575 boolean_counts: None,
576 skip_statistics: false,
577 skip_patterns: false,
578 exact_numeric: None,
579 exact_date_matches: None,
580 locale: None,
581 });
582
583 assert!(matches!(profile.stats, ColumnStats::Numeric(_)));
584 assert_eq!(profile.data_type, DataType::Integer);
585 }
586}