Skip to main content

laminar_connectors/schema/csv/
mod.rs

1//! CSV format decoder implementing [`FormatDecoder`].
2//!
3//! Converts raw CSV byte payloads into Arrow `RecordBatch`es.
4//! Constructed once at `CREATE SOURCE` time with a frozen Arrow schema
5//! and CSV format configuration. The decoder is stateless after
6//! construction so the Ring 1 hot path has zero configuration lookups.
7//!
8//! Uses the `csv` crate's `ByteRecord` API for zero-copy field access
9//! where possible. Type coercion (string → int, string → timestamp, etc.)
10//! is performed during the Arrow builder append phase.
11
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::sync::Arc;
14
15use arrow_array::builder::{
16    BooleanBuilder, Date32Builder, Float64Builder, Int64Builder, StringBuilder,
17    TimestampNanosecondBuilder,
18};
19use arrow_array::{ArrayRef, RecordBatch};
20use arrow_schema::{DataType, Field, SchemaRef, TimeUnit};
21
22use crate::schema::error::{SchemaError, SchemaResult};
23use crate::schema::traits::{FormatDecoder, FormatEncoder};
24use crate::schema::types::RawRecord;
25
26/// Strategy for rows with incorrect field count.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum FieldCountMismatchStrategy {
29    /// Pad missing fields with null, ignore extra fields. Default.
30    Null,
31    /// Skip the malformed row entirely.
32    Skip,
33    /// Return a decode error on the first malformed row.
34    Reject,
35}
36
37/// CSV decoder configuration.
38///
39/// Maps directly to the SQL `FORMAT CSV (...)` options.
40/// All fields have sensible defaults matching RFC 4180.
41#[derive(Debug, Clone)]
42pub struct CsvDecoderConfig {
43    /// Field delimiter character. Default: `','` (comma).
44    /// Common alternatives: `'\t'` (tab), `'|'` (pipe), `';'` (semicolon).
45    pub delimiter: u8,
46
47    /// Quote character for fields containing delimiters or newlines.
48    /// Default: `'"'` (double quote). Set to `None` to disable quoting.
49    pub quote: Option<u8>,
50
51    /// Escape character within quoted fields.
52    /// Default: `None` (RFC 4180 uses doubled quote chars for escaping).
53    /// Set to `Some(b'\\')` for backslash-escaped CSVs.
54    pub escape: Option<u8>,
55
56    /// Whether the first row is a header row with column names.
57    /// Default: `true`.
58    pub has_header: bool,
59
60    /// String value to interpret as SQL NULL.
61    /// Default: `""` (empty string). Common alternatives: `"NA"`, `"null"`, `"\\N"`.
62    pub null_string: String,
63
64    /// Comment line prefix. Lines starting with this character are skipped.
65    /// Default: `None` (no comment support).
66    pub comment: Option<u8>,
67
68    /// Number of rows to skip at the beginning of the data (after header).
69    /// Default: `0`.
70    pub skip_rows: usize,
71
72    /// Timestamp format pattern for parsing timestamp columns.
73    /// Default: `"%Y-%m-%d %H:%M:%S%.f"`.
74    pub timestamp_format: String,
75
76    /// Date format pattern for parsing date columns.
77    /// Default: `"%Y-%m-%d"`.
78    pub date_format: String,
79
80    /// How to handle rows with wrong number of fields.
81    /// Default: `Null` (pad missing fields with null, truncate extra).
82    pub field_count_mismatch: FieldCountMismatchStrategy,
83}
84
85impl Default for CsvDecoderConfig {
86    fn default() -> Self {
87        Self {
88            delimiter: b',',
89            quote: Some(b'"'),
90            escape: None,
91            has_header: true,
92            null_string: String::new(),
93            comment: None,
94            skip_rows: 0,
95            timestamp_format: "%Y-%m-%d %H:%M:%S%.f".into(),
96            date_format: "%Y-%m-%d".into(),
97            field_count_mismatch: FieldCountMismatchStrategy::Null,
98        }
99    }
100}
101
102/// Pre-computed coercion strategy for a single CSV column.
103#[derive(Debug, Clone)]
104enum CsvCoercion {
105    /// Parse as boolean (`"true"`/`"false"`, `"1"`/`"0"`, `"yes"`/`"no"`).
106    Boolean,
107    /// Parse as i64.
108    Int64,
109    /// Parse as f64.
110    Float64,
111    /// Parse as `Timestamp(Nanosecond, UTC)` using the configured format.
112    Timestamp(String),
113    /// Parse as `Date32` using the configured format.
114    Date(String),
115    /// No coercion needed — keep as UTF-8 string.
116    Utf8,
117}
118
119/// Decodes CSV byte payloads into Arrow `RecordBatch`es.
120///
121/// # Ring Placement
122///
123/// - **Ring 1**: `decode_batch()` — parse CSV, build columnar Arrow output
124/// - **Ring 2**: Construction (`new` / `with_config`) — one-time setup
125pub struct CsvDecoder {
126    /// Frozen output schema.
127    schema: SchemaRef,
128    /// CSV format configuration.
129    config: CsvDecoderConfig,
130    /// Per-column type coercion functions, indexed by column position.
131    /// Pre-computed at construction time to avoid per-record dispatch.
132    coercions: Vec<CsvCoercion>,
133    /// Cumulative count of parse errors (for diagnostics).
134    parse_error_count: AtomicU64,
135}
136
137#[allow(clippy::missing_fields_in_debug)]
138impl std::fmt::Debug for CsvDecoder {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        f.debug_struct("CsvDecoder")
141            .field("schema", &self.schema)
142            .field("config", &self.config)
143            .field(
144                "parse_error_count",
145                &self.parse_error_count.load(Ordering::Relaxed),
146            )
147            .finish()
148    }
149}
150
151impl CsvDecoder {
152    /// Validate the actual builders used by this decoder before activating a reader.
153    ///
154    /// # Errors
155    /// Rejects types the current CSV decoder cannot represent without a lossy conversion.
156    pub fn validate_schema(schema: &SchemaRef) -> SchemaResult<()> {
157        for (field, mut builder) in schema.fields().iter().zip(create_builders(schema, 0)) {
158            if !narrows_to(builder.finish().data_type(), field.data_type()) {
159                return Err(SchemaError::Incompatible(format!(
160                    "CSV reader field '{}' cannot decode {}; use a supported type or another format",
161                    field.name(), field.data_type()
162                )));
163            }
164        }
165        Ok(())
166    }
167
168    /// Creates a new CSV decoder for the given Arrow schema with default config.
169    #[must_use]
170    pub fn new(schema: SchemaRef) -> Self {
171        Self::with_config(schema, CsvDecoderConfig::default())
172    }
173
174    /// Creates a new CSV decoder with custom configuration.
175    #[must_use]
176    pub fn with_config(schema: SchemaRef, config: CsvDecoderConfig) -> Self {
177        let coercions: Vec<CsvCoercion> = schema
178            .fields()
179            .iter()
180            .map(|field| Self::coercion_for_type(field.data_type(), &config))
181            .collect();
182
183        Self {
184            schema,
185            config,
186            coercions,
187            parse_error_count: AtomicU64::new(0),
188        }
189    }
190
191    /// Returns the cumulative parse error count.
192    pub fn parse_error_count(&self) -> u64 {
193        self.parse_error_count.load(Ordering::Relaxed)
194    }
195
196    /// Determines the coercion strategy for an Arrow data type.
197    fn coercion_for_type(data_type: &DataType, config: &CsvDecoderConfig) -> CsvCoercion {
198        match data_type {
199            DataType::Boolean => CsvCoercion::Boolean,
200            DataType::Int8
201            | DataType::Int16
202            | DataType::Int32
203            | DataType::Int64
204            | DataType::UInt8
205            | DataType::UInt16
206            | DataType::UInt32
207            | DataType::UInt64 => CsvCoercion::Int64,
208            DataType::Float16 | DataType::Float32 | DataType::Float64 => CsvCoercion::Float64,
209            DataType::Timestamp(_, _) => CsvCoercion::Timestamp(config.timestamp_format.clone()),
210            DataType::Date32 | DataType::Date64 => CsvCoercion::Date(config.date_format.clone()),
211            _ => CsvCoercion::Utf8,
212        }
213    }
214
215    /// Builds a `csv::ReaderBuilder` from the decoder config.
216    fn make_reader_builder(&self) -> csv::ReaderBuilder {
217        let mut rb = csv::ReaderBuilder::new();
218        rb.delimiter(self.config.delimiter)
219            .has_headers(false) // We handle headers ourselves
220            .flexible(true); // Allow variable field counts
221
222        if let Some(q) = self.config.quote {
223            rb.quote(q);
224        }
225        if let Some(e) = self.config.escape {
226            rb.escape(Some(e));
227        }
228        if let Some(c) = self.config.comment {
229            rb.comment(Some(c));
230        }
231
232        rb
233    }
234}
235
236impl FormatDecoder for CsvDecoder {
237    fn output_schema(&self) -> SchemaRef {
238        self.schema.clone()
239    }
240
241    fn decode_batch(&self, records: &[RawRecord]) -> SchemaResult<RecordBatch> {
242        let values: Vec<&[u8]> = records
243            .iter()
244            .map(|record| record.value.as_slice())
245            .collect();
246        self.decode_slices(&values)
247    }
248
249    fn format_name(&self) -> &'static str {
250        "csv"
251    }
252}
253
254impl CsvDecoder {
255    /// Decodes borrowed CSV payloads without first copying them into raw records.
256    ///
257    /// # Errors
258    ///
259    /// Returns a decode error for malformed CSV or values incompatible with the schema.
260    pub fn decode_slices(&self, records: &[&[u8]]) -> SchemaResult<RecordBatch> {
261        if records.is_empty() {
262            return Ok(RecordBatch::new_empty(self.schema.clone()));
263        }
264
265        let num_fields = self.schema.fields().len();
266        let capacity = records.len();
267
268        // Initialize one builder per schema column.
269        let mut builders = create_builders(&self.schema, capacity);
270
271        // Concatenate all raw record bytes, ensuring newline separation.
272        let mut combined = Vec::with_capacity(records.iter().map(|record| record.len() + 1).sum());
273        for record in records {
274            combined.extend_from_slice(record);
275            if !record.ends_with(b"\n") {
276                combined.push(b'\n');
277            }
278        }
279
280        let rb = self.make_reader_builder();
281        let mut reader = rb.from_reader(combined.as_slice());
282
283        let mut rows_skipped = 0usize;
284        let mut header_skipped = false;
285        let mut row_count = 0usize;
286
287        let mut byte_record = csv::ByteRecord::new();
288        while reader
289            .read_byte_record(&mut byte_record)
290            .map_err(|e| SchemaError::DecodeError(format!("CSV parse error: {e}")))?
291        {
292            // Skip header row if configured.
293            if self.config.has_header && !header_skipped {
294                header_skipped = true;
295                continue;
296            }
297
298            // Skip initial data rows per config.
299            if rows_skipped < self.config.skip_rows {
300                rows_skipped += 1;
301                continue;
302            }
303
304            let field_count = byte_record.len();
305
306            // Handle field count mismatch.
307            if field_count != num_fields {
308                match self.config.field_count_mismatch {
309                    FieldCountMismatchStrategy::Reject => {
310                        return Err(SchemaError::DecodeError(format!(
311                            "field count mismatch: expected {num_fields}, got {field_count}"
312                        )));
313                    }
314                    FieldCountMismatchStrategy::Skip => {
315                        self.parse_error_count.fetch_add(1, Ordering::Relaxed);
316                        continue;
317                    }
318                    FieldCountMismatchStrategy::Null => {
319                        // Will pad/truncate below.
320                    }
321                }
322            }
323
324            // Process each column.
325            for col_idx in 0..num_fields {
326                if col_idx >= field_count {
327                    // Missing field — append null.
328                    append_null(&mut builders[col_idx]);
329                    continue;
330                }
331
332                let raw_field = &byte_record[col_idx];
333                let field_str = std::str::from_utf8(raw_field).unwrap_or("");
334                let trimmed = field_str.trim();
335
336                // Check for null string.
337                if trimmed == self.config.null_string {
338                    append_null(&mut builders[col_idx]);
339                    continue;
340                }
341
342                // Apply coercion.
343                let ok = append_coerced(&mut builders[col_idx], &self.coercions[col_idx], trimmed);
344
345                if !ok {
346                    self.parse_error_count.fetch_add(1, Ordering::Relaxed);
347                    append_null(&mut builders[col_idx]);
348                }
349            }
350
351            row_count += 1;
352        }
353
354        // If no data rows were processed, return empty batch.
355        if row_count == 0 {
356            return Ok(RecordBatch::new_empty(self.schema.clone()));
357        }
358
359        let columns = builders
360            .into_iter()
361            .zip(self.schema.fields().iter())
362            .map(|(mut builder, field)| self.narrow_column(builder.finish(), field))
363            .collect::<SchemaResult<Vec<ArrayRef>>>()?;
364
365        RecordBatch::try_new(self.schema.clone(), columns)
366            .map_err(|e| SchemaError::DecodeError(format!("RecordBatch construction: {e}")))
367    }
368
369    /// Casts a finished column to its declared type. Values outside the declared range
370    /// become nulls and count as parse errors, like values that do not parse.
371    fn narrow_column(&self, column: ArrayRef, field: &Field) -> SchemaResult<ArrayRef> {
372        if column.data_type() == field.data_type() {
373            return Ok(column);
374        }
375        let narrowed = arrow_cast::cast(&column, field.data_type()).map_err(|error| {
376            SchemaError::DecodeError(format!(
377                "CSV column '{}' cannot be read as {}: {error}",
378                field.name(),
379                field.data_type()
380            ))
381        })?;
382        let rejected = narrowed.null_count().saturating_sub(column.null_count());
383        if rejected > 0 {
384            self.parse_error_count.fetch_add(
385                u64::try_from(rejected).unwrap_or(u64::MAX),
386                Ordering::Relaxed,
387            );
388        }
389        Ok(narrowed)
390    }
391}
392
393// ── Builder helpers ────────────────────────────────────────────────
394
395/// Builders parse into one wide type per value family; these are the declared types each
396/// can be cast to without changing what a valid value means.
397fn narrows_to(built: &DataType, declared: &DataType) -> bool {
398    built == declared
399        || match (built, declared) {
400            (DataType::Int64, declared) => declared.is_integer(),
401            (DataType::Float64, declared) => declared.is_floating(),
402            (DataType::Timestamp(TimeUnit::Nanosecond, built_tz), DataType::Timestamp(_, tz)) => {
403                built_tz == tz
404            }
405            (DataType::Date32, DataType::Date64) => true,
406            _ => false,
407        }
408}
409
410/// Trait-object wrapper so we can store heterogeneous builders in a `Vec`.
411trait ColumnBuilder: Send {
412    fn finish(&mut self) -> ArrayRef;
413    fn append_null_value(&mut self);
414    fn as_any_mut(&mut self) -> &mut dyn std::any::Any;
415}
416
417macro_rules! impl_column_builder {
418    ($builder:ty) => {
419        impl ColumnBuilder for $builder {
420            fn finish(&mut self) -> ArrayRef {
421                Arc::new(<$builder>::finish(self))
422            }
423            fn append_null_value(&mut self) {
424                self.append_null();
425            }
426            fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
427                self
428            }
429        }
430    };
431}
432
433impl_column_builder!(BooleanBuilder);
434impl_column_builder!(Int64Builder);
435impl_column_builder!(Float64Builder);
436impl_column_builder!(StringBuilder);
437impl_column_builder!(TimestampNanosecondBuilder);
438impl_column_builder!(Date32Builder);
439
440fn create_builders(schema: &SchemaRef, capacity: usize) -> Vec<Box<dyn ColumnBuilder>> {
441    schema
442        .fields()
443        .iter()
444        .map(|f| create_builder(f.data_type(), capacity))
445        .collect()
446}
447
448fn create_builder(data_type: &DataType, capacity: usize) -> Box<dyn ColumnBuilder> {
449    match data_type {
450        DataType::Boolean => Box::new(BooleanBuilder::with_capacity(capacity)),
451        DataType::Int8
452        | DataType::Int16
453        | DataType::Int32
454        | DataType::Int64
455        | DataType::UInt8
456        | DataType::UInt16
457        | DataType::UInt32
458        | DataType::UInt64 => Box::new(Int64Builder::with_capacity(capacity)),
459        DataType::Float16 | DataType::Float32 | DataType::Float64 => {
460            Box::new(Float64Builder::with_capacity(capacity))
461        }
462        DataType::Timestamp(_, tz) => {
463            let builder =
464                TimestampNanosecondBuilder::with_capacity(capacity).with_timezone_opt(tz.clone());
465            Box::new(builder)
466        }
467        DataType::Date32 | DataType::Date64 => Box::new(Date32Builder::with_capacity(capacity)),
468        // Fallback: store as UTF-8 string.
469        _ => Box::new(StringBuilder::with_capacity(capacity, capacity * 32)),
470    }
471}
472
473fn append_null(builder: &mut Box<dyn ColumnBuilder>) {
474    builder.append_null_value();
475}
476
477/// Appends a coerced value to the appropriate builder. Returns `true` on
478/// success, `false` if the value could not be parsed.
479fn append_coerced(
480    builder: &mut Box<dyn ColumnBuilder>,
481    coercion: &CsvCoercion,
482    value: &str,
483) -> bool {
484    match coercion {
485        CsvCoercion::Boolean => {
486            let b = builder
487                .as_any_mut()
488                .downcast_mut::<BooleanBuilder>()
489                .unwrap();
490            match value.to_ascii_lowercase().as_str() {
491                "true" | "1" | "yes" | "t" | "y" => {
492                    b.append_value(true);
493                    true
494                }
495                "false" | "0" | "no" | "f" | "n" => {
496                    b.append_value(false);
497                    true
498                }
499                _ => false,
500            }
501        }
502        CsvCoercion::Int64 => {
503            let b = builder.as_any_mut().downcast_mut::<Int64Builder>().unwrap();
504            match value.parse::<i64>() {
505                Ok(v) => {
506                    b.append_value(v);
507                    true
508                }
509                Err(_) => false,
510            }
511        }
512        CsvCoercion::Float64 => {
513            let b = builder
514                .as_any_mut()
515                .downcast_mut::<Float64Builder>()
516                .unwrap();
517            match value.parse::<f64>() {
518                Ok(v) => {
519                    b.append_value(v);
520                    true
521                }
522                Err(_) => false,
523            }
524        }
525        CsvCoercion::Timestamp(fmt) => {
526            let b = builder
527                .as_any_mut()
528                .downcast_mut::<TimestampNanosecondBuilder>()
529                .unwrap();
530            // Try the configured format first.
531            if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(value, fmt) {
532                let nanos = ndt.and_utc().timestamp_nanos_opt().unwrap_or(0);
533                b.append_value(nanos);
534                return true;
535            }
536            // Try ISO 8601 fallback.
537            if let Ok(nanos) = arrow_cast::parse::string_to_timestamp_nanos(value) {
538                b.append_value(nanos);
539                return true;
540            }
541            false
542        }
543        CsvCoercion::Date(fmt) => {
544            let b = builder
545                .as_any_mut()
546                .downcast_mut::<Date32Builder>()
547                .unwrap();
548            if let Ok(date) = chrono::NaiveDate::parse_from_str(value, fmt) {
549                // Date32 stores days since epoch (1970-01-01).
550                let epoch = chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
551                let days = (date - epoch).num_days();
552                #[allow(clippy::cast_possible_truncation)]
553                {
554                    b.append_value(days as i32);
555                }
556                return true;
557            }
558            false
559        }
560        CsvCoercion::Utf8 => {
561            let b = builder
562                .as_any_mut()
563                .downcast_mut::<StringBuilder>()
564                .unwrap();
565            b.append_value(value);
566            true
567        }
568    }
569}
570
571/// Configuration for [`CsvEncoder`].
572#[derive(Debug, Clone)]
573pub struct CsvEncoderConfig {
574    /// Field delimiter. Default: `','`.
575    pub delimiter: u8,
576    /// Whether to include a header row. Default: `false`.
577    pub has_header: bool,
578}
579
580impl Default for CsvEncoderConfig {
581    fn default() -> Self {
582        Self {
583            delimiter: b',',
584            has_header: false,
585        }
586    }
587}
588
589/// Encodes Arrow `RecordBatch`es into CSV byte records via `arrow_csv::writer`.
590#[derive(Debug)]
591pub struct CsvEncoder {
592    schema: SchemaRef,
593    config: CsvEncoderConfig,
594}
595
596impl CsvEncoder {
597    /// Creates a new CSV encoder for the given schema with default config.
598    #[must_use]
599    pub fn new(schema: SchemaRef) -> Self {
600        Self::with_config(schema, CsvEncoderConfig::default())
601    }
602
603    /// Creates a new CSV encoder with custom configuration.
604    #[must_use]
605    pub fn with_config(schema: SchemaRef, config: CsvEncoderConfig) -> Self {
606        Self { schema, config }
607    }
608}
609
610impl FormatEncoder for CsvEncoder {
611    fn input_schema(&self) -> SchemaRef {
612        self.schema.clone()
613    }
614
615    fn encode_batch(&self, batch: &RecordBatch) -> SchemaResult<Vec<Vec<u8>>> {
616        if batch.num_rows() == 0 {
617            return Ok(Vec::new());
618        }
619
620        let mut buf = Vec::new();
621        {
622            let writer = arrow_csv::writer::WriterBuilder::new()
623                .with_header(self.config.has_header)
624                .with_delimiter(self.config.delimiter);
625            let mut csv_writer = writer.build(&mut buf);
626            csv_writer
627                .write(batch)
628                .map_err(|e| SchemaError::DecodeError(format!("CSV encode error: {e}")))?;
629        }
630
631        let output: Vec<Vec<u8>> = buf
632            .split(|&b| b == b'\n')
633            .filter(|line| !line.is_empty())
634            .map(<[u8]>::to_vec)
635            .collect();
636
637        Ok(output)
638    }
639
640    fn format_name(&self) -> &'static str {
641        "csv"
642    }
643}
644
645#[cfg(test)]
646mod tests;