Skip to main content

dataprof_metrics/analysis/
column.rs

1use 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
12/// Which parts of a column analysis to perform.
13///
14/// The streaming engines express the same choices through
15/// `profile_builder::build_column_profile`; this is the in-memory equivalent for
16/// callers that hold a whole column as `Vec<String>` (the database connectors).
17struct ColumnAnalysis {
18    /// Produce [`ColumnStats::None`] instead of computing statistics.
19    skip_statistics: bool,
20    /// Produce `patterns: None` instead of running detection.
21    skip_patterns: bool,
22    /// Locale used to rank detected patterns.
23    locale: Option<Locale>,
24    /// Leave `unique_count` absent instead of counting distinct values.
25    skip_unique_count: bool,
26}
27
28/// Analyze a column with full profiling (includes pattern detection and unique counts)
29pub 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
42/// Analyze a column in fast mode (skips expensive operations)
43pub 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
56/// Analyze a column, honouring the caller's analysis selection.
57///
58/// Metric packs decide whether statistics and pattern detection run, and the
59/// locale ranks whatever patterns are detected — the same contract the file and
60/// streaming paths honour. Distinct-value counting is schema-level information
61/// rather than a pack, so it always runs here; use [`analyze_column_fast`] to
62/// skip it.
63pub 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
80/// Analyze a column with configurable options
81///
82/// # Performance Considerations
83/// - Skipping patterns and unique counts avoids the expensive passes
84/// - Whitespace-only values are treated as null (aligned with inference logic)
85///
86/// # Returns
87/// Complete column profile including type, stats, and optionally patterns
88fn analyze_column_with_options(
89    name: &str,
90    data: &[String],
91    analysis: &ColumnAnalysis,
92) -> ColumnProfile {
93    let total_count = data.len();
94
95    // Aligned with inference.rs: whitespace-only strings are treated as null
96    let null_count = data.iter().filter(|s| is_null_like_token(s.trim())).count();
97
98    // Infer type (uses same whitespace logic internally)
99    let data_type = infer_type(data);
100
101    // Calculate stats. Numeric columns also yield how many values parsed as
102    // finite numbers, so the invalid count comes from the same single pass —
103    // which is why skipping statistics also leaves `invalid_count` absent: it is
104    // a by-product of the parse, not an independently measured fact.
105    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                // Non-null values that fail the finite-numeric parse are excluded
113                // from the statistics; expose the count so denominators stay
114                // auditable.
115                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        // Count unique non-null values (aligned with whitespace logic)
174        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        // Distinct values are counted with an exact HashSet, so the count is
189        // exact whenever it was computed at all.
190        unique_count_is_approximate: unique_count.map(|_| false),
191        invalid_count,
192        // Classified over `data`, which on this path is the whole column, so the
193        // counts are exact rather than sampled. Not gated by the analysis
194        // selection: which forms a column holds is schema-level evidence like
195        // the inferred type itself, not a statistic derived from a pack.
196        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)); // Only non-null unique values
231    }
232
233    #[test]
234    fn test_analyze_column_whitespace_as_null() {
235        let data = vec![
236            "1".to_string(),
237            "  ".to_string(), // Whitespace-only treated as null
238            "3".to_string(),
239            "\t".to_string(), // Tab treated as null
240        ];
241        let profile = analyze_column("test_col", &data);
242
243        assert_eq!(profile.total_count, 4);
244        assert_eq!(profile.null_count, 2); // Whitespace counted as null
245        assert_eq!(profile.unique_count, Some(2)); // Only "1" and "3"
246        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); // Trimmed values are not null
255        assert!(matches!(profile.data_type, DataType::Integer));
256    }
257
258    #[test]
259    fn type_homogeneity_covers_the_whole_column_on_this_path() {
260        // The in-memory path holds every value, so the counts are exact rather
261        // than sampled: they must add up to the non-null total.
262        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        // Absence has to mean "not classified". A clean numeric column reports
284        // one class holding everything, not a missing field.
285        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        // Which forms a column holds is schema-level evidence like the inferred
297        // type, not a statistic a narrowed pack selection can take away.
298        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()); // Fast mode skips patterns entirely
324        assert_eq!(profile.unique_count, None); // Fast mode skips unique count
325    }
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        // Normal mode detects patterns
337        assert!(profile.patterns.is_some_and(|p| !p.is_empty()));
338        assert_eq!(profile.unique_count, Some(3)); // Normal mode calculates unique count
339    }
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)); // No non-null unique values
359        assert!(matches!(profile.data_type, DataType::String)); // Default for all-null
360    }
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        // Test that unique count excludes whitespace-only values
386        let data = vec![
387            "value1".to_string(),
388            "value2".to_string(),
389            "  ".to_string(),
390            "value1".to_string(), // Duplicate
391            "\t".to_string(),
392        ];
393        let profile = analyze_column("test_col", &data);
394
395        assert_eq!(profile.null_count, 2); // 2 whitespace-only
396        assert_eq!(profile.unique_count, Some(2)); // "value1" and "value2"
397    }
398}