Skip to content

Commit 5918ef8

Browse files
authored
Merge pull request #10 from polygon-io/support_csv_truncate
X-1035 Datafusion: Support csv truncate
2 parents 95aadb9 + 5aa43e5 commit 5918ef8

10 files changed

Lines changed: 191 additions & 2 deletions

File tree

datafusion/common/src/config.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1946,6 +1946,10 @@ config_namespace! {
19461946
// The input regex for Nulls when loading CSVs.
19471947
pub null_regex: Option<String>, default = None
19481948
pub comment: Option<u8>, default = None
1949+
// Whether to allow truncated rows when parsing.
1950+
// By default this is set to false and will error if the CSV rows have different lengths.
1951+
// When set to true then it will allow records with less than the expected number of columns
1952+
pub truncated_rows: Option<bool>, default = None
19491953
}
19501954
}
19511955

@@ -2038,6 +2042,15 @@ impl CsvOptions {
20382042
self
20392043
}
20402044

2045+
/// Whether to allow truncated rows when parsing.
2046+
/// By default this is set to false and will error if the CSV rows have different lengths.
2047+
/// When set to true then it will allow records with less than the expected number of columns and fill the missing columns with nulls.
2048+
/// If the record’s schema is not nullable, then it will still return an error.
2049+
pub fn with_truncated_rows(mut self, allow: bool) -> Self {
2050+
self.truncated_rows = Some(allow);
2051+
self
2052+
}
2053+
20412054
/// The delimiter character.
20422055
pub fn delimiter(&self) -> u8 {
20432056
self.delimiter

datafusion/core/src/datasource/file_format/csv.rs

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,15 @@ mod tests {
4747
use datafusion_physical_plan::{collect, ExecutionPlan};
4848

4949
use arrow::array::{
50-
BooleanArray, Float64Array, Int32Array, RecordBatch, StringArray,
50+
Array, BooleanArray, Float64Array, Int32Array, RecordBatch, StringArray,
5151
};
5252
use arrow::compute::concat_batches;
5353
use arrow::csv::ReaderBuilder;
5454
use arrow::util::pretty::pretty_format_batches;
5555
use async_trait::async_trait;
5656
use bytes::Bytes;
5757
use chrono::DateTime;
58+
use datafusion_common::config::CsvOptions;
5859
use futures::stream::BoxStream;
5960
use futures::StreamExt;
6061
use insta::assert_snapshot;
@@ -1174,4 +1175,141 @@ mod tests {
11741175
.build_decoder();
11751176
DecoderDeserializer::new(CsvDecoder::new(decoder))
11761177
}
1178+
1179+
fn csv_deserializer_with_truncated(
1180+
batch_size: usize,
1181+
schema: &Arc<Schema>,
1182+
) -> impl BatchDeserializer<Bytes> {
1183+
// using Arrow's ReaderBuilder and enabling truncated_rows
1184+
let decoder = ReaderBuilder::new(schema.clone())
1185+
.with_batch_size(batch_size)
1186+
.with_truncated_rows(true) // <- enable runtime truncated_rows
1187+
.build_decoder();
1188+
DecoderDeserializer::new(CsvDecoder::new(decoder))
1189+
}
1190+
1191+
#[tokio::test]
1192+
async fn infer_schema_with_truncated_rows_true() -> Result<()> {
1193+
let session_ctx = SessionContext::new();
1194+
let state = session_ctx.state();
1195+
1196+
// CSV: header has 3 columns, but first data row has only 2 columns, second row has 3
1197+
let csv_data = Bytes::from("a,b,c\n1,2\n3,4,5\n");
1198+
let variable_object_store = Arc::new(VariableStream::new(csv_data, 1));
1199+
let object_meta = ObjectMeta {
1200+
location: Path::parse("/")?,
1201+
last_modified: DateTime::default(),
1202+
size: u64::MAX,
1203+
e_tag: None,
1204+
version: None,
1205+
};
1206+
1207+
// Construct CsvFormat and enable truncated_rows via CsvOptions
1208+
let csv_options = CsvOptions::default().with_truncated_rows(true);
1209+
let csv_format = CsvFormat::default()
1210+
.with_has_header(true)
1211+
.with_options(csv_options)
1212+
.with_schema_infer_max_rec(10);
1213+
1214+
let inferred_schema = csv_format
1215+
.infer_schema(
1216+
&state,
1217+
&(variable_object_store.clone() as Arc<dyn ObjectStore>),
1218+
&[object_meta],
1219+
)
1220+
.await?;
1221+
1222+
// header has 3 columns; inferred schema should also have 3
1223+
assert_eq!(inferred_schema.fields().len(), 3);
1224+
1225+
// inferred columns should be nullable
1226+
for f in inferred_schema.fields() {
1227+
assert!(f.is_nullable());
1228+
}
1229+
1230+
Ok(())
1231+
}
1232+
#[test]
1233+
fn test_decoder_truncated_rows_runtime() -> Result<()> {
1234+
// Synchronous test: Decoder API used here is synchronous
1235+
let schema = csv_schema(); // helper already defined in file
1236+
1237+
// Construct a decoder that enables truncated_rows at runtime
1238+
let mut deserializer = csv_deserializer_with_truncated(10, &schema);
1239+
1240+
// Provide two rows: first row complete, second row missing last column
1241+
let input = Bytes::from("0,0.0,true,0-string\n1,1.0,true\n");
1242+
deserializer.digest(input);
1243+
1244+
// Finish and collect output
1245+
deserializer.finish();
1246+
1247+
let output = deserializer.next()?;
1248+
match output {
1249+
DeserializerOutput::RecordBatch(batch) => {
1250+
// ensure at least two rows present
1251+
assert!(batch.num_rows() >= 2);
1252+
// column 4 (index 3) should be a StringArray where second row is NULL
1253+
let col4 = batch
1254+
.column(3)
1255+
.as_any()
1256+
.downcast_ref::<StringArray>()
1257+
.expect("column 4 should be StringArray");
1258+
1259+
// first row present, second row should be null
1260+
assert!(!col4.is_null(0));
1261+
assert!(col4.is_null(1));
1262+
}
1263+
other => panic!("expected RecordBatch but got {other:?}"),
1264+
}
1265+
Ok(())
1266+
}
1267+
1268+
#[tokio::test]
1269+
async fn infer_schema_truncated_rows_false_error() -> Result<()> {
1270+
let session_ctx = SessionContext::new();
1271+
let state = session_ctx.state();
1272+
1273+
// CSV: header has 4 cols, first data row has 3 cols -> truncated at end
1274+
let csv_data = Bytes::from("id,a,b,c\n1,foo,bar\n2,foo,bar,baz\n");
1275+
let variable_object_store = Arc::new(VariableStream::new(csv_data, 1));
1276+
let object_meta = ObjectMeta {
1277+
location: Path::parse("/")?,
1278+
last_modified: DateTime::default(),
1279+
size: u64::MAX,
1280+
e_tag: None,
1281+
version: None,
1282+
};
1283+
1284+
// CsvFormat without enabling truncated_rows (default behavior = false)
1285+
let csv_format = CsvFormat::default()
1286+
.with_has_header(true)
1287+
.with_schema_infer_max_rec(10);
1288+
1289+
let res = csv_format
1290+
.infer_schema(
1291+
&state,
1292+
&(variable_object_store.clone() as Arc<dyn ObjectStore>),
1293+
&[object_meta],
1294+
)
1295+
.await;
1296+
1297+
// Expect an error due to unequal lengths / incorrect number of fields
1298+
assert!(
1299+
res.is_err(),
1300+
"expected infer_schema to error on truncated rows when disabled"
1301+
);
1302+
1303+
// Optional: check message contains indicative text (two known possibilities)
1304+
if let Err(err) = res {
1305+
let msg = format!("{err}");
1306+
assert!(
1307+
msg.contains("Encountered unequal lengths")
1308+
|| msg.contains("incorrect number of fields"),
1309+
"unexpected error message: {msg}",
1310+
);
1311+
}
1312+
1313+
Ok(())
1314+
}
11771315
}

datafusion/datasource-csv/src/file_format.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -505,7 +505,8 @@ impl CsvFormat {
505505
.unwrap_or_else(|| state.config_options().catalog.has_header),
506506
)
507507
.with_delimiter(self.options.delimiter)
508-
.with_quote(self.options.quote);
508+
.with_quote(self.options.quote)
509+
.with_truncated_rows(self.options.truncated_rows.unwrap_or(false));
509510

510511
if let Some(null_regex) = &self.options.null_regex {
511512
let regex = Regex::new(null_regex.as_str())

datafusion/proto-common/proto/datafusion_common.proto

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,7 @@ message CsvOptions {
424424
bytes double_quote = 15; // Indicates if quotes are doubled
425425
bytes newlines_in_values = 16; // Indicates if newlines are supported in values
426426
bytes terminator = 17; // Optional terminator character as a byte
427+
bytes truncated_rows = 18; // Indicates if truncated rows are allowed
427428
}
428429

429430
// Options controlling CSV format

datafusion/proto-common/src/from_proto/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -900,6 +900,7 @@ impl TryFrom<&protobuf::CsvOptions> for CsvOptions {
900900
null_regex: (!proto_opts.null_regex.is_empty())
901901
.then(|| proto_opts.null_regex.clone()),
902902
comment: proto_opts.comment.first().copied(),
903+
truncated_rows: proto_opts.truncated_rows.first().map(|h| *h != 0),
903904
})
904905
}
905906
}

