This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion.git
The following commit(s) were added to refs/heads/main by this push:
new c1cb715f7f refactor(proto): migrate CsvSource serde (#24177)
c1cb715f7f is described below
commit c1cb715f7f5bdd2710417bcddb212f14f8c8c5fd
Author: Burak Şen <[email protected]>
AuthorDate: Sat Aug 8 09:22:06 2026 +0300
refactor(proto): migrate CsvSource serde (#24177)
## Which issue does this PR close?
- Part of #23516.
## Rationale for this change
Part of epic #23494. Moves `CsvSource` protobuf serialization from the
central dispatch into the source implementation.
## What changes are included in this PR?
Add protobuf serialization and deserialization to `CsvSource`, including
the CSV format options represented by the existing wire format.
Repoint the live decode arm to `CsvSource::try_from_proto` and remove
the old central encode arm. Keep `try_into_csv_scan_physical_plan` as a
deprecated compatibility wrapper that delegates to the new
implementation.
The protobuf wire format remains unchanged.
## Are these changes tested?
Yes. Added `roundtrip_csv_scan_preserves_format_options`, covering
header, delimiter, quote, escape, comment, multiline values, and
truncated rows.
## Are there any user-facing changes?
The existing `PhysicalPlanNodeExt` method remains available as a
deprecated compatibility wrapper. There is no immediate API removal or
wire-format change.
---
datafusion/datasource-csv/Cargo.toml | 1 +
datafusion/datasource-csv/src/source.rs | 137 +++++++++++++++++++++
datafusion/proto/src/common.rs | 21 ----
datafusion/proto/src/physical_plan/mod.rs | 105 +++-------------
.../proto/tests/cases/roundtrip_physical_plan.rs | 60 ++++++++-
5 files changed, 211 insertions(+), 113 deletions(-)
diff --git a/datafusion/datasource-csv/Cargo.toml
b/datafusion/datasource-csv/Cargo.toml
index 4026e6e808..7e7195dfda 100644
--- a/datafusion/datasource-csv/Cargo.toml
+++ b/datafusion/datasource-csv/Cargo.toml
@@ -31,6 +31,7 @@ version.workspace = true
all-features = true
[features]
+# Enables protobuf serialization hooks for CSV sources and sinks.
proto = [
"dep:datafusion-proto-models",
"datafusion-datasource/proto",
diff --git a/datafusion/datasource-csv/src/source.rs
b/datafusion/datasource-csv/src/source.rs
index 25ec311880..f498a3c5b7 100644
--- a/datafusion/datasource-csv/src/source.rs
+++ b/datafusion/datasource-csv/src/source.rs
@@ -308,6 +308,49 @@ impl FileSource for CsvSource {
DisplayFormatType::TreeRender => Ok(()),
}
}
+
+ /// Emit a `CsvScan` node wrapping the shared base config and CSV options.
+ #[cfg(feature = "proto")]
+ fn try_to_proto(
+ &self,
+ base: &FileScanConfig,
+ ctx: &datafusion_physical_plan::proto::ExecutionPlanEncodeCtx<'_>,
+ ) -> Result<Option<datafusion_proto_models::protobuf::PhysicalPlanNode>> {
+ use datafusion_proto_models::protobuf;
+ use protobuf::physical_plan_node::PhysicalPlanType;
+
+ let node = protobuf::CsvScanExecNode {
+ base_conf: Some(base.try_to_proto(ctx)?),
+ has_header: self.has_header(),
+ delimiter: proto_byte_to_string(self.delimiter(), "delimiter")?,
+ quote: proto_byte_to_string(self.quote(), "quote")?,
+ optional_escape: self
+ .escape()
+ .map(|escape| {
+ Ok::<_, DataFusionError>(
+ protobuf::csv_scan_exec_node::OptionalEscape::Escape(
+ proto_byte_to_string(escape, "escape")?,
+ ),
+ )
+ })
+ .transpose()?,
+ optional_comment: self
+ .comment()
+ .map(|comment| {
+ Ok::<_, DataFusionError>(
+ protobuf::csv_scan_exec_node::OptionalComment::Comment(
+ proto_byte_to_string(comment, "comment")?,
+ ),
+ )
+ })
+ .transpose()?,
+ newlines_in_values: self.newlines_in_values(),
+ truncate_rows: self.truncate_rows(),
+ };
+ Ok(Some(protobuf::PhysicalPlanNode {
+ physical_plan_type: Some(PhysicalPlanType::CsvScan(node)),
+ }))
+ }
}
impl FileOpener for CsvOpener {
@@ -501,3 +544,97 @@ pub async fn plan_to_csv(
Ok(())
}
+
+#[cfg(feature = "proto")]
+fn proto_byte_to_string(b: u8, description: &str) -> Result<String> {
+ let bytes = &[b];
+ let s = std::str::from_utf8(bytes).map_err(|_| {
+ datafusion_common::internal_datafusion_err!(
+ "Invalid CSV {description}: can not represent {bytes:0x?} as utf8"
+ )
+ })?;
+ Ok(s.to_owned())
+}
+
+#[cfg(feature = "proto")]
+fn proto_str_to_byte(s: &str, description: &str) -> Result<u8> {
+ datafusion_common::assert_eq_or_internal_err!(
+ s.len(),
+ 1,
+ "Invalid CSV {description}: expected single character, got {s}"
+ );
+ Ok(s.as_bytes()[0])
+}
+
+#[cfg(feature = "proto")]
+impl CsvSource {
+ /// Reconstructs a `DataSourceExec` from a protobuf `CsvScan`.
+ ///
+ /// Custom line terminators are not represented in the wire format.
+ pub fn try_from_proto(
+ node: &datafusion_proto_models::protobuf::PhysicalPlanNode,
+ ctx: &datafusion_physical_plan::proto::ExecutionPlanDecodeCtx<'_>,
+ ) -> Result<Arc<dyn ExecutionPlan>> {
+ use datafusion_common::config::CsvOptions;
+ use datafusion_datasource::file_compression_type::FileCompressionType;
+ use datafusion_datasource::file_scan_config::{
+ FileScanConfig, FileScanConfigBuilder,
+ };
+ use datafusion_datasource::source::DataSourceExec;
+ use datafusion_proto_models::protobuf;
+
+ let scan = match &node.physical_plan_type {
+
Some(protobuf::physical_plan_node::PhysicalPlanType::CsvScan(scan)) => scan,
+ _ => {
+ return datafusion_common::internal_err!(
+ "PhysicalPlanNode is not a CsvScan"
+ );
+ }
+ };
+
+ let base_conf = scan.base_conf.as_ref().ok_or_else(|| {
+ datafusion_common::internal_datafusion_err!(
+ "CsvScanExecNode is missing required field 'base_conf'"
+ )
+ })?;
+
+ let escape = match &scan.optional_escape {
+ Some(protobuf::csv_scan_exec_node::OptionalEscape::Escape(escape))
=> {
+ Some(proto_str_to_byte(escape, "escape")?)
+ }
+ None => None,
+ };
+ let comment = match &scan.optional_comment {
+
Some(protobuf::csv_scan_exec_node::OptionalComment::Comment(comment)) => {
+ Some(proto_str_to_byte(comment, "comment")?)
+ }
+ None => None,
+ };
+
+ let table_schema =
FileScanConfig::parse_table_schema_from_proto(base_conf)?;
+
+ let csv_options = CsvOptions {
+ has_header: Some(scan.has_header),
+ delimiter: proto_str_to_byte(&scan.delimiter, "delimiter")?,
+ quote: proto_str_to_byte(&scan.quote, "quote")?,
+ newlines_in_values: Some(scan.newlines_in_values),
+ truncated_rows: Some(scan.truncate_rows),
+ ..Default::default()
+ };
+ let source = Arc::new(
+ CsvSource::new(table_schema)
+ .with_csv_options(csv_options)
+ .with_escape(escape)
+ .with_comment(comment),
+ );
+
+ // The compression type is not on the wire; CSV scans always
+ // deserialize as uncompressed.
+ let conf = FileScanConfigBuilder::from(FileScanConfig::try_from_proto(
+ base_conf, ctx, source,
+ )?)
+ .with_file_compression_type(FileCompressionType::UNCOMPRESSED)
+ .build();
+ Ok(DataSourceExec::from_data_source(conf))
+ }
+}
diff --git a/datafusion/proto/src/common.rs b/datafusion/proto/src/common.rs
index bff017edbc..dd9af97781 100644
--- a/datafusion/proto/src/common.rs
+++ b/datafusion/proto/src/common.rs
@@ -15,27 +15,6 @@
// specific language governing permissions and limitations
// under the License.
-use datafusion_common::{Result, assert_eq_or_internal_err,
internal_datafusion_err};
-
-pub(crate) fn str_to_byte(s: &String, description: &str) -> Result<u8> {
- assert_eq_or_internal_err!(
- s.len(),
- 1,
- "Invalid CSV {description}: expected single character, got {s}"
- );
- Ok(s.as_bytes()[0])
-}
-
-pub(crate) fn byte_to_string(b: u8, description: &str) -> Result<String> {
- let b = &[b];
- let b = std::str::from_utf8(b).map_err(|_| {
- internal_datafusion_err!(
- "Invalid CSV {description}: can not represent {b:0x?} as utf8"
- )
- })?;
- Ok(b.to_owned())
-}
-
#[macro_export]
macro_rules! convert_required {
($PB:expr) => {{
diff --git a/datafusion/proto/src/physical_plan/mod.rs
b/datafusion/proto/src/physical_plan/mod.rs
index 7e162bf954..22f623c8aa 100644
--- a/datafusion/proto/src/physical_plan/mod.rs
+++ b/datafusion/proto/src/physical_plan/mod.rs
@@ -23,14 +23,12 @@ use std::sync::Arc;
use arrow::datatypes::{IntervalMonthDayNanoType, Schema, SchemaRef};
use datafusion_catalog::memory::MemorySourceConfig;
-use datafusion_common::config::CsvOptions;
use datafusion_common::{
DataFusionError, Result, internal_datafusion_err, internal_err,
not_impl_err,
};
#[cfg(feature = "parquet")]
use datafusion_datasource::file::FileSource;
-use datafusion_datasource::file_compression_type::FileCompressionType;
-use datafusion_datasource::file_scan_config::{FileScanConfig,
FileScanConfigBuilder};
+use datafusion_datasource::file_scan_config::FileScanConfig;
use datafusion_datasource::sink::DataSinkExec;
use datafusion_datasource::source::{DataSource, DataSourceExec};
use datafusion_datasource_arrow::source::ArrowSource;
@@ -94,7 +92,6 @@ use datafusion_physical_plan::{ExecutionPlan, PhysicalExpr};
use prost::Message;
use prost::bytes::BufMut;
-use crate::common::{byte_to_string, str_to_byte};
use crate::convert_required;
use crate::physical_plan::from_proto::{
parse_physical_expr_with_converter, parse_physical_sort_exprs,
@@ -126,6 +123,7 @@ mod file_scan_config_serde {
use datafusion_common::{Constraint, Constraints, ScalarValue, Statistics};
use datafusion_datasource::file::FileSource;
use datafusion_datasource::file_groups::FileGroup;
+ use datafusion_datasource::file_scan_config::FileScanConfigBuilder;
use datafusion_datasource::file_stream::FileOpener;
use datafusion_datasource::{PartitionedFile, TableSchema};
use datafusion_execution::object_store::ObjectStoreUrl;
@@ -1084,8 +1082,8 @@ pub trait PhysicalPlanNodeExt: Sized {
PhysicalPlanType::Filter(_) => {
FilterExec::try_from_proto(self.node(), &decode_ctx)
}
- PhysicalPlanType::CsvScan(scan) => {
- self.try_into_csv_scan_physical_plan(scan, ctx,
proto_converter)
+ PhysicalPlanType::CsvScan(_) => {
+ CsvSource::try_from_proto(self.node(), &decode_ctx)
}
PhysicalPlanType::JsonScan(scan) => {
self.try_into_json_scan_physical_plan(scan, ctx,
proto_converter)
@@ -1340,57 +1338,25 @@ pub trait PhysicalPlanNodeExt: Sized {
FilterExec::try_from_proto(&node, &decode_ctx)
}
+ #[deprecated(
+ since = "55.0.0",
+ note = "unused by DataFusion; `CsvSource` deserializes itself via
`CsvSource::try_from_proto`"
+ )]
fn try_into_csv_scan_physical_plan(
&self,
scan: &protobuf::CsvScanExecNode,
ctx: &PhysicalPlanDecodeContext<'_>,
proto_converter: &dyn PhysicalProtoConverterExtension,
) -> Result<Arc<dyn ExecutionPlan>> {
- let escape =
- if let
Some(protobuf::csv_scan_exec_node::OptionalEscape::Escape(escape)) =
- &scan.optional_escape
- {
- Some(str_to_byte(escape, "escape")?)
- } else {
- None
- };
-
- let comment = if let Some(
- protobuf::csv_scan_exec_node::OptionalComment::Comment(comment),
- ) = &scan.optional_comment
- {
- Some(str_to_byte(comment, "comment")?)
- } else {
- None
- };
-
- // Parse table schema with partition columns
- let table_schema =
- parse_table_schema_from_proto(scan.base_conf.as_ref().unwrap())?;
-
- let csv_options = CsvOptions {
- has_header: Some(scan.has_header),
- delimiter: str_to_byte(&scan.delimiter, "delimiter")?,
- quote: str_to_byte(&scan.quote, "quote")?,
- newlines_in_values: Some(scan.newlines_in_values),
- ..Default::default()
- };
- let source = Arc::new(
- CsvSource::new(table_schema)
- .with_csv_options(csv_options)
- .with_escape(escape)
- .with_comment(comment),
- );
-
- let conf = FileScanConfigBuilder::from(parse_protobuf_file_scan_config(
- scan.base_conf.as_ref().unwrap(),
+ let node = protobuf::PhysicalPlanNode {
+ physical_plan_type: Some(PhysicalPlanType::CsvScan(scan.clone())),
+ };
+ let decoder = ConverterPlanDecoder {
ctx,
proto_converter,
- source,
- )?)
- .with_file_compression_type(FileCompressionType::UNCOMPRESSED)
- .build();
- Ok(DataSourceExec::from_data_source(conf))
+ };
+ let decode_ctx = ExecutionPlanDecodeCtx::new(&decoder);
+ CsvSource::try_from_proto(&node, &decode_ctx)
}
fn try_into_json_scan_physical_plan(
@@ -2561,47 +2527,6 @@ pub trait PhysicalPlanNodeExt: Sized {
proto_converter: &dyn PhysicalProtoConverterExtension,
) -> Result<Option<protobuf::PhysicalPlanNode>> {
let data_source = data_source_exec.data_source();
- if let Some(maybe_csv) = data_source.downcast_ref::<FileScanConfig>() {
- let source = maybe_csv.file_source();
- if let Some(csv_config) = source.downcast_ref::<CsvSource>() {
- return Ok(Some(protobuf::PhysicalPlanNode {
- physical_plan_type: Some(PhysicalPlanType::CsvScan(
- protobuf::CsvScanExecNode {
- base_conf: Some(serialize_file_scan_config(
- maybe_csv,
- codec,
- proto_converter,
- )?),
- has_header: csv_config.has_header(),
- delimiter: byte_to_string(
- csv_config.delimiter(),
- "delimiter",
- )?,
- quote: byte_to_string(csv_config.quote(),
"quote")?,
- optional_escape: if let Some(escape) =
csv_config.escape() {
- Some(
-
protobuf::csv_scan_exec_node::OptionalEscape::Escape(
- byte_to_string(escape, "escape")?,
- ),
- )
- } else {
- None
- },
- optional_comment: if let Some(comment) =
csv_config.comment()
- {
-
Some(protobuf::csv_scan_exec_node::OptionalComment::Comment(
- byte_to_string(comment, "comment")?,
- ))
- } else {
- None
- },
- newlines_in_values:
csv_config.newlines_in_values(),
- truncate_rows: csv_config.truncate_rows(),
- },
- )),
- }));
- }
- }
if let Some(scan_conf) = data_source.downcast_ref::<FileScanConfig>() {
let source = scan_conf.file_source();
diff --git a/datafusion/proto/tests/cases/roundtrip_physical_plan.rs
b/datafusion/proto/tests/cases/roundtrip_physical_plan.rs
index afd4057d04..65a817b4cb 100644
--- a/datafusion/proto/tests/cases/roundtrip_physical_plan.rs
+++ b/datafusion/proto/tests/cases/roundtrip_physical_plan.rs
@@ -36,8 +36,8 @@ use datafusion::datasource::listing::{
};
use datafusion::datasource::object_store::ObjectStoreUrl;
use datafusion::datasource::physical_plan::{
- ArrowSource, FileGroup, FileOutputMode, FileScanConfig,
FileScanConfigBuilder,
- FileSinkConfig, ParquetSource, wrap_partition_type_in_dict,
+ ArrowSource, CsvSource, FileGroup, FileOutputMode, FileScanConfig,
+ FileScanConfigBuilder, FileSinkConfig, ParquetSource,
wrap_partition_type_in_dict,
wrap_partition_value_in_dict,
};
use datafusion::datasource::sink::{DataSink, DataSinkExec};
@@ -1363,6 +1363,62 @@ fn roundtrip_arrow_scan() -> Result<()> {
roundtrip_test(DataSourceExec::from_data_source(scan_config))
}
+#[test]
+fn roundtrip_csv_scan_preserves_format_options() -> Result<()> {
+ use datafusion::common::config::CsvOptions;
+
+ let file_schema =
+ Arc::new(Schema::new(vec![Field::new("col", DataType::Utf8, false)]));
+ let table_schema = TableSchema::from(&file_schema);
+ let file_source =
+ Arc::new(CsvSource::new(table_schema).with_csv_options(CsvOptions {
+ has_header: Some(false),
+ delimiter: b'|',
+ quote: b'\'',
+ escape: Some(b'\\'),
+ comment: Some(b'#'),
+ newlines_in_values: Some(true),
+ truncated_rows: Some(true),
+ ..Default::default()
+ }));
+
+ let scan_config =
+ FileScanConfigBuilder::new(ObjectStoreUrl::local_filesystem(),
file_source)
+ .with_file_groups(vec![FileGroup::new(vec![PartitionedFile::new(
+ "/path/to/file.csv".to_string(),
+ 1024,
+ )])])
+ .build();
+
+ let ctx = SessionContext::new();
+ let roundtripped = roundtrip_test_and_return(
+ DataSourceExec::from_data_source(scan_config),
+ &ctx,
+ &DefaultPhysicalExtensionCodec {},
+ &DefaultPhysicalProtoConverter {},
+ )?;
+ let data_source = roundtripped
+ .downcast_ref::<DataSourceExec>()
+ .ok_or_else(|| internal_datafusion_err!("Expected DataSourceExec"))?;
+ let file_scan = data_source
+ .data_source()
+ .downcast_ref::<FileScanConfig>()
+ .ok_or_else(|| internal_datafusion_err!("Expected FileScanConfig"))?;
+ let csv_source = file_scan
+ .file_source()
+ .downcast_ref::<CsvSource>()
+ .ok_or_else(|| internal_datafusion_err!("Expected CsvSource"))?;
+
+ assert!(!csv_source.has_header());
+ assert_eq!(csv_source.delimiter(), b'|');
+ assert_eq!(csv_source.quote(), b'\'');
+ assert_eq!(csv_source.escape(), Some(b'\\'));
+ assert_eq!(csv_source.comment(), Some(b'#'));
+ assert!(csv_source.newlines_in_values());
+ assert!(csv_source.truncate_rows());
+ Ok(())
+}
+
#[tokio::test]
async fn roundtrip_parquet_exec_with_table_partition_cols() -> Result<()> {
let mut file_group =
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]