use regex::Regex;
use std::sync::LazyLock;
use dataprof_core::{LexicalClass, TypeHomogeneity};
use crate::types::DataType;
pub(crate) const DATE_ISO_PATTERN: &str = r"^\d{4}-\d{2}-\d{2}$";
pub(crate) const DATE_SLASH_PATTERN: &str = r"^\d{2}/\d{2}/\d{4}$";
pub(crate) const DATE_DASH_PATTERN: &str = r"^\d{2}-\d{2}-\d{4}$";
pub(crate) const DATE_YEAR_SLASH_PATTERN: &str = r"^\d{4}/\d{2}/\d{2}$";
pub(crate) const DATE_DOT_PATTERN: &str = r"^\d{2}\.\d{2}\.\d{4}$";
pub(crate) const ISO_DATETIME_PATTERN: &str =
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-](?:[01]\d|2[0-3]):[0-5]\d)?$";
static DATE_REGEXES: LazyLock<Vec<Regex>> = LazyLock::new(|| {
vec![
Regex::new(DATE_ISO_PATTERN)
.expect("BUG: Invalid hardcoded regex pattern for ISO 8601 date"),
Regex::new(DATE_SLASH_PATTERN)
.expect("BUG: Invalid hardcoded regex pattern for DD/MM/YYYY date"),
Regex::new(DATE_DASH_PATTERN)
.expect("BUG: Invalid hardcoded regex pattern for DD-MM-YYYY date"),
Regex::new(DATE_YEAR_SLASH_PATTERN)
.expect("BUG: Invalid hardcoded regex pattern for YYYY/MM/DD date"),
Regex::new(DATE_DOT_PATTERN)
.expect("BUG: Invalid hardcoded regex pattern for DD.MM.YYYY date"),
Regex::new(ISO_DATETIME_PATTERN)
.expect("BUG: Invalid hardcoded regex pattern for ISO datetime"),
Regex::new(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$")
.expect("BUG: Invalid hardcoded regex pattern for spaced ISO datetime"),
Regex::new(r"^\d{2}/\d{2}/\d{4} \d{2}:\d{2}:\d{2}$")
.expect("BUG: Invalid hardcoded regex pattern for DD/MM/YYYY datetime"),
]
});
static LOCALE_NUMBER_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(concat!(
r"^[+-]?(?:",
r"[0-9]+,[0-9]+",
r"|[0-9]{1,3}(?:\.[0-9]{3})+(?:,[0-9]+)?",
r"|[0-9]{1,3}(?:,[0-9]{3})+(?:\.[0-9]+)?",
r"|[0-9]{1,2}(?:,[0-9]{2})+,[0-9]{3}(?:\.[0-9]+)?",
r"|[0-9]{1,3}(?:['\u{2019}][0-9]{3})+(?:[.,][0-9]+)?",
r"|[0-9]{1,3}(?:[ \u{A0}\u{202F}][0-9]{3})+[.,][0-9]+",
r")$",
))
.expect("BUG: Invalid hardcoded regex pattern for locale-formatted numbers")
});
pub fn infer_type(data: &[String]) -> DataType {
let non_empty: Vec<&String> = data
.iter()
.filter(|s| !is_null_like_token(s.trim()))
.collect();
if non_empty.is_empty() {
return DataType::String;
}
let mut integer_count = 0;
let mut float_count = 0;
for s in &non_empty {
let trimmed = s.trim();
if is_integer_token(trimmed) {
integer_count += 1;
float_count += 1; } else if trimmed.parse::<f64>().is_ok() {
float_count += 1;
}
}
let digit_codes = holds_digit_codes(non_empty.iter().map(|s| s.trim()));
if !digit_codes && integer_count == non_empty.len() {
return DataType::Integer;
}
if !digit_codes && float_count as f64 / non_empty.len() as f64 > 0.8 {
return DataType::Float;
}
let bool_count = non_empty
.iter()
.filter(|s| parse_strict_boolean_token(s.trim()).is_some())
.count();
if bool_count as f64 / non_empty.len() as f64 >= 0.9 {
return DataType::Boolean;
}
let date_matches = non_empty
.iter()
.filter(|s| is_inferred_date_token(s.trim()))
.count();
if date_matches as f64 / non_empty.len() as f64 > 0.7 {
return DataType::Date;
}
DataType::String
}
pub fn is_inferred_date_token(value: &str) -> bool {
DATE_REGEXES.iter().any(|regex| regex.is_match(value))
}
pub(crate) fn is_date_token(value: &str) -> bool {
is_inferred_date_token(value) || crate::analysis::metrics::utils::is_valid_date_format(value)
}
pub fn holds_digit_codes<'a>(values: impl IntoIterator<Item = &'a str>) -> bool {
let mut numeric = 0usize;
let mut compact_dates = 0usize;
for value in values {
if is_zero_padded_digits(value) {
return true;
}
if is_integer_token(value) || value.parse::<f64>().is_ok() {
numeric += 1;
if is_compact_date_token(value) {
compact_dates += 1;
}
}
}
numeric > 0 && compact_dates == numeric
}
fn is_zero_padded_digits(value: &str) -> bool {
value.len() > 1 && value.starts_with('0') && value.bytes().all(|b| b.is_ascii_digit())
}
fn is_compact_date_token(value: &str) -> bool {
let digits = value.as_bytes();
if digits.len() != 8 || !digits.iter().all(u8::is_ascii_digit) {
return false;
}
let number = |range: std::ops::Range<usize>| {
digits[range]
.iter()
.fold(0u32, |n, digit| n * 10 + u32::from(digit - b'0'))
};
let year = number(0..4);
(1800..=2199).contains(&year)
&& chrono::NaiveDate::from_ymd_opt(year as i32, number(4..6), number(6..8)).is_some()
}
pub fn is_integer_token(value: &str) -> bool {
value.parse::<i64>().is_ok() || value.parse::<u64>().is_ok()
}
pub use dataprof_core::is_null_like_token;
pub fn parse_strict_boolean_token(value: &str) -> Option<bool> {
let trimmed = value.trim();
if trimmed.eq_ignore_ascii_case("true") {
Some(true)
} else if trimmed.eq_ignore_ascii_case("false") {
Some(false)
} else {
None
}
}
pub fn lexical_class(value: &str) -> LexicalClass {
if is_integer_token(value) || value.parse::<f64>().is_ok() {
LexicalClass::Numeric
} else if is_date_token(value) {
LexicalClass::Date
} else if parse_strict_boolean_token(value).is_some() {
LexicalClass::Boolean
} else {
LexicalClass::Text
}
}
pub fn classify_lexical_forms<S: AsRef<str>>(values: &[S]) -> TypeHomogeneity {
let mut homogeneity = TypeHomogeneity::default();
for value in values {
let trimmed = value.as_ref().trim();
if !is_null_like_token(trimmed) {
homogeneity.record(lexical_class(trimmed));
}
}
homogeneity
}
pub fn is_locale_number_token(value: &str) -> bool {
LOCALE_NUMBER_REGEX.is_match(value) && lexical_class(value) == LexicalClass::Text
}
pub fn count_locale_numbers<S: AsRef<str>>(values: &[S]) -> usize {
values
.iter()
.map(|value| value.as_ref().trim())
.filter(|trimmed| !is_null_like_token(trimmed) && is_locale_number_token(trimmed))
.count()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
#[test]
fn each_value_lands_in_exactly_one_class() {
for (value, expected) in [
("42", LexicalClass::Numeric),
("-1.5", LexicalClass::Numeric),
(u64::MAX.to_string().as_str(), LexicalClass::Numeric),
("2024-01-15", LexicalClass::Date),
("2024-01-15T10:30:00", LexicalClass::Date),
("15.01.2024", LexicalClass::Date),
("true", LexicalClass::Boolean),
("FALSE", LexicalClass::Boolean),
("junk0", LexicalClass::Text),
("N/A", LexicalClass::Text),
] {
assert_eq!(lexical_class(value), expected, "{value}");
}
}
#[test]
fn digit_codes_are_text_and_quantities_stay_numeric() {
let strings = |values: &[&str]| values.iter().map(|v| v.to_string()).collect::<Vec<_>>();
for codes in [
&["00123", "00456", "01234", "09999"][..],
&["28013", "08001", "41001"],
&["20240115", "20240216", "19991231"],
&["0612345678", "3471234567"],
&["007", "12", "N/A"],
&["20240115", "", "null"],
] {
assert_eq!(infer_type(&strings(codes)), DataType::String, "{codes:?}");
}
for (quantities, expected) in [
(&["0", "1", "10", "250"][..], DataType::Integer),
(&["12345678", "20240115"], DataType::Integer),
(&["20241315", "20240230"], DataType::Integer),
(&["17991231", "22000101"], DataType::Integer),
(&["0.5", "0.25", "12"], DataType::Float),
(&["-01", "-02", "3"], DataType::Integer),
] {
assert_eq!(infer_type(&strings(quantities)), expected, "{quantities:?}");
}
}
#[test]
fn classification_counts_only_non_null_values() {
let values = ["1", "2", "", "null", "NaN", "junk", "2024-01-15"].map(String::from);
let counts = classify_lexical_forms(&values);
assert_eq!(counts.numeric, 2);
assert_eq!(counts.date, 1);
assert_eq!(counts.text, 1);
assert_eq!(counts.classified_count(), 4);
}
#[test]
fn classification_matches_the_consistency_score_it_shares_a_classifier_with() {
let values: Vec<String> = (0..800)
.map(|i| (1000 + i).to_string())
.chain((0..200).map(|i| format!("junk{i}")))
.collect();
let counts = classify_lexical_forms(&values);
assert_eq!(counts.dominant(), Some((LexicalClass::Numeric, 800)));
assert_eq!(counts.dominant_share(), Some(0.8));
}
#[test]
fn a_column_of_only_nulls_is_classified_and_holds_nothing() {
let values = ["", " ", "NULL"].map(String::from);
assert_eq!(classify_lexical_forms(&values), TypeHomogeneity::default());
}
#[test]
fn locale_formatted_numbers_are_recognized_and_nothing_else_is() {
for value in [
"10,50",
"-0,5",
"+3,14",
"1,234",
"1.234,56",
"1.234.567",
"12.345.678,9",
"1,234.56",
"1,234,567",
"12,34,567",
"1,00,000.50",
"1'234.56",
"1\u{2019}234,5",
"1 234,56",
"1\u{A0}234,56",
"1\u{202F}234.5",
] {
assert!(is_locale_number_token(value), "{value:?} not recognized");
assert_eq!(lexical_class(value), LexicalClass::Text, "{value:?}");
}
for value in [
"1.234",
"10.50",
"1234",
"-7",
"1e3",
"15.01.2024",
"2024-01-15",
"1.2.3",
"192.168.1.1",
"333 123 456",
"1 234",
"1,2,3",
"1,23,4",
"123,45,678",
"1.23,4",
",5",
"5,",
"1,,5",
"\u{0661},\u{0665}",
"10,50 EUR",
"\u{20AC} 10,50",
"10,5%",
"junk",
] {
assert!(!is_locale_number_token(value), "{value:?} recognized");
}
}
#[test]
fn locale_numbers_are_counted_among_the_text_values_only() {
let values = [
"1.234,56",
" 10,50 ",
"",
"null",
"1.234",
"12",
"junk",
"15.01.2024",
]
.map(String::from);
assert_eq!(count_locale_numbers(&values), 2);
assert_eq!(classify_lexical_forms(&values).text, 3);
}
#[test]
fn test_infer_integer() {
let data = vec!["1".to_string(), "2".to_string(), "3".to_string()];
assert!(matches!(infer_type(&data), DataType::Integer));
}
#[test]
fn test_infer_float() {
let data = vec!["1.5".to_string(), "2.3".to_string(), "3.7".to_string()];
assert!(matches!(infer_type(&data), DataType::Float));
}
#[test]
fn test_infer_mixed_numeric_as_float() {
let data = vec!["1".to_string(), "2.5".to_string(), "3".to_string()];
assert!(matches!(infer_type(&data), DataType::Float));
}
#[test]
fn test_infer_unsigned_integer_beyond_i64() {
let data = vec![
u64::MAX.to_string(),
(u64::MAX - 1).to_string(),
(i64::MAX as u64 + 1).to_string(),
];
assert!(matches!(infer_type(&data), DataType::Integer));
}
#[test]
fn test_infer_non_finite_numeric_tokens_as_float() {
let data = vec![
"1.0".to_string(),
"Infinity".to_string(),
"-inf".to_string(),
"2.0".to_string(),
];
assert!(matches!(infer_type(&data), DataType::Float));
}
#[test]
fn test_infer_date_iso() {
let data = vec![
"2023-01-15".to_string(),
"2023-02-20".to_string(),
"2023-03-25".to_string(),
];
assert!(matches!(infer_type(&data), DataType::Date));
}
#[test]
fn test_infer_date_european_slash() {
let data = vec![
"15/01/2023".to_string(),
"20/02/2023".to_string(),
"25/03/2023".to_string(),
];
assert!(matches!(infer_type(&data), DataType::Date));
}
#[test]
fn test_infer_date_european_dash() {
let data = vec![
"15-01-2023".to_string(),
"20-02-2023".to_string(),
"25-03-2023".to_string(),
];
assert!(matches!(infer_type(&data), DataType::Date));
}
#[test]
fn test_infer_date_european_dot() {
let data = vec![
"15.01.2023".to_string(),
"20.02.2023".to_string(),
"25.03.2023".to_string(),
];
assert!(matches!(infer_type(&data), DataType::Date));
}
#[test]
fn test_infer_date_threshold() {
let data = vec![
"2023-01-15".to_string(),
"2023-02-20".to_string(),
"2023-03-25".to_string(),
"2023-04-30".to_string(),
"2023-05-15".to_string(),
"not a date".to_string(),
"also not".to_string(),
];
assert!(matches!(infer_type(&data), DataType::Date));
}
#[test]
fn test_infer_mixed_date_formats() {
let data = vec![
"2024-01-15".to_string(),
"15/01/2024".to_string(),
"2024-01-16".to_string(),
"16-01-2024".to_string(),
"2024-01-17".to_string(),
"2024-01-18".to_string(),
"2024/01/19".to_string(),
"19/01/2024".to_string(),
"".to_string(),
"2024-01-20".to_string(),
];
assert!(matches!(infer_type(&data), DataType::Date));
}
#[test]
fn test_infer_iso_datetime() {
let data = vec![
"2024-01-15T10:00:00".to_string(),
"2024-01-15T10:15:00".to_string(),
"2024-01-15T10:30:00".to_string(),
];
assert!(matches!(infer_type(&data), DataType::Date));
}
#[test]
fn test_infer_string() {
let data = vec!["hello".to_string(), "world".to_string()];
assert!(matches!(infer_type(&data), DataType::String));
}
#[test]
fn test_infer_empty_data() {
let data: Vec<String> = vec![];
assert!(matches!(infer_type(&data), DataType::String));
}
#[test]
fn test_infer_all_empty_strings() {
let data = vec!["".to_string(), "".to_string(), "".to_string()];
assert!(matches!(infer_type(&data), DataType::String));
}
#[test]
fn test_infer_whitespace_handling() {
let data = vec![" 1 ".to_string(), " 2".to_string(), "3 ".to_string()];
assert!(matches!(infer_type(&data), DataType::Integer));
}
#[test]
fn test_infer_whitespace_only_strings() {
let data = vec![" ".to_string(), "\t".to_string(), " \n ".to_string()];
assert!(matches!(infer_type(&data), DataType::String));
}
#[test]
fn test_infer_dates_with_whitespace() {
let data = vec![
" 2023-01-15 ".to_string(),
" 2023-02-20".to_string(),
"2023-03-25 ".to_string(),
];
assert!(matches!(infer_type(&data), DataType::Date));
}
#[test]
fn test_infer_floats_with_whitespace() {
let data = vec![
" 1.5 ".to_string(),
" 2.3".to_string(),
"3.7 ".to_string(),
];
assert!(matches!(infer_type(&data), DataType::Float));
}
#[test]
fn test_infer_mixed_non_numeric() {
let data = vec![
"hello".to_string(),
"123abc".to_string(),
"2023".to_string(), ];
assert!(matches!(infer_type(&data), DataType::String));
}
#[test]
fn test_infer_boolean_lowercase() {
let data = vec![
"true".to_string(),
"false".to_string(),
"true".to_string(),
"false".to_string(),
];
assert!(matches!(infer_type(&data), DataType::Boolean));
}
#[test]
fn test_infer_boolean_titlecase() {
let data = vec!["True".to_string(), "False".to_string(), "True".to_string()];
assert!(matches!(infer_type(&data), DataType::Boolean));
}
#[test]
fn test_infer_boolean_uppercase() {
let data = vec!["TRUE".to_string(), "FALSE".to_string(), "TRUE".to_string()];
assert!(matches!(infer_type(&data), DataType::Boolean));
}
#[test]
fn test_infer_boolean_yes_no() {
let data = vec!["yes".to_string(), "no".to_string(), "yes".to_string()];
assert!(matches!(infer_type(&data), DataType::String));
}
#[test]
fn test_infer_boolean_mixed_case() {
let data = vec![
"True".to_string(),
"false".to_string(),
"TRUE".to_string(),
"False".to_string(),
];
assert!(matches!(infer_type(&data), DataType::Boolean));
}
#[test]
fn test_infer_boolean_with_whitespace() {
let data = vec![
" true ".to_string(),
" false".to_string(),
"true ".to_string(),
];
assert!(matches!(infer_type(&data), DataType::Boolean));
}
#[test]
fn test_infer_boolean_threshold() {
let data = vec![
"true".to_string(),
"false".to_string(),
"true".to_string(),
"false".to_string(),
"true".to_string(),
"false".to_string(),
"true".to_string(),
"false".to_string(),
"true".to_string(),
"maybe".to_string(),
];
assert!(matches!(infer_type(&data), DataType::Boolean));
}
#[test]
fn test_infer_not_boolean_below_threshold() {
let data = vec![
"true".to_string(),
"false".to_string(),
"hello".to_string(),
"world".to_string(),
];
assert!(matches!(infer_type(&data), DataType::String));
}
#[test]
fn test_infer_pure_01_stays_integer() {
let data = vec![
"0".to_string(),
"1".to_string(),
"0".to_string(),
"1".to_string(),
];
assert!(matches!(infer_type(&data), DataType::Integer));
}
#[test]
fn test_infer_boolean_with_null_like_tokens() {
let data = vec![
"true".to_string(),
"FALSE".to_string(),
"null".to_string(),
"NULL".to_string(),
"nan".to_string(),
"NaN".to_string(),
"".to_string(),
];
assert!(matches!(infer_type(&data), DataType::Boolean));
}
#[test]
fn test_date_regex_patterns_are_valid() {
assert_eq!(DATE_REGEXES.len(), 8);
}
#[test]
fn test_is_inferred_date_token() {
assert!(is_inferred_date_token("2024-01-15"));
assert!(is_inferred_date_token("15/01/2024"));
assert!(is_inferred_date_token("15-01-2024"));
assert!(is_inferred_date_token("2024/01/15"));
assert!(is_inferred_date_token("15.01.2024"));
assert!(is_inferred_date_token("2024-01-15T10:30:00"));
assert!(!is_inferred_date_token("not a date"));
assert!(!is_inferred_date_token("12345"));
assert!(!is_inferred_date_token("user@example.com"));
}
#[test]
fn inferred_date_token_accepts_offsets_and_rejects_what_chrono_cannot_parse() {
for value in [
"2024-01-15T10:30:00Z",
"2024-01-15T10:30:00+02:00",
"2024-01-15T10:30:00.500-05:00",
"2024-01-15 10:30:00",
"15/01/2024 10:30:00",
"2024-01-15",
"15.01.2024",
] {
assert!(
is_inferred_date_token(value),
"{value:?} should be an inferred date"
);
}
for value in [
"2024-01-15T10:",
"2024-01-15T10:30",
"2024-01-15T10:30:00 (UTC)",
"2024-01-15T10:30:00+0200",
"2024-01-15T10:30:00+24:00",
"2024-01-15T10:30:00+99:99",
"2024-01-15T10:30:00-00:60",
"not a date",
] {
assert!(
!is_inferred_date_token(value),
"{value:?} should not be an inferred date"
);
}
}
const DATE_FORM_EXAMPLES: [(&str, &str); 11] = [
(r"^\d{4}-\d{2}-\d{2}$", "2024-01-15"),
(r"^\d{2}/\d{2}/\d{4}$", "15/01/2024"),
(r"^\d{2}-\d{2}-\d{4}$", "15-01-2024"),
(r"^\d{4}/\d{2}/\d{2}$", "2024/01/15"),
(r"^\d{2}\.\d{2}\.\d{4}$", "15.01.2024"),
(
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-](?:[01]\d|2[0-3]):[0-5]\d)?$",
"2024-01-15T10:30:00",
),
(
r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$",
"2024-01-15 10:30:00",
),
(
r"^\d{2}/\d{2}/\d{4} \d{2}:\d{2}:\d{2}$",
"15/01/2024 10:30:00",
),
(r"^\d{1,2}/\d{1,2}/\d{4}$", "1/2/2024"),
(r"^\d{4}-\d{1,2}-\d{1,2}$", "2024-1-5"),
(r"^\d{1,2}-\d{1,2}-\d{4}$", "1-2-2024"),
];
#[test]
fn is_date_token_accepts_every_form_either_regex_set_recognizes() {
let validation = &crate::analysis::metrics::utils::DATE_VALIDATION_REGEXES;
let recognized: BTreeSet<&str> = DATE_REGEXES
.iter()
.chain(validation.iter())
.map(|regex| regex.as_str())
.collect();
let covered: BTreeSet<&str> = DATE_FORM_EXAMPLES
.iter()
.map(|(pattern, _)| *pattern)
.collect();
assert_eq!(
recognized, covered,
"the recognized date patterns and the examples below have diverged"
);
for (pattern, example) in DATE_FORM_EXAMPLES {
let regex = DATE_REGEXES
.iter()
.chain(validation.iter())
.find(|regex| regex.as_str() == pattern)
.expect("checked by the set equality above");
assert!(
regex.is_match(example),
"{example:?} is not an example of {pattern}"
);
assert!(
is_date_token(example),
"{example:?} is a recognized date form but is_date_token rejects it"
);
}
}
#[test]
fn a_value_that_is_not_a_date_is_not_a_date_token() {
for value in [
"not-a-date",
"2024",
"15/01",
"2024-13-45x",
"",
"junk1",
"10:30:00",
] {
assert!(!is_date_token(value), "{value:?} was accepted as a date");
}
}
}