Skip to main content

rudb_functions/
file.rs

1//! The table functions that read a file, which today are `read_parquet` and `read_csv`.
2//!
3//! These are the ones [`crate::table`] cannot finish resolving on its own, because their columns
4//! are in the file rather than in a table in this crate. So a caller resolves the call, gets
5//! [`Columns::Parquet`] back, and comes here with the path.
6//!
7//! A read can cover more than one file, because the path can be a pattern, and the two formats
8//! settle their schema differently when it does. A Parquet file states its schema in its footer, so
9//! the first file's word is taken and a later file that disagrees is cast to it. A CSV file states
10//! nothing, so [`csv_fields`] sniffs every file the pattern named and combines the answers, which is
11//! what the binary does and is the only way the answer can be right.
12//!
13//! The file is opened twice for a query that runs, once by the binder to read the schema and once
14//! by the executor to read the rows. That is what DuckDB does too and it is not a mistake: the
15//! binder has to know the column names before the rest of the statement can bind, and holding an
16//! open file between binding and execution would mean a prepared statement holding a descriptor for
17//! as long as it lives. The second open re-reads the footer, which is one read of the last few
18//! kilobytes of the file.
19//!
20//! The filesystem is the real one. `rudb-io` has the seam for a second one and nothing reaches it
21//! from SQL yet, so plumbing a choice through the binder and the executor before there is a second
22//! choice to make would be an argument every caller passes and nobody varies.
23//!
24//! [`Columns::Parquet`]: crate::table::Columns::Parquet
25
26use std::path::Path;
27
28use rudb_common::{Error, Field, Result, Value};
29use rudb_csv::{Given, Reader as CsvReader};
30use rudb_io::glob::has_magic;
31use rudb_io::{File, Filesystem, OpenMode, RealFilesystem, expand};
32use rudb_parquet::Reader;
33
34/// A reader over the Parquet file at `path`, positioned before its first row group.
35///
36/// # Errors
37///
38/// When the file is not there, with DuckDB's own wording, and whatever reading the footer reports.
39pub fn open_parquet(path: &str) -> Result<Reader> {
40    Reader::open(open_file(path)?)
41}
42
43/// A reader over the CSV file at `path`, positioned at its first row, with its punctuation and its
44/// column types already worked out.
45///
46/// `given` is whatever the call said about how the file is written, and what it does not say is
47/// sniffed. The binder and the executor each open the file and both hand the same thing in, which is
48/// what keeps the columns a query was planned against and the columns it reads the same columns.
49///
50/// # Errors
51///
52/// When the file is not there, with DuckDB's own wording, and whatever sniffing it reports.
53pub fn open_csv(path: &str, given: Given) -> Result<CsvReader> {
54    CsvReader::open_with(open_file(path)?, path, given)
55}
56
57/// What a call's named parameters say about how a CSV file is written.
58///
59/// The binder works this out to sniff the file with and the executor works it out again to read it
60/// with, both from the list the plan kept, which is what keeps the columns a query was planned
61/// against and the columns it reads the same columns. A name this does not know is a name that says
62/// nothing about punctuation, such as `all_varchar`, and is somebody else's to act on.
63///
64/// # Errors
65///
66/// When a punctuation parameter was given something other than a single byte.
67pub fn csv_given(options: &[(&str, Value)]) -> Result<Given> {
68    let mut given = Given::default();
69    for (name, value) in options {
70        match (*name, value) {
71            ("header", Value::Boolean(on)) => given.header = Some(*on),
72            ("delim" | "sep", Value::Varchar(text)) => {
73                given.delimiter = Some(one_byte(name, text)?)
74            }
75            ("quote", Value::Varchar(text)) => given.quote = Some(one_byte(name, text)?),
76            ("escape", Value::Varchar(text)) => given.escape = Some(one_byte(name, text)?),
77            _ => {}
78        }
79    }
80    Ok(given)
81}
82
83/// The one byte a punctuation parameter was given.
84///
85/// DuckDB takes a string of any length here and splits on the whole of it, so `delim='||'` is a two
86/// byte delimiter there and `delim=''` is a file of one column. The scanner underneath this compares
87/// one byte, so anything else is turned away rather than quietly read as the first byte of it, which
88/// would be a wrong answer on a file that really is written that way.
89fn one_byte(parameter: &str, text: &str) -> Result<u8> {
90    match *text.as_bytes() {
91        [byte] => Ok(byte),
92        _ => Err(Error::not_implemented(format!(
93            "the named parameter {parameter} given {} bytes rather than one",
94            text.len()
95        ))),
96    }
97}
98
99/// Whether there is a file, rather than a directory, at `path`.
100///
101/// The replacement scan asks, because a name that looks like a file and is not one is a different
102/// answer from a file this build has no reader for. A directory is not a file: DuckDB reports
103/// `SELECT * FROM 'some/directory'` as a table that does not exist, which was measured.
104#[must_use]
105pub fn is_file(path: &str) -> bool {
106    let at = Path::new(path);
107    let filesystem = RealFilesystem::new();
108    filesystem.exists(at) && !filesystem.is_dir(at)
109}
110
111/// Whether a path argument stands for a set of files rather than for one.
112///
113/// The binder asks because the two are named differently. A file gives its columns the stem of its
114/// name to answer to and a pattern gives them the whole of what was written, both measured.
115#[must_use]
116pub fn is_pattern(path: &str) -> bool {
117    has_magic(path)
118}
119
120/// The files a path argument names, which is one file, or every file a pattern matched.
121///
122/// Expanded here rather than in the executor because DuckDB expands at bind time: a pattern that
123/// matches nothing is an error before the query starts, and the schema comes from the first file, so
124/// the binder has to know which file that is.
125///
126/// # Errors
127///
128/// When nothing matched, with DuckDB's own wording, which says pattern whether or not one was
129/// written because a path that is simply missing and a pattern that matched nothing are the same
130/// answer there.
131pub fn files(pattern: &str) -> Result<Vec<String>> {
132    let found = expand(&RealFilesystem::new(), pattern)?;
133    if found.is_empty() {
134        return Err(Error::io(format!("No files found that match the pattern \"{pattern}\"")));
135    }
136    Ok(found)
137}
138
139/// The file at `path`, open for reading.
140fn open_file(path: &str) -> Result<Box<dyn File>> {
141    let filesystem = RealFilesystem::new();
142    let at = Path::new(path);
143    if !filesystem.exists(at) {
144        // DuckDB's message, which says pattern because the argument is a glob there and will be
145        // here. A path that is simply missing and a glob that matched nothing are the same answer.
146        return Err(Error::io(format!("No files found that match the pattern \"{path}\"")));
147    }
148    filesystem.open(at, OpenMode::Read)
149}
150
151/// The columns of the Parquet file at `path`, in the order the file stores them.
152///
153/// # Errors
154///
155/// Everything [`open_parquet`] reports.
156pub fn parquet_fields(path: &str) -> Result<Vec<Field>> {
157    Ok(open_parquet(path)?.fields())
158}
159
160/// The columns a `read_csv` of `paths` produces, sniffed out of the front of every one of them.
161///
162/// Every file and not only the first, which is the one place this differs from Parquet and is
163/// DuckDB's rule rather than a choice made here. It was measured at two, three, four and six files:
164/// four files where only the fourth holds a decimal answer DOUBLE, and six where only the sixth
165/// holds text answer VARCHAR. A Parquet file states its schema in its footer, so there is a first
166/// file's word to take. A CSV file states nothing, so there is not, and a directory of daily exports
167/// where one day happens to hold whole numbers in an otherwise decimal column would come out BIGINT
168/// or DOUBLE depending on which day sorted first. So all of them are sniffed and the answers are
169/// combined by [`rudb_csv::across`].
170///
171/// That is an open and one sample read per file at bind time. It is what the binary does, it is the
172/// only way the answer can be right, and it is a sample against a scan that is about to read all of
173/// those files anyway.
174///
175/// # Errors
176///
177/// Everything [`open_csv`] reports, and a file that is missing a column the first one has.
178pub fn csv_fields(paths: &[String], given: Given) -> Result<Vec<Field>> {
179    let mut sniffed = Vec::with_capacity(paths.len());
180    for path in paths {
181        sniffed.push((path.clone(), open_csv(path, given)?.fields()));
182    }
183    rudb_csv::across(&sniffed)
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn a_file_that_is_not_there_is_duckdbs_own_message() {
192        let error = open_parquet("/nowhere/at/all.parquet").unwrap_err();
193        assert_eq!(
194            error.message(),
195            "No files found that match the pattern \"/nowhere/at/all.parquet\""
196        );
197    }
198
199    #[test]
200    fn a_file_that_is_there_and_is_not_parquet_fails_on_the_footer_rather_than_on_the_open() {
201        // Cargo.toml of this crate, which exists and is not a Parquet file. The distinction
202        // matters: a missing file and a file that is not what it claims are different mistakes and
203        // a reader that reported both as missing would send somebody looking in the wrong place.
204        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml");
205        let error = open_parquet(path).unwrap_err();
206        assert!(!error.message().contains("No files found"), "{error}");
207    }
208}