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