datafusion/proto-common/src/generated/pbjson.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1566,6 +1566,9 @@ impl serde::Serialize for CsvOptions {
15661566
if !self.terminator.is_empty() {
15671567
len += 1;
15681568
}
1569+
if !self.truncated_rows.is_empty() {
1570+
len += 1;
1571+
}
15691572
let mut struct_ser = serializer.serialize_struct("datafusion_common.CsvOptions", len)?;
15701573
if !self.has_header.is_empty() {
15711574
#[allow(clippy::needless_borrow)]
@@ -1638,6 +1641,11 @@ impl serde::Serialize for CsvOptions {
16381641
#[allow(clippy::needless_borrows_for_generic_args)]
16391642
struct_ser.serialize_field("terminator", pbjson::private::base64::encode(&self.terminator).as_str())?;
16401643
}
1644+
if !self.truncated_rows.is_empty() {
1645+
#[allow(clippy::needless_borrow)]
1646+
#[allow(clippy::needless_borrows_for_generic_args)]
1647+
struct_ser.serialize_field("truncatedRows", pbjson::private::base64::encode(&self.truncated_rows).as_str())?;
1648+
}
16411649
struct_ser.end()
16421650
}
16431651
}
@@ -1676,6 +1684,8 @@ impl<'de> serde::Deserialize<'de> for CsvOptions {
16761684
"newlines_in_values",
16771685
"newlinesInValues",
16781686
"terminator",
1687+
"truncated_rows",
1688+
"truncatedRows",
16791689
];
16801690

