Skip to main content

datafusion_datasource_csv/
file_format.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//! [`CsvFormat`], Comma Separated Value (CSV) [`FileFormat`] abstractions
19
20use std::collections::{HashMap, HashSet};
21use std::fmt::{self, Debug};
22use std::sync::Arc;
23
24use crate::source::CsvSource;
25
26use arrow::array::RecordBatch;
27use arrow::csv::WriterBuilder;
28use arrow::datatypes::{DataType, Field, Fields, Schema, SchemaRef};
29use arrow::error::ArrowError;
30use datafusion_common::config::{ConfigField, ConfigFileType, CsvOptions};
31use datafusion_common::file_options::csv_writer::CsvWriterOptions;
32use datafusion_common::{
33    DEFAULT_CSV_EXTENSION, DataFusionError, GetExt, Result, Statistics, exec_err,
34    not_impl_err,
35};
36use datafusion_common_runtime::SpawnedTask;
37use datafusion_datasource::TableSchema;
38use datafusion_datasource::decoder::Decoder;
39use datafusion_datasource::display::FileGroupDisplay;
40use datafusion_datasource::file::FileSource;
41use datafusion_datasource::file_compression_type::FileCompressionType;
42use datafusion_datasource::file_format::{
43    DEFAULT_SCHEMA_INFER_MAX_RECORD, FileFormat, FileFormatFactory,
44};
45use datafusion_datasource::file_scan_config::{FileScanConfig, FileScanConfigBuilder};
46use datafusion_datasource::file_sink_config::{FileSink, FileSinkConfig};
47use datafusion_datasource::sink::{DataSink, DataSinkExec};
48use datafusion_datasource::write::BatchSerializer;
49use datafusion_datasource::write::demux::DemuxedStreamReceiver;
50use datafusion_datasource::write::orchestration::spawn_writer_tasks_and_join;
51use datafusion_execution::{SendableRecordBatchStream, TaskContext};
52use datafusion_expr::dml::InsertOp;
53use datafusion_physical_expr_common::sort_expr::LexRequirement;
54use datafusion_physical_plan::{DisplayAs, DisplayFormatType, ExecutionPlan};
55use datafusion_session::Session;
56
57use async_trait::async_trait;
58use bytes::{Buf, Bytes};
59use datafusion_datasource::source::DataSourceExec;
60use futures::stream::BoxStream;
61use futures::{Stream, StreamExt, TryStreamExt, pin_mut};
62use object_store::{
63    ObjectMeta, ObjectStore, ObjectStoreExt, delimited::newline_delimited_stream,
64};
65use regex::Regex;
66
67#[derive(Default)]
68/// Factory used to create [`CsvFormat`]
69pub struct CsvFormatFactory {
70    /// the options for csv file read
71    pub options: Option<CsvOptions>,
72}
73
74impl CsvFormatFactory {
75    /// Creates an instance of [`CsvFormatFactory`]
76    pub fn new() -> Self {
77        Self { options: None }
78    }
79
80    /// Creates an instance of [`CsvFormatFactory`] with customized default options
81    pub fn new_with_options(options: CsvOptions) -> Self {
82        Self {
83            options: Some(options),
84        }
85    }
86}
87
88impl Debug for CsvFormatFactory {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        f.debug_struct("CsvFormatFactory")
91            .field("options", &self.options)
92            .finish()
93    }
94}
95
96impl FileFormatFactory for CsvFormatFactory {
97    fn create(
98        &self,
99        state: &dyn Session,
100        format_options: &HashMap<String, String>,
101    ) -> Result<Arc<dyn FileFormat>> {
102        let csv_options = match &self.options {
103            None => {
104                let mut table_options = state.default_table_options();
105                table_options.set_config_format(ConfigFileType::CSV);
106                table_options.alter_with_string_hash_map(format_options)?;
107                table_options.csv
108            }
109            Some(csv_options) => {
110                let mut csv_options = csv_options.clone();
111                for (k, v) in format_options {
112                    csv_options.set(k, v)?;
113                }
114                csv_options
115            }
116        };
117
118        Ok(Arc::new(CsvFormat::default().with_options(csv_options)))
119    }
120
121    fn default(&self) -> Arc<dyn FileFormat> {
122        Arc::new(CsvFormat::default())
123    }
124}
125
126impl GetExt for CsvFormatFactory {
127    fn get_ext(&self) -> String {
128        // Removes the dot, i.e. ".csv" -> "csv"
129        DEFAULT_CSV_EXTENSION[1..].to_string()
130    }
131}
132
133/// Character Separated Value [`FileFormat`] implementation.
134#[derive(Debug, Default)]
135pub struct CsvFormat {
136    options: CsvOptions,
137}
138
139impl CsvFormat {
140    /// Return a newline delimited stream from the specified file on
141    /// Stream, decompressing if necessary
142    /// Each returned `Bytes` has a whole number of newline delimited rows
143    async fn read_to_delimited_chunks<'a>(
144        &self,
145        store: &Arc<dyn ObjectStore>,
146        object: &ObjectMeta,
147    ) -> BoxStream<'a, Result<Bytes>> {
148        // stream to only read as many rows as needed into memory
149        let stream = store
150            .get(&object.location)
151            .await
152            .map_err(|e| DataFusionError::ObjectStore(Box::new(e)));
153        let stream = match stream {
154            Ok(stream) => self
155                .read_to_delimited_chunks_from_stream(
156                    stream
157                        .into_stream()
158                        .map_err(|e| DataFusionError::ObjectStore(Box::new(e)))
159                        .boxed(),
160                )
161                .map_err(DataFusionError::from)
162                .left_stream(),
163            Err(e) => {
164                futures::stream::once(futures::future::ready(Err(e))).right_stream()
165            }
166        };
167        stream.boxed()
168    }
169
170    /// Convert a stream of bytes into a stream of [`Bytes`] containing newline
171    /// delimited CSV records, while accounting for `\` and `"`.
172    pub fn read_to_delimited_chunks_from_stream<'a>(
173        &self,
174        stream: BoxStream<'a, Result<Bytes>>,
175    ) -> BoxStream<'a, Result<Bytes>> {
176        let file_compression_type: FileCompressionType = self.options.compression.into();
177        let decoder = file_compression_type.convert_stream(stream);
178        let stream = match decoder {
179            Ok(decoded_stream) => {
180                newline_delimited_stream(decoded_stream.map_err(|e| match e {
181                    DataFusionError::ObjectStore(e) => *e,
182                    err => object_store::Error::Generic {
183                        store: "read to delimited chunks failed",
184                        source: Box::new(err),
185                    },
186                }))
187                .map_err(DataFusionError::from)
188                .left_stream()
189            }
190            Err(e) => {
191                futures::stream::once(futures::future::ready(Err(e))).right_stream()
192            }
193        };
194        stream.boxed()
195    }
196
197    /// Set the csv options
198    pub fn with_options(mut self, options: CsvOptions) -> Self {
199        self.options = options;
200        self
201    }
202
203    /// Retrieve the csv options
204    pub fn options(&self) -> &CsvOptions {
205        &self.options
206    }
207
208    /// Set a limit in terms of records to scan to infer the schema
209    /// - default to `DEFAULT_SCHEMA_INFER_MAX_RECORD`
210    ///
211    /// # Behavior when set to 0
212    ///
213    /// When `max_rec` is set to 0, schema inference is disabled and all fields
214    /// will be inferred as `Utf8` (string) type, regardless of their actual content.
215    pub fn with_schema_infer_max_rec(mut self, max_rec: usize) -> Self {
216        self.options.schema_infer_max_rec = Some(max_rec);
217        self
218    }
219
220    /// Set true to indicate that the first line is a header.
221    /// - default to true
222    pub fn with_has_header(mut self, has_header: bool) -> Self {
223        self.options.has_header = Some(has_header);
224        self
225    }
226
227    pub fn with_truncated_rows(mut self, truncated_rows: bool) -> Self {
228        self.options.truncated_rows = Some(truncated_rows);
229        self
230    }
231
232    /// Set the regex to use for null values in the CSV reader.
233    /// - default to treat empty values as null.
234    pub fn with_null_regex(mut self, null_regex: Option<String>) -> Self {
235        self.options.null_regex = null_regex;
236        self
237    }
238
239    /// Returns `Some(true)` if the first line is a header, `Some(false)` if
240    /// it is not, and `None` if it is not specified.
241    pub fn has_header(&self) -> Option<bool> {
242        self.options.has_header
243    }
244
245    /// Lines beginning with this byte are ignored.
246    pub fn with_comment(mut self, comment: Option<u8>) -> Self {
247        self.options.comment = comment;
248        self
249    }
250
251    /// The character separating values within a row.
252    /// - default to ','
253    pub fn with_delimiter(mut self, delimiter: u8) -> Self {
254        self.options.delimiter = delimiter;
255        self
256    }
257
258    /// The quote character in a row.
259    /// - default to '"'
260    pub fn with_quote(mut self, quote: u8) -> Self {
261        self.options.quote = quote;
262        self
263    }
264
265    /// The escape character in a row.
266    /// - default is None
267    pub fn with_escape(mut self, escape: Option<u8>) -> Self {
268        self.options.escape = escape;
269        self
270    }
271
272    /// The character used to indicate the end of a row.
273    /// - default to None (CRLF)
274    pub fn with_terminator(mut self, terminator: Option<u8>) -> Self {
275        self.options.terminator = terminator;
276        self
277    }
278
279    /// Specifies whether newlines in (quoted) values are supported.
280    ///
281    /// Parsing newlines in quoted values may be affected by execution behaviour such as
282    /// parallel file scanning. Setting this to `true` ensures that newlines in values are
283    /// parsed successfully, which may reduce performance.
284    ///
285    /// The default behaviour depends on the `datafusion.catalog.newlines_in_values` setting.
286    pub fn with_newlines_in_values(mut self, newlines_in_values: bool) -> Self {
287        self.options.newlines_in_values = Some(newlines_in_values);
288        self
289    }
290
291    /// Set a `FileCompressionType` of CSV
292    /// - defaults to `FileCompressionType::UNCOMPRESSED`
293    pub fn with_file_compression_type(
294        mut self,
295        file_compression_type: FileCompressionType,
296    ) -> Self {
297        self.options.compression = file_compression_type.into();
298        self
299    }
300
301    /// Set whether rows should be truncated to the column width
302    /// - defaults to false
303    pub fn with_truncate_rows(mut self, truncate_rows: bool) -> Self {
304        self.options.truncated_rows = Some(truncate_rows);
305        self
306    }
307
308    /// The delimiter character.
309    pub fn delimiter(&self) -> u8 {
310        self.options.delimiter
311    }
312
313    /// The quote character.
314    pub fn quote(&self) -> u8 {
315        self.options.quote
316    }
317
318    /// The escape character.
319    pub fn escape(&self) -> Option<u8> {
320        self.options.escape
321    }
322}
323
324#[derive(Debug)]
325pub struct CsvDecoder {
326    inner: arrow::csv::reader::Decoder,
327}
328
329impl CsvDecoder {
330    pub fn new(decoder: arrow::csv::reader::Decoder) -> Self {
331        Self { inner: decoder }
332    }
333}
334
335impl Decoder for CsvDecoder {
336    fn decode(&mut self, buf: &[u8]) -> Result<usize, ArrowError> {
337        self.inner.decode(buf)
338    }
339
340    fn flush(&mut self) -> Result<Option<RecordBatch>, ArrowError> {
341        self.inner.flush()
342    }
343
344    fn can_flush_early(&self) -> bool {
345        self.inner.capacity() == 0
346    }
347}
348
349impl Debug for CsvSerializer {
350    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
351        f.debug_struct("CsvSerializer")
352            .field("header", &self.header)
353            .finish()
354    }
355}
356
357#[async_trait]
358impl FileFormat for CsvFormat {
359    fn get_ext(&self) -> String {
360        CsvFormatFactory::new().get_ext()
361    }
362
363    fn get_ext_with_compression(
364        &self,
365        file_compression_type: &FileCompressionType,
366    ) -> Result<String> {
367        let ext = self.get_ext();
368        Ok(format!("{}{}", ext, file_compression_type.get_ext()))
369    }
370
371    fn compression_type(&self) -> Option<FileCompressionType> {
372        Some(self.options.compression.into())
373    }
374
375    async fn infer_schema(
376        &self,
377        state: &dyn Session,
378        store: &Arc<dyn ObjectStore>,
379        objects: &[ObjectMeta],
380    ) -> Result<SchemaRef> {
381        let mut schemas = vec![];
382
383        let mut records_to_read = self
384            .options
385            .schema_infer_max_rec
386            .unwrap_or(DEFAULT_SCHEMA_INFER_MAX_RECORD);
387
388        for object in objects {
389            let stream = self.read_to_delimited_chunks(store, object).await;
390            let (schema, records_read) = self
391                .infer_schema_from_stream(state, records_to_read, stream)
392                .await
393                .map_err(|err| {
394                    DataFusionError::Context(
395                        format!("Error when processing CSV file {}", object.location),
396                        Box::new(err),
397                    )
398                })?;
399            records_to_read -= records_read;
400            schemas.push(schema);
401            if records_to_read == 0 {
402                break;
403            }
404        }
405
406        let merged_schema = Schema::try_merge(schemas)?;
407        Ok(Arc::new(merged_schema))
408    }
409
410    async fn infer_stats(
411        &self,
412        _state: &dyn Session,
413        _store: &Arc<dyn ObjectStore>,
414        table_schema: SchemaRef,
415        _object: &ObjectMeta,
416    ) -> Result<Statistics> {
417        Ok(Statistics::new_unknown(&table_schema))
418    }
419
420    async fn create_physical_plan(
421        &self,
422        state: &dyn Session,
423        conf: FileScanConfig,
424    ) -> Result<Arc<dyn ExecutionPlan>> {
425        // Consult configuration options for default values
426        let has_header = self
427            .options
428            .has_header
429            .unwrap_or_else(|| state.config_options().catalog.has_header);
430        let newlines_in_values = self
431            .options
432            .newlines_in_values
433            .unwrap_or_else(|| state.config_options().catalog.newlines_in_values);
434
435        let mut csv_options = self.options.clone();
436        csv_options.has_header = Some(has_header);
437        csv_options.newlines_in_values = Some(newlines_in_values);
438
439        // Get the existing CsvSource and update its options
440        // We need to preserve the table_schema from the original source (which includes partition columns)
441        let csv_source = conf
442            .file_source
443            .downcast_ref::<CsvSource>()
444            .expect("file_source should be a CsvSource");
445        let source = Arc::new(csv_source.clone().with_csv_options(csv_options));
446
447        let config = FileScanConfigBuilder::from(conf)
448            .with_file_compression_type(self.options.compression.into())
449            .with_source(source)
450            .build();
451
452        Ok(DataSourceExec::from_data_source(config))
453    }
454
455    async fn create_writer_physical_plan(
456        &self,
457        input: Arc<dyn ExecutionPlan>,
458        state: &dyn Session,
459        conf: FileSinkConfig,
460        order_requirements: Option<LexRequirement>,
461    ) -> Result<Arc<dyn ExecutionPlan>> {
462        if conf.insert_op != InsertOp::Append {
463            return not_impl_err!("Overwrites are not implemented yet for CSV");
464        }
465
466        // `has_header` and `newlines_in_values` fields of CsvOptions may inherit
467        // their values from session from configuration settings. To support
468        // this logic, writer options are built from the copy of `self.options`
469        // with updated values of these special fields.
470        let has_header = self
471            .options()
472            .has_header
473            .unwrap_or_else(|| state.config_options().catalog.has_header);
474        let newlines_in_values = self
475            .options()
476            .newlines_in_values
477            .unwrap_or_else(|| state.config_options().catalog.newlines_in_values);
478
479        let options = self
480            .options()
481            .clone()
482            .with_has_header(has_header)
483            .with_newlines_in_values(newlines_in_values);
484
485        let writer_options = CsvWriterOptions::try_from(&options)?;
486
487        let sink = Arc::new(CsvSink::new(conf, writer_options));
488
489        Ok(Arc::new(DataSinkExec::new(input, sink, order_requirements)) as _)
490    }
491
492    fn file_source(&self, table_schema: TableSchema) -> Arc<dyn FileSource> {
493        let mut csv_options = self.options.clone();
494        if csv_options.has_header.is_none() {
495            csv_options.has_header = Some(true);
496        }
497        Arc::new(CsvSource::new(table_schema).with_csv_options(csv_options))
498    }
499}
500
501impl CsvFormat {
502    /// Return the inferred schema reading up to records_to_read from a
503    /// stream of delimited chunks returning the inferred schema and the
504    /// number of lines that were read.
505    ///
506    /// This method can handle CSV files with different numbers of columns.
507    /// The inferred schema will be the union of all columns found across all files.
508    /// Files with fewer columns will have missing columns filled with null values.
509    ///
510    /// # Example
511    ///
512    /// If you have two CSV files:
513    /// - `file1.csv`: `col1,col2,col3`
514    /// - `file2.csv`: `col1,col2,col3,col4,col5`
515    ///
516    /// The inferred schema will contain all 5 columns, with files that don't
517    /// have columns 4 and 5 having null values for those columns.
518    pub async fn infer_schema_from_stream(
519        &self,
520        state: &dyn Session,
521        mut records_to_read: usize,
522        stream: impl Stream<Item = Result<Bytes>>,
523    ) -> Result<(Schema, usize)> {
524        let mut total_records_read = 0;
525        let mut column_names = vec![];
526        let mut column_type_possibilities = vec![];
527        let mut record_number = -1;
528        let initial_records_to_read = records_to_read;
529
530        pin_mut!(stream);
531
532        while let Some(chunk) = stream.next().await.transpose()? {
533            record_number += 1;
534            let first_chunk = record_number == 0;
535            let mut format = arrow::csv::reader::Format::default()
536                .with_header(
537                    first_chunk
538                        && self
539                            .options
540                            .has_header
541                            .unwrap_or_else(|| state.config_options().catalog.has_header),
542                )
543                .with_delimiter(self.options.delimiter)
544                .with_quote(self.options.quote)
545                .with_truncated_rows(self.options.truncated_rows.unwrap_or(false));
546
547            if let Some(null_regex) = &self.options.null_regex {
548                let regex = Regex::new(null_regex.as_str())
549                    .expect("Unable to parse CSV null regex.");
550                format = format.with_null_regex(regex);
551            }
552
553            if let Some(escape) = self.options.escape {
554                format = format.with_escape(escape);
555            }
556
557            if let Some(comment) = self.options.comment {
558                format = format.with_comment(comment);
559            }
560
561            let (Schema { fields, .. }, records_read) =
562                format.infer_schema(chunk.reader(), Some(records_to_read))?;
563
564            records_to_read -= records_read;
565            total_records_read += records_read;
566
567            if first_chunk {
568                // set up initial structures for recording inferred schema across chunks
569                (column_names, column_type_possibilities) = fields
570                    .into_iter()
571                    .map(|field| {
572                        let mut possibilities = HashSet::new();
573                        if records_read > 0 {
574                            // at least 1 data row read, record the inferred datatype
575                            possibilities.insert(field.data_type().clone());
576                        }
577                        (field.name().clone(), possibilities)
578                    })
579                    .unzip();
580            } else {
581                if fields.len() != column_type_possibilities.len()
582                    && !self.options.truncated_rows.unwrap_or(false)
583                {
584                    return exec_err!(
585                        "Encountered unequal lengths between records on CSV file whilst inferring schema. \
586                         Expected {} fields, found {} fields at record {}",
587                        column_type_possibilities.len(),
588                        fields.len(),
589                        record_number + 1
590                    );
591                }
592
593                // First update type possibilities for existing columns using zip
594                column_type_possibilities.iter_mut().zip(&fields).for_each(
595                    |(possibilities, field)| {
596                        possibilities.insert(field.data_type().clone());
597                    },
598                );
599
600                // Handle files with different numbers of columns by extending the schema
601                if fields.len() > column_type_possibilities.len() {
602                    // New columns found - extend our tracking structures
603                    for field in fields.iter().skip(column_type_possibilities.len()) {
604                        column_names.push(field.name().clone());
605                        let mut possibilities = HashSet::new();
606                        if records_read > 0 {
607                            possibilities.insert(field.data_type().clone());
608                        }
609                        column_type_possibilities.push(possibilities);
610                    }
611                }
612            }
613
614            if records_to_read == 0 {
615                break;
616            }
617        }
618
619        let schema = build_schema_helper(
620            column_names,
621            column_type_possibilities,
622            initial_records_to_read == 0,
623        );
624        Ok((schema, total_records_read))
625    }
626}
627
628/// Builds a schema from column names and their possible data types.
629///
630/// # Arguments
631///
632/// * `names` - Vector of column names
633/// * `types` - Vector of possible data types for each column (as HashSets)
634/// * `disable_inference` - When true, forces all columns with no inferred types to be Utf8.
635///   This should be set to true when `schema_infer_max_rec` is explicitly
636///   set to 0, indicating the user wants to skip type inference and treat
637///   all fields as strings. When false, columns with no inferred types
638///   will be set to Null, allowing schema merging to work properly.
639fn build_schema_helper(
640    names: Vec<String>,
641    types: Vec<HashSet<DataType>>,
642    disable_inference: bool,
643) -> Schema {
644    let fields = names
645        .into_iter()
646        .zip(types)
647        .map(|(field_name, mut data_type_possibilities)| {
648            // ripped from arrow::csv::reader::infer_reader_schema_with_csv_options
649            // determine data type based on possible types
650            // if there are incompatible types, use DataType::Utf8
651
652            // ignore nulls, to avoid conflicting datatypes (e.g. [nulls, int]) being inferred as Utf8.
653            data_type_possibilities.remove(&DataType::Null);
654
655            match data_type_possibilities.len() {
656                // When no types were inferred (empty HashSet):
657                // - If schema_infer_max_rec was explicitly set to 0, return Utf8
658                // - Otherwise return Null (whether from reading null values or empty files)
659                //   This allows schema merging to work when reading folders with empty files
660                0 => {
661                    if disable_inference {
662                        Field::new(field_name, DataType::Utf8, true)
663                    } else {
664                        Field::new(field_name, DataType::Null, true)
665                    }
666                }
667                1 => Field::new(
668                    field_name,
669                    data_type_possibilities.iter().next().unwrap().clone(),
670                    true,
671                ),
672                2 => {
673                    if data_type_possibilities.contains(&DataType::Int64)
674                        && data_type_possibilities.contains(&DataType::Float64)
675                    {
676                        // we have an integer and double, fall down to double
677                        Field::new(field_name, DataType::Float64, true)
678                    } else {
679                        // default to Utf8 for conflicting datatypes (e.g bool and int)
680                        Field::new(field_name, DataType::Utf8, true)
681                    }
682                }
683                _ => Field::new(field_name, DataType::Utf8, true),
684            }
685        })
686        .collect::<Fields>();
687    Schema::new(fields)
688}
689
690impl Default for CsvSerializer {
691    fn default() -> Self {
692        Self::new()
693    }
694}
695
696/// Define a struct for serializing CSV records to a stream
697pub struct CsvSerializer {
698    // CSV writer builder
699    builder: WriterBuilder,
700    // Flag to indicate whether there will be a header
701    header: bool,
702}
703
704impl CsvSerializer {
705    /// Constructor for the CsvSerializer object
706    pub fn new() -> Self {
707        Self {
708            builder: WriterBuilder::new(),
709            header: true,
710        }
711    }
712
713    /// Method for setting the CSV writer builder
714    pub fn with_builder(mut self, builder: WriterBuilder) -> Self {
715        self.builder = builder;
716        self
717    }
718
719    /// Method for setting the CSV writer header status
720    pub fn with_header(mut self, header: bool) -> Self {
721        self.header = header;
722        self
723    }
724}
725
726impl BatchSerializer for CsvSerializer {
727    fn serialize(&self, batch: RecordBatch, initial: bool) -> Result<Bytes> {
728        let mut buffer = Vec::with_capacity(4096);
729        let builder = self.builder.clone();
730        let header = self.header && initial;
731        let mut writer = builder.with_header(header).build(&mut buffer);
732        writer.write(&batch)?;
733        drop(writer);
734        Ok(Bytes::from(buffer))
735    }
736}
737
738/// Implements [`DataSink`] for writing to a CSV file.
739pub struct CsvSink {
740    /// Config options for writing data
741    config: FileSinkConfig,
742    writer_options: CsvWriterOptions,
743}
744
745impl Debug for CsvSink {
746    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
747        f.debug_struct("CsvSink").finish()
748    }
749}
750
751impl DisplayAs for CsvSink {
752    fn fmt_as(&self, t: DisplayFormatType, f: &mut fmt::Formatter<'_>) -> fmt::Result {
753        match t {
754            DisplayFormatType::Default | DisplayFormatType::Verbose => {
755                write!(f, "CsvSink(file_groups=",)?;
756                FileGroupDisplay(&self.config.file_group).fmt_as(t, f)?;
757                write!(f, ")")
758            }
759            DisplayFormatType::TreeRender => {
760                writeln!(f, "format: csv")?;
761                write!(f, "file={}", self.config.original_url)
762            }
763        }
764    }
765}
766
767impl CsvSink {
768    /// Create from config.
769    pub fn new(config: FileSinkConfig, writer_options: CsvWriterOptions) -> Self {
770        Self {
771            config,
772            writer_options,
773        }
774    }
775
776    /// Retrieve the writer options
777    pub fn writer_options(&self) -> &CsvWriterOptions {
778        &self.writer_options
779    }
780}
781
782#[async_trait]
783impl FileSink for CsvSink {
784    fn config(&self) -> &FileSinkConfig {
785        &self.config
786    }
787
788    async fn spawn_writer_tasks_and_join(
789        &self,
790        context: &Arc<TaskContext>,
791        demux_task: SpawnedTask<Result<()>>,
792        file_stream_rx: DemuxedStreamReceiver,
793        object_store: Arc<dyn ObjectStore>,
794    ) -> Result<u64> {
795        let builder = self.writer_options.writer_options.clone();
796        let header = builder.header();
797        let serializer = Arc::new(
798            CsvSerializer::new()
799                .with_builder(builder)
800                .with_header(header),
801        ) as _;
802        spawn_writer_tasks_and_join(
803            context,
804            serializer,
805            self.writer_options.compression.into(),
806            self.writer_options.compression_level,
807            object_store,
808            demux_task,
809            file_stream_rx,
810        )
811        .await
812    }
813}
814
815#[async_trait]
816impl DataSink for CsvSink {
817    fn schema(&self) -> &SchemaRef {
818        self.config.output_schema()
819    }
820
821    async fn write_all(
822        &self,
823        data: SendableRecordBatchStream,
824        context: &Arc<TaskContext>,
825    ) -> Result<u64> {
826        FileSink::write_all(self, data, context).await
827    }
828
829    #[cfg(feature = "proto")]
830    fn try_to_proto(
831        &self,
832        exec: &DataSinkExec,
833        ctx: &datafusion_physical_plan::proto::ExecutionPlanEncodeCtx<'_>,
834    ) -> Result<Option<datafusion_proto_models::protobuf::PhysicalPlanNode>> {
835        use datafusion_proto_models::protobuf;
836        use protobuf::physical_plan_node::PhysicalPlanType;
837
838        let input = ctx.encode_child(exec.input())?;
839        let sort_order = exec.encode_sort_order(ctx)?;
840        let sink = protobuf::CsvSink::try_from(self)?;
841        let node = protobuf::CsvSinkExecNode {
842            input: Some(Box::new(input)),
843            sink: Some(sink),
844            sink_schema: Some(exec.schema().as_ref().try_into()?),
845            sort_order,
846        };
847        Ok(Some(protobuf::PhysicalPlanNode {
848            physical_plan_type: Some(PhysicalPlanType::CsvSink(Box::new(node))),
849        }))
850    }
851}
852
853#[cfg(feature = "proto")]
854impl TryFrom<&CsvSink> for datafusion_proto_models::protobuf::CsvSink {
855    type Error = DataFusionError;
856
857    fn try_from(value: &CsvSink) -> Result<Self> {
858        Ok(Self {
859            config: Some(value.config().try_into()?),
860            writer_options: Some(value.writer_options().try_into()?),
861        })
862    }
863}
864
865#[cfg(feature = "proto")]
866impl TryFrom<&datafusion_proto_models::protobuf::CsvSink> for CsvSink {
867    type Error = DataFusionError;
868
869    fn try_from(value: &datafusion_proto_models::protobuf::CsvSink) -> Result<Self> {
870        let config =
871            FileSinkConfig::try_from(value.config.as_ref().ok_or_else(|| {
872                datafusion_common::internal_datafusion_err!(
873                    "CsvSink is missing required field 'config'"
874                )
875            })?)?;
876        let writer_options = value
877            .writer_options
878            .as_ref()
879            .ok_or_else(|| {
880                datafusion_common::internal_datafusion_err!(
881                    "CsvSink is missing required field 'writer_options'"
882                )
883            })?
884            .try_into()?;
885
886        Ok(Self::new(config, writer_options))
887    }
888}
889
890#[cfg(feature = "proto")]
891impl CsvSink {
892    /// Reconstructs a [`DataSinkExec`] containing a `CsvSink` from protobuf.
893    pub fn try_from_proto(
894        node: &datafusion_proto_models::protobuf::PhysicalPlanNode,
895        ctx: &datafusion_physical_plan::proto::ExecutionPlanDecodeCtx<'_>,
896    ) -> Result<Arc<dyn ExecutionPlan>> {
897        use datafusion_proto_models::protobuf;
898
899        let sink_node = datafusion_physical_plan::expect_plan_variant!(
900            node,
901            protobuf::physical_plan_node::PhysicalPlanType::CsvSink,
902            "CsvSink",
903        );
904        let input = ctx.decode_required_child(
905            sink_node.input.as_deref(),
906            "CsvSinkExecNode",
907            "input",
908        )?;
909        let proto_sink = sink_node.sink.as_ref().ok_or_else(|| {
910            datafusion_common::internal_datafusion_err!(
911                "CsvSinkExecNode is missing required field 'sink'"
912            )
913        })?;
914        let data_sink = CsvSink::try_from(proto_sink)?;
915        let sort_order = DataSinkExec::decode_sort_order(
916            sink_node.sort_order.as_ref(),
917            ctx,
918            input.schema().as_ref(),
919        )?;
920
921        Ok(Arc::new(DataSinkExec::new(
922            input,
923            Arc::new(data_sink),
924            sort_order,
925        )))
926    }
927}
928
929/// Encode a [`CsvFormatFactory`]'s options as their protobuf form.
930///
931/// The reverse direction is `From<&protobuf::CsvOptions> for CsvOptions` in
932/// `datafusion-proto-models`: `CsvOptions` is a `datafusion-common` type, so
933/// that half cannot live here.
934#[cfg(feature = "proto")]
935impl From<&CsvFormatFactory> for datafusion_proto_models::protobuf::CsvOptions {
936    fn from(factory: &CsvFormatFactory) -> Self {
937        if let Some(options) = &factory.options {
938            datafusion_proto_models::protobuf::CsvOptions {
939                has_header: options.has_header.map_or(vec![], |v| vec![v as u8]),
940                delimiter: vec![options.delimiter],
941                quote: vec![options.quote],
942                terminator: options.terminator.map_or(vec![], |v| vec![v]),
943                escape: options.escape.map_or(vec![], |v| vec![v]),
944                double_quote: options.double_quote.map_or(vec![], |v| vec![v as u8]),
945                compression: options.compression as i32,
946                schema_infer_max_rec: options.schema_infer_max_rec.map(|v| v as u64),
947                date_format: options.date_format.clone().unwrap_or_default(),
948                datetime_format: options.datetime_format.clone().unwrap_or_default(),
949                timestamp_format: options.timestamp_format.clone().unwrap_or_default(),
950                timestamp_tz_format: options
951                    .timestamp_tz_format
952                    .clone()
953                    .unwrap_or_default(),
954                time_format: options.time_format.clone().unwrap_or_default(),
955                null_value: options.null_value.clone().unwrap_or_default(),
956                null_regex: options.null_regex.clone().unwrap_or_default(),
957                comment: options.comment.map_or(vec![], |v| vec![v]),
958                newlines_in_values: options
959                    .newlines_in_values
960                    .map_or(vec![], |v| vec![v as u8]),
961                truncated_rows: options.truncated_rows.map_or(vec![], |v| vec![v as u8]),
962                compression_level: options.compression_level,
963                quote_style: options.quote_style as i32,
964                ignore_leading_whitespace: options
965                    .ignore_leading_whitespace
966                    .map_or(vec![], |v| vec![v as u8]),
967                ignore_trailing_whitespace: options
968                    .ignore_trailing_whitespace
969                    .map_or(vec![], |v| vec![v as u8]),
970            }
971        } else {
972            datafusion_proto_models::protobuf::CsvOptions::default()
973        }
974    }
975}
976
977#[cfg(test)]
978mod tests {
979    use super::build_schema_helper;
980    use arrow::datatypes::DataType;
981    use std::collections::HashSet;
982
983    #[test]
984    fn test_build_schema_helper_different_column_counts() {
985        // Test the core schema building logic with different column counts
986        let mut column_names =
987            vec!["col1".to_string(), "col2".to_string(), "col3".to_string()];
988
989        // Simulate adding two more columns from another file
990        column_names.push("col4".to_string());
991        column_names.push("col5".to_string());
992
993        let column_type_possibilities = vec![
994            HashSet::from([DataType::Int64]),
995            HashSet::from([DataType::Utf8]),
996            HashSet::from([DataType::Float64]),
997            HashSet::from([DataType::Utf8]), // col4
998            HashSet::from([DataType::Utf8]), // col5
999        ];
1000
1001        let schema = build_schema_helper(column_names, column_type_possibilities, false);
1002
1003        // Verify schema has 5 columns
1004        assert_eq!(schema.fields().len(), 5);
1005        assert_eq!(schema.field(0).name(), "col1");
1006        assert_eq!(schema.field(1).name(), "col2");
1007        assert_eq!(schema.field(2).name(), "col3");
1008        assert_eq!(schema.field(3).name(), "col4");
1009        assert_eq!(schema.field(4).name(), "col5");
1010
1011        // All fields should be nullable
1012        for field in schema.fields() {
1013            assert!(
1014                field.is_nullable(),
1015                "Field {} should be nullable",
1016                field.name()
1017            );
1018        }
1019    }
1020
1021    #[test]
1022    fn test_build_schema_helper_type_merging() {
1023        // Test type merging logic
1024        let column_names = vec!["col1".to_string(), "col2".to_string()];
1025
1026        let column_type_possibilities = vec![
1027            HashSet::from([DataType::Int64, DataType::Float64]), // Should resolve to Float64
1028            HashSet::from([DataType::Utf8]),                     // Should remain Utf8
1029        ];
1030
1031        let schema = build_schema_helper(column_names, column_type_possibilities, false);
1032
1033        // col1 should be Float64 due to Int64 + Float64 = Float64
1034        assert_eq!(*schema.field(0).data_type(), DataType::Float64);
1035
1036        // col2 should remain Utf8
1037        assert_eq!(*schema.field(1).data_type(), DataType::Utf8);
1038    }
1039
1040    #[test]
1041    fn test_build_schema_helper_conflicting_types() {
1042        // Test when we have incompatible types - should default to Utf8
1043        let column_names = vec!["col1".to_string()];
1044
1045        let column_type_possibilities = vec![
1046            HashSet::from([DataType::Boolean, DataType::Int64, DataType::Utf8]), // Should resolve to Utf8 due to conflicts
1047        ];
1048
1049        let schema = build_schema_helper(column_names, column_type_possibilities, false);
1050
1051        // Should default to Utf8 for conflicting types
1052        assert_eq!(*schema.field(0).data_type(), DataType::Utf8);
1053    }
1054}