laminar_connectors/schema/csv/
mod.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum FieldCountMismatchStrategy {
29 Null,
31 Skip,
33 Reject,
35}
36
37#[derive(Debug, Clone)]
42pub struct CsvDecoderConfig {
43 pub delimiter: u8,
46
47 pub quote: Option<u8>,
50
51 pub escape: Option<u8>,
55
56 pub has_header: bool,
59
60 pub null_string: String,
63
64 pub comment: Option<u8>,
67
68 pub skip_rows: usize,
71
72 pub timestamp_format: String,
75
76 pub date_format: String,
79
80 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#[derive(Debug, Clone)]
104enum CsvCoercion {
105 Boolean,
107 Int64,
109 Float64,
111 Timestamp(String),
113 Date(String),
115 Utf8,
117}
118
119pub struct CsvDecoder {
126 schema: SchemaRef,
128 config: CsvDecoderConfig,
130 coercions: Vec<CsvCoercion>,
133 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 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 #[must_use]
170 pub fn new(schema: SchemaRef) -> Self {
171 Self::with_config(schema, CsvDecoderConfig::default())
172 }
173
174 #[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 pub fn parse_error_count(&self) -> u64 {
193 self.parse_error_count.load(Ordering::Relaxed)
194 }
195
196 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 fn make_reader_builder(&self) -> csv::ReaderBuilder {
217 let mut rb = csv::ReaderBuilder::new();
218 rb.delimiter(self.config.delimiter)
219 .has_headers(false) .flexible(true); 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 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 let mut builders = create_builders(&self.schema, capacity);
270
271 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 if self.config.has_header && !header_skipped {
294 header_skipped = true;
295 continue;
296 }
297
298 if rows_skipped < self.config.skip_rows {
300 rows_skipped += 1;
301 continue;
302 }
303
304 let field_count = byte_record.len();
305
306 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 }
321 }
322 }
323
324 for col_idx in 0..num_fields {
326 if col_idx >= field_count {
327 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 if trimmed == self.config.null_string {
338 append_null(&mut builders[col_idx]);
339 continue;
340 }
341
342 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 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 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
393fn 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
410trait 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 _ => 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
477fn 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 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 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 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#[derive(Debug, Clone)]
573pub struct CsvEncoderConfig {
574 pub delimiter: u8,
576 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#[derive(Debug)]
591pub struct CsvEncoder {
592 schema: SchemaRef,
593 config: CsvEncoderConfig,
594}
595
596impl CsvEncoder {
597 #[must_use]
599 pub fn new(schema: SchemaRef) -> Self {
600 Self::with_config(schema, CsvEncoderConfig::default())
601 }
602
603 #[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;