16811691
#[allow(clippy::enum_variant_names)]
@@ -1697,6 +1707,7 @@ impl<'de> serde::Deserialize<'de> for CsvOptions {
16971707
DoubleQuote,
16981708
NewlinesInValues,
16991709
Terminator,
1710+
TruncatedRows,
17001711
}
17011712
impl<'de> serde::Deserialize<'de> for GeneratedField {
17021713
fn deserialize<D>(deserializer: D) -> std::result::Result<GeneratedField, D::Error>
@@ -1735,6 +1746,7 @@ impl<'de> serde::Deserialize<'de> for CsvOptions {
17351746
"doubleQuote" | "double_quote" => Ok(GeneratedField::DoubleQuote),
17361747
"newlinesInValues" | "newlines_in_values" => Ok(GeneratedField::NewlinesInValues),
17371748
"terminator" => Ok(GeneratedField::Terminator),
1749+
"truncatedRows" | "truncated_rows" => Ok(GeneratedField::TruncatedRows),
17381750
_ => Err(serde::de::Error::unknown_field(value, FIELDS)),
17391751
}
17401752
}
@@ -1771,6 +1783,7 @@ impl<'de> serde::Deserialize<'de> for CsvOptions {
17711783
let mut double_quote__ = None;
17721784
let mut newlines_in_values__ = None;
17731785
let mut terminator__ = None;
1786+
let mut truncated_rows__ = None;
17741787
while let Some(k) = map_.next_key()? {
17751788
match k {
17761789
GeneratedField::HasHeader => {
@@ -1893,6 +1906,14 @@ impl<'de> serde::Deserialize<'de> for CsvOptions {
18931906
Some(map_.next_value::<::pbjson::private::BytesDeserialize<_>>()?.0)
18941907
;
18951908
}
1909+
GeneratedField::TruncatedRows => {
1910+
if truncated_rows__.is_some() {
1911+
return Err(serde::de::Error::duplicate_field("truncatedRows"));
1912+
}
1913+
truncated_rows__ =
1914+
Some(map_.next_value::<::pbjson::private::BytesDeserialize<_>>()?.0)
1915+
;
1916+
}
18961917
}
18971918
}
18981919
Ok(CsvOptions {
@@ -1913,6 +1934,7 @@ impl<'de> serde::Deserialize<'de> for CsvOptions {
19131934
double_quote: double_quote__.unwrap_or_default(),
19141935
newlines_in_values: newlines_in_values__.unwrap_or_default(),
19151936
terminator: terminator__.unwrap_or_default(),
1937+
truncated_rows: truncated_rows__.unwrap_or_default(),
19161938
})
19171939
}
19181940
}

datafusion/proto-common/src/generated/prost.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,9 @@ pub struct CsvOptions {
604604
/// Optional terminator character as a byte
605605
#[prost(bytes = "vec", tag = "17")]
606606
pub terminator: ::prost::alloc::vec::Vec<u8>,
607+
/// Indicates if truncated rows are allowed
608+
#[prost(bytes = "vec", tag = "18")]
609+
pub truncated_rows: ::prost::alloc::vec::Vec<u8>,
607610
}
608611
/// Options controlling CSV format
609612
#[derive(Clone, Copy, PartialEq, ::prost::Message)]

datafusion/proto-common/src/to_proto/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -934,6 +934,7 @@ impl TryFrom<&CsvOptions> for protobuf::CsvOptions {
934934
null_value: opts.null_value.clone().unwrap_or_default(),
935935
null_regex: opts.null_regex.clone().unwrap_or_default(),
936936
comment: opts.comment.map_or_else(Vec::new, |h| vec![h]),
937+
truncated_rows: opts.truncated_rows.map_or_else(Vec::new, |h| vec![h as u8]),
937938
})
938939
}
939940
}

