rudb_csv/infer.rs
1//! What type a column of text is.
2//!
3//! A CSV file has no types in it, so a reader that wants to hand a `BIGINT` to an aggregate has to
4//! decide, and a wrong decision is a wrong answer rather than a slow query. The rule is DuckDB's
5//! and it is a ladder: every value in the column is tried against each type in turn and the first
6//! type every value fits is the column's type, with `VARCHAR` at the bottom because everything fits
7//! it. A column of nothing but nulls is `VARCHAR` too, which is the bottom of the same ladder.
8//!
9//! The order was read off duckdb v1.4.1 rather than chosen. `BOOLEAN` before `BIGINT` matters,
10//! because a column of `true` and `false` is a boolean and not a pair of words. `BIGINT` before
11//! `DOUBLE` matters, because a column of whole numbers should be whole. `DATE` before `TIMESTAMP`
12//! is the order the binary uses, and those two do overlap because a cast to `DATE` reads a time on
13//! the end and throws it away, so `timed` below keeps a timestamp off the `DATE` rung.
14//!
15//! `TIME` sits between `DOUBLE` and `DATE`, which is where the binary has it. Nothing it takes is
16//! anything `DATE` takes, so the position between those two is not observable and the rung above it
17//! is, which is the one that matters.
18//!
19//! Six of the rules are the sniffer's rather than the cast's, and all six were measured. `007`
20//! is a `VARCHAR` here although `CAST('007' AS BIGINT)` is 7, because a column of zero padded
21//! numbers is a column of codes and adding them up is not what anybody meant. `+1` is a `VARCHAR`
22//! for the same sort of reason. A day with a time on it is a `TIMESTAMP` and not a `DATE`, and a
23//! clock with anything else around it is a `VARCHAR` and not a `TIME`, although the casts to `DATE`
24//! and to `TIME` take both. A point or an exponent keeps a value off the `BIGINT` rung, so a column
25//! of `1.5` is a `DOUBLE` column although the cast reads that as two, and a separator keeps it off
26//! both number rungs, so a column of `1_000` is a `VARCHAR` column although the cast reads that as a
27//! thousand. Everything else defers to the cast, which is the point: a string the sniffer calls a
28//! `BIGINT` is a string the reader then casts to `BIGINT`, so a test that disagreed with the cast
29//! would produce a column whose declared type its own values do not fit.
30//!
31//! There is one column where upstream does disagree with itself and this does not follow it. A
32//! column holding both `0x10` and `1.5` sniffs as `DOUBLE` there, because its `DOUBLE` rung takes a
33//! radix that its cast to `DOUBLE` then refuses, so reading the file raises. Here the rung defers to
34//! the cast, the column comes out `VARCHAR`, and the file reads.
35
36use rudb_common::{LogicalType, Value};
37use rudb_kernels::cast_value;
38
39/// The types tried, in the order they are tried. `VARCHAR` is the bottom and is not in here because
40/// it never fails.
41pub const LADDER: [LogicalType; 6] = [
42 LogicalType::Boolean,
43 LogicalType::BigInt,
44 LogicalType::Double,
45 LogicalType::Time,
46 LogicalType::Date,
47 LogicalType::Timestamp,
48];
49
50/// How many rows the sniffer looks at.
51///
52/// DuckDB's `sample_size` default, which it prints in the block under a conversion error. A value
53/// past this that does not fit the type the sample chose is an error at read time rather than a
54/// wider type, because widening would mean going back and rewriting the chunks already handed out.
55pub const SAMPLE: usize = 20480;
56
57/// The type of a column, given every value the sample had for it.
58///
59/// A `None` is a null and is skipped, because a null fits every type and a column that is all nulls
60/// has nothing to go on.
61#[must_use]
62pub fn column(values: &[Option<&str>]) -> LogicalType {
63 if values.iter().all(Option::is_none) {
64 // Otherwise every rung is satisfied vacuously and the column comes back as the first one.
65 return LogicalType::Varchar;
66 }
67 for candidate in LADDER {
68 if values.iter().flatten().all(|text| fits(text, &candidate)) {
69 return candidate;
70 }
71 }
72 LogicalType::Varchar
73}
74
75/// Whether one value would read as `candidate`.
76#[must_use]
77pub fn fits(text: &str, candidate: &LogicalType) -> bool {
78 match candidate {
79 LogicalType::Boolean => is_boolean(text),
80 LogicalType::BigInt if !numeric(text) || !whole(text) => false,
81 LogicalType::Double if !numeric(text) => false,
82 LogicalType::Date if timed(text) => false,
83 LogicalType::Time if !clock(text) => false,
84 _ => {
85 let value = Value::Varchar(text.to_string());
86 matches!(cast_value(&value, candidate, true), Ok(converted) if !converted.is_null())
87 }
88 }
89}
90
91/// Whether a written day carries a time on the end of it.
92///
93/// The third rule that is not the cast's. `CAST('2013-07-15 10:00:00' AS DATE)` is a date upstream,
94/// the time is read and thrown away, so the two rungs do overlap and the sniffer has to tell them
95/// apart itself or every timestamp column would come back as a `DATE`.
96fn timed(text: &str) -> bool {
97 text.trim().contains([' ', 'T'])
98}
99
100/// Whether a value is nothing but a clock.
101///
102/// The fourth rule that is not the cast's, and the same sort of rule as `timed`. The cast to `TIME`
103/// takes a date in front and any amount of rubbish behind, so it would take a whole timestamp
104/// column and every value in it would lose its day. A column of `12:34:56 UTC` is a `VARCHAR` to
105/// the binary, which is what this refuses it for.
106fn clock(text: &str) -> bool {
107 let text = text.trim();
108 !text.is_empty()
109 && text.bytes().all(|byte| byte.is_ascii_digit() || byte == b':' || byte == b'.')
110}
111
112/// The spellings DuckDB's sniffer reads as a boolean.
113///
114/// Not `1` and `0`, although the cast takes both, because a column of ones and zeroes is a column of
115/// numbers far more often than it is a column of flags and the binary agrees. Not `y` and `n`
116/// either, and not `on` and `off`, both of which were tried against it.
117fn is_boolean(text: &str) -> bool {
118 ["true", "false", "t", "f", "yes", "no"]
119 .iter()
120 .any(|spelling| text.trim().eq_ignore_ascii_case(spelling))
121}
122
123/// Whether a number written like this is a number to the sniffer.
124///
125/// Three of the rules that are not the cast's. A leading `+` is refused, and so is a leading zero
126/// with another digit behind it, which is how a column of `007` stays a column of `007` rather than
127/// becoming a column of sevens. The sign is looked past for neither of them, because the binary does
128/// not look past it either: `-007` really is a `BIGINT` there and this reproduces that rather than
129/// tidying it up. A separator is refused on both rungs, so a column of `1_000` is a `VARCHAR` column
130/// although `CAST('1_000' AS BIGINT)` is a thousand.
131fn numeric(text: &str) -> bool {
132 let text = text.trim();
133 if text.contains('_') {
134 return false;
135 }
136 let mut bytes = text.bytes();
137 match bytes.next() {
138 Some(b'+') => false,
139 Some(b'0') => !matches!(bytes.next(), Some(byte) if byte.is_ascii_digit()),
140 _ => true,
141 }
142}
143
144/// Whether a whole number written like this is one to the sniffer.
145///
146/// The sixth rule that is not the cast's, and the one the rest of #369 turned up. The cast reads
147/// `'1.5'` as two and `'1e3'` as a thousand, and upstream sniffs a column of either as `DOUBLE`, so
148/// the rung above `DOUBLE` has to refuse a point and an exponent itself or every column of written
149/// decimals would come back rounded. A radix is looked at first, because `0x1e` is thirty and the
150/// `e` in the middle of it is a digit.
151fn whole(text: &str) -> bool {
152 let text = text.trim();
153 matches!(text.get(..2), Some("0x" | "0X" | "0b" | "0B")) || !text.contains(['.', 'e', 'E'])
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 fn of(values: &[&str]) -> LogicalType {
161 let values: Vec<Option<&str>> = values.iter().map(|text| Some(*text)).collect();
162 column(&values)
163 }
164
165 #[test]
166 fn each_rung_of_the_ladder_is_the_type_duckdb_sniffs_for_it() {
167 assert_eq!(of(&["true", "false"]), LogicalType::Boolean);
168 assert_eq!(of(&["1", "2"]), LogicalType::BigInt);
169 assert_eq!(of(&["1.5", "2"]), LogicalType::Double);
170 assert_eq!(of(&["2020-01-02", "2021-03-04"]), LogicalType::Date);
171 assert_eq!(of(&["2020-01-02 03:04:05"]), LogicalType::Timestamp);
172 assert_eq!(of(&["1", "x"]), LogicalType::Varchar);
173 }
174
175 /// The spellings the cast learned in #369 and what the sniffer does with each of them, all of
176 /// it read off the pinned binary one file at a time. A radix stays on the `BIGINT` rung, a
177 /// point and an exponent drop to `DOUBLE`, and a separator drops all the way.
178 #[test]
179 fn a_column_of_numbers_written_the_other_ways_lands_on_the_rung_duckdb_puts_it_on() {
180 assert_eq!(of(&["0x10"]), LogicalType::BigInt);
181 assert_eq!(of(&["0X10"]), LogicalType::BigInt);
182 assert_eq!(of(&["0x1e"]), LogicalType::BigInt);
183 assert_eq!(of(&["0b101"]), LogicalType::BigInt);
184 assert_eq!(of(&["1.5"]), LogicalType::Double);
185 assert_eq!(of(&["1."]), LogicalType::Double);
186 assert_eq!(of(&[".5"]), LogicalType::Double);
187 assert_eq!(of(&["1e3"]), LogicalType::Double);
188 assert_eq!(of(&["1E3"]), LogicalType::Double);
189 assert_eq!(of(&["1e-3"]), LogicalType::Double);
190 assert_eq!(of(&["1e18"]), LogicalType::Double);
191 assert_eq!(of(&["1_000"]), LogicalType::Varchar);
192 assert_eq!(of(&["1_000", "1"]), LogicalType::Varchar);
193 assert_eq!(of(&["1.5", "1_0.5"]), LogicalType::Varchar);
194 assert_eq!(of(&["1e"]), LogicalType::Varchar);
195 }
196
197 /// The `TIME` rung, and the two values next to it that the binary leaves alone although the
198 /// cast underneath takes both.
199 #[test]
200 fn a_column_of_clocks_is_a_time_and_a_column_of_anything_else_is_not() {
201 assert_eq!(of(&["03:04:05", "12:34:56"]), LogicalType::Time);
202 assert_eq!(of(&["12:34"]), LogicalType::Time);
203 assert_eq!(of(&["12:34:56.5"]), LogicalType::Time);
204 assert_eq!(of(&["12:34:56 UTC"]), LogicalType::Varchar);
205 assert_eq!(of(&["2020-01-02 03:04:05"]), LogicalType::Timestamp);
206 for taken in ["12:34:56 UTC", "2020-01-02 03:04:05"] {
207 let value = Value::Varchar(taken.into());
208 assert!(
209 cast_value(&value, &LogicalType::Time, true).is_ok_and(|time| !time.is_null()),
210 "{taken}: the cast takes it, which is why the rung has a rule of its own"
211 );
212 }
213 }
214
215 /// The `DATE` rung would swallow this column otherwise, because the cast under it takes a time
216 /// on the end of a day and throws it away, so the column would be declared a `DATE` and every
217 /// value in it would lose its time.
218 #[test]
219 fn a_column_of_timestamps_does_not_stop_at_the_date_rung() {
220 assert_eq!(of(&["2020-01-02 03:04:05", "2020-01-03 00:00:00"]), LogicalType::Timestamp);
221 assert_eq!(of(&["2020-01-02T03:04:05"]), LogicalType::Timestamp);
222 assert_eq!(of(&["2020-01-02", "2020-01-03 03:04:05"]), LogicalType::Timestamp);
223 let date = Value::Varchar("2020-01-02 03:04:05".into());
224 assert!(
225 cast_value(&date, &LogicalType::Date, true).is_ok_and(|value| !value.is_null()),
226 "the cast still takes it, which is why the rung has a rule of its own"
227 );
228 }
229
230 #[test]
231 fn a_column_of_nothing_but_nulls_is_a_varchar() {
232 assert_eq!(column(&[None, None]), LogicalType::Varchar);
233 assert_eq!(column(&[]), LogicalType::Varchar);
234 }
235
236 #[test]
237 fn a_null_in_a_column_does_not_change_what_the_rest_of_it_is() {
238 assert_eq!(column(&[Some("1"), None, Some("2")]), LogicalType::BigInt);
239 }
240
241 #[test]
242 fn ones_and_zeroes_are_numbers_rather_than_flags() {
243 // Measured. `CAST('1' AS BOOLEAN)` is true, so a ladder that asked the cast would call this
244 // column a boolean, and duckdb v1.4.1 calls it a BIGINT.
245 assert_eq!(of(&["0", "1"]), LogicalType::BigInt);
246 }
247
248 #[test]
249 fn the_boolean_spellings_are_the_six_the_binary_takes_and_no_more() {
250 for yes in ["true", "TRUE", "True", "t", "T", "yes", "Yes"] {
251 assert_eq!(of(&[yes]), LogicalType::Boolean, "{yes}");
252 }
253 for no in ["on", "off", "y", "n"] {
254 assert_eq!(of(&[no]), LogicalType::Varchar, "{no}");
255 }
256 }
257
258 #[test]
259 fn a_zero_padded_number_stays_the_text_it_was_written_as() {
260 assert_eq!(of(&["007", "008"]), LogicalType::Varchar);
261 assert_eq!(of(&["00"]), LogicalType::Varchar);
262 // And the two that go the other way, both measured against the binary.
263 assert_eq!(of(&["0"]), LogicalType::BigInt);
264 assert_eq!(of(&["-007"]), LogicalType::BigInt);
265 }
266
267 #[test]
268 fn a_leading_plus_is_not_a_number_to_the_sniffer() {
269 assert_eq!(of(&["+1"]), LogicalType::Varchar);
270 }
271
272 #[test]
273 fn space_around_a_number_does_not_stop_it_being_one() {
274 assert_eq!(of(&[" 1", "2 "]), LogicalType::BigInt);
275 }
276
277 #[test]
278 fn a_whole_number_too_big_for_a_bigint_widens_to_a_double() {
279 assert_eq!(of(&["99999999999999999999"]), LogicalType::Double);
280 }
281}