use std::fs::{self, File};
use std::io::BufReader;
use std::path::{Path, PathBuf};
use minarrow::Table;
use crate::error::IoError;
use crate::models::readers::parquet::{load_parquet_table_cols, load_parquet_table};
use crate::traits::chunked_table_reader::ChunkedTableReader;
use std::sync::Arc;
use minarrow::SuperTable;
pub struct ChunkedParquetReader {
paths: Vec<PathBuf>,
cursor: usize,
}
impl ChunkedTableReader for ChunkedParquetReader {
type Error = IoError;
type Options = ();
fn open<P: AsRef<Path>>(dir: P, base: &str, _options: ()) -> Result<Self, IoError> {
let paths = Self::list_paths(dir, base)?;
Ok(Self { paths, cursor: 0 })
}
fn paths(&self) -> &[PathBuf] {
&self.paths
}
fn list_paths<P: AsRef<Path>>(dir: P, base: &str) -> Result<Vec<PathBuf>, IoError> {
let prefix = format!("{base}-");
let mut indexed: Vec<(u64, PathBuf)> = Vec::new();
for entry in fs::read_dir(dir.as_ref())? {
let entry = entry?;
let path = entry.path();
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
if !name.starts_with(&prefix) || !name.ends_with(".parquet") {
continue;
}
let index_str = &name[prefix.len()..name.len() - ".parquet".len()];
let Ok(index) = index_str.parse::<u64>() else {
continue;
};
indexed.push((index, path));
}
indexed.sort_by_key(|(i, _)| *i);
Ok(indexed.into_iter().map(|(_, p)| p).collect())
}
fn read_chunk(&self, path: &Path) -> Result<Table, IoError> {
let file = File::open(path).map_err(IoError::from)?;
load_parquet_table(BufReader::new(file))
}
fn read_chunk_cols(&self, path: &Path, columns: &[&str]) -> Result<Table, IoError> {
let file = File::open(path).map_err(IoError::from)?;
load_parquet_table_cols(BufReader::new(file), columns)
}
fn load_batched_cols(self, columns: &[&str]) -> Result<SuperTable, IoError> {
let mut batches: Vec<Arc<Table>> = Vec::new();
let mut name: Option<String> = None;
for path in &self.paths[self.cursor..] {
let table = self.read_chunk_cols(path, columns)?;
if name.is_none() {
name = Some(table.name.clone());
}
batches.push(Arc::new(table));
}
Ok(SuperTable::from_batches(
batches,
name.or(Some("chunked".into())),
))
}
}
impl Iterator for ChunkedParquetReader {
type Item = Result<Table, IoError>;
fn next(&mut self) -> Option<Self::Item> {
let path = self.paths.get(self.cursor)?.clone();
self.cursor += 1;
Some(self.read_chunk(&path))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::writers::chunked::parquet::ChunkedParquetWriter;
use crate::traits::chunked_table_writer::ChunkedTableWriter;
use minarrow::{Table, fa_i32};
#[test]
fn par_load_batched_returns_batches_in_write_order() {
let dir = std::env::temp_dir().join("lightstream_chunked_parquet_par_reader");
let _ = fs::remove_dir_all(&dir);
let mut w = ChunkedParquetWriter::new(&dir, "part", None).unwrap();
for i in 0..8i32 {
w.write_chunk(&Table::new(
"b",
Some(vec![fa_i32!("n", i, i + 100)]),
))
.unwrap();
}
let st = ChunkedParquetReader::par_load_batched(&dir, "part", (), None).unwrap();
assert_eq!(st.batches.len(), 8);
for (i, batch) in st.batches.iter().enumerate() {
assert_eq!(batch.n_rows, 2);
let arr = &batch.cols[0].array;
let s = format!("{arr:?}");
assert!(
s.contains(&format!("{i}")),
"batch {i} first column did not contain {i}: {s}"
);
}
fs::remove_dir_all(&dir).ok();
}
#[test]
fn categorical_column_roundtrips_via_dictionary_page() {
use crate::models::readers::parquet::load_parquet_table;
use crate::models::writers::parquet::write_parquet_table;
use minarrow::{
Array, ArrowType, Bitmask, Buffer, CategoricalArray, Field, FieldArray, TextArray,
Vec64, ffi::arrow_dtype::CategoricalIndexType,
};
use std::sync::Arc;
let n_rows = 256usize;
let unique_values = Vec64::from(vec![
"red".to_string(),
"green".to_string(),
"blue".to_string(),
]);
#[cfg(feature = "default_categorical_8")]
let (dtype, array) = {
let indices: Vec64<u8> = (0..n_rows).map(|i| (i % 3) as u8).collect();
(
ArrowType::Dictionary(CategoricalIndexType::UInt8),
Array::TextArray(TextArray::Categorical8(Arc::new(CategoricalArray {
data: Buffer::from(indices),
unique_values,
null_mask: Some(Bitmask::new_set_all(n_rows, true)),
}))),
)
};
#[cfg(not(feature = "default_categorical_8"))]
let (dtype, array) = {
let indices: Vec64<u32> = (0..n_rows).map(|i| (i % 3) as u32).collect();
(
ArrowType::Dictionary(CategoricalIndexType::UInt32),
Array::TextArray(TextArray::Categorical32(Arc::new(CategoricalArray {
data: Buffer::from(indices),
unique_values,
null_mask: Some(Bitmask::new_set_all(n_rows, true)),
}))),
)
};
let dict_col = FieldArray::new(
Field {
name: "category".into(),
dtype,
nullable: true,
metadata: Default::default(),
},
array,
);
let table = Table::new("t", Some(vec![dict_col]));
let path = std::env::temp_dir().join("ls_categorical_roundtrip.parquet");
let _ = std::fs::remove_file(&path);
write_parquet_table(
&table,
std::fs::File::create(&path).unwrap(),
None,
)
.unwrap();
let got = load_parquet_table(std::io::BufReader::new(std::fs::File::open(&path).unwrap()))
.expect("categorical column must round-trip via Parquet");
assert_eq!(got.n_rows, n_rows);
assert_eq!(got.cols.len(), 1);
let _ = std::fs::remove_file(&path);
}
#[test]
fn par_load_batched_handles_empty_directory() {
let dir = std::env::temp_dir().join("lightstream_chunked_parquet_par_reader_empty");
let _ = fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let st = ChunkedParquetReader::par_load_batched(&dir, "part", (), None).unwrap();
assert!(st.batches.is_empty());
assert_eq!(st.n_rows, 0);
fs::remove_dir_all(&dir).ok();
}
}