Skip to main content

polars_io/csv/read/
schema_inference.rs

1use polars_buffer::Buffer;
2use polars_core::prelude::*;
3#[cfg(feature = "polars-time")]
4use polars_time::chunkedarray::string::infer as date_infer;
5#[cfg(feature = "polars-time")]
6use polars_time::prelude::string::Pattern;
7use polars_utils::format_pl_smallstr;
8
9use super::splitfields::SplitFields;
10use super::{CsvParseOptions, NullValues};
11use crate::utils::{BOOLEAN_RE, FLOAT_RE, FLOAT_RE_DECIMAL, INTEGER_RE};
12
13/// Low-level CSV schema inference function.
14///
15/// Use `read_until_start_and_infer_schema` instead.
16#[allow(clippy::too_many_arguments)]
17pub(super) fn infer_file_schema_impl(
18    header_line: &Option<Buffer<u8>>,
19    content_lines: &[Buffer<u8>],
20    infer_all_as_str: bool,
21    parse_options: &CsvParseOptions,
22    column_names_overwrite: Option<&[PlSmallStr]>,
23    schema_overwrite: Option<&Schema>,
24) -> PolarsResult<Schema> {
25    let mut headers = if let Some(header_line) = header_line {
26        infer_headers(header_line, parse_options)?
27    } else {
28        Vec::with_capacity(8)
29    };
30
31    let extend_header_with_unknown_column = header_line.is_none();
32
33    let mut column_types = vec![PlIndexSet::<DataType>::with_capacity(4); headers.len()];
34    let mut nulls = vec![false; headers.len()];
35
36    for content_line in content_lines {
37        infer_types_from_line(
38            content_line,
39            infer_all_as_str,
40            &mut headers,
41            extend_header_with_unknown_column,
42            parse_options,
43            &mut column_types,
44            &mut nulls,
45        );
46    }
47
48    if let Some(column_names_overwrite) = column_names_overwrite {
49        // 2.0: Replace with checks against missing/extra columns policy.
50        polars_ensure!(
51            column_names_overwrite.len() <= headers.len(),
52            ShapeMismatch:
53            "The length of the new names list should be equal to or less than the original column length",
54        );
55        for (i, name) in column_names_overwrite.iter().cloned().enumerate() {
56            if i < headers.len() {
57                headers[i] = name
58            } else {
59                headers.push(name)
60            }
61
62            if i >= column_types.len() {
63                column_types.push(PlIndexSet::from_iter(Some(DataType::Null)))
64            }
65        }
66    }
67
68    Ok(build_schema(&headers, &column_types, schema_overwrite))
69}
70
71fn infer_headers(
72    mut header_line: &[u8],
73    parse_options: &CsvParseOptions,
74) -> PolarsResult<Vec<PlSmallStr>> {
75    let len = header_line.len();
76
77    if header_line.last().copied() == Some(b'\r') {
78        header_line = &header_line[..len - 1];
79    }
80
81    let byterecord = SplitFields::new(
82        header_line,
83        parse_options.separator,
84        parse_options.quote_char,
85        parse_options.eol_char,
86    );
87
88    let headers = byterecord
89        .map(|(slice, needs_escaping)| {
90            let slice_escaped = if needs_escaping && (slice.len() >= 2) {
91                &slice[1..(slice.len() - 1)]
92            } else {
93                slice
94            };
95            String::from_utf8_lossy(slice_escaped)
96        })
97        .collect::<Vec<_>>();
98
99    let mut deduplicated_headers = PlIndexSet::with_capacity(headers.len());
100    let mut header_names = PlHashMap::with_capacity(headers.len());
101
102    for name in &headers {
103        let count = header_names.entry(name.as_ref()).or_insert(0usize);
104        let duplicated = *count != 0;
105        let deduplicated_name = if duplicated {
106            format_pl_smallstr!("{}_duplicated_{}", name, *count - 1)
107        } else {
108            PlSmallStr::from_str(name)
109        };
110
111        if !deduplicated_headers.insert(deduplicated_name.clone()) {
112            let (deduplicated_from, nth_duplicated) = if duplicated {
113                (name.as_ref(), 1 + *count)
114            } else {
115                let i = deduplicated_name.rfind("_duplicated_").unwrap();
116                (
117                    &deduplicated_name[..i],
118                    2 + deduplicated_name[i + 12..].parse::<usize>().unwrap(),
119                )
120            };
121
122            polars_bail!(
123                Duplicate:
124                "de-duplication of occurrence #{nth_duplicated} of column name '{deduplicated_from}' \
125                failed; the name '{deduplicated_name}' also exists in the file."
126            )
127        }
128
129        *count += 1;
130    }
131
132    Ok(Vec::from_iter(deduplicated_headers))
133}
134
135fn infer_types_from_line(
136    mut line: &[u8],
137    infer_all_as_str: bool,
138    headers: &mut Vec<PlSmallStr>,
139    extend_header_with_unknown_column: bool,
140    parse_options: &CsvParseOptions,
141    column_types: &mut Vec<PlIndexSet<DataType>>,
142    nulls: &mut Vec<bool>,
143) {
144    let line_len = line.len();
145    if line.last().copied() == Some(b'\r') {
146        line = &line[..line_len - 1];
147    }
148
149    let record = SplitFields::new(
150        line,
151        parse_options.separator,
152        parse_options.quote_char,
153        parse_options.eol_char,
154    );
155
156    for (i, (slice, needs_escaping)) in record.enumerate() {
157        if i >= headers.len() {
158            if extend_header_with_unknown_column {
159                headers.push(column_name(i));
160                column_types.push(Default::default());
161                nulls.push(false);
162            } else {
163                break;
164            }
165        }
166
167        if infer_all_as_str {
168            column_types[i].insert(DataType::String);
169            continue;
170        }
171
172        if slice.is_empty() {
173            nulls[i] = true;
174        } else {
175            let slice_escaped = if needs_escaping && (slice.len() >= 2) {
176                &slice[1..(slice.len() - 1)]
177            } else {
178                slice
179            };
180            let s = String::from_utf8_lossy(slice_escaped);
181            let dtype = match &parse_options.null_values {
182                None => Some(infer_field_schema(
183                    &s,
184                    parse_options.try_parse_dates,
185                    parse_options.decimal_comma,
186                )),
187                Some(NullValues::AllColumns(names)) => {
188                    if !names.iter().any(|nv| nv == s.as_ref()) {
189                        Some(infer_field_schema(
190                            &s,
191                            parse_options.try_parse_dates,
192                            parse_options.decimal_comma,
193                        ))
194                    } else {
195                        None
196                    }
197                },
198                Some(NullValues::AllColumnsSingle(name)) => {
199                    if s.as_ref() != name.as_str() {
200                        Some(infer_field_schema(
201                            &s,
202                            parse_options.try_parse_dates,
203                            parse_options.decimal_comma,
204                        ))
205                    } else {
206                        None
207                    }
208                },
209                Some(NullValues::Named(names)) => {
210                    let current_name = &headers[i];
211                    let null_name = &names.iter().find(|name| name.0 == current_name);
212
213                    if let Some(null_name) = null_name {
214                        if null_name.1.as_str() != s.as_ref() {
215                            Some(infer_field_schema(
216                                &s,
217                                parse_options.try_parse_dates,
218                                parse_options.decimal_comma,
219                            ))
220                        } else {
221                            None
222                        }
223                    } else {
224                        Some(infer_field_schema(
225                            &s,
226                            parse_options.try_parse_dates,
227                            parse_options.decimal_comma,
228                        ))
229                    }
230                },
231            };
232            if let Some(dtype) = dtype {
233                column_types[i].insert(dtype);
234            }
235        }
236    }
237}
238
239fn build_schema(
240    headers: &[PlSmallStr],
241    column_types: &[PlIndexSet<DataType>],
242    schema_overwrite: Option<&Schema>,
243) -> Schema {
244    assert!(headers.len() == column_types.len());
245
246    let get_schema_overwrite = |field_name| {
247        if let Some(schema_overwrite) = schema_overwrite {
248            // Apply schema_overwrite by column name only. Positional overrides are handled
249            // separately via dtype_overwrite.
250            if let Some((_, name, dtype)) = schema_overwrite.get_full(field_name) {
251                return Some((name.clone(), dtype.clone()));
252            }
253        }
254
255        None
256    };
257
258    Schema::from_iter(
259        headers
260            .iter()
261            .zip(column_types)
262            .map(|(field_name, type_possibilities)| {
263                let (name, dtype) = get_schema_overwrite(field_name).unwrap_or_else(|| {
264                    (
265                        field_name.clone(),
266                        finish_infer_field_schema(type_possibilities),
267                    )
268                });
269
270                Field::new(name, dtype)
271            }),
272    )
273}
274
275pub fn finish_infer_field_schema(possibilities: &PlIndexSet<DataType>) -> DataType {
276    // determine data type based on possible types
277    // if there are incompatible types, use DataType::String
278    match possibilities.len() {
279        1 => possibilities.iter().next().unwrap().clone(),
280        2 if possibilities.contains(&DataType::Int64)
281            && possibilities.contains(&DataType::Float64) =>
282        {
283            // we have an integer and double, fall down to double
284            DataType::Float64
285        },
286        #[cfg(feature = "dtype-i128")]
287        2 if possibilities.contains(&DataType::Int64)
288            && possibilities.contains(&DataType::Int128) =>
289        {
290            // all values fit within i128
291            DataType::Int128
292        },
293        #[cfg(feature = "dtype-i128")]
294        2 if possibilities.contains(&DataType::Int128)
295            && possibilities.contains(&DataType::Float64) =>
296        {
297            // fall down to double for mixed int128 and float
298            DataType::Float64
299        },
300        // default to String for conflicting datatypes (e.g bool and int)
301        _ => DataType::String,
302    }
303}
304
305/// Infer the data type of a record
306pub fn infer_field_schema(string: &str, try_parse_dates: bool, decimal_comma: bool) -> DataType {
307    // when quoting is enabled in the reader, these quotes aren't escaped, we default to
308    // String for them
309    let bytes = string.as_bytes();
310    if bytes.len() >= 2 && *bytes.first().unwrap() == b'"' && *bytes.last().unwrap() == b'"' {
311        if try_parse_dates {
312            #[cfg(feature = "polars-time")]
313            {
314                match date_infer::infer_pattern_single(&string[1..string.len() - 1]) {
315                    Some(pattern_with_offset) => match pattern_with_offset {
316                        Pattern::DatetimeYMD | Pattern::DatetimeDMY => {
317                            DataType::Datetime(TimeUnit::Microseconds, None)
318                        },
319                        Pattern::DateYMD | Pattern::DateDMY => DataType::Date,
320                        Pattern::DatetimeYMDZ => {
321                            DataType::Datetime(TimeUnit::Microseconds, Some(TimeZone::UTC))
322                        },
323                        Pattern::Time => DataType::Time,
324                    },
325                    None => DataType::String,
326                }
327            }
328            #[cfg(not(feature = "polars-time"))]
329            {
330                panic!("activate one of {{'dtype-date', 'dtype-datetime', dtype-time'}} features")
331            }
332        } else {
333            DataType::String
334        }
335    }
336    // match regex in a particular order
337    else if BOOLEAN_RE.is_match(string) {
338        DataType::Boolean
339    } else if !decimal_comma && FLOAT_RE.is_match(string)
340        || decimal_comma && FLOAT_RE_DECIMAL.is_match(string)
341    {
342        DataType::Float64
343    } else if INTEGER_RE.is_match(string) {
344        if string.parse::<i64>().is_ok() {
345            DataType::Int64
346        } else {
347            #[cfg(feature = "dtype-i128")]
348            {
349                DataType::Int128
350            }
351            #[cfg(not(feature = "dtype-i128"))]
352            {
353                DataType::Int64
354            }
355        }
356    } else if try_parse_dates {
357        #[cfg(feature = "polars-time")]
358        {
359            match date_infer::infer_pattern_single(string) {
360                Some(pattern_with_offset) => match pattern_with_offset {
361                    Pattern::DatetimeYMD | Pattern::DatetimeDMY => {
362                        DataType::Datetime(TimeUnit::Microseconds, None)
363                    },
364                    Pattern::DateYMD | Pattern::DateDMY => DataType::Date,
365                    Pattern::DatetimeYMDZ => {
366                        DataType::Datetime(TimeUnit::Microseconds, Some(TimeZone::UTC))
367                    },
368                    Pattern::Time => DataType::Time,
369                },
370                None => DataType::String,
371            }
372        }
373        #[cfg(not(feature = "polars-time"))]
374        {
375            panic!("activate one of {{'dtype-date', 'dtype-datetime', dtype-time'}} features")
376        }
377    } else {
378        DataType::String
379    }
380}
381
382fn column_name(i: usize) -> PlSmallStr {
383    format_pl_smallstr!("column_{}", i + 1)
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    #[test]
391    fn test_infer_field_schema_i64_overflow() {
392        // Values within i64 range should infer as Int64.
393        assert_eq!(
394            infer_field_schema("9223372036854775807", false, false),
395            DataType::Int64,
396        );
397
398        // Values exceeding i64::MAX should infer as Int128 when the feature is enabled,
399        // otherwise as String.
400        let large = "12345678901234567890";
401        #[cfg(feature = "dtype-i128")]
402        assert_eq!(infer_field_schema(large, false, false), DataType::Int128,);
403        #[cfg(not(feature = "dtype-i128"))]
404        assert_eq!(infer_field_schema(large, false, false), DataType::Int64,);
405    }
406
407    #[test]
408    #[cfg(feature = "dtype-i128")]
409    fn test_finish_infer_field_schema_i64_and_i128() {
410        let mut possibilities = PlIndexSet::new();
411        possibilities.insert(DataType::Int64);
412        possibilities.insert(DataType::Int128);
413        assert_eq!(finish_infer_field_schema(&possibilities), DataType::Int128);
414    }
415}