1use std::fs::File;
8use std::io::{BufReader, Cursor, Read};
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use arrow::datatypes::{Schema, SchemaRef};
13use arrow::record_batch::RecordBatch;
14use arrow_csv::reader::{Format, ReaderBuilder};
15use regex::Regex;
16
17use crate::io::options::CsvReadOptions;
18use crate::physical::budget::{ResourceReservation, ResourceScope};
19use crate::physical::{
20 BatchOpenContext, BatchSource, BatchSourceFactory, SourceLimit, StreamBatch,
21};
22use crate::{DataFrameError, Result};
23
24#[derive(Debug, Clone)]
26pub struct CsvBatchSourceFactory {
27 path: PathBuf,
28 options: CsvReadOptions,
29}
30
31impl CsvBatchSourceFactory {
32 pub fn new(path: impl AsRef<Path>, options: CsvReadOptions) -> Self {
34 Self {
35 path: path.as_ref().to_path_buf(),
36 options,
37 }
38 }
39
40 pub(crate) fn from_scan(
43 path: PathBuf,
44 predicate: Option<crate::Expr>,
45 projection: Option<Vec<String>>,
46 ) -> Self {
47 let options = CsvReadOptions {
48 predicate,
49 projection,
50 ..CsvReadOptions::default()
51 };
52 Self { path, options }
53 }
54}
55
56impl BatchSourceFactory for CsvBatchSourceFactory {
57 fn source_name(&self) -> &'static str {
58 "csv"
59 }
60
61 fn schema(&self) -> Result<SchemaRef> {
62 Err(DataFrameError::streaming_unsupported(
63 "csv_scan",
64 "schema_is_available_after_bounded_open",
65 ))
66 }
67
68 fn source_limits(&self) -> Vec<SourceLimit> {
69 vec![SourceLimit {
70 source: crate::physical::PlanSubject::CsvScan,
71 code: "stable_input_order",
72 description: "CSV streaming preserves physical input record order",
73 }]
74 }
75
76 fn open(&self, context: BatchOpenContext) -> Result<Box<dyn BatchSource>> {
77 validate_options(&self.options)?;
78 if self.options.predicate.is_some() {
79 return Err(DataFrameError::streaming_unsupported(
80 "csv_scan",
81 "predicate_batch_operator_not_installed",
82 ));
83 }
84
85 let block_bytes = bounded_block_bytes(context.options.memory_limit_bytes)?;
86 let (full_schema, schema_reservation) =
87 infer_schema_bounded(&self.path, &self.options, &context, block_bytes)?;
88 let projection_indices =
89 projection_indices(&full_schema, self.options.projection.as_deref())?;
90 let output_schema = projected_schema(&full_schema, projection_indices.as_deref());
91
92 let file = File::open(&self.path)
93 .map_err(|source| DataFrameError::io_with_path(source, &self.path))?;
94 let mut framer = BoundedCsvFramer::new(
95 file,
96 self.path.clone(),
97 self.options.quote_char,
98 block_bytes,
99 );
100 if self.options.has_header {
101 framer.skip_record(&context)?;
102 }
103
104 Ok(Box::new(CsvBatchSource {
105 context,
106 framer,
107 full_schema,
108 output_schema,
109 projection_indices,
110 options: self.options.clone(),
111 schema_reservation: Some(schema_reservation),
112 }))
113 }
114}
115
116struct CsvBatchSource {
117 context: BatchOpenContext,
118 framer: BoundedCsvFramer<File>,
119 full_schema: SchemaRef,
120 output_schema: SchemaRef,
121 projection_indices: Option<Vec<usize>>,
122 options: CsvReadOptions,
123 schema_reservation: Option<ResourceReservation>,
125}
126
127impl BatchSource for CsvBatchSource {
128 fn schema(&self) -> SchemaRef {
129 self.output_schema.clone()
130 }
131
132 fn next_batch(&mut self) -> Result<Option<StreamBatch>> {
133 let Some(frame) = self
134 .framer
135 .next_frame(self.context.options.batch_rows.get(), &self.context)?
136 else {
137 return Ok(None);
138 };
139
140 let decode_bytes = conservative_decode_upper_bound(
141 frame.bytes.len(),
142 frame.rows,
143 self.full_schema.fields().len(),
144 );
145 let reservation = self
146 .context
147 .budget
148 .reserve_batch(ResourceScope::Decode, decode_bytes)?;
149
150 let batch = decode_frame(
151 &frame.bytes,
152 frame.rows,
153 self.full_schema.clone(),
154 &self.options,
155 )?;
156 let batch = project_batch(batch, self.projection_indices.as_deref())?;
157 drop(frame);
158
159 Ok(Some(StreamBatch::new(batch, reservation)))
160 }
161
162 fn close(&mut self) -> Result<()> {
163 self.schema_reservation.take();
164 Ok(())
165 }
166}
167
168fn infer_schema_bounded(
169 path: &Path,
170 options: &CsvReadOptions,
171 context: &BatchOpenContext,
172 block_bytes: usize,
173) -> Result<(SchemaRef, ResourceReservation)> {
174 let file = File::open(path).map_err(|source| DataFrameError::io_with_path(source, path))?;
175 let mut framer =
176 BoundedCsvFramer::new(file, path.to_path_buf(), options.quote_char, block_bytes);
177 let record_count = options
178 .infer_schema_length
179 .saturating_add(usize::from(options.has_header))
180 .max(1);
181 let Some(frame) = framer.next_frame(record_count, context)? else {
182 return Err(DataFrameError::schema_mismatch("CSV input is empty"));
183 };
184 let schema_reservation = context.budget.reserve(
185 ResourceScope::Source,
186 schema_reservation_bytes(frame.bytes.len()),
187 )?;
188 let mut reader = BufReader::with_capacity(
189 frame.bytes.len().max(1),
190 Cursor::new(frame.bytes.as_slice()),
191 );
192 let (schema, _) = csv_format(options, options.has_header)?
193 .infer_schema(&mut reader, Some(options.infer_schema_length))
194 .map_err(|source| DataFrameError::Arrow { source })?;
195 drop(frame);
196 Ok((Arc::new(schema), schema_reservation))
197}
198
199fn decode_frame(
200 bytes: &[u8],
201 rows: usize,
202 schema: SchemaRef,
203 options: &CsvReadOptions,
204) -> Result<RecordBatch> {
205 let reader = ReaderBuilder::new(schema)
206 .with_format(csv_format(options, false)?)
207 .with_batch_size(rows.max(1))
208 .build(Cursor::new(bytes))
209 .map_err(|source| DataFrameError::Arrow { source })?;
210 let mut batches = reader;
211 let batch = batches
212 .next()
213 .transpose()
214 .map_err(|source| DataFrameError::Arrow { source })?
215 .ok_or_else(|| DataFrameError::schema_mismatch("CSV frame contained no records"))?;
216 if batches.next().is_some() {
217 return Err(DataFrameError::schema_mismatch(
218 "CSV frame decoded to more than one bounded batch",
219 ));
220 }
221 Ok(batch)
222}
223
224fn csv_format(options: &CsvReadOptions, has_header: bool) -> Result<Format> {
225 let mut format = Format::default()
226 .with_header(has_header)
227 .with_delimiter(options.delimiter);
228 if let Some(quote_char) = options.quote_char {
229 format = format.with_quote(quote_char);
230 }
231 if !options.null_values.is_empty() {
232 let pattern = options
233 .null_values
234 .iter()
235 .map(|value| regex::escape(value))
236 .collect::<Vec<_>>()
237 .join("|");
238 let regex = Regex::new(&format!("^(?:{pattern})$")).map_err(|error| {
239 DataFrameError::configuration("null_values", format!("invalid regex: {error}"))
240 })?;
241 format = format.with_null_regex(regex);
242 }
243 Ok(format)
244}
245
246fn validate_options(options: &CsvReadOptions) -> Result<()> {
247 if options.delimiter == b'\0' {
248 return Err(DataFrameError::configuration(
249 "delimiter",
250 "delimiter must not be NUL (0x00)",
251 ));
252 }
253 if options.quote_char == Some(b'\0') {
254 return Err(DataFrameError::configuration(
255 "quote_char",
256 "quote_char must not be NUL (0x00)",
257 ));
258 }
259 Ok(())
260}
261
262fn projection_indices(
263 schema: &SchemaRef,
264 projection: Option<&[String]>,
265) -> Result<Option<Vec<usize>>> {
266 let Some(projection) = projection else {
267 return Ok(None);
268 };
269 projection
270 .iter()
271 .map(|name| {
272 schema
273 .fields()
274 .iter()
275 .position(|field| field.name() == name)
276 .ok_or_else(|| DataFrameError::column_not_found(name.clone()))
277 })
278 .collect::<Result<Vec<_>>>()
279 .map(Some)
280}
281
282fn projected_schema(schema: &SchemaRef, projection: Option<&[usize]>) -> SchemaRef {
283 let Some(projection) = projection else {
284 return schema.clone();
285 };
286 Arc::new(Schema::new(
287 projection
288 .iter()
289 .map(|index| schema.field(*index).clone())
290 .collect::<Vec<_>>(),
291 ))
292}
293
294fn project_batch(batch: RecordBatch, projection: Option<&[usize]>) -> Result<RecordBatch> {
295 match projection {
296 Some(indices) => batch.project(indices).map_err(|error| {
297 DataFrameError::schema_mismatch(format!("failed to project CSV batch: {error}"))
298 }),
299 None => Ok(batch),
300 }
301}
302
303fn bounded_block_bytes(memory_limit_bytes: u64) -> Result<usize> {
304 let bytes = (memory_limit_bytes / 32).clamp(1, 64 * 1024);
305 usize::try_from(bytes).map_err(|_| {
306 DataFrameError::configuration("memory_limit_bytes", "does not fit this platform")
307 })
308}
309
310fn conservative_decode_upper_bound(raw_bytes: usize, rows: usize, columns: usize) -> u64 {
311 let raw = u64::try_from(raw_bytes).unwrap_or(u64::MAX);
312 let cells = u64::try_from(rows)
313 .unwrap_or(u64::MAX)
314 .saturating_mul(u64::try_from(columns).unwrap_or(u64::MAX));
315 raw.saturating_mul(16)
316 .saturating_add(cells.saturating_mul(16))
317 .saturating_add(1024)
318}
319
320fn schema_reservation_bytes(raw_bytes: usize) -> u64 {
321 u64::try_from(raw_bytes)
322 .unwrap_or(u64::MAX)
323 .saturating_mul(2)
324 .saturating_add(128)
325}
326
327struct FramedBytes {
328 bytes: Vec<u8>,
329 rows: usize,
330 _reservation: ResourceReservation,
331}
332
333struct BoundedCsvFramer<R> {
335 reader: R,
336 path: PathBuf,
337 quote_char: Option<u8>,
338 byte_cap: usize,
339 eof: bool,
340}
341
342impl<R: Read> BoundedCsvFramer<R> {
343 fn new(reader: R, path: PathBuf, quote_char: Option<u8>, byte_cap: usize) -> Self {
344 Self {
345 reader,
346 path,
347 quote_char,
348 byte_cap,
349 eof: false,
350 }
351 }
352
353 fn next_frame(
354 &mut self,
355 max_rows: usize,
356 context: &BatchOpenContext,
357 ) -> Result<Option<FramedBytes>> {
358 if self.eof {
359 return Ok(None);
360 }
361 let reservation = context
362 .budget
363 .reserve(ResourceScope::Source, self.byte_cap as u64)?;
364 let mut bytes = Vec::with_capacity(self.byte_cap);
365 let mut tracker = QuoteTracker::new(self.quote_char);
366 let mut rows: usize = 0;
367
368 loop {
369 let Some(byte) = self.read_byte()? else {
370 self.eof = true;
371 if bytes.is_empty() {
372 return Ok(None);
373 }
374 tracker.finish()?;
375 if bytes.last() != Some(&b'\n') {
376 rows = rows.saturating_add(1);
377 }
378 break;
379 };
380
381 if bytes.len() == self.byte_cap {
382 self.discard_record_tail(tracker, byte)?;
383 return Err(self.record_limit_error(context));
384 }
385 bytes.push(byte);
386 if tracker.observe(byte) {
387 rows = rows.saturating_add(1);
388 if rows == max_rows {
389 break;
390 }
391 }
392 }
393
394 Ok(Some(FramedBytes {
395 bytes,
396 rows,
397 _reservation: reservation,
398 }))
399 }
400
401 fn skip_record(&mut self, context: &BatchOpenContext) -> Result<()> {
402 let mut tracker = QuoteTracker::new(self.quote_char);
403 let mut bytes = 0usize;
404 loop {
405 let Some(byte) = self.read_byte()? else {
406 self.eof = true;
407 tracker.finish()?;
408 return Ok(());
409 };
410 bytes = bytes.saturating_add(1);
411 if bytes > self.byte_cap {
412 self.discard_record_tail(tracker, byte)?;
413 return Err(self.record_limit_error(context));
414 }
415 if tracker.observe(byte) {
416 return Ok(());
417 }
418 }
419 }
420
421 fn read_byte(&mut self) -> Result<Option<u8>> {
422 let mut byte = [0_u8; 1];
423 match self.reader.read(&mut byte) {
424 Ok(0) => Ok(None),
425 Ok(_) => Ok(Some(byte[0])),
426 Err(source) => Err(DataFrameError::io_with_path(source, &self.path)),
427 }
428 }
429
430 fn discard_record_tail(&mut self, mut tracker: QuoteTracker, first: u8) -> Result<()> {
431 if tracker.observe(first) {
432 return Ok(());
433 }
434 while let Some(byte) = self.read_byte()? {
435 if tracker.observe(byte) {
436 return Ok(());
437 }
438 }
439 self.eof = true;
440 tracker.finish()
441 }
442
443 fn record_limit_error(&self, context: &BatchOpenContext) -> DataFrameError {
444 DataFrameError::resource_limit_exceeded(
445 context.budget.memory_limit_bytes(),
446 context
447 .budget
448 .usage()
449 .reserved_bytes
450 .saturating_add(self.byte_cap as u64)
451 .saturating_add(1),
452 context.budget.max_in_flight_batches(),
453 context.budget.usage().reserved_batches,
454 ResourceScope::Source,
455 )
456 }
457}
458
459#[derive(Clone, Copy)]
460struct QuoteTracker {
461 quote: Option<u8>,
462 in_quotes: bool,
463 quote_pending: bool,
464}
465
466impl QuoteTracker {
467 fn new(quote: Option<u8>) -> Self {
468 Self {
469 quote,
470 in_quotes: false,
471 quote_pending: false,
472 }
473 }
474
475 fn observe(&mut self, byte: u8) -> bool {
476 let Some(quote) = self.quote else {
477 return byte == b'\n';
478 };
479
480 if self.quote_pending {
481 if byte == quote {
482 self.quote_pending = false;
483 return false;
484 }
485 self.quote_pending = false;
486 self.in_quotes = false;
487 }
488 if self.in_quotes {
489 if byte == quote {
490 self.quote_pending = true;
491 }
492 return false;
493 }
494 if byte == quote {
495 self.in_quotes = true;
496 false
497 } else {
498 byte == b'\n'
499 }
500 }
501
502 fn finish(&mut self) -> Result<()> {
503 if self.quote_pending {
504 self.quote_pending = false;
505 self.in_quotes = false;
506 }
507 if self.in_quotes {
508 return Err(DataFrameError::schema_mismatch(
509 "CSV input ended inside a quoted record",
510 ));
511 }
512 Ok(())
513 }
514}
515
516#[cfg(test)]
517mod tests {
518 use std::num::NonZeroUsize;
519
520 use arrow::array::Int64Array;
521
522 use super::CsvBatchSourceFactory;
523 use crate::io::CsvReadOptions;
524 use crate::physical::budget::StreamOptions;
525 use crate::physical::DataFrameStream;
526 use crate::DataFrameError;
527
528 fn options(memory_limit_bytes: u64, batch_rows: usize) -> StreamOptions {
529 StreamOptions::new(
530 memory_limit_bytes,
531 NonZeroUsize::new(1).unwrap(),
532 NonZeroUsize::new(batch_rows).unwrap(),
533 )
534 }
535
536 #[test]
537 fn quoted_newline_is_one_record_and_input_order_is_stable() {
538 let dir = tempfile::tempdir().unwrap();
539 let path = dir.path().join("quoted.csv");
540 std::fs::write(&path, "a,b\n1,\"first\nline\"\n2,second\n").unwrap();
541
542 let factory = CsvBatchSourceFactory::new(
543 &path,
544 CsvReadOptions::default().with_infer_schema_length(1),
545 );
546 let mut stream = DataFrameStream::from_factory(&factory, options(16 * 1024, 1)).unwrap();
547 let first = stream.next_batch().unwrap().unwrap();
548 let second = stream.next_batch().unwrap().unwrap();
549
550 assert_eq!(first.height(), 1);
551 assert_eq!(second.height(), 1);
552 let first_values = first.column("a").unwrap().to_arrow();
553 let second_values = second.column("a").unwrap().to_arrow();
554 assert_eq!(
555 first_values[0]
556 .as_any()
557 .downcast_ref::<Int64Array>()
558 .unwrap()
559 .value(0),
560 1
561 );
562 assert_eq!(
563 second_values[0]
564 .as_any()
565 .downcast_ref::<Int64Array>()
566 .unwrap()
567 .value(0),
568 2
569 );
570 }
571
572 #[test]
573 fn oversized_record_fails_without_accumulating_the_record() {
574 let dir = tempfile::tempdir().unwrap();
575 let path = dir.path().join("oversized.csv");
576 std::fs::write(&path, format!("a\n{}\n", "x".repeat(256))).unwrap();
577
578 let factory = CsvBatchSourceFactory::new(&path, CsvReadOptions::default());
579 let err = match DataFrameStream::from_factory(&factory, options(512, 1)) {
580 Ok(_) => panic!("oversized CSV record must not open a stream"),
581 Err(error) => error,
582 };
583 assert!(matches!(err, DataFrameError::ResourceLimitExceeded { .. }));
584 }
585
586 #[test]
587 fn schema_error_is_terminal_and_does_not_yield_another_batch() {
588 let dir = tempfile::tempdir().unwrap();
589 let path = dir.path().join("invalid.csv");
590 std::fs::write(&path, "a,b\n1,2\n3,4,5\n").unwrap();
591
592 let factory = CsvBatchSourceFactory::new(
593 &path,
594 CsvReadOptions::default().with_infer_schema_length(1),
595 );
596 let mut stream = DataFrameStream::from_factory(&factory, options(16 * 1024, 1)).unwrap();
597 assert!(stream.next_batch().unwrap().is_some());
598 assert!(matches!(
599 stream.next_batch(),
600 Err(DataFrameError::StreamFailed { .. })
601 ));
602 assert!(matches!(
603 stream.next_batch(),
604 Err(DataFrameError::StreamFailed { .. })
605 ));
606 }
607
608 #[test]
609 fn invalid_utf8_is_a_repeatable_decode_terminal() {
610 let dir = tempfile::tempdir().unwrap();
611 let path = dir.path().join("invalid-utf8.csv");
612 std::fs::write(&path, b"a\nok\n\xff\n").unwrap();
613
614 let factory = CsvBatchSourceFactory::new(
615 &path,
616 CsvReadOptions::default().with_infer_schema_length(1),
617 );
618 let mut stream = DataFrameStream::from_factory(&factory, options(16 * 1024, 1)).unwrap();
619 assert!(stream.next_batch().unwrap().is_some());
620 assert!(matches!(
621 stream.next_batch(),
622 Err(DataFrameError::StreamFailed {
623 code: "decode_error",
624 ..
625 })
626 ));
627 assert!(matches!(
628 stream.next_batch(),
629 Err(DataFrameError::StreamFailed {
630 code: "decode_error",
631 ..
632 })
633 ));
634 }
635}