use dataprof_core::AnalysisOptions;
use crate::types::{ColumnProfile, ColumnStats, DataType, Locale};
use crate::analysis::inference::{
classify_lexical_forms, infer_type, is_null_like_token, parse_strict_boolean_token,
};
use crate::analysis::patterns::detect_patterns;
use crate::stats::numeric::compute_numeric_stats_with_parsed_count;
use crate::stats::{calculate_datetime_stats, calculate_text_stats};
struct ColumnAnalysis {
skip_statistics: bool,
skip_patterns: bool,
locale: Option<Locale>,
skip_unique_count: bool,
}
pub fn analyze_column(name: &str, data: &[String]) -> ColumnProfile {
analyze_column_with_options(
name,
data,
&ColumnAnalysis {
skip_statistics: false,
skip_patterns: false,
locale: None,
skip_unique_count: false,
},
)
}
pub fn analyze_column_fast(name: &str, data: &[String]) -> ColumnProfile {
analyze_column_with_options(
name,
data,
&ColumnAnalysis {
skip_statistics: false,
skip_patterns: true,
locale: None,
skip_unique_count: true,
},
)
}
pub fn analyze_column_with_analysis_options(
name: &str,
data: &[String],
options: &AnalysisOptions,
) -> ColumnProfile {
analyze_column_with_options(
name,
data,
&ColumnAnalysis {
skip_statistics: !options.include_statistics(),
skip_patterns: !options.include_patterns(),
locale: options.locale(),
skip_unique_count: false,
},
)
}
fn analyze_column_with_options(
name: &str,
data: &[String],
analysis: &ColumnAnalysis,
) -> ColumnProfile {
let total_count = data.len();
let null_count = data.iter().filter(|s| is_null_like_token(s.trim())).count();
let data_type = infer_type(data);
let mut invalid_count = None;
let stats = if analysis.skip_statistics {
ColumnStats::None
} else {
match data_type {
DataType::Integer | DataType::Float => {
let (numeric, parsed) = compute_numeric_stats_with_parsed_count(data);
invalid_count = Some(
total_count
.saturating_sub(null_count)
.saturating_sub(parsed),
);
ColumnStats::Numeric(numeric)
}
DataType::Date => {
let parsed = data
.iter()
.filter(|value| {
super::metrics::value_matches_hint(
value,
dataprof_core::SemanticHintKind::Temporal,
)
})
.count();
invalid_count = Some(
total_count
.saturating_sub(null_count)
.saturating_sub(parsed),
);
calculate_datetime_stats(data)
}
DataType::Boolean => {
let tc = data
.iter()
.filter(|v| parse_strict_boolean_token(v.trim()) == Some(true))
.count();
let fc = data
.iter()
.filter(|v| parse_strict_boolean_token(v.trim()) == Some(false))
.count();
let total = tc + fc;
let true_ratio = if total > 0 {
tc as f64 / total as f64
} else {
0.0
};
ColumnStats::Boolean(crate::types::BooleanStats {
true_count: tc,
false_count: fc,
true_ratio,
})
}
DataType::String | DataType::Identifier => calculate_text_stats(data),
}
};
let patterns = if analysis.skip_patterns {
None
} else {
Some(detect_patterns(data, analysis.locale))
};
let unique_count = if analysis.skip_unique_count {
None
} else {
Some(
data.iter()
.filter(|s| !is_null_like_token(s.trim()))
.collect::<std::collections::HashSet<_>>()
.len(),
)
};
ColumnProfile {
name: name.to_string(),
data_type,
null_count,
total_count,
unique_count,
unique_count_is_approximate: unique_count.map(|_| false),
invalid_count,
type_homogeneity: Some(classify_lexical_forms(data)),
stats,
patterns,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_analyze_column_basic() {
let data = vec!["1".to_string(), "2".to_string(), "3".to_string()];
let profile = analyze_column("test_col", &data);
assert_eq!(profile.name, "test_col");
assert!(matches!(profile.data_type, DataType::Integer));
assert_eq!(profile.total_count, 3);
assert_eq!(profile.null_count, 0);
assert_eq!(profile.unique_count, Some(3));
}
#[test]
fn test_analyze_column_with_nulls() {
let data = vec![
"1".to_string(),
"".to_string(),
"3".to_string(),
"".to_string(),
];
let profile = analyze_column("test_col", &data);
assert_eq!(profile.total_count, 4);
assert_eq!(profile.null_count, 2);
assert_eq!(profile.unique_count, Some(2)); }
#[test]
fn test_analyze_column_whitespace_as_null() {
let data = vec![
"1".to_string(),
" ".to_string(), "3".to_string(),
"\t".to_string(), ];
let profile = analyze_column("test_col", &data);
assert_eq!(profile.total_count, 4);
assert_eq!(profile.null_count, 2); assert_eq!(profile.unique_count, Some(2)); assert!(matches!(profile.data_type, DataType::Integer));
}
#[test]
fn test_analyze_column_with_whitespace_values() {
let data = vec![" 1 ".to_string(), " 2".to_string(), "3 ".to_string()];
let profile = analyze_column("test_col", &data);
assert_eq!(profile.null_count, 0); assert!(matches!(profile.data_type, DataType::Integer));
}
#[test]
fn type_homogeneity_covers_the_whole_column_on_this_path() {
let data: Vec<String> = (0..80)
.map(|i| i.to_string())
.chain((0..20).map(|i| format!("junk{i}")))
.chain(std::iter::once(String::new()))
.collect();
let profile = analyze_column("v", &data);
let counts = profile
.type_homogeneity
.expect("classification runs on every column");
assert_eq!(counts.numeric, 80);
assert_eq!(counts.text, 20);
assert_eq!(
counts.classified_count(),
profile.total_count - profile.null_count
);
}
#[test]
fn type_homogeneity_is_recorded_even_when_the_column_is_ordinary() {
let data = ["1", "2", "3"].map(String::from).to_vec();
let counts = analyze_column("v", &data)
.type_homogeneity
.expect("present");
assert_eq!(counts.dominant_share(), Some(1.0));
}
#[test]
fn a_narrowed_metric_selection_still_classifies_the_column() {
let data = ["1", "2", "junk"].map(String::from).to_vec();
let options = AnalysisOptions::default().with_metric_packs(Some(vec![]));
assert!(!options.include_statistics());
let profile = analyze_column_with_analysis_options("v", &data, &options);
assert!(matches!(profile.stats, ColumnStats::None));
assert_eq!(
profile
.type_homogeneity
.expect("present")
.classified_count(),
3
);
}
#[test]
fn test_analyze_column_fast_mode() {
let data = vec![
"user@example.com".to_string(),
"admin@test.org".to_string(),
"contact@company.com".to_string(),
];
let profile = analyze_column_fast("test_col", &data);
assert!(profile.patterns.is_none()); assert_eq!(profile.unique_count, None); }
#[test]
fn test_analyze_column_normal_mode() {
let data = vec![
"user@example.com".to_string(),
"admin@test.org".to_string(),
"contact@company.com".to_string(),
];
let profile = analyze_column("test_col", &data);
assert!(profile.patterns.is_some_and(|p| !p.is_empty()));
assert_eq!(profile.unique_count, Some(3)); }
#[test]
fn test_analyze_column_empty_data() {
let data: Vec<String> = vec![];
let profile = analyze_column("test_col", &data);
assert_eq!(profile.total_count, 0);
assert_eq!(profile.null_count, 0);
assert_eq!(profile.unique_count, Some(0));
}
#[test]
fn test_analyze_column_all_null() {
let data = vec!["".to_string(), " ".to_string(), "\t".to_string()];
let profile = analyze_column("test_col", &data);
assert_eq!(profile.total_count, 3);
assert_eq!(profile.null_count, 3);
assert_eq!(profile.unique_count, Some(0)); assert!(matches!(profile.data_type, DataType::String)); }
#[test]
fn test_analyze_column_float_detection() {
let data = vec!["1.5".to_string(), "2.3".to_string(), "3.7".to_string()];
let profile = analyze_column("test_col", &data);
assert!(matches!(profile.data_type, DataType::Float));
assert_eq!(profile.null_count, 0);
}
#[test]
fn test_analyze_column_date_detection() {
let data = vec![
"2023-01-15".to_string(),
"2023-02-20".to_string(),
"2023-03-25".to_string(),
];
let profile = analyze_column("test_col", &data);
assert!(matches!(profile.data_type, DataType::Date));
}
#[test]
fn test_analyze_column_unique_count_consistency() {
let data = vec![
"value1".to_string(),
"value2".to_string(),
" ".to_string(),
"value1".to_string(), "\t".to_string(),
];
let profile = analyze_column("test_col", &data);
assert_eq!(profile.null_count, 2); assert_eq!(profile.unique_count, Some(2)); }
}