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, 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
13/// Which parts of a column analysis to perform.
14///
15/// The streaming engines express the same choices through
16/// `profile_builder::build_column_profile`; this is the in-memory equivalent for
17/// callers that hold a whole column as `Vec<String>` (the database connectors).
18struct ColumnAnalysis {
19    /// Produce [`ColumnStats::None`] instead of computing statistics.
20    skip_statistics: bool,
21    /// Produce `patterns: None` instead of running detection.
22    skip_patterns: bool,
23    /// Locale used to rank detected patterns.
24    locale: Option<Locale>,
25    /// Leave `unique_count` absent instead of counting distinct values.
26    skip_unique_count: bool,
27}
28
29/// The statistics a column carries: none when it holds no non-null value.
30///
31/// Every statistic is an aggregate over values, and over no values it is
32/// undefined, which the repo reports as absent, not zero. Each type's arm can
33/// still produce one: a text column of nulls only reported lengths of 0, the
34/// same as a column of real zero-length values (#983). Shared by this path and
35/// `profile_builder::build_column_profile`, so every engine draws the line in
36/// one place; a column with no rows is the case where both counts are 0.
37pub 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
45/// Analyze a column with full profiling (includes pattern detection and unique counts)
46pub 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
59/// Analyze a column in fast mode (skips expensive operations)
60pub 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
73/// Analyze a column, honouring the caller's analysis selection.
74///
75/// Metric packs decide whether statistics and pattern detection run, and the
76/// locale ranks whatever patterns are detected — the same contract the file and
77/// streaming paths honour. Distinct-value counting is schema-level information
78/// rather than a pack, so it always runs here; use [`analyze_column_fast`] to
79/// skip it.
80pub 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
97/// Analyze a column with configurable options
98///
99/// # Performance Considerations
100/// - Skipping patterns and unique counts avoids the expensive passes
101/// - Whitespace-only values are treated as null (aligned with inference logic)
102///
103/// # Returns
104/// Complete column profile including type, stats, and optionally patterns
105fn analyze_column_with_options(
106    name: &str,
107    data: &[String],
108    analysis: &ColumnAnalysis,
109) -> ColumnProfile {
110    let total_count = data.len();
111
112    // Aligned with inference.rs: whitespace-only strings are treated as null
113    let null_count = data.iter().filter(|s| is_null_like_token(s.trim())).count();
114
115    // Infer type (uses same whitespace logic internally)
116    let data_type = infer_type(data);
117
118    // Calculate stats. Numeric columns also yield how many values parsed as
119    // finite numbers, so the invalid count comes from the same single pass —
120    // which is why skipping statistics also leaves `invalid_count` absent: it is
121    // a by-product of the parse, not an independently measured fact.
122    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                // Non-null values that fail the finite-numeric parse are excluded
130                // from the statistics; expose the count so denominators stay
131                // auditable.
132                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                // Null-like tokens are counted as nulls above; they are not
182                // text values, so they must not enter the length statistics.
183                // The streaming engines feed a null-excluding accumulator here
184                // and this path has to agree with them (#547).
185                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            // `infer_type` reads text, and text carries no container type.
192            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        // Count unique non-null values (aligned with whitespace logic)
207        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        // Distinct values are counted with an exact HashSet, so the count is
222        // exact whenever it was computed at all.
223        unique_count_is_approximate: unique_count.map(|_| false),
224        unique_count_lower_bound: None,
225        invalid_count,
226        // Classified over `data`, which on this path is the whole column, so the
227        // counts are exact rather than sampled. Not gated by the analysis
228        // selection: which forms a column holds is schema-level evidence like
229        // the inferred type itself, not a statistic derived from a pack.
230        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    /// The database connectors profile through this path. A column holding
242    /// only nulls and null tokens has no values, so no statistics, where it
243    /// used to report text lengths of 0 (#983); one value restores them.
244    #[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)); // Only non-null unique values
318    }
319
320    #[test]
321    fn test_analyze_column_whitespace_as_null() {
322        let data = vec![
323            "1".to_string(),
324            "  ".to_string(), // Whitespace-only treated as null
325            "3".to_string(),
326            "\t".to_string(), // Tab treated as null
327        ];
328        let profile = analyze_column("test_col", &data);
329
330        assert_eq!(profile.total_count, 4);
331        assert_eq!(profile.null_count, 2); // Whitespace counted as null
332        assert_eq!(profile.unique_count, Some(2)); // Only "1" and "3"
333        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); // Trimmed values are not null
342        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        // The in-memory path holds every value, so the counts are exact rather
355        // than sampled: they must add up to the non-null total.
356        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        // Absence has to mean "not classified". A clean numeric column reports
378        // one class holding everything, not a missing field.
379        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        // Which forms a column holds is schema-level evidence like the inferred
391        // type, not a statistic a narrowed pack selection can take away.
392        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()); // Fast mode skips patterns entirely
418        assert_eq!(profile.unique_count, None); // Fast mode skips unique count
419    }
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        // Normal mode detects patterns
431        assert!(profile.patterns.is_some_and(|p| !p.is_empty()));
432        assert_eq!(profile.unique_count, Some(3)); // Normal mode calculates unique count
433    }
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)); // No non-null unique values
453        assert!(matches!(profile.data_type, DataType::String)); // Default for all-null
454    }
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        // Test that unique count excludes whitespace-only values
480        let data = vec![
481            "value1".to_string(),
482            "value2".to_string(),
483            "  ".to_string(),
484            "value1".to_string(), // Duplicate
485            "\t".to_string(),
486        ];
487        let profile = analyze_column("test_col", &data);
488
489        assert_eq!(profile.null_count, 2); // 2 whitespace-only
490        assert_eq!(profile.unique_count, Some(2)); // "value1" and "value2"
491    }
492
493    #[test]
494    fn text_lengths_exclude_null_like_tokens() {
495        // This path feeds the database connectors; the streaming engines use a
496        // null-excluding length accumulator. A token counted as a null must not
497        // also be counted as a four-character string (#547).
498        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}