dataprof_metrics/analysis/
column.rs1use dataprof_core::AnalysisOptions;
2
3use crate::types::{ColumnProfile, ColumnStats, DataType, Locale};
4
5use crate::analysis::inference::{
6 classify_lexical_forms, infer_type, is_null_like_token, parse_strict_boolean_token,
7};
8use crate::analysis::patterns::detect_patterns;
9use crate::stats::numeric::compute_numeric_stats_with_parsed_count;
10use crate::stats::{calculate_datetime_stats, calculate_text_stats};
11
12struct ColumnAnalysis {
18 skip_statistics: bool,
20 skip_patterns: bool,
22 locale: Option<Locale>,
24 skip_unique_count: bool,
26}
27
28pub fn analyze_column(name: &str, data: &[String]) -> ColumnProfile {
30 analyze_column_with_options(
31 name,
32 data,
33 &ColumnAnalysis {
34 skip_statistics: false,
35 skip_patterns: false,
36 locale: None,
37 skip_unique_count: false,
38 },
39 )
40}
41
42pub fn analyze_column_fast(name: &str, data: &[String]) -> ColumnProfile {
44 analyze_column_with_options(
45 name,
46 data,
47 &ColumnAnalysis {
48 skip_statistics: false,
49 skip_patterns: true,
50 locale: None,
51 skip_unique_count: true,
52 },
53 )
54}
55
56pub fn analyze_column_with_analysis_options(
64 name: &str,
65 data: &[String],
66 options: &AnalysisOptions,
67) -> ColumnProfile {
68 analyze_column_with_options(
69 name,
70 data,
71 &ColumnAnalysis {
72 skip_statistics: !options.include_statistics(),
73 skip_patterns: !options.include_patterns(),
74 locale: options.locale(),
75 skip_unique_count: false,
76 },
77 )
78}
79
80fn analyze_column_with_options(
89 name: &str,
90 data: &[String],
91 analysis: &ColumnAnalysis,
92) -> ColumnProfile {
93 let total_count = data.len();
94
95 let null_count = data.iter().filter(|s| is_null_like_token(s.trim())).count();
97
98 let data_type = infer_type(data);
100
101 let mut invalid_count = None;
106 let stats = if analysis.skip_statistics {
107 ColumnStats::None
108 } else {
109 match data_type {
110 DataType::Integer | DataType::Float => {
111 let (numeric, parsed) = compute_numeric_stats_with_parsed_count(data);
112 invalid_count = Some(
116 total_count
117 .saturating_sub(null_count)
118 .saturating_sub(parsed),
119 );
120 ColumnStats::Numeric(numeric)
121 }
122 DataType::Date => {
123 let parsed = data
124 .iter()
125 .filter(|value| {
126 super::metrics::value_matches_hint(
127 value,
128 dataprof_core::SemanticHintKind::Temporal,
129 )
130 })
131 .count();
132 invalid_count = Some(
133 total_count
134 .saturating_sub(null_count)
135 .saturating_sub(parsed),
136 );
137 calculate_datetime_stats(data)
138 }
139 DataType::Boolean => {
140 let tc = data
141 .iter()
142 .filter(|v| parse_strict_boolean_token(v.trim()) == Some(true))
143 .count();
144 let fc = data
145 .iter()
146 .filter(|v| parse_strict_boolean_token(v.trim()) == Some(false))
147 .count();
148 let total = tc + fc;
149 let true_ratio = if total > 0 {
150 tc as f64 / total as f64
151 } else {
152 0.0
153 };
154 ColumnStats::Boolean(crate::types::BooleanStats {
155 true_count: tc,
156 false_count: fc,
157 true_ratio,
158 })
159 }
160 DataType::String | DataType::Identifier => calculate_text_stats(data),
161 }
162 };
163
164 let patterns = if analysis.skip_patterns {
165 None
166 } else {
167 Some(detect_patterns(data, analysis.locale))
168 };
169
170 let unique_count = if analysis.skip_unique_count {
171 None
172 } else {
173 Some(
175 data.iter()
176 .filter(|s| !is_null_like_token(s.trim()))
177 .collect::<std::collections::HashSet<_>>()
178 .len(),
179 )
180 };
181
182 ColumnProfile {
183 name: name.to_string(),
184 data_type,
185 null_count,
186 total_count,
187 unique_count,
188 unique_count_is_approximate: unique_count.map(|_| false),
191 invalid_count,
192 type_homogeneity: Some(classify_lexical_forms(data)),
197 stats,
198 patterns,
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn test_analyze_column_basic() {
208 let data = vec!["1".to_string(), "2".to_string(), "3".to_string()];
209 let profile = analyze_column("test_col", &data);
210
211 assert_eq!(profile.name, "test_col");
212 assert!(matches!(profile.data_type, DataType::Integer));
213 assert_eq!(profile.total_count, 3);
214 assert_eq!(profile.null_count, 0);
215 assert_eq!(profile.unique_count, Some(3));
216 }
217
218 #[test]
219 fn test_analyze_column_with_nulls() {
220 let data = vec![
221 "1".to_string(),
222 "".to_string(),
223 "3".to_string(),
224 "".to_string(),
225 ];
226 let profile = analyze_column("test_col", &data);
227
228 assert_eq!(profile.total_count, 4);
229 assert_eq!(profile.null_count, 2);
230 assert_eq!(profile.unique_count, Some(2)); }
232
233 #[test]
234 fn test_analyze_column_whitespace_as_null() {
235 let data = vec![
236 "1".to_string(),
237 " ".to_string(), "3".to_string(),
239 "\t".to_string(), ];
241 let profile = analyze_column("test_col", &data);
242
243 assert_eq!(profile.total_count, 4);
244 assert_eq!(profile.null_count, 2); assert_eq!(profile.unique_count, Some(2)); assert!(matches!(profile.data_type, DataType::Integer));
247 }
248
249 #[test]
250 fn test_analyze_column_with_whitespace_values() {
251 let data = vec![" 1 ".to_string(), " 2".to_string(), "3 ".to_string()];
252 let profile = analyze_column("test_col", &data);
253
254 assert_eq!(profile.null_count, 0); assert!(matches!(profile.data_type, DataType::Integer));
256 }
257
258 #[test]
259 fn type_homogeneity_covers_the_whole_column_on_this_path() {
260 let data: Vec<String> = (0..80)
263 .map(|i| i.to_string())
264 .chain((0..20).map(|i| format!("junk{i}")))
265 .chain(std::iter::once(String::new()))
266 .collect();
267
268 let profile = analyze_column("v", &data);
269 let counts = profile
270 .type_homogeneity
271 .expect("classification runs on every column");
272
273 assert_eq!(counts.numeric, 80);
274 assert_eq!(counts.text, 20);
275 assert_eq!(
276 counts.classified_count(),
277 profile.total_count - profile.null_count
278 );
279 }
280
281 #[test]
282 fn type_homogeneity_is_recorded_even_when_the_column_is_ordinary() {
283 let data = ["1", "2", "3"].map(String::from).to_vec();
286
287 let counts = analyze_column("v", &data)
288 .type_homogeneity
289 .expect("present");
290
291 assert_eq!(counts.dominant_share(), Some(1.0));
292 }
293
294 #[test]
295 fn a_narrowed_metric_selection_still_classifies_the_column() {
296 let data = ["1", "2", "junk"].map(String::from).to_vec();
299 let options = AnalysisOptions::default().with_metric_packs(Some(vec![]));
300 assert!(!options.include_statistics());
301
302 let profile = analyze_column_with_analysis_options("v", &data, &options);
303
304 assert!(matches!(profile.stats, ColumnStats::None));
305 assert_eq!(
306 profile
307 .type_homogeneity
308 .expect("present")
309 .classified_count(),
310 3
311 );
312 }
313
314 #[test]
315 fn test_analyze_column_fast_mode() {
316 let data = vec![
317 "user@example.com".to_string(),
318 "admin@test.org".to_string(),
319 "contact@company.com".to_string(),
320 ];
321 let profile = analyze_column_fast("test_col", &data);
322
323 assert!(profile.patterns.is_none()); assert_eq!(profile.unique_count, None); }
326
327 #[test]
328 fn test_analyze_column_normal_mode() {
329 let data = vec![
330 "user@example.com".to_string(),
331 "admin@test.org".to_string(),
332 "contact@company.com".to_string(),
333 ];
334 let profile = analyze_column("test_col", &data);
335
336 assert!(profile.patterns.is_some_and(|p| !p.is_empty()));
338 assert_eq!(profile.unique_count, Some(3)); }
340
341 #[test]
342 fn test_analyze_column_empty_data() {
343 let data: Vec<String> = vec![];
344 let profile = analyze_column("test_col", &data);
345
346 assert_eq!(profile.total_count, 0);
347 assert_eq!(profile.null_count, 0);
348 assert_eq!(profile.unique_count, Some(0));
349 }
350
351 #[test]
352 fn test_analyze_column_all_null() {
353 let data = vec!["".to_string(), " ".to_string(), "\t".to_string()];
354 let profile = analyze_column("test_col", &data);
355
356 assert_eq!(profile.total_count, 3);
357 assert_eq!(profile.null_count, 3);
358 assert_eq!(profile.unique_count, Some(0)); assert!(matches!(profile.data_type, DataType::String)); }
361
362 #[test]
363 fn test_analyze_column_float_detection() {
364 let data = vec!["1.5".to_string(), "2.3".to_string(), "3.7".to_string()];
365 let profile = analyze_column("test_col", &data);
366
367 assert!(matches!(profile.data_type, DataType::Float));
368 assert_eq!(profile.null_count, 0);
369 }
370
371 #[test]
372 fn test_analyze_column_date_detection() {
373 let data = vec![
374 "2023-01-15".to_string(),
375 "2023-02-20".to_string(),
376 "2023-03-25".to_string(),
377 ];
378 let profile = analyze_column("test_col", &data);
379
380 assert!(matches!(profile.data_type, DataType::Date));
381 }
382
383 #[test]
384 fn test_analyze_column_unique_count_consistency() {
385 let data = vec![
387 "value1".to_string(),
388 "value2".to_string(),
389 " ".to_string(),
390 "value1".to_string(), "\t".to_string(),
392 ];
393 let profile = analyze_column("test_col", &data);
394
395 assert_eq!(profile.null_count, 2); assert_eq!(profile.unique_count, Some(2)); }
398}