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, 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 #[must_use]
154 pub fn new(schema: SchemaRef) -> Self {
155 Self::with_config(schema, CsvDecoderConfig::default())
156 }
157
158 #[must_use]
160 pub fn with_config(schema: SchemaRef, config: CsvDecoderConfig) -> Self {
161 let coercions: Vec<CsvCoercion> = schema
162 .fields()
163 .iter()
164 .map(|field| Self::coercion_for_type(field.data_type(), &config))
165 .collect();
166
167 Self {
168 schema,
169 config,
170 coercions,
171 parse_error_count: AtomicU64::new(0),
172 }
173 }
174
175 pub fn parse_error_count(&self) -> u64 {
177 self.parse_error_count.load(Ordering::Relaxed)
178 }
179
180 fn coercion_for_type(data_type: &DataType, config: &CsvDecoderConfig) -> CsvCoercion {
182 match data_type {
183 DataType::Boolean => CsvCoercion::Boolean,
184 DataType::Int8
185 | DataType::Int16
186 | DataType::Int32
187 | DataType::Int64
188 | DataType::UInt8
189 | DataType::UInt16
190 | DataType::UInt32
191 | DataType::UInt64 => CsvCoercion::Int64,
192 DataType::Float16 | DataType::Float32 | DataType::Float64 => CsvCoercion::Float64,
193 DataType::Timestamp(_, _) => CsvCoercion::Timestamp(config.timestamp_format.clone()),
194 DataType::Date32 | DataType::Date64 => CsvCoercion::Date(config.date_format.clone()),
195 _ => CsvCoercion::Utf8,
196 }
197 }
198
199 fn make_reader_builder(&self) -> csv::ReaderBuilder {
201 let mut rb = csv::ReaderBuilder::new();
202 rb.delimiter(self.config.delimiter)
203 .has_headers(false) .flexible(true); if let Some(q) = self.config.quote {
207 rb.quote(q);
208 }
209 if let Some(e) = self.config.escape {
210 rb.escape(Some(e));
211 }
212 if let Some(c) = self.config.comment {
213 rb.comment(Some(c));
214 }
215
216 rb
217 }
218}
219
220impl FormatDecoder for CsvDecoder {
221 fn output_schema(&self) -> SchemaRef {
222 self.schema.clone()
223 }
224
225 fn decode_batch(&self, records: &[RawRecord]) -> SchemaResult<RecordBatch> {
226 let values: Vec<&[u8]> = records
227 .iter()
228 .map(|record| record.value.as_slice())
229 .collect();
230 self.decode_slices(&values)
231 }
232
233 fn format_name(&self) -> &'static str {
234 "csv"
235 }
236}
237
238impl CsvDecoder {
239 pub fn decode_slices(&self, records: &[&[u8]]) -> SchemaResult<RecordBatch> {
245 if records.is_empty() {
246 return Ok(RecordBatch::new_empty(self.schema.clone()));
247 }
248
249 let num_fields = self.schema.fields().len();
250 let capacity = records.len();
251
252 let mut builders = create_builders(&self.schema, capacity);
254
255 let mut combined = Vec::with_capacity(records.iter().map(|record| record.len() + 1).sum());
257 for record in records {
258 combined.extend_from_slice(record);
259 if !record.ends_with(b"\n") {
260 combined.push(b'\n');
261 }
262 }
263
264 let rb = self.make_reader_builder();
265 let mut reader = rb.from_reader(combined.as_slice());
266
267 let mut rows_skipped = 0usize;
268 let mut header_skipped = false;
269 let mut row_count = 0usize;
270
271 let mut byte_record = csv::ByteRecord::new();
272 while reader
273 .read_byte_record(&mut byte_record)
274 .map_err(|e| SchemaError::DecodeError(format!("CSV parse error: {e}")))?
275 {
276 if self.config.has_header && !header_skipped {
278 header_skipped = true;
279 continue;
280 }
281
282 if rows_skipped < self.config.skip_rows {
284 rows_skipped += 1;
285 continue;
286 }
287
288 let field_count = byte_record.len();
289
290 if field_count != num_fields {
292 match self.config.field_count_mismatch {
293 FieldCountMismatchStrategy::Reject => {
294 return Err(SchemaError::DecodeError(format!(
295 "field count mismatch: expected {num_fields}, got {field_count}"
296 )));
297 }
298 FieldCountMismatchStrategy::Skip => {
299 self.parse_error_count.fetch_add(1, Ordering::Relaxed);
300 continue;
301 }
302 FieldCountMismatchStrategy::Null => {
303 }
305 }
306 }
307
308 for col_idx in 0..num_fields {
310 if col_idx >= field_count {
311 append_null(&mut builders[col_idx]);
313 continue;
314 }
315
316 let raw_field = &byte_record[col_idx];
317 let field_str = std::str::from_utf8(raw_field).unwrap_or("");
318 let trimmed = field_str.trim();
319
320 if trimmed == self.config.null_string {
322 append_null(&mut builders[col_idx]);
323 continue;
324 }
325
326 let ok = append_coerced(&mut builders[col_idx], &self.coercions[col_idx], trimmed);
328
329 if !ok {
330 self.parse_error_count.fetch_add(1, Ordering::Relaxed);
331 append_null(&mut builders[col_idx]);
332 }
333 }
334
335 row_count += 1;
336 }
337
338 if row_count == 0 {
340 return Ok(RecordBatch::new_empty(self.schema.clone()));
341 }
342
343 let columns: Vec<ArrayRef> = builders.into_iter().map(|mut b| b.finish()).collect();
345
346 RecordBatch::try_new(self.schema.clone(), columns)
347 .map_err(|e| SchemaError::DecodeError(format!("RecordBatch construction: {e}")))
348 }
349}
350
351trait ColumnBuilder: Send {
355 fn finish(&mut self) -> ArrayRef;
356 fn append_null_value(&mut self);
357 fn as_any_mut(&mut self) -> &mut dyn std::any::Any;
358}
359
360macro_rules! impl_column_builder {
361 ($builder:ty) => {
362 impl ColumnBuilder for $builder {
363 fn finish(&mut self) -> ArrayRef {
364 Arc::new(<$builder>::finish(self))
365 }
366 fn append_null_value(&mut self) {
367 self.append_null();
368 }
369 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
370 self
371 }
372 }
373 };
374}
375
376impl_column_builder!(BooleanBuilder);
377impl_column_builder!(Int64Builder);
378impl_column_builder!(Float64Builder);
379impl_column_builder!(StringBuilder);
380impl_column_builder!(TimestampNanosecondBuilder);
381impl_column_builder!(Date32Builder);
382
383fn create_builders(schema: &SchemaRef, capacity: usize) -> Vec<Box<dyn ColumnBuilder>> {
384 schema
385 .fields()
386 .iter()
387 .map(|f| create_builder(f.data_type(), capacity))
388 .collect()
389}
390
391fn create_builder(data_type: &DataType, capacity: usize) -> Box<dyn ColumnBuilder> {
392 match data_type {
393 DataType::Boolean => Box::new(BooleanBuilder::with_capacity(capacity)),
394 DataType::Int8
395 | DataType::Int16
396 | DataType::Int32
397 | DataType::Int64
398 | DataType::UInt8
399 | DataType::UInt16
400 | DataType::UInt32
401 | DataType::UInt64 => Box::new(Int64Builder::with_capacity(capacity)),
402 DataType::Float16 | DataType::Float32 | DataType::Float64 => {
403 Box::new(Float64Builder::with_capacity(capacity))
404 }
405 DataType::Timestamp(TimeUnit::Nanosecond, tz) => {
406 let builder =
407 TimestampNanosecondBuilder::with_capacity(capacity).with_timezone_opt(tz.clone());
408 Box::new(builder)
409 }
410 DataType::Date32 | DataType::Date64 => Box::new(Date32Builder::with_capacity(capacity)),
411 _ => Box::new(StringBuilder::with_capacity(capacity, capacity * 32)),
413 }
414}
415
416fn append_null(builder: &mut Box<dyn ColumnBuilder>) {
417 builder.append_null_value();
418}
419
420fn append_coerced(
423 builder: &mut Box<dyn ColumnBuilder>,
424 coercion: &CsvCoercion,
425 value: &str,
426) -> bool {
427 match coercion {
428 CsvCoercion::Boolean => {
429 let b = builder
430 .as_any_mut()
431 .downcast_mut::<BooleanBuilder>()
432 .unwrap();
433 match value.to_ascii_lowercase().as_str() {
434 "true" | "1" | "yes" | "t" | "y" => {
435 b.append_value(true);
436 true
437 }
438 "false" | "0" | "no" | "f" | "n" => {
439 b.append_value(false);
440 true
441 }
442 _ => false,
443 }
444 }
445 CsvCoercion::Int64 => {
446 let b = builder.as_any_mut().downcast_mut::<Int64Builder>().unwrap();
447 match value.parse::<i64>() {
448 Ok(v) => {
449 b.append_value(v);
450 true
451 }
452 Err(_) => false,
453 }
454 }
455 CsvCoercion::Float64 => {
456 let b = builder
457 .as_any_mut()
458 .downcast_mut::<Float64Builder>()
459 .unwrap();
460 match value.parse::<f64>() {
461 Ok(v) => {
462 b.append_value(v);
463 true
464 }
465 Err(_) => false,
466 }
467 }
468 CsvCoercion::Timestamp(fmt) => {
469 let b = builder
470 .as_any_mut()
471 .downcast_mut::<TimestampNanosecondBuilder>()
472 .unwrap();
473 if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(value, fmt) {
475 let nanos = ndt.and_utc().timestamp_nanos_opt().unwrap_or(0);
476 b.append_value(nanos);
477 return true;
478 }
479 if let Ok(nanos) = arrow_cast::parse::string_to_timestamp_nanos(value) {
481 b.append_value(nanos);
482 return true;
483 }
484 false
485 }
486 CsvCoercion::Date(fmt) => {
487 let b = builder
488 .as_any_mut()
489 .downcast_mut::<Date32Builder>()
490 .unwrap();
491 if let Ok(date) = chrono::NaiveDate::parse_from_str(value, fmt) {
492 let epoch = chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
494 let days = (date - epoch).num_days();
495 #[allow(clippy::cast_possible_truncation)]
496 {
497 b.append_value(days as i32);
498 }
499 return true;
500 }
501 false
502 }
503 CsvCoercion::Utf8 => {
504 let b = builder
505 .as_any_mut()
506 .downcast_mut::<StringBuilder>()
507 .unwrap();
508 b.append_value(value);
509 true
510 }
511 }
512}
513
514#[derive(Debug, Clone)]
516pub struct CsvEncoderConfig {
517 pub delimiter: u8,
519 pub has_header: bool,
521}
522
523impl Default for CsvEncoderConfig {
524 fn default() -> Self {
525 Self {
526 delimiter: b',',
527 has_header: false,
528 }
529 }
530}
531
532#[derive(Debug)]
534pub struct CsvEncoder {
535 schema: SchemaRef,
536 config: CsvEncoderConfig,
537}
538
539impl CsvEncoder {
540 #[must_use]
542 pub fn new(schema: SchemaRef) -> Self {
543 Self::with_config(schema, CsvEncoderConfig::default())
544 }
545
546 #[must_use]
548 pub fn with_config(schema: SchemaRef, config: CsvEncoderConfig) -> Self {
549 Self { schema, config }
550 }
551}
552
553impl FormatEncoder for CsvEncoder {
554 fn input_schema(&self) -> SchemaRef {
555 self.schema.clone()
556 }
557
558 fn encode_batch(&self, batch: &RecordBatch) -> SchemaResult<Vec<Vec<u8>>> {
559 if batch.num_rows() == 0 {
560 return Ok(Vec::new());
561 }
562
563 let mut buf = Vec::new();
564 {
565 let writer = arrow_csv::writer::WriterBuilder::new()
566 .with_header(self.config.has_header)
567 .with_delimiter(self.config.delimiter);
568 let mut csv_writer = writer.build(&mut buf);
569 csv_writer
570 .write(batch)
571 .map_err(|e| SchemaError::DecodeError(format!("CSV encode error: {e}")))?;
572 }
573
574 let output: Vec<Vec<u8>> = buf
575 .split(|&b| b == b'\n')
576 .filter(|line| !line.is_empty())
577 .map(<[u8]>::to_vec)
578 .collect();
579
580 Ok(output)
581 }
582
583 fn format_name(&self) -> &'static str {
584 "csv"
585 }
586}
587
588#[cfg(test)]
589mod tests;