1use dataprof_core::AnalysisOptions;
2
3use crate::types::{ColumnProfile, ColumnStats, DataType, Locale};
4
5use crate::analysis::inference::{
6 classify_lexical_forms, count_locale_numbers, infer_type, is_null_like_token,
7 parse_strict_boolean_token,
8};
9use crate::analysis::patterns::detect_patterns;
10use crate::stats::numeric::compute_numeric_stats_with_parsed_count;
11use crate::stats::{calculate_datetime_stats, calculate_text_stats_from_refs};
12
13struct ColumnAnalysis {
19 skip_statistics: bool,
21 skip_patterns: bool,
23 locale: Option<Locale>,
25 skip_unique_count: bool,
27}
28
29pub fn stats_over_values(stats: ColumnStats, total_count: usize, null_count: usize) -> ColumnStats {
38 if null_count >= total_count {
39 ColumnStats::None
40 } else {
41 stats
42 }
43}
44
45pub fn analyze_column(name: &str, data: &[String]) -> ColumnProfile {
47 analyze_column_with_options(
48 name,
49 data,
50 &ColumnAnalysis {
51 skip_statistics: false,
52 skip_patterns: false,
53 locale: None,
54 skip_unique_count: false,
55 },
56 )
57}
58
59pub fn analyze_column_fast(name: &str, data: &[String]) -> ColumnProfile {
61 analyze_column_with_options(
62 name,
63 data,
64 &ColumnAnalysis {
65 skip_statistics: false,
66 skip_patterns: true,
67 locale: None,
68 skip_unique_count: true,
69 },
70 )
71}
72
73pub fn analyze_column_with_analysis_options(
81 name: &str,
82 data: &[String],
83 options: &AnalysisOptions,
84) -> ColumnProfile {
85 analyze_column_with_options(
86 name,
87 data,
88 &ColumnAnalysis {
89 skip_statistics: !options.include_statistics(),
90 skip_patterns: !options.include_patterns(),
91 locale: options.locale(),
92 skip_unique_count: false,
93 },
94 )
95}
96
97fn analyze_column_with_options(
106 name: &str,
107 data: &[String],
108 analysis: &ColumnAnalysis,
109) -> ColumnProfile {
110 let total_count = data.len();
111
112 let null_count = data.iter().filter(|s| is_null_like_token(s.trim())).count();
114
115 let data_type = infer_type(data);
117
118 let mut invalid_count = None;
123 let stats = if analysis.skip_statistics {
124 ColumnStats::None
125 } else {
126 match data_type {
127 DataType::Integer | DataType::Float => {
128 let (numeric, parsed) = compute_numeric_stats_with_parsed_count(data);
129 invalid_count = Some(
133 total_count
134 .saturating_sub(null_count)
135 .saturating_sub(parsed),
136 );
137 if parsed == 0 {
138 ColumnStats::None
139 } else {
140 ColumnStats::Numeric(numeric)
141 }
142 }
143 DataType::Date => {
144 let parsed = data
145 .iter()
146 .filter(|value| {
147 super::metrics::value_matches_hint(
148 value,
149 dataprof_core::SemanticHintKind::Temporal,
150 )
151 })
152 .count();
153 invalid_count = Some(
154 total_count
155 .saturating_sub(null_count)
156 .saturating_sub(parsed),
157 );
158 calculate_datetime_stats(data)
159 }
160 DataType::Boolean => {
161 let tc = data
162 .iter()
163 .filter(|v| parse_strict_boolean_token(v.trim()) == Some(true))
164 .count();
165 let fc = data
166 .iter()
167 .filter(|v| parse_strict_boolean_token(v.trim()) == Some(false))
168 .count();
169 let total = tc + fc;
170 if total > 0 {
171 ColumnStats::Boolean(crate::types::BooleanStats {
172 true_count: tc,
173 false_count: fc,
174 true_ratio: tc as f64 / total as f64,
175 })
176 } else {
177 ColumnStats::None
178 }
179 }
180 DataType::String | DataType::Identifier => {
181 let values: Vec<&String> = data
186 .iter()
187 .filter(|s| !is_null_like_token(s.trim()))
188 .collect();
189 calculate_text_stats_from_refs(&values)
190 }
191 DataType::Nested => unreachable!("infer_type never answers Nested"),
193 }
194 };
195 let stats = stats_over_values(stats, total_count, null_count);
196
197 let patterns = if analysis.skip_patterns {
198 None
199 } else {
200 Some(detect_patterns(data, analysis.locale))
201 };
202
203 let unique_count = if analysis.skip_unique_count {
204 None
205 } else {
206 Some(
208 data.iter()
209 .filter(|s| !is_null_like_token(s.trim()))
210 .collect::<std::collections::HashSet<_>>()
211 .len(),
212 )
213 };
214
215 ColumnProfile {
216 name: name.to_string(),
217 data_type,
218 null_count,
219 total_count,
220 unique_count,
221 unique_count_is_approximate: unique_count.map(|_| false),
224 unique_count_lower_bound: None,
225 invalid_count,
226 type_homogeneity: Some(classify_lexical_forms(data)),
231 locale_number_count: Some(count_locale_numbers(data)),
232 stats,
233 patterns,
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
245 fn a_column_without_a_value_has_no_statistics() {
246 let nulls: Vec<String> = ["", "NA", "null", " "].map(String::from).to_vec();
247 let profile = analyze_column("empty", &nulls);
248 assert_eq!((profile.null_count, profile.total_count), (4, 4));
249 assert!(
250 matches!(profile.stats, ColumnStats::None),
251 "{:?}",
252 profile.stats
253 );
254
255 let mut one = nulls.clone();
256 one.push("ab".to_string());
257 match analyze_column("one", &one).stats {
258 ColumnStats::Text(text) => assert_eq!((text.min_length, text.max_length), (2, 2)),
259 other => panic!("expected text statistics, got {other:?}"),
260 }
261 }
262
263 #[test]
264 fn non_finite_numeric_column_has_no_statistics() {
265 let data = ["Infinity", "-inf"].map(String::from);
266 let profile = analyze_column("v", &data);
267 assert_eq!(profile.data_type, DataType::Float);
268 assert_eq!(profile.total_count, 2);
269 assert_eq!(profile.null_count, 0);
270 assert_eq!(profile.invalid_count, Some(2));
271 assert!(matches!(profile.stats, ColumnStats::None));
272 }
273
274 #[test]
275 fn measured_zeros_survive_in_memory_analysis() {
276 let numeric = ["", "0", "0"].map(String::from);
277 let profile = analyze_column("n", &numeric);
278 let ColumnStats::Numeric(stats) = profile.stats else {
279 panic!("expected numeric statistics over real zeros");
280 };
281 assert_eq!(stats.min, 0.0);
282 assert_eq!(stats.mean, 0.0);
283 assert_eq!(profile.invalid_count, Some(0));
284
285 let boolean = ["", "false", "false"].map(String::from);
286 let ColumnStats::Boolean(stats) = analyze_column("b", &boolean).stats else {
287 panic!("expected boolean statistics over real false values");
288 };
289 assert_eq!(stats.true_ratio, 0.0);
290 assert_eq!(stats.false_count, 2);
291 }
292
293 #[test]
294 fn test_analyze_column_basic() {
295 let data = vec!["1".to_string(), "2".to_string(), "3".to_string()];
296 let profile = analyze_column("test_col", &data);
297
298 assert_eq!(profile.name, "test_col");
299 assert!(matches!(profile.data_type, DataType::Integer));
300 assert_eq!(profile.total_count, 3);
301 assert_eq!(profile.null_count, 0);
302 assert_eq!(profile.unique_count, Some(3));
303 }
304
305 #[test]
306 fn test_analyze_column_with_nulls() {
307 let data = vec![
308 "1".to_string(),
309 "".to_string(),
310 "3".to_string(),
311 "".to_string(),
312 ];
313 let profile = analyze_column("test_col", &data);
314
315 assert_eq!(profile.total_count, 4);
316 assert_eq!(profile.null_count, 2);
317 assert_eq!(profile.unique_count, Some(2)); }
319
320 #[test]
321 fn test_analyze_column_whitespace_as_null() {
322 let data = vec![
323 "1".to_string(),
324 " ".to_string(), "3".to_string(),
326 "\t".to_string(), ];
328 let profile = analyze_column("test_col", &data);
329
330 assert_eq!(profile.total_count, 4);
331 assert_eq!(profile.null_count, 2); assert_eq!(profile.unique_count, Some(2)); assert!(matches!(profile.data_type, DataType::Integer));
334 }
335
336 #[test]
337 fn test_analyze_column_with_whitespace_values() {
338 let data = vec![" 1 ".to_string(), " 2".to_string(), "3 ".to_string()];
339 let profile = analyze_column("test_col", &data);
340
341 assert_eq!(profile.null_count, 0); assert!(matches!(profile.data_type, DataType::Integer));
343 assert_eq!(profile.invalid_count, Some(0));
344 let ColumnStats::Numeric(stats) = profile.stats else {
345 panic!("whitespace-padded integers should have numeric statistics");
346 };
347 assert_eq!(stats.min, 1.0);
348 assert_eq!(stats.max, 3.0);
349 assert_eq!(stats.mean, 2.0);
350 }
351
352 #[test]
353 fn type_homogeneity_covers_the_whole_column_on_this_path() {
354 let data: Vec<String> = (0..80)
357 .map(|i| i.to_string())
358 .chain((0..20).map(|i| format!("junk{i}")))
359 .chain(std::iter::once(String::new()))
360 .collect();
361
362 let profile = analyze_column("v", &data);
363 let counts = profile
364 .type_homogeneity
365 .expect("classification runs on every column");
366
367 assert_eq!(counts.numeric, 80);
368 assert_eq!(counts.text, 20);
369 assert_eq!(
370 counts.classified_count(),
371 profile.total_count - profile.null_count
372 );
373 }
374
375 #[test]
376 fn type_homogeneity_is_recorded_even_when_the_column_is_ordinary() {
377 let data = ["1", "2", "3"].map(String::from).to_vec();
380
381 let counts = analyze_column("v", &data)
382 .type_homogeneity
383 .expect("present");
384
385 assert_eq!(counts.dominant_share(), Some(1.0));
386 }
387
388 #[test]
389 fn a_narrowed_metric_selection_still_classifies_the_column() {
390 let data = ["1", "2", "junk"].map(String::from).to_vec();
393 let options = AnalysisOptions::default().with_metric_packs(Some(vec![]));
394 assert!(!options.include_statistics());
395
396 let profile = analyze_column_with_analysis_options("v", &data, &options);
397
398 assert!(matches!(profile.stats, ColumnStats::None));
399 assert_eq!(
400 profile
401 .type_homogeneity
402 .expect("present")
403 .classified_count(),
404 3
405 );
406 }
407
408 #[test]
409 fn test_analyze_column_fast_mode() {
410 let data = vec![
411 "user@example.com".to_string(),
412 "admin@test.org".to_string(),
413 "contact@company.com".to_string(),
414 ];
415 let profile = analyze_column_fast("test_col", &data);
416
417 assert!(profile.patterns.is_none()); assert_eq!(profile.unique_count, None); }
420
421 #[test]
422 fn test_analyze_column_normal_mode() {
423 let data = vec![
424 "user@example.com".to_string(),
425 "admin@test.org".to_string(),
426 "contact@company.com".to_string(),
427 ];
428 let profile = analyze_column("test_col", &data);
429
430 assert!(profile.patterns.is_some_and(|p| !p.is_empty()));
432 assert_eq!(profile.unique_count, Some(3)); }
434
435 #[test]
436 fn test_analyze_column_empty_data() {
437 let data: Vec<String> = vec![];
438 let profile = analyze_column("test_col", &data);
439
440 assert_eq!(profile.total_count, 0);
441 assert_eq!(profile.null_count, 0);
442 assert_eq!(profile.unique_count, Some(0));
443 }
444
445 #[test]
446 fn test_analyze_column_all_null() {
447 let data = vec!["".to_string(), " ".to_string(), "\t".to_string()];
448 let profile = analyze_column("test_col", &data);
449
450 assert_eq!(profile.total_count, 3);
451 assert_eq!(profile.null_count, 3);
452 assert_eq!(profile.unique_count, Some(0)); assert!(matches!(profile.data_type, DataType::String)); }
455
456 #[test]
457 fn test_analyze_column_float_detection() {
458 let data = vec!["1.5".to_string(), "2.3".to_string(), "3.7".to_string()];
459 let profile = analyze_column("test_col", &data);
460
461 assert!(matches!(profile.data_type, DataType::Float));
462 assert_eq!(profile.null_count, 0);
463 }
464
465 #[test]
466 fn test_analyze_column_date_detection() {
467 let data = vec![
468 "2023-01-15".to_string(),
469 "2023-02-20".to_string(),
470 "2023-03-25".to_string(),
471 ];
472 let profile = analyze_column("test_col", &data);
473
474 assert!(matches!(profile.data_type, DataType::Date));
475 }
476
477 #[test]
478 fn test_analyze_column_unique_count_consistency() {
479 let data = vec![
481 "value1".to_string(),
482 "value2".to_string(),
483 " ".to_string(),
484 "value1".to_string(), "\t".to_string(),
486 ];
487 let profile = analyze_column("test_col", &data);
488
489 assert_eq!(profile.null_count, 2); assert_eq!(profile.unique_count, Some(2)); }
492
493 #[test]
494 fn text_lengths_exclude_null_like_tokens() {
495 let data = vec![
499 "NULL".to_string(),
500 "a".to_string(),
501 "nan".to_string(),
502 "b".to_string(),
503 ];
504 let profile = analyze_column("test_col", &data);
505
506 assert_eq!(profile.null_count, 2);
507 let ColumnStats::Text(stats) = profile.stats else {
508 panic!("expected text stats");
509 };
510 assert_eq!(stats.min_length, 1);
511 assert_eq!(stats.max_length, 1);
512 assert_eq!(stats.avg_length, 1.0);
513 }
514}