Skip to content

Commit ad7fb62

Browse files
committed
fix(proto): preserve CSV sink writer options
1 parent 92746a9 commit ad7fb62

8 files changed

Lines changed: 352 additions & 117 deletions

File tree

datafusion/datasource-csv/src/file_format.rs

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -848,6 +848,13 @@ impl DataSink for CsvSink {
848848
use datafusion_proto_models::protobuf;
849849
use protobuf::physical_plan_node::PhysicalPlanType;
850850

851+
// Keep an exhaustive guard in the active hook while centralizing the
852+
// field mapping in the exhaustive `TryFrom<&CsvSink>` below.
853+
let Self {
854+
config: _,
855+
writer_options: _,
856+
} = self;
857+
851858
let input = ctx.encode_child(exec.input())?;
852859
let sort_order = exec.encode_sort_order(ctx)?;
853860
let sink = protobuf::CsvSink::try_from(self)?;
@@ -868,9 +875,13 @@ impl TryFrom<&CsvSink> for datafusion_proto_models::protobuf::CsvSink {
868875
type Error = DataFusionError;
869876

870877
fn try_from(value: &CsvSink) -> Result<Self> {
878+
let CsvSink {
879+
config,
880+
writer_options,
881+
} = value;
871882
Ok(Self {
872-
config: Some(value.config().try_into()?),
873-
writer_options: Some(value.writer_options().try_into()?),
883+
config: Some(config.try_into()?),
884+
writer_options: Some(writer_options.try_into()?),
874885
})
875886
}
876887
}
@@ -880,14 +891,16 @@ impl TryFrom<&datafusion_proto_models::protobuf::CsvSink> for CsvSink {
880891
type Error = DataFusionError;
881892

882893
fn try_from(value: &datafusion_proto_models::protobuf::CsvSink) -> Result<Self> {
883-
let config =
884-
FileSinkConfig::try_from(value.config.as_ref().ok_or_else(|| {
885-
datafusion_common::internal_datafusion_err!(
886-
"CsvSink is missing required field 'config'"
887-
)
888-
})?)?;
889-
let writer_options = value
890-
.writer_options
894+
let datafusion_proto_models::protobuf::CsvSink {
895+
config,
896+
writer_options,
897+
} = value;
898+
let config = FileSinkConfig::try_from(config.as_ref().ok_or_else(|| {
899+
datafusion_common::internal_datafusion_err!(
900+
"CsvSink is missing required field 'config'"
901+
)
902+
})?)?;
903+
let writer_options = writer_options
891904
.as_ref()
892905
.ok_or_else(|| {
893906
datafusion_common::internal_datafusion_err!(
@@ -914,19 +927,24 @@ impl CsvSink {
914927
protobuf::physical_plan_node::PhysicalPlanType::CsvSink,
915928
"CsvSink",
916929
);
917-
let input = ctx.decode_required_child(
918-
sink_node.input.as_deref(),
919-
"CsvSinkExecNode",
920-
"input",
921-
)?;
922-
let proto_sink = sink_node.sink.as_ref().ok_or_else(|| {
930+
let protobuf::CsvSinkExecNode {
931+
input,
932+
sink,
933+
// The output schema is recomputed by `DataSinkExec::new`.
934+
sink_schema: _,
935+
sort_order,
936+
} = sink_node.as_ref();
937+
938+
let input =
939+
ctx.decode_required_child(input.as_deref(), "CsvSinkExecNode", "input")?;
940+
let proto_sink = sink.as_ref().ok_or_else(|| {
923941
datafusion_common::internal_datafusion_err!(
924942
"CsvSinkExecNode is missing required field 'sink'"
925943
)
926944
})?;
927945
let data_sink = CsvSink::try_from(proto_sink)?;
928946
let sort_order = DataSinkExec::decode_sort_order(
929-
sink_node.sort_order.as_ref(),
947+
sort_order.as_ref(),
930948
ctx,
931949
input.schema().as_ref(),
932950
)?;

datafusion/proto-common/proto/datafusion_common.proto

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,12 @@ message CsvWriterOptions {
471471
bool ignore_leading_whitespace = 13;
472472
// Whether to ignore trailing whitespace in string values
473473
bool ignore_trailing_whitespace = 14;
474+
// Optional compression level
475+
optional uint32 compression_level = 15;
476+
// Optional timestamp format for timestamp with timezone arrays
477+
string timestamp_tz_format = 16;
478+
// Optional line terminator. Empty defaults to LF; valid values are one byte or CRLF
479+
bytes terminator = 17;
474480
}
475481

476482
// Options controlling CSV format

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

Lines changed: 107 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ use crate::common::proto_error;
2222
use crate::protobuf_common as protobuf;
2323
use arrow::array::{ArrayRef, AsArray};
2424
use arrow::buffer::Buffer;
25+
use arrow::csv::writer::Terminator;
2526
use arrow::csv::{QuoteStyle, WriterBuilder};
2627
use arrow::datatypes::{
2728
DataType, Field, IntervalDayTimeType, IntervalMonthDayNanoType, IntervalUnit, Schema,
@@ -985,9 +986,112 @@ impl TryFrom<&protobuf::CsvWriterOptions> for CsvWriterOptions {
985986
fn try_from(
986987
opts: &protobuf::CsvWriterOptions,
987988
) -> datafusion_common::Result<Self, Self::Error> {
988-
let write_options = csv_writer_options_from_proto(opts)?;
989-
let compression: CompressionTypeVariant = opts.compression().into();
990-
Ok(CsvWriterOptions::new(write_options, compression))
989+
let protobuf::CsvWriterOptions {
990+
compression,
991+
delimiter,
992+
has_header,
993+
date_format,
994+
datetime_format,
995+
timestamp_format,
996+
time_format,
997+
null_value,
998+
quote,
999+
escape,
1000+
double_quote,
1001+
quote_style,
1002+
ignore_leading_whitespace,
1003+
ignore_trailing_whitespace,
1004+
compression_level,
1005+
timestamp_tz_format,
1006+
terminator,
1007+
} = opts;
1008+
1009+
let mut writer_options = WriterBuilder::new();
1010+
if !delimiter.is_empty() {
1011+
if let Some(delimiter) = delimiter.chars().next() {
1012+
if delimiter.is_ascii() {
1013+
writer_options = writer_options.with_delimiter(delimiter as u8);
1014+
} else {
1015+
return Err(proto_error("CSV Delimiter is not ASCII"));
1016+
}
1017+
} else {
1018+
return Err(proto_error("Error parsing CSV Delimiter"));
1019+
}
1020+
}
1021+
if !quote.is_empty() {
1022+
if let Some(quote) = quote.chars().next() {
1023+
if quote.is_ascii() {
1024+
writer_options = writer_options.with_quote(quote as u8);
1025+
} else {
1026+
return Err(proto_error("CSV Quote is not ASCII"));
1027+
}
1028+
} else {
1029+
return Err(proto_error("Error parsing CSV Quote"));
1030+
}
1031+
}
1032+
if !escape.is_empty() {
1033+
if let Some(escape) = escape.chars().next() {
1034+
if escape.is_ascii() {
1035+
writer_options = writer_options.with_escape(escape as u8);
1036+
} else {
1037+
return Err(proto_error("CSV Escape is not ASCII"));
1038+
}
1039+
} else {
1040+
return Err(proto_error("Error parsing CSV Escape"));
1041+
}
1042+
}
1043+
let quote_style = match protobuf::CsvQuoteStyle::try_from(*quote_style) {
1044+
Ok(protobuf::CsvQuoteStyle::Always) => QuoteStyle::Always,
1045+
Ok(protobuf::CsvQuoteStyle::NonNumeric) => QuoteStyle::NonNumeric,
1046+
Ok(protobuf::CsvQuoteStyle::Never) => QuoteStyle::Never,
1047+
Ok(protobuf::CsvQuoteStyle::Necessary) => QuoteStyle::Necessary,
1048+
_ => {
1049+
return Err(proto_error(
1050+
"Unknown quote style, must be one of: 'Always', 'NonNumeric', 'Never', 'Necessary'",
1051+
));
1052+
}
1053+
};
1054+
writer_options = writer_options
1055+
.with_header(*has_header)
1056+
.with_null(null_value.clone())
1057+
.with_double_quote(*double_quote)
1058+
.with_quote_style(quote_style)
1059+
.with_ignore_leading_whitespace(*ignore_leading_whitespace)
1060+
.with_ignore_trailing_whitespace(*ignore_trailing_whitespace);
1061+
if !date_format.is_empty() {
1062+
writer_options = writer_options.with_date_format(date_format.clone());
1063+
}
1064+
if !datetime_format.is_empty() {
1065+
writer_options = writer_options.with_datetime_format(datetime_format.clone());
1066+
}
1067+
if !timestamp_format.is_empty() {
1068+
writer_options =
1069+
writer_options.with_timestamp_format(timestamp_format.clone());
1070+
}
1071+
if !timestamp_tz_format.is_empty() {
1072+
writer_options =
1073+
writer_options.with_timestamp_tz_format(timestamp_tz_format.clone());
1074+
}
1075+
if !time_format.is_empty() {
1076+
writer_options = writer_options.with_time_format(time_format.clone());
1077+
}
1078+
writer_options = match terminator.as_slice() {
1079+
[] => writer_options,
1080+
[byte] => writer_options.with_line_terminator(Terminator::Any(*byte)),
1081+
[b'\r', b'\n'] => writer_options.with_line_terminator(Terminator::CRLF),
1082+
_ => {
1083+
return Err(proto_error("CSV line terminator must be one byte or CRLF"));
1084+
}
1085+
};
1086+
1087+
let compression = protobuf::CompressionTypeVariant::try_from(*compression)
1088+
.unwrap_or_default()
1089+
.into();
1090+
Ok(CsvWriterOptions {
1091+
writer_options,
1092+
compression,
1093+
compression_level: *compression_level,
1094+
})
9911095
}
9921096
}
9931097

@@ -1324,66 +1428,6 @@ where
13241428
.collect::<datafusion_common::Result<_, _>>()
13251429
}
13261430

1327-
pub(crate) fn csv_writer_options_from_proto(
1328-
writer_options: &protobuf::CsvWriterOptions,
1329-
) -> datafusion_common::Result<WriterBuilder> {
1330-
let mut builder = WriterBuilder::new();
1331-
if !writer_options.delimiter.is_empty() {
1332-
if let Some(delimiter) = writer_options.delimiter.chars().next() {
1333-
if delimiter.is_ascii() {
1334-
builder = builder.with_delimiter(delimiter as u8);
1335-
} else {
1336-
return Err(proto_error("CSV Delimiter is not ASCII"));
1337-
}
1338-
} else {
1339-
return Err(proto_error("Error parsing CSV Delimiter"));
1340-
}
1341-
}
1342-
if !writer_options.quote.is_empty() {
1343-
if let Some(quote) = writer_options.quote.chars().next() {
1344-
if quote.is_ascii() {
1345-
builder = builder.with_quote(quote as u8);
1346-
} else {
1347-
return Err(proto_error("CSV Quote is not ASCII"));
1348-
}
1349-
} else {
1350-
return Err(proto_error("Error parsing CSV Quote"));
1351-
}
1352-
}
1353-
if !writer_options.escape.is_empty() {
1354-
if let Some(escape) = writer_options.escape.chars().next() {
1355-
if escape.is_ascii() {
1356-
builder = builder.with_escape(escape as u8);
1357-
} else {
1358-
return Err(proto_error("CSV Escape is not ASCII"));
1359-
}
1360-
} else {
1361-
return Err(proto_error("Error parsing CSV Escape"));
1362-
}
1363-
}
1364-
let quote_style = match protobuf::CsvQuoteStyle::try_from(writer_options.quote_style)
1365-
{
1366-
Ok(protobuf::CsvQuoteStyle::Always) => QuoteStyle::Always,
1367-
Ok(protobuf::CsvQuoteStyle::NonNumeric) => QuoteStyle::NonNumeric,
1368-
Ok(protobuf::CsvQuoteStyle::Never) => QuoteStyle::Never,
1369-
Ok(protobuf::CsvQuoteStyle::Necessary) => QuoteStyle::Necessary,
1370-
_ => Err(proto_error(
1371-
"Unknown quote style, must be one of: 'Always', 'NonNumeric', 'Never', 'Necessary'",
1372-
))?,
1373-
};
1374-
Ok(builder
1375-
.with_header(writer_options.has_header)
1376-
.with_date_format(writer_options.date_format.clone())
1377-
.with_datetime_format(writer_options.datetime_format.clone())
1378-
.with_timestamp_format(writer_options.timestamp_format.clone())
1379-
.with_time_format(writer_options.time_format.clone())
1380-
.with_null(writer_options.null_value.clone())
1381-
.with_double_quote(writer_options.double_quote)
1382-
.with_quote_style(quote_style)
1383-
.with_ignore_leading_whitespace(writer_options.ignore_leading_whitespace)
1384-
.with_ignore_trailing_whitespace(writer_options.ignore_trailing_whitespace))
1385-
}
1386-
13871431
#[cfg(test)]
13881432
mod tests {
13891433
use datafusion_common::config::{

0 commit comments

Comments
 (0)