datafusion/proto/src/generated/datafusion_proto_common.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,9 @@ pub struct CsvOptions {
604604
/// Optional terminator character as a byte
605605
#[prost(bytes = "vec", tag = "17")]
606606
pub terminator: ::prost::alloc::vec::Vec<u8>,
607+
/// Indicates if truncated rows are allowed
608+
#[prost(bytes = "vec", tag = "18")]
609+
pub truncated_rows: ::prost::alloc::vec::Vec<u8>,
607610
}
608611
/// Options controlling CSV format
609612
#[derive(Clone, Copy, PartialEq, ::prost::Message)]

datafusion/proto/src/logical_plan/file_formats.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ impl CsvOptionsProto {
7272
newlines_in_values: options
7373
.newlines_in_values
7474
.map_or(vec![], |v| vec![v as u8]),
75+
truncated_rows: options.truncated_rows.map_or(vec![], |v| vec![v as u8]),
7576
}
7677
} else {
7778
CsvOptionsProto::default()
@@ -157,6 +158,11 @@ impl From<&CsvOptionsProto> for CsvOptions {
157158
} else {
158159
Some(proto.newlines_in_values[0] != 0)
159160
},
161+
truncated_rows: if proto.truncated_rows.is_empty() {
162+
None
163+
} else {
164+
Some(proto.truncated_rows[0] != 0)
165+
},
160166
}
161167
}
162168
}

0 commit comments

Comments
 (0)