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                .await
162                .map_err(DataFusionError::from)
163                .left_stream(),
164            Err(e) => {
165                futures::stream::once(futures::future::ready(Err(e))).right_stream()
166            }
167        };
168        stream.boxed()
169    }
170
171    /// Convert a stream of bytes into a stream of of [`Bytes`] containing newline
172    /// delimited CSV records, while accounting for `\` and `"`.
173    pub async fn read_to_delimited_chunks_from_stream<'a>(
174        &self,
175        stream: BoxStream<'a, Result<Bytes>>,
176    ) -> BoxStream<'a, Result<Bytes>> {
177        let file_compression_type: FileCompressionType = self.options.compression.into();
178        let decoder = file_compression_type.convert_stream(stream);
179        let stream = match decoder {
180            Ok(decoded_stream) => {
181                newline_delimited_stream(decoded_stream.map_err(|e| match e {
182                    DataFusionError::ObjectStore(e) => *e,
183                    err => object_store::Error::Generic {
184                        store: "read to delimited chunks failed",
185                        source: Box::new(err),
186                    },
187                }))
188                .map_err(DataFusionError::from)
189                .left_stream()
190            }
191            Err(e) => {
192                futures::stream::once(futures::future::ready(Err(e))).right_stream()
193            }
194        };
195        stream.boxed()
196    }
197
198    /// Set the csv options
199    pub fn with_options(mut self, options: CsvOptions) -> Self {
200        self.options = options;
201        self
202    }
203
204    /// Retrieve the csv options
205    pub fn options(&self) -> &CsvOptions {
206        &self.options
207    }
208
209    /// Set a limit in terms of records to scan to infer the schema
210    /// - default to `DEFAULT_SCHEMA_INFER_MAX_RECORD`
211    ///
212    /// # Behavior when set to 0
213    ///
214    /// When `max_rec` is set to 0, schema inference is disabled and all fields
215    /// will be inferred as `Utf8` (string) type, regardless of their actual content.
216    pub fn with_schema_infer_max_rec(mut self, max_rec: usize) -> Self {
217        self.options.schema_infer_max_rec = Some(max_rec);
218        self
219    }
220
221    /// Set true to indicate that the first line is a header.
222    /// - default to true
223    pub fn with_has_header(mut self, has_header: bool) -> Self {
224        self.options.has_header = Some(has_header);
225        self
226    }
227
228    pub fn with_truncated_rows(mut self, truncated_rows: bool) -> Self {
229        self.options.truncated_rows = Some(truncated_rows);
230        self
231    }
232
233    /// Set the regex to use for null values in the CSV reader.
234    /// - default to treat empty values as null.
235    pub fn with_null_regex(mut self, null_regex: Option<String>) -> Self {
236        self.options.null_regex = null_regex;
237        self
238    }
239
240    /// Returns `Some(true)` if the first line is a header, `Some(false)` if
241    /// it is not, and `None` if it is not specified.
242    pub fn has_header(&self) -> Option<bool> {
243        self.options.has_header
244    }
245
246    /// Lines beginning with this byte are ignored.
247    pub fn with_comment(mut self, comment: Option<u8>) -> Self {
248        self.options.comment = comment;
249        self
250    }
251
252    /// The character separating values within a row.
253    /// - default to ','
254    pub fn with_delimiter(mut self, delimiter: u8) -> Self {
255        self.options.delimiter = delimiter;
256        self
257    }
258
259    /// The quote character in a row.
260    /// - default to '"'
261    pub fn with_quote(mut self, quote: u8) -> Self {
262        self.options.quote = quote;
263        self
264    }
265
266    /// The escape character in a row.
267    /// - default is None
268    pub fn with_escape(mut self, escape: Option<u8>) -> Self {
269        self.options.escape = escape;
270        self
271    }
272
273    /// The character used to indicate the end of a row.
274    /// - default to None (CRLF)
275    pub fn with_terminator(mut self, terminator: Option<u8>) -> Self {
276        self.options.terminator = terminator;
277        self
278    }
279
280    /// Specifies whether newlines in (quoted) values are supported.
281    ///
282    /// Parsing newlines in quoted values may be affected by execution behaviour such as
283    /// parallel file scanning. Setting this to `true` ensures that newlines in values are
284    /// parsed successfully, which may reduce performance.
285    ///
286    /// The default behaviour depends on the `datafusion.catalog.newlines_in_values` setting.
287    pub fn with_newlines_in_values(mut self, newlines_in_values: bool) -> Self {
288        self.options.newlines_in_values = Some(newlines_in_values);
289        self
290    }
291
292    /// Set a `FileCompressionType` of CSV
293    /// - defaults to `FileCompressionType::UNCOMPRESSED`
294    pub fn with_file_compression_type(
295        mut self,
296        file_compression_type: FileCompressionType,
297    ) -> Self {
298        self.options.compression = file_compression_type.into();
299        self
300    }
301
302    /// Set whether rows should be truncated to the column width
303    /// - defaults to false
304    pub fn with_truncate_rows(mut self, truncate_rows: bool) -> Self {
305        self.options.truncated_rows = Some(truncate_rows);
306        self
307    }
308
309    /// The delimiter character.
310    pub fn delimiter(&self) -> u8 {
311        self.options.delimiter
312    }
313
314    /// The quote character.
315    pub fn quote(&self) -> u8 {
316        self.options.quote
317    }
318
319    /// The escape character.
320    pub fn escape(&self) -> Option<u8> {
321        self.options.escape
322    }
323}
324
325#[derive(Debug)]
326pub struct CsvDecoder {
327    inner: arrow::csv::reader::Decoder,
328}
329
330impl CsvDecoder {
331    pub fn new(decoder: arrow::csv::reader::Decoder) -> Self {
332        Self { inner: decoder }
333    }
334}
335
336impl Decoder for CsvDecoder {
337    fn decode(&mut self, buf: &[u8]) -> Result<usize, ArrowError> {
338        self.inner.decode(buf)
339    }
340
341    fn flush(&mut self) -> Result<Option<RecordBatch>, ArrowError> {
342        self.inner.flush()
343    }
344
345    fn can_flush_early(&self) -> bool {
346        self.inner.capacity() == 0
347    }
348}
349
350impl Debug for CsvSerializer {
351    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
352        f.debug_struct("CsvSerializer")
353            .field("header", &self.header)
354            .finish()
355    }
356}
357
358#[async_trait]
359impl FileFormat for CsvFormat {
360    fn get_ext(&self) -> String {
361        CsvFormatFactory::new().get_ext()
362    }
363
364    fn get_ext_with_compression(
365        &self,
366        file_compression_type: &FileCompressionType,
367    ) -> Result<String> {
368        let ext = self.get_ext();
369        Ok(format!("{}{}", ext, file_compression_type.get_ext()))
370    }
371
372    fn compression_type(&self) -> Option<FileCompressionType> {
373        Some(self.options.compression.into())
374    }
375
376    async fn infer_schema(
377        &self,
378        state: &dyn Session,
379        store: &Arc<dyn ObjectStore>,
380        objects: &[ObjectMeta],
381    ) -> Result<SchemaRef> {
382        let mut schemas = vec![];
383
384        let mut records_to_read = self
385            .options
386            .schema_infer_max_rec
387            .unwrap_or(DEFAULT_SCHEMA_INFER_MAX_RECORD);
388
389        for object in objects {
390            let stream = self.read_to_delimited_chunks(store, object).await;
391            let (schema, records_read) = self
392                .infer_schema_from_stream(state, records_to_read, stream)
393                .await
394                .map_err(|err| {
395                    DataFusionError::Context(
396                        format!("Error when processing CSV file {}", &object.location),
397                        Box::new(err),
398                    )
399                })?;
400            records_to_read -= records_read;
401            schemas.push(schema);
402            if records_to_read == 0 {
403                break;
404            }
405        }
406
407        let merged_schema = Schema::try_merge(schemas)?;
408        Ok(Arc::new(merged_schema))
409    }
410
411    async fn infer_stats(
412        &self,
413        _state: &dyn Session,
414        _store: &Arc<dyn ObjectStore>,
415        table_schema: SchemaRef,
416        _object: &ObjectMeta,
417    ) -> Result<Statistics> {
418        Ok(Statistics::new_unknown(&table_schema))
419    }
420
421    async fn create_physical_plan(
422        &self,
423        state: &dyn Session,
424        conf: FileScanConfig,
425    ) -> Result<Arc<dyn ExecutionPlan>> {
426        // Consult configuration options for default values
427        let has_header = self
428            .options
429            .has_header
430            .unwrap_or_else(|| state.config_options().catalog.has_header);
431        let newlines_in_values = self
432            .options
433            .newlines_in_values
434            .unwrap_or_else(|| state.config_options().catalog.newlines_in_values);
435
436        let mut csv_options = self.options.clone();
437        csv_options.has_header = Some(has_header);
438        csv_options.newlines_in_values = Some(newlines_in_values);
439
440        // Get the existing CsvSource and update its options
441        // We need to preserve the table_schema from the original source (which includes partition columns)
442        let csv_source = conf
443            .file_source
444            .downcast_ref::<CsvSource>()
445            .expect("file_source should be a CsvSource");
446        let source = Arc::new(csv_source.clone().with_csv_options(csv_options));
447
448        let config = FileScanConfigBuilder::from(conf)
449            .with_file_compression_type(self.options.compression.into())
450            .with_source(source)
451            .build();
452
453        Ok(DataSourceExec::from_data_source(config))
454    }
455
456    async fn create_writer_physical_plan(
457        &self,
458        input: Arc<dyn ExecutionPlan>,
459        state: &dyn Session,
460        conf: FileSinkConfig,
461        order_requirements: Option<LexRequirement>,
462    ) -> Result<Arc<dyn ExecutionPlan>> {
463        if conf.insert_op != InsertOp::Append {
464            return not_impl_err!("Overwrites are not implemented yet for CSV");
465        }
466
467        // `has_header` and `newlines_in_values` fields of CsvOptions may inherit
468        // their values from session from configuration settings. To support
469        // this logic, writer options are built from the copy of `self.options`
470        // with updated values of these special fields.
471        let has_header = self
472            .options()
473            .has_header
474            .unwrap_or_else(|| state.config_options().catalog.has_header);
475        let newlines_in_values = self
476            .options()
477            .newlines_in_values
478            .unwrap_or_else(|| state.config_options().catalog.newlines_in_values);
479
480        let options = self
481            .options()
482            .clone()
483            .with_has_header(has_header)
484            .with_newlines_in_values(newlines_in_values);
485
486        let writer_options = CsvWriterOptions::try_from(&options)?;
487
488        let sink = Arc::new(CsvSink::new(conf, writer_options));
489
490        Ok(Arc::new(DataSinkExec::new(input, sink, order_requirements)) as _)
491    }
492
493    fn file_source(&self, table_schema: TableSchema) -> Arc<dyn FileSource> {
494        let mut csv_options = self.options.clone();
495        if csv_options.has_header.is_none() {
496            csv_options.has_header = Some(true);
497        }
498        Arc::new(CsvSource::new(table_schema).with_csv_options(csv_options))
499    }
500}
501
502impl CsvFormat {
503    /// Return the inferred schema reading up to records_to_read from a
504    /// stream of delimited chunks returning the inferred schema and the
505    /// number of lines that were read.
506    ///
507    /// This method can handle CSV files with different numbers of columns.
508    /// The inferred schema will be the union of all columns found across all files.
509    /// Files with fewer columns will have missing columns filled with null values.
510    ///
511    /// # Example
512    ///
513    /// If you have two CSV files:
514    /// - `file1.csv`: `col1,col2,col3`
515    /// - `file2.csv`: `col1,col2,col3,col4,col5`
516    ///
517    /// The inferred schema will contain all 5 columns, with files that don't
518    /// have columns 4 and 5 having null values for those columns.
519    pub async fn infer_schema_from_stream(
520        &self,
521        state: &dyn Session,
522        mut records_to_read: usize,
523        stream: impl Stream<Item = Result<Bytes>>,
524    ) -> Result<(Schema, usize)> {
525        let mut total_records_read = 0;
526        let mut column_names = vec![];
527        let mut column_type_possibilities = vec![];
528        let mut record_number = -1;
529        let initial_records_to_read = records_to_read;
530
531        pin_mut!(stream);
532
533        while let Some(chunk) = stream.next().await.transpose()? {
534            record_number += 1;
535            let first_chunk = record_number == 0;
536            let mut format = arrow::csv::reader::Format::default()
537                .with_header(
538                    first_chunk
539                        && self
540                            .options
541                            .has_header
542                            .unwrap_or_else(|| state.config_options().catalog.has_header),
543                )
544                .with_delimiter(self.options.delimiter)
545                .with_quote(self.options.quote)
546                .with_truncated_rows(self.options.truncated_rows.unwrap_or(false));
547
548            if let Some(null_regex) = &self.options.null_regex {
549                let regex = Regex::new(null_regex.as_str())
550                    .expect("Unable to parse CSV null regex.");
551                format = format.with_null_regex(regex);
552            }
553
554            if let Some(escape) = self.options.escape {
555                format = format.with_escape(escape);
556            }
557
558            if let Some(comment) = self.options.comment {
559                format = format.with_comment(comment);
560            }
561
562            let (Schema { fields, .. }, records_read) =
563                format.infer_schema(chunk.reader(), Some(records_to_read))?;
564
565            records_to_read -= records_read;
566            total_records_read += records_read;
567
568            if first_chunk {
569                // set up initial structures for recording inferred schema across chunks
570                (column_names, column_type_possibilities) = fields
571                    .into_iter()
572                    .map(|field| {
573                        let mut possibilities = HashSet::new();
574                        if records_read > 0 {
575                            // at least 1 data row read, record the inferred datatype
576                            possibilities.insert(field.data_type().clone());
577                        }
578                        (field.name().clone(), possibilities)
579                    })
580                    .unzip();
581            } else {
582                if fields.len() != column_type_possibilities.len()
583                    && !self.options.truncated_rows.unwrap_or(false)
584                {
585                    return exec_err!(
586                        "Encountered unequal lengths between records on CSV file whilst inferring schema. \
587                         Expected {} fields, found {} fields at record {}",
588                        column_type_possibilities.len(),
589                        fields.len(),
590                        record_number + 1
591                    );
592                }
593
594                // First update type possibilities for existing columns using zip
595                column_type_possibilities.iter_mut().zip(&fields).for_each(
596                    |(possibilities, field)| {
597                        possibilities.insert(field.data_type().clone());
598                    },
599                );
600
601                // Handle files with different numbers of columns by extending the schema
602                if fields.len() > column_type_possibilities.len() {
603                    // New columns found - extend our tracking structures
604                    for field in fields.iter().skip(column_type_possibilities.len()) {
605                        column_names.push(field.name().clone());
606                        let mut possibilities = HashSet::new();
607                        if records_read > 0 {
608                            possibilities.insert(field.data_type().clone());
609                        }
610                        column_type_possibilities.push(possibilities);
611                    }
612                }
613            }
614
615            if records_to_read == 0 {
616                break;
617            }
618        }
619
620        let schema = build_schema_helper(
621            column_names,
622            column_type_possibilities,
623            initial_records_to_read == 0,
624        );
625        Ok((schema, total_records_read))
626    }
627}
628
629/// Builds a schema from column names and their possible data types.
630///
631/// # Arguments
632///
633/// * `names` - Vector of column names
634/// * `types` - Vector of possible data types for each column (as HashSets)
635/// * `disable_inference` - When true, forces all columns with no inferred types to be Utf8.
636///   This should be set to true when `schema_infer_max_rec` is explicitly
637///   set to 0, indicating the user wants to skip type inference and treat
638///   all fields as strings. When false, columns with no inferred types
639///   will be set to Null, allowing schema merging to work properly.
640fn build_schema_helper(
641    names: Vec<String>,
642    types: Vec<HashSet<DataType>>,
643    disable_inference: bool,
644) -> Schema {
645    let fields = names
646        .into_iter()
647        .zip(types)
648        .map(|(field_name, mut data_type_possibilities)| {
649            // ripped from arrow::csv::reader::infer_reader_schema_with_csv_options
650            // determine data type based on possible types
651            // if there are incompatible types, use DataType::Utf8
652
653            // ignore nulls, to avoid conflicting datatypes (e.g. [nulls, int]) being inferred as Utf8.
654            data_type_possibilities.remove(&DataType::Null);
655
656            match data_type_possibilities.len() {
657                // When no types were inferred (empty HashSet):
658                // - If schema_infer_max_rec was explicitly set to 0, return Utf8
659                // - Otherwise return Null (whether from reading null values or empty files)
660                //   This allows schema merging to work when reading folders with empty files
661                0 => {
662                    if disable_inference {
663                        Field::new(field_name, DataType::Utf8, true)
664                    } else {
665                        Field::new(field_name, DataType::Null, true)
666                    }
667                }
668                1 => Field::new(
669                    field_name,
670                    data_type_possibilities.iter().next().unwrap().clone(),
671                    true,
672                ),
673                2 => {
674                    if data_type_possibilities.contains(&DataType::Int64)
675                        && data_type_possibilities.contains(&DataType::Float64)
676                    {
677                        // we have an integer and double, fall down to double
678                        Field::new(field_name, DataType::Float64, true)
679                    } else {
680                        // default to Utf8 for conflicting datatypes (e.g bool and int)
681                        Field::new(field_name, DataType::Utf8, true)
682                    }
683                }
684                _ => Field::new(field_name, DataType::Utf8, true),
685            }
686        })
687        .collect::<Fields>();
688    Schema::new(fields)
689}
690
691impl Default for CsvSerializer {
692    fn default() -> Self {
693        Self::new()
694    }
695}
696
697/// Define a struct for serializing CSV records to a stream
698pub struct CsvSerializer {
699    // CSV writer builder
700    builder: WriterBuilder,
701    // Flag to indicate whether there will be a header
702    header: bool,
703}
704
705impl CsvSerializer {
706    /// Constructor for the CsvSerializer object
707    pub fn new() -> Self {
708        Self {
709            builder: WriterBuilder::new(),
710            header: true,
711        }
712    }
713
714    /// Method for setting the CSV writer builder
715    pub fn with_builder(mut self, builder: WriterBuilder) -> Self {
716        self.builder = builder;
717        self
718    }
719
720    /// Method for setting the CSV writer header status
721    pub fn with_header(mut self, header: bool) -> Self {
722        self.header = header;
723        self
724    }
725}
726
727impl BatchSerializer for CsvSerializer {
728    fn serialize(&self, batch: RecordBatch, initial: bool) -> Result<Bytes> {
729        let mut buffer = Vec::with_capacity(4096);
730        let builder = self.builder.clone();
731        let header = self.header && initial;
732        let mut writer = builder.with_header(header).build(&mut buffer);
733        writer.write(&batch)?;
734        drop(writer);
735        Ok(Bytes::from(buffer))
736    }
737}
738
739/// Implements [`DataSink`] for writing to a CSV file.
740pub struct CsvSink {
741    /// Config options for writing data
742    config: FileSinkConfig,
743    writer_options: CsvWriterOptions,
744}
745
746impl Debug for CsvSink {
747    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
748        f.debug_struct("CsvSink").finish()
749    }
750}
751
752impl DisplayAs for CsvSink {
753    fn fmt_as(&self, t: DisplayFormatType, f: &mut fmt::Formatter<'_>) -> fmt::Result {
754        match t {
755            DisplayFormatType::Default | DisplayFormatType::Verbose => {
756                write!(f, "CsvSink(file_groups=",)?;
757                FileGroupDisplay(&self.config.file_group).fmt_as(t, f)?;
758                write!(f, ")")
759            }
760            DisplayFormatType::TreeRender => {
761                writeln!(f, "format: csv")?;
762                write!(f, "file={}", &self.config.original_url)
763            }
764        }
765    }
766}
767
768impl CsvSink {
769    /// Create from config.
770    pub fn new(config: FileSinkConfig, writer_options: CsvWriterOptions) -> Self {
771        Self {
772            config,
773            writer_options,
774        }
775    }
776
777    /// Retrieve the writer options
778    pub fn writer_options(&self) -> &CsvWriterOptions {
779        &self.writer_options
780    }
781}
782
783#[async_trait]
784impl FileSink for CsvSink {
785    fn config(&self) -> &FileSinkConfig {
786        &self.config
787    }
788
789    async fn spawn_writer_tasks_and_join(
790        &self,
791        context: &Arc<TaskContext>,
792        demux_task: SpawnedTask<Result<()>>,
793        file_stream_rx: DemuxedStreamReceiver,
794        object_store: Arc<dyn ObjectStore>,
795    ) -> Result<u64> {
796        let builder = self.writer_options.writer_options.clone();
797        let header = builder.header();
798        let serializer = Arc::new(
799            CsvSerializer::new()
800                .with_builder(builder)
801                .with_header(header),
802        ) as _;
803        spawn_writer_tasks_and_join(
804            context,
805            serializer,
806            self.writer_options.compression.into(),
807            self.writer_options.compression_level,
808            object_store,
809            demux_task,
810            file_stream_rx,
811        )
812        .await
813    }
814}
815
816#[async_trait]
817impl DataSink for CsvSink {
818    fn schema(&self) -> &SchemaRef {
819        self.config.output_schema()
820    }
821
822    async fn write_all(
823        &self,
824        data: SendableRecordBatchStream,
825        context: &Arc<TaskContext>,
826    ) -> Result<u64> {
827        FileSink::write_all(self, data, context).await
828    }
829}
830
831#[cfg(test)]
832mod tests {
833    use super::build_schema_helper;
834    use arrow::datatypes::DataType;
835    use std::collections::HashSet;
836
837    #[test]
838    fn test_build_schema_helper_different_column_counts() {
839        // Test the core schema building logic with different column counts
840        let mut column_names =
841            vec!["col1".to_string(), "col2".to_string(), "col3".to_string()];
842
843        // Simulate adding two more columns from another file
844        column_names.push("col4".to_string());
845        column_names.push("col5".to_string());
846
847        let column_type_possibilities = vec![
848            HashSet::from([DataType::Int64]),
849            HashSet::from([DataType::Utf8]),
850            HashSet::from([DataType::Float64]),
851            HashSet::from([DataType::Utf8]), // col4
852            HashSet::from([DataType::Utf8]), // col5
853        ];
854
855        let schema = build_schema_helper(column_names, column_type_possibilities, false);
856
857        // Verify schema has 5 columns
858        assert_eq!(schema.fields().len(), 5);
859        assert_eq!(schema.field(0).name(), "col1");
860        assert_eq!(schema.field(1).name(), "col2");
861        assert_eq!(schema.field(2).name(), "col3");
862        assert_eq!(schema.field(3).name(), "col4");
863        assert_eq!(schema.field(4).name(), "col5");
864
865        // All fields should be nullable
866        for field in schema.fields() {
867            assert!(
868                field.is_nullable(),
869                "Field {} should be nullable",
870                field.name()
871            );
872        }
873    }
874
875    #[test]
876    fn test_build_schema_helper_type_merging() {
877        // Test type merging logic
878        let column_names = vec!["col1".to_string(), "col2".to_string()];
879
880        let column_type_possibilities = vec![
881            HashSet::from([DataType::Int64, DataType::Float64]), // Should resolve to Float64
882            HashSet::from([DataType::Utf8]),                     // Should remain Utf8
883        ];
884
885        let schema = build_schema_helper(column_names, column_type_possibilities, false);
886
887        // col1 should be Float64 due to Int64 + Float64 = Float64
888        assert_eq!(*schema.field(0).data_type(), DataType::Float64);
889
890        // col2 should remain Utf8
891        assert_eq!(*schema.field(1).data_type(), DataType::Utf8);
892    }
893
894    #[test]
895    fn test_build_schema_helper_conflicting_types() {
896        // Test when we have incompatible types - should default to Utf8
897        let column_names = vec!["col1".to_string()];
898
899        let column_type_possibilities = vec![
900            HashSet::from([DataType::Boolean, DataType::Int64, DataType::Utf8]), // Should resolve to Utf8 due to conflicts
901        ];
902
903        let schema = build_schema_helper(column_names, column_type_possibilities, false);
904
905        // Should default to Utf8 for conflicting types
906        assert_eq!(*schema.field(0).data_type(), DataType::Utf8);
907    }
908}