1use rudb_common::{Error, Field, LogicalType, Result};
21
22#[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
40pub 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#[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 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}