use std::path::Path;
use std::sync::Arc;
use rudb_common::bounds::Zones;
use rudb_common::stat::Direction;
use rudb_common::{Error, Field, Provenance, Result, Stat, Value};
use rudb_csv::{Given, Reader as CsvReader};
use rudb_io::glob::has_magic;
use rudb_io::{File, Filesystem, OpenMode, RealFilesystem, expand};
use rudb_parquet::Reader;
pub fn open_parquet(path: &str) -> Result<Reader> {
Reader::open(open_file(path)?)
}
pub fn open_csv(path: &str, given: Given) -> Result<CsvReader> {
CsvReader::open_with(open_file(path)?, path, given)
}
pub fn csv_given(options: &[(&str, Value)]) -> Result<Given> {
let mut given = Given::default();
for (name, value) in options {
match (*name, value) {
("header", Value::Boolean(on)) => given.header = Some(*on),
("delim" | "sep", Value::Varchar(text)) => {
given.delimiter = Some(one_byte(name, text)?)
}
("quote", Value::Varchar(text)) => given.quote = Some(one_byte(name, text)?),
("escape", Value::Varchar(text)) => given.escape = Some(one_byte(name, text)?),
_ => {}
}
}
Ok(given)
}
fn one_byte(parameter: &str, text: &str) -> Result<u8> {
match *text.as_bytes() {
[byte] => Ok(byte),
_ => Err(Error::not_implemented(format!(
"the named parameter {parameter} given {} bytes rather than one",
text.len()
))),
}
}
#[must_use]
pub fn is_file(path: &str) -> bool {
let at = Path::new(path);
let filesystem = RealFilesystem::new();
filesystem.exists(at) && !filesystem.is_dir(at)
}
#[must_use]
pub fn is_pattern(path: &str) -> bool {
has_magic(path)
}
pub fn files(pattern: &str) -> Result<Vec<String>> {
let found = expand(&RealFilesystem::new(), pattern)?;
if found.is_empty() {
return Err(Error::io(format!("No files found that match the pattern \"{pattern}\"")));
}
Ok(found)
}
fn open_file(path: &str) -> Result<Box<dyn File>> {
let filesystem = RealFilesystem::new();
let at = Path::new(path);
if !filesystem.exists(at) {
return Err(Error::io(format!("No files found that match the pattern \"{path}\"")));
}
filesystem.open(at, OpenMode::Read)
}
#[derive(Debug, Clone, Default)]
pub struct Footers {
pub fields: Vec<Field>,
pub rows: Stat<u64>,
pub distincts: Vec<(String, Stat<u64>)>,
pub zones: Option<Arc<dyn Zones>>,
}
pub fn parquet_footers(paths: &[String]) -> Result<Footers> {
let first = paths.first().map_or("", String::as_str);
let reader = open_parquet(first)?;
let fields = reader.fields();
let zones = (paths.len() == 1).then(|| Arc::new(reader.zones()) as Arc<dyn Zones>);
let mut largest: Vec<Counted> =
reader.metadata().schema.iter().map(|column| Counted::new(&column.name)).collect();
largest_distincts(&reader, &mut largest);
let counted = |rows: Option<u64>, largest: Vec<Counted>| {
let Some(rows) = rows else {
return Footers {
fields: fields.clone(),
rows: Stat::Unknown,
distincts: Vec::new(),
zones: zones.clone(),
};
};
let distincts = largest
.into_iter()
.filter_map(|column| {
let name = column.name.clone();
match column.stat(rows) {
Stat::Unknown => None,
stat => Some((name, stat)),
}
})
.collect();
Footers {
fields: fields.clone(),
rows: Stat::exact(rows, Provenance::RowCount),
distincts,
zones: zones.clone(),
}
};
let Some(mut total) = reader.rows() else { return Ok(counted(None, largest)) };
for path in paths.iter().skip(1) {
let Ok(reader) = open_parquet(path) else { return Ok(counted(None, largest)) };
let Some(rows) = reader.rows() else { return Ok(counted(None, largest)) };
let Some(sum) = total.checked_add(rows) else { return Ok(counted(None, largest)) };
total = sum;
largest_distincts(&reader, &mut largest);
}
Ok(counted(Some(total), largest))
}
pub fn parquet_outline(path: &str) -> Result<Footers> {
let outline = rudb_parquet::Outline::read(open_file(path)?.as_ref())?;
let rows = outline.rows().map_or(Stat::Unknown, |rows| Stat::exact(rows, Provenance::RowCount));
Ok(Footers { fields: outline.fields(), rows, distincts: Vec::new(), zones: None })
}
struct Counted {
name: String,
largest: Option<u64>,
total: Option<u64>,
}
impl Counted {
fn new(name: &str) -> Self {
Self { name: name.to_owned(), largest: Some(0), total: Some(0) }
}
fn stat(&self, rows: u64) -> Stat<u64> {
let (Some(largest), Some(total)) = (self.largest, self.total) else { return Stat::Unknown };
if largest > rows {
return Stat::Unknown;
}
let ceiling = total.min(rows);
if ceiling == largest {
return Stat::exact(largest, Provenance::Dictionary);
}
let Some(bound) = relative(largest, ceiling) else { return Stat::Unknown };
Stat::certified(largest, bound, Direction::AtLeast, Provenance::Dictionary)
}
}
fn relative(value: u64, ceiling: u64) -> Option<f64> {
if value == 0 {
return None;
}
#[expect(clippy::cast_precision_loss, reason = "a relative error is a fraction, not a count")]
Some((ceiling - value) as f64 / value as f64)
}
fn largest_distincts(reader: &Reader, largest: &mut [Counted]) {
for group in &reader.metadata().row_groups {
for chunk in &group.columns {
let Some(held) = largest.get_mut(chunk.column) else {
continue;
};
let stated = chunk.stats.as_ref().and_then(|stats| stats.distinct);
let stated = stated.and_then(|count| u64::try_from(count).ok());
held.largest = match (held.largest, stated) {
(Some(held), Some(stated)) => Some(held.max(stated)),
_ => None,
};
held.total = match (held.total, stated) {
(Some(held), Some(stated)) => held.checked_add(stated),
_ => None,
};
}
}
}
pub fn csv_fields(paths: &[String], given: Given) -> Result<Vec<Field>> {
let mut sniffed = Vec::with_capacity(paths.len());
for path in paths {
sniffed.push((path.clone(), open_csv(path, given)?.fields()));
}
rudb_csv::across(&sniffed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_file_that_is_not_there_is_duckdbs_own_message() {
let error = open_parquet("/nowhere/at/all.parquet").unwrap_err();
assert_eq!(
error.message(),
"No files found that match the pattern \"/nowhere/at/all.parquet\""
);
}
#[test]
fn a_file_that_is_there_and_is_not_parquet_fails_on_the_footer_rather_than_on_the_open() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml");
let error = open_parquet(path).unwrap_err();
assert!(!error.message().contains("No files found"), "{error}");
}
#[test]
fn the_rows_and_the_counts_the_writer_stated_come_out_of_the_one_read() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../rudb-parquet/testdata/mixed.parquet");
let footers = parquet_footers(&[path.to_string()]).expect("the fixture");
assert_eq!(footers.rows, Stat::exact(4096, Provenance::RowCount));
assert_eq!(
footers.distincts,
vec![
("a".to_string(), certified(97, 1.0)),
("s".to_string(), certified(5, 1.0)),
("d".to_string(), certified(64, 1.0)),
]
);
}
fn certified(value: u64, bound: f64) -> Stat<u64> {
Stat::certified(value, bound, Direction::AtLeast, Provenance::Dictionary)
}
fn counted(stated: &[Option<u64>], rows: u64) -> Stat<u64> {
let mut column = Counted::new("c");
for group in stated {
column.largest = match (column.largest, *group) {
(Some(held), Some(stated)) => Some(held.max(stated)),
_ => None,
};
column.total = match (column.total, *group) {
(Some(held), Some(stated)) => held.checked_add(stated),
_ => None,
};
}
column.stat(rows)
}
#[test]
fn a_file_of_one_row_group_counted_the_column_and_the_count_says_so() {
assert_eq!(counted(&[Some(97)], 4096), Stat::exact(97, Provenance::Dictionary));
}
#[test]
fn a_file_of_several_row_groups_states_how_far_apart_the_two_ends_are() {
assert_eq!(counted(&[Some(1000); 4], 100_000), certified(1000, 3.0));
}
#[test]
fn the_rows_are_the_other_ceiling_and_they_tighten_the_certificate() {
assert_eq!(counted(&[Some(100); 10], 400), certified(100, 3.0));
}
#[test]
fn one_row_group_that_stated_nothing_gives_up_the_column_however_many_others_stated() {
assert_eq!(counted(&[Some(97), None, Some(97)], 4096), Stat::Unknown);
}
#[test]
fn a_count_larger_than_the_file_has_rows_is_a_file_contradicting_itself() {
assert_eq!(counted(&[Some(500)], 400), Stat::Unknown);
}
}