use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::fs::File;
use std::path::Path;
use std::sync::Arc;
use arrow_array::cast::AsArray;
use arrow_array::types::{
Date32Type, Date64Type, Decimal128Type, Decimal256Type, DurationMicrosecondType,
DurationMillisecondType, DurationNanosecondType, DurationSecondType, Float16Type, Float32Type,
Float64Type, Int16Type, Int32Type, Int64Type, Int8Type, Time32MillisecondType,
Time32SecondType, Time64MicrosecondType, Time64NanosecondType, TimestampMicrosecondType,
TimestampMillisecondType, TimestampNanosecondType, TimestampSecondType, UInt16Type, UInt32Type,
UInt64Type, UInt8Type,
};
use arrow_array::{Array, ArrowPrimitiveType, PrimitiveArray, RecordBatch, RecordBatchReader};
use arrow_schema::{DataType, TimeUnit};
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
use parquet::arrow::ProjectionMask;
use crate::error::{Error, Result};
use super::{ArrayData, DType, NativeDataset, NativeField, Reader, Selection};
const BATCH_ROWS: usize = 8192;
#[derive(Debug, Clone, Default)]
pub struct ParquetReader {
float_columns: BTreeSet<String>,
null_int: Option<i64>,
null_string: Option<String>,
}
impl ParquetReader {
pub fn new() -> Self {
Self::default()
}
pub fn float_columns<I, S>(mut self, columns: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.float_columns = columns.into_iter().map(Into::into).collect();
self
}
pub fn null_int(mut self, sentinel: i64) -> Self {
self.null_int = Some(sentinel);
self
}
pub fn null_string(mut self, text: impl Into<String>) -> Self {
self.null_string = Some(text.into());
self
}
fn from_options(options: &serde_json::Map<String, serde_json::Value>) -> Result<Self> {
let mut r = ParquetReader::new();
for (k, v) in options {
match k.as_str() {
"float_columns" => {
let arr = v.as_array().ok_or_else(|| {
fmt_err(format!("reader option {k:?} must be an array of strings"))
})?;
let names: Result<Vec<String>> = arr
.iter()
.map(|m| {
m.as_str().map(str::to_string).ok_or_else(|| {
fmt_err(format!("reader option {k:?} must be an array of strings"))
})
})
.collect();
r = r.float_columns(names?);
}
"null_int" => {
let n = v.as_i64().ok_or_else(|| {
fmt_err(format!("reader option {k:?} must be an integer"))
})?;
r = r.null_int(n);
}
"null_string" => {
let s = v
.as_str()
.ok_or_else(|| fmt_err(format!("reader option {k:?} must be a string")))?;
r = r.null_string(s);
}
other => {
return Err(fmt_err(format!(
"unknown reader option {other:?}; parquet takes float_columns, \
null_int, null_string"
)));
}
}
}
Ok(r)
}
}
impl Reader for ParquetReader {
fn formats(&self) -> &'static [&'static str] {
&["parquet"]
}
fn extensions(&self) -> &'static [&'static str] {
&["parquet", "parq", "pq"]
}
fn configured(
&self,
options: &serde_json::Map<String, serde_json::Value>,
) -> Result<Option<Arc<dyn Reader>>> {
if options.is_empty() {
return Ok(None);
}
Ok(Some(Arc::new(ParquetReader::from_options(options)?)))
}
fn read_native(
&self,
blob_path: &Path,
variables: &[String],
_select: &Selection,
) -> Result<NativeDataset> {
let file =
File::open(blob_path).map_err(|e| Error::io(Some(blob_path.to_path_buf()), e))?;
let builder = ParquetRecordBatchReaderBuilder::try_new(file).map_err(pq_err)?;
let roots: Vec<String> = builder
.parquet_schema()
.root_schema()
.get_fields()
.iter()
.map(|f| f.name().to_string())
.collect();
let builder = if variables.is_empty() {
builder
} else {
let mut missing: Vec<&str> = Vec::new();
let mut want: BTreeSet<usize> = BTreeSet::new();
for v in variables {
match roots.iter().position(|r| r == v) {
Some(i) => {
want.insert(i);
}
None => missing.push(v.as_str()),
}
}
if !missing.is_empty() {
missing.sort_unstable();
missing.dedup();
return Err(fmt_err(format!(
"requested variables not in the parquet file: {missing:?}; present: {roots:?}"
)));
}
let mask = ProjectionMask::roots(builder.parquet_schema(), want);
builder.with_projection(mask)
};
let reader = builder
.with_batch_size(BATCH_ROWS)
.build()
.map_err(pq_err)?;
let schema = reader.schema();
let forced: HashSet<&str> = self.float_columns.iter().map(String::as_str).collect();
let mut accs: BTreeMap<String, (Acc, DType)> = BTreeMap::new();
let mut order: Vec<String> = Vec::new();
for f in schema.fields() {
let name = f.name();
let Some(dt) = target_dtype(f.data_type(), forced.contains(name.as_str())) else {
if variables.iter().any(|v| v == name) {
return Err(fmt_err(format!(
"column {name:?} has arrow type {} , which has no rank-1 native \
reading (nested and binary columns are not supported)",
f.data_type()
)));
}
continue;
};
accs.insert(name.clone(), (Acc::new(dt), dt));
order.push(name.clone());
}
let mut nrows = 0usize;
for batch in reader {
let batch: RecordBatch = batch.map_err(arrow_err)?;
let bschema = batch.schema();
for (i, f) in bschema.fields().iter().enumerate() {
let Some((acc, _)) = accs.get_mut(f.name()) else {
continue; };
let cells = cells(batch.column(i).as_ref(), f.name(), nrows)?;
acc.extend(
cells,
f.name(),
nrows,
self.null_int,
self.null_string.as_deref(),
)?;
}
nrows += batch.num_rows();
}
let mut out = NativeDataset::default();
for name in order {
let (acc, dtype) = accs
.remove(&name)
.expect("accumulator was registered above");
let data = acc.finish();
debug_assert_eq!(data.len(), nrows);
out.variables.insert(
name,
NativeField {
dtype,
dims: vec!["index".to_string()],
shape: vec![nrows],
fill_value: match dtype {
DType::Int32 | DType::Int64 => self.null_int.map(|n| n as f64),
_ => None,
},
data,
},
);
}
Ok(out)
}
}
fn target_dtype(dt: &DataType, forced_float: bool) -> Option<DType> {
if forced_float {
return match dt {
DataType::List(_)
| DataType::LargeList(_)
| DataType::ListView(_)
| DataType::LargeListView(_)
| DataType::FixedSizeList(_, _)
| DataType::Struct(_)
| DataType::Map(_, _)
| DataType::Union(_, _)
| DataType::RunEndEncoded(_, _)
| DataType::Binary
| DataType::LargeBinary
| DataType::BinaryView
| DataType::FixedSizeBinary(_) => None,
_ => Some(DType::Float64),
};
}
match dt {
DataType::Boolean => Some(DType::Bool),
DataType::Int8 | DataType::Int16 | DataType::Int32 | DataType::UInt8 | DataType::UInt16 => {
Some(DType::Int32)
}
DataType::Int64 | DataType::UInt32 | DataType::UInt64 => Some(DType::Int64),
DataType::Float16 | DataType::Float32 | DataType::Float64 => Some(DType::Float64),
DataType::Decimal128(_, _) | DataType::Decimal256(_, _) => Some(DType::Float64),
DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => Some(DType::Str),
DataType::Date32 | DataType::Time32(_) => Some(DType::Int32),
DataType::Date64
| DataType::Time64(_)
| DataType::Timestamp(_, _)
| DataType::Duration(_) => Some(DType::Int64),
DataType::Dictionary(_, value) => target_dtype(value, false),
DataType::Null => Some(DType::Float64),
_ => None,
}
}
#[derive(Debug, Clone)]
enum Cell {
F(f64),
I(i64),
U(u64),
S(String),
B(bool),
}
fn cells(col: &dyn Array, name: &str, row0: usize) -> Result<Vec<Option<Cell>>> {
macro_rules! prim {
($t:ty, $f:expr) => {{
let a: &PrimitiveArray<$t> = col.as_primitive::<$t>();
#[allow(clippy::redundant_closure_call)]
Ok(a.iter().map(|v| v.map($f)).collect())
}};
}
match col.data_type() {
DataType::Null => Ok(vec![None; col.len()]),
DataType::Boolean => Ok(col.as_boolean().iter().map(|v| v.map(Cell::B)).collect()),
DataType::Int8 => prim!(Int8Type, |v| Cell::I(v as i64)),
DataType::Int16 => prim!(Int16Type, |v| Cell::I(v as i64)),
DataType::Int32 => prim!(Int32Type, |v| Cell::I(v as i64)),
DataType::Int64 => prim!(Int64Type, Cell::I),
DataType::UInt8 => prim!(UInt8Type, |v| Cell::I(v as i64)),
DataType::UInt16 => prim!(UInt16Type, |v| Cell::I(v as i64)),
DataType::UInt32 => prim!(UInt32Type, |v| Cell::I(v as i64)),
DataType::UInt64 => prim!(UInt64Type, Cell::U),
DataType::Float16 => prim!(Float16Type, |v| Cell::F(f64::from(v))),
DataType::Float32 => prim!(Float32Type, |v| Cell::F(v as f64)),
DataType::Float64 => prim!(Float64Type, Cell::F),
DataType::Decimal128(_, scale) => {
let s = 10f64.powi(i32::from(*scale));
prim!(Decimal128Type, |v: i128| Cell::F(v as f64 / s))
}
DataType::Decimal256(_, scale) => {
let s = 10f64.powi(i32::from(*scale));
let a = col.as_primitive::<Decimal256Type>();
let mut out = Vec::with_capacity(a.len());
for (i, v) in a.iter().enumerate() {
out.push(match v {
None => None,
Some(x) => {
let t = x.to_string();
let f: f64 = t.parse().map_err(|_| {
fmt_err(format!(
"column {name:?} row {}: decimal256 value {t:?} is not \
representable as float64",
row0 + i
))
})?;
Some(Cell::F(f / s))
}
});
}
Ok(out)
}
DataType::Utf8 => Ok(col
.as_string::<i32>()
.iter()
.map(|v| v.map(|s| Cell::S(s.to_string())))
.collect()),
DataType::LargeUtf8 => Ok(col
.as_string::<i64>()
.iter()
.map(|v| v.map(|s| Cell::S(s.to_string())))
.collect()),
DataType::Utf8View => Ok(col
.as_string_view()
.iter()
.map(|v| v.map(|s| Cell::S(s.to_string())))
.collect()),
DataType::Date32 => prim!(Date32Type, |v| Cell::I(v as i64)),
DataType::Date64 => prim!(Date64Type, Cell::I),
DataType::Time32(TimeUnit::Second) => prim!(Time32SecondType, |v| Cell::I(v as i64)),
DataType::Time32(_) => prim!(Time32MillisecondType, |v| Cell::I(v as i64)),
DataType::Time64(TimeUnit::Microsecond) => prim!(Time64MicrosecondType, Cell::I),
DataType::Time64(_) => prim!(Time64NanosecondType, Cell::I),
DataType::Timestamp(TimeUnit::Second, _) => prim!(TimestampSecondType, Cell::I),
DataType::Timestamp(TimeUnit::Millisecond, _) => prim!(TimestampMillisecondType, Cell::I),
DataType::Timestamp(TimeUnit::Microsecond, _) => prim!(TimestampMicrosecondType, Cell::I),
DataType::Timestamp(TimeUnit::Nanosecond, _) => prim!(TimestampNanosecondType, Cell::I),
DataType::Duration(TimeUnit::Second) => prim!(DurationSecondType, Cell::I),
DataType::Duration(TimeUnit::Millisecond) => prim!(DurationMillisecondType, Cell::I),
DataType::Duration(TimeUnit::Microsecond) => prim!(DurationMicrosecondType, Cell::I),
DataType::Duration(TimeUnit::Nanosecond) => prim!(DurationNanosecondType, Cell::I),
DataType::Dictionary(key, _) => {
let keys: Vec<Option<usize>> = match **key {
DataType::Int8 => dict_keys::<Int8Type>(col),
DataType::Int16 => dict_keys::<Int16Type>(col),
DataType::Int32 => dict_keys::<Int32Type>(col),
DataType::Int64 => dict_keys::<Int64Type>(col),
DataType::UInt8 => dict_keys::<UInt8Type>(col),
DataType::UInt16 => dict_keys::<UInt16Type>(col),
DataType::UInt32 => dict_keys::<UInt32Type>(col),
DataType::UInt64 => dict_keys::<UInt64Type>(col),
_ => {
return Err(fmt_err(format!(
"column {name:?}: dictionary key type {key} is not an integer"
)))
}
};
let values = col.as_any_dictionary().values().clone();
let vcells = cells(values.as_ref(), name, 0)?;
Ok(keys
.into_iter()
.map(|k| k.and_then(|k| vcells.get(k).cloned().flatten()))
.collect())
}
other => Err(fmt_err(format!(
"column {name:?}: arrow type {other} has no rank-1 native reading"
))),
}
}
fn dict_keys<K>(col: &dyn Array) -> Vec<Option<usize>>
where
K: ArrowPrimitiveType,
K::Native: TryInto<usize>,
{
col.as_any_dictionary()
.keys()
.as_primitive::<K>()
.iter()
.map(|k| k.and_then(|k| k.try_into().ok()))
.collect()
}
#[derive(Debug)]
enum Acc {
F64(Vec<f64>),
I64(Vec<i64>),
I32(Vec<i32>),
Str(Vec<String>),
Bool(Vec<bool>),
}
impl Acc {
fn new(dtype: DType) -> Self {
match dtype {
DType::Float64 => Acc::F64(Vec::new()),
DType::Int64 => Acc::I64(Vec::new()),
DType::Int32 => Acc::I32(Vec::new()),
DType::Str => Acc::Str(Vec::new()),
DType::Bool => Acc::Bool(Vec::new()),
}
}
fn finish(self) -> ArrayData {
match self {
Acc::F64(v) => ArrayData::F64(v),
Acc::I64(v) => ArrayData::I64(v),
Acc::I32(v) => ArrayData::I32(v),
Acc::Str(v) => ArrayData::Str(v),
Acc::Bool(v) => ArrayData::Bool(v),
}
}
fn extend(
&mut self,
cells: Vec<Option<Cell>>,
name: &str,
row0: usize,
null_int: Option<i64>,
null_string: Option<&str>,
) -> Result<()> {
for (i, cell) in cells.into_iter().enumerate() {
let row = row0 + i;
match self {
Acc::F64(out) => out.push(match cell {
None => f64::NAN,
Some(Cell::F(v)) => v,
Some(Cell::I(v)) => v as f64,
Some(Cell::U(v)) => v as f64,
Some(Cell::B(_)) => {
return Err(fmt_err(format!(
"column {name:?} row {row}: a boolean cell cannot be read as float64"
)))
}
Some(Cell::S(s)) => {
let t = s.trim();
if t.is_empty() {
f64::NAN
} else {
t.parse::<f64>().map_err(|_| {
fmt_err(format!(
"column {name:?} row {row}: {s:?} is not a float64 (the \
column is declared in float_columns)"
))
})?
}
}
}),
Acc::I64(out) => out.push(int_cell(cell, name, row, null_int)?),
Acc::I32(out) => {
let v = int_cell(cell, name, row, null_int)?;
out.push(i32::try_from(v).map_err(|_| {
fmt_err(format!(
"column {name:?} row {row}: value {v} does not fit the int32 \
native dtype"
))
})?);
}
Acc::Str(out) => out.push(match cell {
Some(Cell::S(s)) => s,
Some(_) => {
return Err(fmt_err(format!(
"column {name:?} row {row}: expected a text cell"
)))
}
None => match null_string {
Some(s) => s.to_string(),
None => return Err(null_err(name, row, "string", "null_string")),
},
}),
Acc::Bool(out) => out.push(match cell {
Some(Cell::B(b)) => b,
Some(_) => {
return Err(fmt_err(format!(
"column {name:?} row {row}: expected a boolean cell"
)))
}
None => {
return Err(fmt_err(format!(
"column {name:?} row {row} is null, and a boolean native field has \
no missing value; declare the column in the `float_columns` reader \
option if a third state is meant"
)))
}
}),
}
}
Ok(())
}
}
fn int_cell(cell: Option<Cell>, name: &str, row: usize, null_int: Option<i64>) -> Result<i64> {
match cell {
Some(Cell::I(v)) => Ok(v),
Some(Cell::U(u)) => i64::try_from(u).map_err(|_| {
fmt_err(format!(
"column {name:?} row {row}: uint64 value {u} exceeds the int64 native dtype"
))
}),
Some(_) => Err(fmt_err(format!(
"column {name:?} row {row}: expected an integer cell"
))),
None => match null_int {
Some(s) => Ok(s),
None => Err(null_err(name, row, "integer", "null_int")),
},
}
}
fn null_err(name: &str, row: usize, kind: &str, option: &str) -> Error {
fmt_err(format!(
"column {name:?} row {row} is null, and a {kind} native field has no missing value; \
declare the `{option}` reader option to substitute one, or list the column in \
`float_columns` to read it as float64 with NaN"
))
}
fn pq_err(e: parquet::errors::ParquetError) -> Error {
fmt_err(e.to_string())
}
fn arrow_err(e: arrow_schema::ArrowError) -> Error {
fmt_err(e.to_string())
}
fn fmt_err(detail: impl Into<String>) -> Error {
Error::Format {
format: "parquet".to_string(),
detail: detail.into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn integer_widths_follow_the_netcdf_split() {
assert_eq!(target_dtype(&DataType::Int8, false), Some(DType::Int32));
assert_eq!(target_dtype(&DataType::Int16, false), Some(DType::Int32));
assert_eq!(target_dtype(&DataType::Int32, false), Some(DType::Int32));
assert_eq!(target_dtype(&DataType::UInt8, false), Some(DType::Int32));
assert_eq!(target_dtype(&DataType::UInt16, false), Some(DType::Int32));
assert_eq!(target_dtype(&DataType::Int64, false), Some(DType::Int64));
assert_eq!(target_dtype(&DataType::UInt32, false), Some(DType::Int64));
assert_eq!(target_dtype(&DataType::UInt64, false), Some(DType::Int64));
}
#[test]
fn a_forced_column_is_float_whatever_it_was() {
assert_eq!(target_dtype(&DataType::Utf8, true), Some(DType::Float64));
assert_eq!(target_dtype(&DataType::Int64, true), Some(DType::Float64));
assert_eq!(target_dtype(&DataType::Utf8, false), Some(DType::Str));
}
#[test]
fn a_categorical_reads_as_its_value_type() {
let d = DataType::Dictionary(Box::new(DataType::Int32), Box::new(DataType::Utf8));
assert_eq!(target_dtype(&d, false), Some(DType::Str));
}
#[test]
fn nested_columns_have_no_rank_1_reading() {
let f = Arc::new(arrow_schema::Field::new("x", DataType::Int32, true));
assert_eq!(target_dtype(&DataType::List(f), false), None);
assert_eq!(target_dtype(&DataType::Binary, false), None);
}
#[test]
fn unknown_reader_options_are_rejected() {
let mut m = serde_json::Map::new();
m.insert("nope".into(), serde_json::Value::Bool(true));
let Err(err) = ParquetReader::new().configured(&m) else {
panic!("an unknown reader option must be rejected");
};
assert!(matches!(err, Error::Format { .. }));
assert!(format!("{err}").contains("nope"));
}
#[test]
fn an_empty_option_set_uses_the_reader_as_registered() {
let m = serde_json::Map::new();
let Ok(None) = ParquetReader::new().configured(&m) else {
panic!("an empty option set must reuse the registered reader");
};
}
#[test]
fn not_a_parquet_file_is_an_error_not_a_panic() {
use std::io::Write;
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(b"definitely not parquet").unwrap();
f.flush().unwrap();
let err = ParquetReader::new()
.read_native(f.path(), &[], &Selection::All)
.unwrap_err();
assert!(matches!(err, Error::Format { .. }));
}
}