#![cfg(feature = "parquet")]
use std::io::Write;
use std::sync::Arc;
use arrow::array::{
ArrayRef, DictionaryArray, Float64Array, Int64Array, StringArray, Time32MillisecondArray,
};
use arrow::datatypes::{Field, Int8Type, Schema};
use arrow::record_batch::RecordBatch;
use dataprof::{DataType, Profiler, analyze_structure, infer_schema};
use parquet::arrow::ArrowWriter;
use tempfile::NamedTempFile;
const ROWS: usize = 50;
const COLUMNS: [&str; 5] = ["when", "ident", "numish", "boolish", "real_int"];
fn when(row: usize) -> String {
format!("2026-01-{:02}T03:04:05Z", row % 28 + 1)
}
fn ident(row: usize) -> String {
format!("ORD-{row}")
}
fn numish(row: usize) -> String {
(row * 3).to_string()
}
fn boolish(row: usize) -> String {
if row.is_multiple_of(2) {
"true"
} else {
"false"
}
.to_string()
}
fn real_int(row: usize) -> i64 {
row as i64
}
fn csv_fixture() -> NamedTempFile {
let mut file = NamedTempFile::with_suffix(".csv").unwrap();
writeln!(file, "{}", COLUMNS.join(",")).unwrap();
for row in 0..ROWS {
writeln!(
file,
"{},{},{},{},{}",
when(row),
ident(row),
numish(row),
boolish(row),
real_int(row)
)
.unwrap();
}
file.flush().unwrap();
file
}
fn parquet_fixture() -> NamedTempFile {
let text = |value: fn(usize) -> String| -> ArrayRef {
Arc::new(StringArray::from(
(0..ROWS).map(value).collect::<Vec<String>>(),
))
};
write_parquet(vec![
("when", text(when)),
("ident", text(ident)),
("numish", text(numish)),
("boolish", text(boolish)),
(
"real_int",
Arc::new(Int64Array::from(
(0..ROWS).map(real_int).collect::<Vec<i64>>(),
)),
),
])
}
fn dictionary_parquet_fixture() -> NamedTempFile {
let text = |value: fn(usize) -> String| -> ArrayRef {
let values: DictionaryArray<Int8Type> = (0..ROWS)
.map(value)
.collect::<Vec<String>>()
.iter()
.map(|value| Some(value.as_str()))
.collect();
Arc::new(values)
};
write_parquet(vec![
("when", text(when)),
("ident", text(ident)),
("numish", text(numish)),
("boolish", text(boolish)),
(
"real_int",
Arc::new(Int64Array::from(
(0..ROWS).map(real_int).collect::<Vec<i64>>(),
)),
),
])
}
fn write_parquet(columns: Vec<(&str, ArrayRef)>) -> NamedTempFile {
let schema = Arc::new(Schema::new(
columns
.iter()
.map(|(name, array)| Field::new(*name, array.data_type().clone(), true))
.collect::<Vec<Field>>(),
));
let batch = RecordBatch::try_new(
schema.clone(),
columns.into_iter().map(|(_, array)| array).collect(),
)
.unwrap();
let file = NamedTempFile::with_suffix(".parquet").unwrap();
let mut writer = ArrowWriter::try_new(file.reopen().unwrap(), schema, None).unwrap();
writer.write(&batch).unwrap();
writer.close().unwrap();
file
}
fn schema_types(file: &NamedTempFile) -> Vec<(String, DataType)> {
infer_schema(file.path())
.unwrap()
.columns
.into_iter()
.map(|column| (column.name, column.data_type))
.collect()
}
fn profile_types(file: &NamedTempFile) -> Vec<(String, DataType)> {
Profiler::new()
.analyze_file(file.path())
.unwrap()
.column_profiles
.into_iter()
.map(|profile| (profile.name, profile.data_type))
.collect()
}
fn structure_types(file: &NamedTempFile) -> Vec<(String, DataType)> {
analyze_structure(file.path(), None)
.unwrap()
.columns
.into_iter()
.map(|column| (column.name, column.data_type))
.collect()
}
fn expected() -> Vec<(String, DataType)> {
vec![
("when".to_string(), DataType::Date),
("ident".to_string(), DataType::String),
("numish".to_string(), DataType::Integer),
("boolish".to_string(), DataType::Boolean),
("real_int".to_string(), DataType::Integer),
]
}
#[test]
fn infer_schema_agrees_with_profile_across_csv_and_parquet() {
let csv = csv_fixture();
let parquet = parquet_fixture();
for (label, file) in [("csv", &csv), ("parquet", &parquet)] {
assert_eq!(schema_types(file), expected(), "infer_schema on {label}");
assert_eq!(profile_types(file), expected(), "profile on {label}");
assert_eq!(
structure_types(file),
expected(),
"analyze_structure on {label}"
);
}
}
#[test]
fn infer_schema_agrees_with_profile_on_a_time_column() {
let file = write_parquet(vec![(
"clock",
Arc::new(Time32MillisecondArray::from(
(0..ROWS)
.map(|row| (row * 1_000) as i32)
.collect::<Vec<i32>>(),
)) as ArrayRef,
)]);
let expected = vec![("clock".to_string(), DataType::String)];
assert_eq!(profile_types(&file), expected);
assert_eq!(schema_types(&file), expected);
assert_eq!(structure_types(&file), expected);
assert_eq!(infer_schema(file.path()).unwrap().rows_sampled, 0);
}
#[test]
fn structure_counters_match_a_full_profile() {
let ints: ArrayRef = Arc::new(Int64Array::from(vec![Some(1), None, Some(3), Some(4)]));
let text: ArrayRef = Arc::new(StringArray::from(vec![
Some("2026-01-02"),
Some("2026-01-03"),
None,
None,
]));
let file = write_parquet(vec![("id", ints), ("when", text)]);
let report = analyze_structure(file.path(), None).unwrap();
let profile = Profiler::new().analyze_file(file.path()).unwrap();
for column in &report.columns {
let profiled = profile
.column_profiles
.iter()
.find(|candidate| candidate.name == column.name)
.expect("every structural column is profiled");
assert_eq!(
(column.total_count, column.null_count),
(Some(profiled.total_count), Some(profiled.null_count)),
"{} disagrees with its profile",
column.name
);
}
assert_eq!(report.columns[0].null_count, Some(1));
assert_eq!(report.columns[1].null_count, Some(2));
}
#[test]
fn float_null_counts_include_nan_like_the_profiler() {
let floats: ArrayRef = Arc::new(Float64Array::from(vec![
Some(1.0),
Some(f64::NAN),
None,
Some(4.0),
]));
let file = write_parquet(vec![("v", floats)]);
let report = analyze_structure(file.path(), None).unwrap();
let profile = Profiler::new().analyze_file(file.path()).unwrap();
assert_eq!(report.columns[0].null_count, Some(2), "NaN counts as null");
assert_eq!(
report.columns[0].null_count,
Some(profile.column_profiles[0].null_count)
);
}
#[test]
fn a_count_that_cannot_cover_the_file_is_absent() {
let rows = 40;
let floats: ArrayRef = Arc::new(Float64Array::from(
(0..rows)
.map(|row| if row % 4 == 0 { None } else { Some(row as f64) })
.collect::<Vec<Option<f64>>>(),
));
let ints: ArrayRef = Arc::new(Int64Array::from(
(0..rows).map(|row| row as i64).collect::<Vec<i64>>(),
));
let file = write_parquet(vec![("v", floats), ("id", ints)]);
let capped = analyze_structure(file.path(), Some(10)).unwrap();
let float_column = &capped.columns[0];
let int_column = &capped.columns[1];
assert_eq!(
float_column.null_count, None,
"a prefix count is not a count"
);
assert_eq!(float_column.null_ratio, None);
assert_eq!(int_column.null_count, Some(0));
assert_eq!(float_column.total_count, Some(rows));
assert_eq!(int_column.total_count, Some(rows));
let whole = analyze_structure(file.path(), Some(rows)).unwrap();
let profile = Profiler::new().analyze_file(file.path()).unwrap();
assert_eq!(whole.columns[0].null_count, Some(10));
assert_eq!(
whole.columns[0].null_count,
Some(profile.column_profiles[0].null_count)
);
}
#[test]
fn duplicate_column_names_are_refused_like_every_other_surface() {
let ints: ArrayRef = Arc::new(Int64Array::from(vec![1_i64, 2, 3]));
let dates: ArrayRef = Arc::new(StringArray::from(vec![
"2026-01-02".to_string(),
"2026-01-03".to_string(),
"2026-01-04".to_string(),
]));
let file = write_parquet(vec![("x", ints), ("x", dates)]);
let error = infer_schema(file.path()).expect_err("duplicate names must be refused");
assert_eq!(error.category(), "duplicate_column_name");
assert!(
error.to_string().contains("'x'"),
"the message must name the duplicate: {error}"
);
assert_eq!(
analyze_structure(file.path(), None)
.expect_err("duplicate names must be refused")
.category(),
"duplicate_column_name"
);
assert!(Profiler::new().analyze_file(file.path()).is_err());
}
#[test]
fn provenance_follows_the_schema_decision_not_the_read() {
let parquet = parquet_fixture();
let capped = analyze_structure(parquet.path(), Some(0)).unwrap();
assert_eq!(capped.rows_sampled, 0);
assert!(capped.truncated);
let provenance: Vec<(String, String)> = capped
.columns
.into_iter()
.map(|column| (column.name, column.provenance))
.collect();
assert_eq!(
provenance,
vec![
("when".to_string(), "sample".to_string()),
("ident".to_string(), "sample".to_string()),
("numish".to_string(), "sample".to_string()),
("boolish".to_string(), "sample".to_string()),
("real_int".to_string(), "metadata".to_string()),
]
);
let empty = write_parquet(vec![
(
"id",
Arc::new(Int64Array::from(Vec::<i64>::new())) as ArrayRef,
),
(
"label",
Arc::new(StringArray::from(Vec::<String>::new())) as ArrayRef,
),
]);
let report = analyze_structure(empty.path(), None).unwrap();
assert_eq!(
report
.columns
.into_iter()
.map(|column| (column.name, column.provenance))
.collect::<Vec<_>>(),
vec![
("id".to_string(), "metadata".to_string()),
("label".to_string(), "sample".to_string()),
]
);
}
#[test]
fn a_zero_row_sample_is_not_a_stable_schema() {
let parquet = parquet_fixture();
let report = analyze_structure(parquet.path(), Some(0)).unwrap();
assert_eq!(report.rows_sampled, 0);
assert!(report.truncated);
assert!(!report.source_exhausted);
}
#[test]
fn an_empty_parquet_file_is_stable_without_reading_rows() {
let empty = write_parquet(vec![
(
"id",
Arc::new(Int64Array::from(Vec::<i64>::new())) as ArrayRef,
),
(
"label",
Arc::new(StringArray::from(Vec::<String>::new())) as ArrayRef,
),
]);
let schema = infer_schema(empty.path()).unwrap();
assert_eq!(schema.rows_sampled, 0);
assert!(schema.schema_stable);
assert_eq!(
schema
.columns
.into_iter()
.map(|column| (column.name, column.data_type))
.collect::<Vec<_>>(),
profile_types(&empty)
);
}
#[test]
fn infer_schema_ignores_dictionary_encoding() {
let dictionary = dictionary_parquet_fixture();
assert_eq!(schema_types(&dictionary), expected());
assert_eq!(profile_types(&dictionary), expected());
}
#[test]
fn parquet_schema_reports_the_rows_it_read() {
let parquet = parquet_fixture();
let schema = infer_schema(parquet.path()).unwrap();
assert_eq!(schema.rows_sampled, ROWS);
assert!(schema.schema_stable, "the whole file was read");
let capped = analyze_structure(parquet.path(), Some(10)).unwrap();
assert_eq!(capped.rows_sampled, 10);
assert!(capped.truncated);
assert_eq!(capped.truncation_reason.as_deref(), Some("max_rows(10)"));
assert!(!capped.source_exhausted);
let provenance: Vec<(String, String)> = capped
.columns
.into_iter()
.map(|column| (column.name, column.provenance))
.collect();
assert_eq!(
provenance,
vec![
("when".to_string(), "sample".to_string()),
("ident".to_string(), "sample".to_string()),
("numish".to_string(), "sample".to_string()),
("boolish".to_string(), "sample".to_string()),
("real_int".to_string(), "metadata".to_string()),
]
);
}