From cd4127ebf6e13eb5df15e073e44e61e4f5715784 Mon Sep 17 00:00:00 2001 From: Tal Gluck Date: Fri, 28 Jun 2024 16:22:21 +0200 Subject: [PATCH] fix: excel data type handling (#3051) update test .xlsx file add excel tests to deal with columns with multiple data types Closes #3052 --------- Co-authored-by: sam kleinman --- crates/datasources/src/excel/errors.rs | 2 + crates/datasources/src/excel/mod.rs | 82 +++++++++++++++++-------- testdata/sqllogictests/xlsx.slt | 24 +++++--- testdata/xlsx/multiple_sheets.xlsx | Bin 6183 -> 7698 bytes 4 files changed, 76 insertions(+), 32 deletions(-) diff --git a/crates/datasources/src/excel/errors.rs b/crates/datasources/src/excel/errors.rs index 306bfb7b8..e370ee03f 100644 --- a/crates/datasources/src/excel/errors.rs +++ b/crates/datasources/src/excel/errors.rs @@ -11,6 +11,8 @@ use crate::object_store::errors::ObjectStoreSourceError; pub enum ExcelError { #[error("Failed to load XLSX: {0}")] Load(String), + #[error("Cannot parse cell value")] + Parse, #[error("Failed to create record batch: {0}")] CreateRecordBatch(#[from] ArrowError), #[error(transparent)] diff --git a/crates/datasources/src/excel/mod.rs b/crates/datasources/src/excel/mod.rs index 43fb969a3..7e8e3390e 100644 --- a/crates/datasources/src/excel/mod.rs +++ b/crates/datasources/src/excel/mod.rs @@ -3,7 +3,14 @@ use std::io::Cursor; use std::sync::Arc; use calamine::{DataType as CalamineDataType, Range, Reader, Sheets}; -use datafusion::arrow::array::{ArrayRef, BooleanArray, Date64Array, PrimitiveArray, StringArray}; +use datafusion::arrow::array::{ + ArrayRef, + BooleanArray, + Date64Array, + NullArray, + PrimitiveArray, + StringArray, +}; use datafusion::arrow::datatypes::{DataType, Field, Float64Type, Int64Type, Schema}; use datafusion::arrow::record_batch::RecordBatch; use object_store::{ObjectMeta, ObjectStore}; @@ -121,11 +128,11 @@ fn xlsx_sheet_value_to_record_batch( .collect::(), ) as ArrayRef, DataType::Int64 => Arc::new( - rows.map(|r| r.get(i).and_then(|v| v.get_int())) + rows.map(|r| r.get(i).and_then(|v| v.as_i64())) .collect::>(), ) as ArrayRef, DataType::Float64 => Arc::new( - rows.map(|r| r.get(i).and_then(|v| v.get_float())) + rows.map(|r| r.get(i).and_then(|v| v.as_f64())) .collect::>(), ) as ArrayRef, DataType::Date64 => { @@ -140,8 +147,9 @@ fn xlsx_sheet_value_to_record_batch( } Arc::new(arr.finish()) } + DataType::Null => Arc::new(NullArray::new(rows.len())), _ => Arc::new( - rows.map(|r| r.get(i).map(|v| v.get_string().unwrap_or("null"))) + rows.map(|r| r.get(i).map(|v| v.as_string().unwrap_or_default())) .collect::(), ) as ArrayRef, } @@ -158,27 +166,30 @@ pub fn infer_schema( ) -> Result { let mut col_types: HashMap<&str, HashSet> = HashMap::new(); let mut rows = r.rows(); - let col_names: Vec = rows - .next() - .unwrap() - .iter() - .enumerate() - .map( - |(i, c)| match (has_header, c.get_string().map(|s| s.to_string())) { - (true, Some(s)) => Ok(s), - (true, None) => Err(ExcelError::Load("failed to parse header".to_string())), - (false, _) => Ok(format!("col{}", i)), - }, - ) - .collect::>()?; + let col_names: Vec = if has_header { + rows.next() + .unwrap() + .iter() + .enumerate() + .map( + |(i, c)| match (has_header, c.get_string().map(|s| s.to_string())) { + (true, Some(s)) => Ok(s), + (true, None) => Err(ExcelError::Load("failed to parse header".to_string())), + (false, _) => Ok(format!("col{}", i)), + }, + ) + .collect::>()? + } else { + (0..r.rows().next().unwrap_or_default().len()) + .map(|n| format!("{}", n)) + .collect() + }; + for row in rows.take(infer_schema_length) { for (i, col_val) in row.iter().enumerate() { let col_name = col_names.get(i).unwrap(); if let Ok(col_type) = infer_value_type(col_val) { - if col_type == DataType::Null { - continue; - } let entry = col_types.entry(col_name).or_default(); entry.insert(col_type); } else { @@ -196,8 +207,27 @@ pub fn infer_schema( set }); - let dt = set.iter().next().cloned().unwrap_or(DataType::Utf8); - Field::new(col_name.replace(' ', "_"), dt, true) + let field_name = col_name.replace(' ', "_"); + + if set.len() == 1 { + Field::new( + field_name, + set.iter().next().cloned().unwrap_or(DataType::Utf8), + true, + ) + } else if set.contains(&DataType::Utf8) { + Field::new(field_name, DataType::Utf8, true) + } else if set.contains(&DataType::Float64) { + Field::new(field_name, DataType::Float64, true) + } else if set.contains(&DataType::Int64) { + Field::new(field_name, DataType::Int64, true) + } else if set.contains(&DataType::Boolean) { + Field::new(field_name, DataType::Boolean, true) + } else if set.contains(&DataType::Null) { + Field::new(field_name, DataType::Null, true) + } else { + Field::new(field_name, DataType::Utf8, true) + } }) .collect(); @@ -210,11 +240,13 @@ fn infer_value_type(v: &calamine::Data) -> Result { calamine::Data::Float(_) => Ok(DataType::Float64), calamine::Data::Bool(_) => Ok(DataType::Boolean), calamine::Data::String(_) => Ok(DataType::Utf8), + // TODO: parsing value errors that we get from the calamine + // library, could either become nulls or could be + // errors. right now they are errors, and this should probably + // be configurable, however... calamine::Data::Error(e) => Err(ExcelError::Load(e.to_string())), calamine::Data::DateTime(_) => Ok(DataType::Date64), calamine::Data::Empty => Ok(DataType::Null), - _ => Err(ExcelError::Load( - "Failed to parse the cell value".to_owned(), - )), + _ => Err(ExcelError::Parse), } } diff --git a/testdata/sqllogictests/xlsx.slt b/testdata/sqllogictests/xlsx.slt index 62a12482a..adc3839f2 100644 --- a/testdata/sqllogictests/xlsx.slt +++ b/testdata/sqllogictests/xlsx.slt @@ -118,10 +118,10 @@ create external table bad_report from excel options(location='./invalid_path/ran # should default to has_header=true and the first sheet of the file, if not specified statement ok -create external table html_table from excel options(location='https://github.com/GlareDB/glaredb/raw/main/testdata/xlsx/multiple_sheets.xlsx'); +create external table http_table from excel options(location='https://github.com/GlareDB/glaredb/raw/main/testdata/xlsx/multiple_sheets.xlsx'); query -select "Resources", "Cost", "Revenue" from html_table; +select "Resources", "Cost", "Revenue" from http_table; ---- 1 10 100 2 20 200 @@ -131,7 +131,7 @@ select "Resources", "Cost", "Revenue" from html_table; # should default to has_header=true and the first sheet of the file, if not specified query -select "Resources", "Cost", "Revenue" from read_excel('https://github.com/GlareDB/glaredb/raw/main/testdata/xlsx/multiple_sheets.xlsx'); +select "Resources", "Cost", "Revenue" from read_excel('./testdata/xlsx/multiple_sheets.xlsx'); ---- 1 10 100 2 20 200 @@ -139,10 +139,20 @@ select "Resources", "Cost", "Revenue" from read_excel('https://github.com/GlareD 4 40 400 5 50 500 -query -select * from read_excel('https://github.com/GlareDB/glaredb/raw/main/testdata/xlsx/multiple_sheets.xlsx', sheet_name => 'other', has_header => false); +query T +select * from read_excel('./testdata/xlsx/multiple_sheets.xlsx', sheet_name => 'other', has_header => false); ---- -NULL +HEADING 1 2 -3 \ No newline at end of file +3 + +query +select * from read_excel('./testdata/xlsx/multiple_sheets.xlsx', sheet_name => 'multiple_data_types', has_header => true); +---- +1 1 foo foo +2 2 bar bar +3 3 baz baz +foo 4.0 4 4.0 +5 5.0 5 5.0 +bar (empty) (empty) (empty) diff --git a/testdata/xlsx/multiple_sheets.xlsx b/testdata/xlsx/multiple_sheets.xlsx index 6031f1c3a88ff158a75209a7d07bbbe955ec69ec..eafe2e72da191273007616836017181e9e0cffcd 100644 GIT binary patch literal 7698 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 literal 6183 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