Skip to main content

datafusion_datasource_csv/
source.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! Execution plan for reading CSV files
19
20use datafusion_datasource::boundary_stream::AlignedBoundaryStream;
21use datafusion_datasource::projection::{ProjectionOpener, SplitProjection};
22use datafusion_physical_plan::projection::ProjectionExprs;
23use std::fmt;
24use std::io::Read;
25use std::sync::Arc;
26
27use datafusion_datasource::decoder::{DecoderDeserializer, deserialize_stream};
28use datafusion_datasource::file_compression_type::FileCompressionType;
29use datafusion_datasource::file_stream::{FileOpenFuture, FileOpener};
30use datafusion_datasource::{
31    FileRange, ListingTableUrl, PartitionedFile, TableSchema, as_file_source,
32};
33
34use arrow::csv;
35use datafusion_common::config::CsvOptions;
36use datafusion_common::tree_node::TreeNodeRecursion;
37use datafusion_common::{DataFusionError, Result, exec_datafusion_err};
38use datafusion_common_runtime::JoinSet;
39use datafusion_datasource::file::FileSource;
40use datafusion_datasource::file_scan_config::FileScanConfig;
41use datafusion_execution::TaskContext;
42use datafusion_physical_plan::metrics::{BaselineMetrics, ExecutionPlanMetricsSet};
43use datafusion_physical_plan::{
44    DisplayFormatType, ExecutionPlan, ExecutionPlanProperties,
45};
46
47use crate::file_format::CsvDecoder;
48use futures::{StreamExt, TryStreamExt};
49use object_store::buffered::BufWriter;
50use object_store::{GetOptions, GetResultPayload, ObjectStore};
51use tokio::io::AsyncWriteExt;
52
53/// A Config for [`CsvOpener`]
54///
55/// # Example: create a `DataSourceExec` for CSV
56/// ```
57/// # use std::sync::Arc;
58/// # use arrow::datatypes::Schema;
59/// # use datafusion_datasource::file_scan_config::{FileScanConfig, FileScanConfigBuilder};
60/// # use datafusion_datasource::PartitionedFile;
61/// # use datafusion_datasource_csv::source::CsvSource;
62/// # use datafusion_execution::object_store::ObjectStoreUrl;
63/// # use datafusion_datasource::source::DataSourceExec;
64/// # use datafusion_common::config::CsvOptions;
65///
66/// # let object_store_url = ObjectStoreUrl::local_filesystem();
67/// # let file_schema = Arc::new(Schema::empty());
68///
69/// let options = CsvOptions {
70///     has_header: Some(true),
71///     delimiter: b',',
72///     quote: b'"',
73///     newlines_in_values: Some(true), // The file contains newlines in values
74///     ..Default::default()
75/// };
76/// let source = Arc::new(CsvSource::new(file_schema.clone())
77///     .with_csv_options(options)
78///     .with_terminator(Some(b'#'))
79/// );
80/// // Create a DataSourceExec for reading the first 100MB of `file1.csv`
81/// let config = FileScanConfigBuilder::new(object_store_url, source)
82///     .with_file(PartitionedFile::new("file1.csv", 100*1024*1024))
83///     .build();
84/// let exec = (DataSourceExec::from_data_source(config));
85/// ```
86#[derive(Debug, Clone)]
87pub struct CsvSource {
88    options: CsvOptions,
89    batch_size: Option<usize>,
90    table_schema: TableSchema,
91    projection: SplitProjection,
92    metrics: ExecutionPlanMetricsSet,
93}
94
95impl CsvSource {
96    /// Returns a [`CsvSource`]
97    pub fn new(table_schema: impl Into<TableSchema>) -> Self {
98        let table_schema = table_schema.into();
99        Self {
100            options: CsvOptions::default(),
101            projection: SplitProjection::unprojected(&table_schema),
102            table_schema,
103            batch_size: None,
104            metrics: ExecutionPlanMetricsSet::new(),
105        }
106    }
107
108    /// Sets the CSV options
109    pub fn with_csv_options(mut self, options: CsvOptions) -> Self {
110        self.options = options;
111        self
112    }
113
114    /// true if the first line of each file is a header
115    pub fn has_header(&self) -> bool {
116        self.options.has_header.unwrap_or(true)
117    }
118
119    // true if rows length support truncate
120    pub fn truncate_rows(&self) -> bool {
121        self.options.truncated_rows.unwrap_or(false)
122    }
123    /// A column delimiter
124    pub fn delimiter(&self) -> u8 {
125        self.options.delimiter
126    }
127
128    /// The quote character
129    pub fn quote(&self) -> u8 {
130        self.options.quote
131    }
132
133    /// The line terminator
134    pub fn terminator(&self) -> Option<u8> {
135        self.options.terminator
136    }
137
138    /// Lines beginning with this byte are ignored.
139    pub fn comment(&self) -> Option<u8> {
140        self.options.comment
141    }
142
143    /// The escape character
144    pub fn escape(&self) -> Option<u8> {
145        self.options.escape
146    }
147
148    /// Initialize a CsvSource with escape
149    pub fn with_escape(&self, escape: Option<u8>) -> Self {
150        let mut conf = self.clone();
151        conf.options.escape = escape;
152        conf
153    }
154
155    /// Initialize a CsvSource with terminator
156    pub fn with_terminator(&self, terminator: Option<u8>) -> Self {
157        let mut conf = self.clone();
158        conf.options.terminator = terminator;
159        conf
160    }
161
162    /// Initialize a CsvSource with comment
163    pub fn with_comment(&self, comment: Option<u8>) -> Self {
164        let mut conf = self.clone();
165        conf.options.comment = comment;
166        conf
167    }
168
169    /// Whether to support truncate rows when read csv file
170    pub fn with_truncate_rows(&self, truncate_rows: bool) -> Self {
171        let mut conf = self.clone();
172        conf.options.truncated_rows = Some(truncate_rows);
173        conf
174    }
175
176    /// Whether values may contain newline characters
177    pub fn newlines_in_values(&self) -> bool {
178        self.options.newlines_in_values.unwrap_or(false)
179    }
180}
181
182impl CsvSource {
183    fn open<R: Read>(&self, reader: R) -> Result<csv::Reader<R>> {
184        Ok(self.builder().build(reader)?)
185    }
186
187    fn builder(&self) -> csv::ReaderBuilder {
188        let mut builder =
189            csv::ReaderBuilder::new(Arc::clone(self.table_schema.file_schema()))
190                .with_delimiter(self.delimiter())
191                .with_batch_size(
192                    self.batch_size
193                        .expect("Batch size must be set before initializing builder"),
194                )
195                .with_header(self.has_header())
196                .with_quote(self.quote())
197                .with_truncated_rows(self.truncate_rows());
198        if let Some(terminator) = self.terminator() {
199            builder = builder.with_terminator(terminator);
200        }
201        builder = builder.with_projection(self.projection.file_indices.clone());
202        if let Some(escape) = self.escape() {
203            builder = builder.with_escape(escape)
204        }
205        if let Some(comment) = self.comment() {
206            builder = builder.with_comment(comment);
207        }
208
209        builder
210    }
211}
212
213/// A [`FileOpener`] that opens a CSV file and yields a [`FileOpenFuture`]
214pub struct CsvOpener {
215    config: Arc<CsvSource>,
216    file_compression_type: FileCompressionType,
217    object_store: Arc<dyn ObjectStore>,
218    partition_index: usize,
219}
220
221impl CsvOpener {
222    /// Returns a [`CsvOpener`]
223    pub fn new(
224        config: Arc<CsvSource>,
225        file_compression_type: FileCompressionType,
226        object_store: Arc<dyn ObjectStore>,
227    ) -> Self {
228        Self {
229            config,
230            file_compression_type,
231            object_store,
232            partition_index: 0,
233        }
234    }
235}
236
237impl From<CsvSource> for Arc<dyn FileSource> {
238    fn from(source: CsvSource) -> Self {
239        as_file_source(source)
240    }
241}
242
243impl FileSource for CsvSource {
244    fn create_file_opener(
245        &self,
246        object_store: Arc<dyn ObjectStore>,
247        base_config: &FileScanConfig,
248        partition_index: usize,
249    ) -> Result<Arc<dyn FileOpener>> {
250        let mut opener = Arc::new(CsvOpener {
251            config: Arc::new(self.clone()),
252            file_compression_type: base_config.file_compression_type,
253            object_store,
254            partition_index,
255        }) as Arc<dyn FileOpener>;
256        opener = ProjectionOpener::try_new(
257            self.projection.clone(),
258            Arc::clone(&opener),
259            self.table_schema.file_schema(),
260        )?;
261        Ok(opener)
262    }
263
264    fn table_schema(&self) -> &TableSchema {
265        &self.table_schema
266    }
267
268    fn with_batch_size(&self, batch_size: usize) -> Arc<dyn FileSource> {
269        let mut conf = self.clone();
270        conf.batch_size = Some(batch_size);
271        Arc::new(conf)
272    }
273
274    fn try_pushdown_projection(
275        &self,
276        projection: &ProjectionExprs,
277    ) -> Result<Option<Arc<dyn FileSource>>> {
278        let mut source = self.clone();
279        let new_projection = self.projection.source.try_merge(projection)?;
280        let split_projection =
281            SplitProjection::new(self.table_schema.file_schema(), &new_projection);
282        source.projection = split_projection;
283        Ok(Some(Arc::new(source)))
284    }
285
286    fn projection(&self) -> Option<&ProjectionExprs> {
287        Some(&self.projection.source)
288    }
289
290    fn metrics(&self) -> &ExecutionPlanMetricsSet {
291        &self.metrics
292    }
293
294    fn file_type(&self) -> &str {
295        "csv"
296    }
297
298    fn supports_repartitioning(&self) -> bool {
299        // Cannot repartition if values may contain newlines, as record
300        // boundaries cannot be determined by byte offset alone
301        !self.options.newlines_in_values.unwrap_or(false)
302    }
303
304    fn fmt_extra(&self, t: DisplayFormatType, f: &mut fmt::Formatter) -> fmt::Result {
305        match t {
306            DisplayFormatType::Default | DisplayFormatType::Verbose => {
307                write!(f, ", has_header={}", self.has_header())
308            }
309            DisplayFormatType::TreeRender => Ok(()),
310        }
311    }
312
313    fn apply_expressions(
314        &self,
315        f: &mut dyn FnMut(
316            &Arc<dyn datafusion_physical_plan::PhysicalExpr>,
317        ) -> Result<TreeNodeRecursion>,
318    ) -> Result<TreeNodeRecursion> {
319        datafusion_physical_plan::apply_expression_roots(self.projection.source.iter(), f)
320    }
321
322    /// Emit a `CsvScan` node wrapping the shared base config and CSV options.
323    #[cfg(feature = "proto")]
324    fn try_to_proto(
325        &self,
326        base: &FileScanConfig,
327        ctx: &datafusion_physical_plan::proto::ExecutionPlanEncodeCtx<'_>,
328    ) -> Result<Option<datafusion_proto_models::protobuf::PhysicalPlanNode>> {
329        use datafusion_proto_models::protobuf;
330        use protobuf::physical_plan_node::PhysicalPlanType;
331
332        let node = protobuf::CsvScanExecNode {
333            base_conf: Some(base.try_to_proto(ctx)?),
334            has_header: self.has_header(),
335            delimiter: proto_byte_to_string(self.delimiter(), "delimiter")?,
336            quote: proto_byte_to_string(self.quote(), "quote")?,
337            optional_escape: self
338                .escape()
339                .map(|escape| {
340                    Ok::<_, DataFusionError>(
341                        protobuf::csv_scan_exec_node::OptionalEscape::Escape(
342                            proto_byte_to_string(escape, "escape")?,
343                        ),
344                    )
345                })
346                .transpose()?,
347            optional_comment: self
348                .comment()
349                .map(|comment| {
350                    Ok::<_, DataFusionError>(
351                        protobuf::csv_scan_exec_node::OptionalComment::Comment(
352                            proto_byte_to_string(comment, "comment")?,
353                        ),
354                    )
355                })
356                .transpose()?,
357            newlines_in_values: self.newlines_in_values(),
358            truncate_rows: self.truncate_rows(),
359        };
360        Ok(Some(protobuf::PhysicalPlanNode {
361            physical_plan_type: Some(PhysicalPlanType::CsvScan(node)),
362        }))
363    }
364}
365
366impl FileOpener for CsvOpener {
367    /// Open a partitioned CSV file.
368    ///
369    /// If `file_meta.range` is `None`, the entire file is opened.
370    /// If `file_meta.range` is `Some(FileRange {start, end})`, this signifies that the partition
371    /// corresponds to the byte range [start, end) within the file.
372    ///
373    /// Note: `start` or `end` might be in the middle of some lines. In such cases, the following rules
374    /// are applied to determine which lines to read:
375    /// 1. The first line of the partition is the line in which the index of the first character >= `start`.
376    /// 2. The last line of the partition is the line in which the byte at position `end - 1` resides.
377    ///
378    /// Examples:
379    /// Consider the following partitions enclosed by braces `{}`:
380    ///
381    /// {A,1,2,3,4,5,6,7,8,9\n
382    ///  A,1,2,3,4,5,6,7,8,9\n}
383    ///  A,1,2,3,4,5,6,7,8,9\n
384    ///  The lines read would be: [0, 1]
385    ///
386    ///  A,{1,2,3,4,5,6,7,8,9\n
387    ///  A,1,2,3,4,5,6,7,8,9\n
388    ///  A},1,2,3,4,5,6,7,8,9\n
389    ///  The lines read would be: [1, 2]
390    fn open(&self, partitioned_file: PartitionedFile) -> Result<FileOpenFuture> {
391        // `self.config.has_header` controls whether to skip reading the 1st line header
392        // If the .csv file is read in parallel and this `CsvOpener` is only reading some middle
393        // partition, then don't skip first line
394        let mut csv_has_header = self.config.has_header();
395        if let Some(FileRange { start, .. }) = partitioned_file.range
396            && start != 0
397        {
398            csv_has_header = false;
399        }
400
401        let mut config = (*self.config).clone();
402        config.options.has_header = Some(csv_has_header);
403        config.options.truncated_rows = Some(config.truncate_rows());
404
405        let file_compression_type = self.file_compression_type.to_owned();
406
407        if partitioned_file.range.is_some() {
408            assert!(
409                !file_compression_type.is_compressed(),
410                "Reading compressed .csv in parallel is not supported"
411            );
412        }
413
414        let store = Arc::clone(&self.object_store);
415        let terminator = self.config.terminator();
416
417        let baseline_metrics =
418            BaselineMetrics::new(&self.config.metrics, self.partition_index);
419
420        Ok(Box::pin(async move {
421            // Current partition contains bytes [start_byte, end_byte) (might contain incomplete lines at boundaries)
422            let file_size = partitioned_file.object_meta.size;
423            let location = partitioned_file.object_meta.location;
424
425            if let Some(file_range) = partitioned_file.range.as_ref() {
426                let raw_start: u64 = file_range.start.try_into().map_err(|_| {
427                    exec_datafusion_err!(
428                        "Expected start range to fit in u64, got {}",
429                        file_range.start
430                    )
431                })?;
432                let raw_end: u64 = file_range.end.try_into().map_err(|_| {
433                    exec_datafusion_err!(
434                        "Expected end range to fit in u64, got {}",
435                        file_range.end
436                    )
437                })?;
438
439                let aligned_stream = AlignedBoundaryStream::new(
440                    Arc::clone(&store),
441                    location.clone(),
442                    raw_start,
443                    raw_end,
444                    file_size,
445                    terminator.unwrap_or(b'\n'),
446                )
447                .await?
448                .map_err(DataFusionError::from);
449
450                let decoder = config.builder().build_decoder();
451                let input = file_compression_type
452                    .convert_stream(aligned_stream.boxed())?
453                    .fuse();
454                let stream = deserialize_stream(
455                    input,
456                    DecoderDeserializer::new(CsvDecoder::new(decoder)),
457                );
458                return Ok(stream.map_err(Into::into).boxed());
459            }
460
461            // No range specified — read the entire file
462            let options = GetOptions::default();
463            let result = store.get_opts(&location, options).await?;
464
465            match result.payload {
466                #[cfg(not(target_arch = "wasm32"))]
467                GetResultPayload::File(file, _) => {
468                    let decoder = file_compression_type.convert_read(file)?;
469                    let mut reader = config.open(decoder)?;
470
471                    // Use std::iter::from_fn to wrap execution of iterator's next() method.
472                    let iterator = std::iter::from_fn(move || {
473                        let mut timer = baseline_metrics.elapsed_compute().timer();
474                        let result = reader.next();
475                        timer.stop();
476                        result
477                    });
478
479                    Ok(futures::stream::iter(iterator)
480                        .map(|r| r.map_err(Into::into))
481                        .boxed())
482                }
483                GetResultPayload::Stream(s) => {
484                    let decoder = config.builder().build_decoder();
485                    let s = s.map_err(DataFusionError::from);
486                    let input = file_compression_type.convert_stream(s.boxed())?.fuse();
487
488                    let stream = deserialize_stream(
489                        input,
490                        DecoderDeserializer::new(CsvDecoder::new(decoder)),
491                    );
492                    Ok(stream.map_err(Into::into).boxed())
493                }
494            }
495        }))
496    }
497}
498
499pub async fn plan_to_csv(
500    task_ctx: Arc<TaskContext>,
501    plan: Arc<dyn ExecutionPlan>,
502    path: impl AsRef<str>,
503) -> Result<()> {
504    let path = path.as_ref();
505    let parsed = ListingTableUrl::parse(path)?;
506    let object_store_url = parsed.object_store();
507    let store = task_ctx.runtime_env().object_store(&object_store_url)?;
508    let writer_buffer_size = task_ctx
509        .session_config()
510        .options()
511        .execution
512        .objectstore_writer_buffer_size;
513    let mut join_set = JoinSet::new();
514    for i in 0..plan.output_partitioning().partition_count() {
515        let storeref = Arc::clone(&store);
516        let plan: Arc<dyn ExecutionPlan> = Arc::clone(&plan);
517        let filename = format!("{}/part-{i}.csv", parsed.prefix());
518        let file = object_store::path::Path::parse(filename)?;
519
520        let mut stream = plan.execute(i, Arc::clone(&task_ctx))?;
521        join_set.spawn(async move {
522            let mut buf_writer =
523                BufWriter::with_capacity(storeref, file.clone(), writer_buffer_size);
524            let mut buffer = Vec::with_capacity(1024);
525            //only write headers on first iteration
526            let mut write_headers = true;
527            while let Some(batch) = stream.next().await.transpose()? {
528                let mut writer = csv::WriterBuilder::new()
529                    .with_header(write_headers)
530                    .build(buffer);
531                writer.write(&batch)?;
532                buffer = writer.into_inner();
533                buf_writer.write_all(&buffer).await?;
534                buffer.clear();
535                //prevent writing headers more than once
536                write_headers = false;
537            }
538            buf_writer.shutdown().await.map_err(DataFusionError::from)
539        });
540    }
541
542    while let Some(result) = join_set.join_next().await {
543        match result {
544            Ok(res) => res?, // propagate DataFusion error
545            Err(e) => {
546                if e.is_panic() {
547                    std::panic::resume_unwind(e.into_panic());
548                } else {
549                    unreachable!();
550                }
551            }
552        }
553    }
554
555    Ok(())
556}
557
558#[cfg(feature = "proto")]
559fn proto_byte_to_string(b: u8, description: &str) -> Result<String> {
560    let bytes = &[b];
561    let s = std::str::from_utf8(bytes).map_err(|_| {
562        datafusion_common::internal_datafusion_err!(
563            "Invalid CSV {description}: can not represent {bytes:0x?} as utf8"
564        )
565    })?;
566    Ok(s.to_owned())
567}
568
569#[cfg(feature = "proto")]
570fn proto_str_to_byte(s: &str, description: &str) -> Result<u8> {
571    datafusion_common::assert_eq_or_internal_err!(
572        s.len(),
573        1,
574        "Invalid CSV {description}: expected single character, got {s}"
575    );
576    Ok(s.as_bytes()[0])
577}
578
579#[cfg(feature = "proto")]
580impl CsvSource {
581    /// Reconstructs a `DataSourceExec` from a protobuf `CsvScan`.
582    ///
583    /// Custom line terminators are not represented in the wire format.
584    pub fn try_from_proto(
585        node: &datafusion_proto_models::protobuf::PhysicalPlanNode,
586        ctx: &datafusion_physical_plan::proto::ExecutionPlanDecodeCtx<'_>,
587    ) -> Result<Arc<dyn ExecutionPlan>> {
588        use datafusion_common::config::CsvOptions;
589        use datafusion_datasource::file_compression_type::FileCompressionType;
590        use datafusion_datasource::file_scan_config::{
591            FileScanConfig, FileScanConfigBuilder,
592        };
593        use datafusion_datasource::source::DataSourceExec;
594        use datafusion_proto_models::protobuf;
595
596        let scan = match &node.physical_plan_type {
597            Some(protobuf::physical_plan_node::PhysicalPlanType::CsvScan(scan)) => scan,
598            _ => {
599                return datafusion_common::internal_err!(
600                    "PhysicalPlanNode is not a CsvScan"
601                );
602            }
603        };
604
605        let base_conf = scan.base_conf.as_ref().ok_or_else(|| {
606            datafusion_common::internal_datafusion_err!(
607                "CsvScanExecNode is missing required field 'base_conf'"
608            )
609        })?;
610
611        let escape = match &scan.optional_escape {
612            Some(protobuf::csv_scan_exec_node::OptionalEscape::Escape(escape)) => {
613                Some(proto_str_to_byte(escape, "escape")?)
614            }
615            None => None,
616        };
617        let comment = match &scan.optional_comment {
618            Some(protobuf::csv_scan_exec_node::OptionalComment::Comment(comment)) => {
619                Some(proto_str_to_byte(comment, "comment")?)
620            }
621            None => None,
622        };
623
624        let table_schema = FileScanConfig::parse_table_schema_from_proto(base_conf)?;
625
626        let csv_options = CsvOptions {
627            has_header: Some(scan.has_header),
628            delimiter: proto_str_to_byte(&scan.delimiter, "delimiter")?,
629            quote: proto_str_to_byte(&scan.quote, "quote")?,
630            newlines_in_values: Some(scan.newlines_in_values),
631            truncated_rows: Some(scan.truncate_rows),
632            ..Default::default()
633        };
634        let source = Arc::new(
635            CsvSource::new(table_schema)
636                .with_csv_options(csv_options)
637                .with_escape(escape)
638                .with_comment(comment),
639        );
640
641        // The compression type is not on the wire; CSV scans always
642        // deserialize as uncompressed.
643        let conf = FileScanConfigBuilder::from(FileScanConfig::try_from_proto(
644            base_conf, ctx, source,
645        )?)
646        .with_file_compression_type(FileCompressionType::UNCOMPRESSED)
647        .build();
648        Ok(DataSourceExec::from_data_source(conf))
649    }
650}