Skip to main content

rudb_csv/
combine.rs

1//! One schema out of several files.
2//!
3//! `read_csv('data/*.csv')` is one stream of rows and a stream has one schema, so the files a
4//! pattern matched have to agree on one. A Parquet file states its schema and the first file's is
5//! taken as the answer, but a CSV file states nothing, so every file is sniffed and the answers are
6//! combined. That is not an optimisation choice, it is a correctness one: a directory of daily
7//! exports where one day's file happens to hold whole numbers in a column that is otherwise decimal
8//! would otherwise come out BIGINT or DOUBLE depending on which day sorted first.
9//!
10//! Both rules here were measured against duckdb v1.4.1 rather than reasoned about.
11//!
12//! The types combine by [`widen`]. Two integers stay an integer, an integer and a double become a
13//! double, and everything else becomes text. Notably a date and a timestamp become text rather than
14//! a timestamp, which is not what a type lattice would say and is what the binary does.
15//!
16//! The names have to match, and a file that is missing a column is [`mismatch`], which is a
17//! different sentence from the one the Parquet reader gives for the same situation because the two
18//! readers in DuckDB are two pieces of code that each wrote their own.
19
20use rudb_common::{Error, Field, LogicalType, Result};
21
22/// The type a column has to be for values from both files to fit in it.
23///
24/// Measured: BIGINT with DOUBLE is DOUBLE, BIGINT with VARCHAR is VARCHAR, BOOLEAN with BIGINT is
25/// VARCHAR, DATE with BIGINT is VARCHAR, DATE with TIMESTAMP is VARCHAR. So the only pair that
26/// widens to anything but text is the numeric one, and everything else falls back to the type that
27/// holds whatever was written.
28#[must_use]
29pub fn widen(one: &LogicalType, other: &LogicalType) -> LogicalType {
30    if one == other {
31        return one.clone();
32    }
33    let numeric = |ty: &LogicalType| matches!(ty, LogicalType::BigInt | LogicalType::Double);
34    if numeric(one) && numeric(other) {
35        return LogicalType::Double;
36    }
37    LogicalType::Varchar
38}
39
40/// The columns of a read that covers several files, given each file's own sniffed columns.
41///
42/// The first file fixes the names and their order. Every file after it has to have all of them, by
43/// name, and contributes its types.
44///
45/// # Errors
46///
47/// When a file is missing a column the first one has, with DuckDB's own wording.
48pub fn across(sniffed: &[(String, Vec<Field>)]) -> Result<Vec<Field>> {
49    let Some((main, first)) = sniffed.first() else { return Ok(Vec::new()) };
50    let mut fields = first.clone();
51    for (path, held) in &sniffed[1..] {
52        for field in &mut fields {
53            let found = held
54                .iter()
55                .find(|column| column.name == field.name)
56                .ok_or_else(|| mismatch(main, path, &field.name))?;
57            field.ty = widen(&field.ty, &found.ty);
58        }
59    }
60    Ok(fields)
61}
62
63/// DuckDB's message for a globbed file that does not have a column the first file has.
64///
65/// The trailing space after `Potential Fixes` is the binary's and is kept, because a compatibility
66/// test that compares output compares all of it and a difference that is invisible on a terminal is
67/// the worst kind to go looking for later.
68#[must_use]
69pub fn mismatch(main: &str, current: &str, missing: &str) -> Error {
70    Error::invalid_input(format!(
71        "Schema mismatch between globbed files.\nMain file schema: {main}\nCurrent file: \
72         {current}\nColumn with name: \"{missing}\" is missing\nPotential Fixes \n* Consider \
73         setting union_by_name=true.\n* Consider setting files_to_sniff to a higher value (e.g., \
74         files_to_sniff = -1)"
75    ))
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    fn named(name: &str, ty: LogicalType) -> Field {
83        Field::new(name, ty)
84    }
85
86    #[test]
87    fn the_only_pair_that_widens_to_anything_but_text_is_the_numeric_one() {
88        assert_eq!(widen(&LogicalType::BigInt, &LogicalType::BigInt), LogicalType::BigInt);
89        assert_eq!(widen(&LogicalType::BigInt, &LogicalType::Double), LogicalType::Double);
90        assert_eq!(widen(&LogicalType::Double, &LogicalType::BigInt), LogicalType::Double);
91        assert_eq!(widen(&LogicalType::Boolean, &LogicalType::BigInt), LogicalType::Varchar);
92        assert_eq!(widen(&LogicalType::Date, &LogicalType::BigInt), LogicalType::Varchar);
93        // Not TIMESTAMP, which is what a lattice would say and is not what the binary does.
94        assert_eq!(widen(&LogicalType::Date, &LogicalType::Timestamp), LogicalType::Varchar);
95    }
96
97    #[test]
98    fn the_first_file_fixes_the_names_and_every_file_contributes_a_type() {
99        let sniffed = vec![
100            (
101                "one.csv".to_string(),
102                vec![named("a", LogicalType::BigInt), named("b", LogicalType::BigInt)],
103            ),
104            (
105                "two.csv".to_string(),
106                vec![named("a", LogicalType::Double), named("b", LogicalType::BigInt)],
107            ),
108        ];
109        let fields = across(&sniffed).expect("agrees");
110        assert_eq!(fields[0].ty, LogicalType::Double);
111        assert_eq!(fields[1].ty, LogicalType::BigInt);
112        assert_eq!(fields[0].name, "a");
113    }
114
115    #[test]
116    fn a_column_that_is_not_in_a_later_file_is_duckdbs_own_complaint() {
117        let sniffed = vec![
118            ("one.csv".to_string(), vec![named("a", LogicalType::BigInt)]),
119            ("two.csv".to_string(), vec![named("z", LogicalType::BigInt)]),
120        ];
121        let error = across(&sniffed).unwrap_err();
122        assert!(error.message().starts_with("Schema mismatch between globbed files."), "{error}");
123        assert!(error.message().contains("Main file schema: one.csv"), "{error}");
124        assert!(error.message().contains("Column with name: \"a\" is missing"), "{error}");
125        assert!(error.message().contains("union_by_name=true"), "{error}");
126    }
127
128    #[test]
129    fn a_column_order_that_differs_between_files_is_matched_by_name_and_not_by_position() {
130        let sniffed = vec![
131            (
132                "one.csv".to_string(),
133                vec![named("a", LogicalType::BigInt), named("b", LogicalType::Varchar)],
134            ),
135            (
136                "two.csv".to_string(),
137                vec![named("b", LogicalType::Varchar), named("a", LogicalType::Double)],
138            ),
139        ];
140        let fields = across(&sniffed).expect("agrees");
141        assert_eq!(fields[0].name, "a");
142        assert_eq!(fields[0].ty, LogicalType::Double);
143    }
144}