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