1use arrow_array::RecordBatch;
10#[cfg(feature = "csv")]
11use arrow_csv::WriterBuilder as CsvWriterBuilder;
12#[cfg(feature = "feather")]
13use arrow_ipc::writer::FileWriter as IpcFileWriter;
14#[cfg(feature = "ndjson")]
15use arrow_json::LineDelimitedWriter as JsonLineDelimitedWriter;
16#[cfg(all(
17 test,
18 feature = "csv",
19 feature = "feather",
20 feature = "ndjson",
21 feature = "parquet"
22))]
23use arrow_schema::Schema;
24use arrow_schema::SchemaRef;
25#[cfg(feature = "parquet")]
26use parquet::{
27 arrow::{
28 ArrowWriter as ParquetArrowWriter,
29 arrow_writer::{
30 ArrowColumnChunk, ArrowLeafColumn, ArrowRowGroupWriterFactory, compute_leaves,
31 },
32 },
33 basic::Compression as ParquetCompressionCodec,
34 file::{properties::WriterProperties, writer::SerializedFileWriter},
35};
36#[cfg(all(
37 not(target_arch = "wasm32"),
38 any(feature = "parquet", feature = "csv", feature = "ndjson")
39))]
40use rayon::prelude::*;
41#[cfg(any(
42 feature = "csv",
43 feature = "feather",
44 feature = "ndjson",
45 feature = "parquet"
46))]
47use std::fs::File;
48#[cfg(any(
49 feature = "csv",
50 feature = "feather",
51 feature = "ndjson",
52 feature = "parquet"
53))]
54use std::io::BufWriter;
55#[cfg(all(any(feature = "csv", feature = "ndjson"), not(target_arch = "wasm32")))]
56use std::io::Write as _;
57#[cfg(feature = "csv")]
58use std::io::stdout;
59#[cfg(any(
60 feature = "csv",
61 feature = "feather",
62 feature = "ndjson",
63 feature = "parquet"
64))]
65use std::path::PathBuf;
66#[cfg(all(
67 test,
68 feature = "csv",
69 feature = "feather",
70 feature = "ndjson",
71 feature = "parquet"
72))]
73use std::sync::Arc;
74
75use crate::err::ReadStatError;
76#[cfg(any(
77 feature = "csv",
78 feature = "feather",
79 feature = "ndjson",
80 feature = "parquet"
81))]
82use crate::rs_write_config::OutFormat;
83#[cfg(feature = "parquet")]
84use crate::rs_write_config::ParquetCompression;
85use crate::rs_write_config::WriteConfig;
86
87#[cfg(any(
88 feature = "parquet",
89 all(any(feature = "csv", feature = "ndjson"), not(target_arch = "wasm32"))
90))]
91struct StagingGuard(Option<PathBuf>);
92
93#[cfg(any(
94 feature = "parquet",
95 all(any(feature = "csv", feature = "ndjson"), not(target_arch = "wasm32"))
96))]
97impl Drop for StagingGuard {
98 fn drop(&mut self) {
99 if let Some(path) = self.0.take() {
100 let _ = std::fs::remove_file(path);
101 }
102 }
103}
104
105#[cfg(feature = "parquet")]
107pub(crate) struct ReadStatParquetWriter {
108 wtr: Option<ParquetArrowWriter<BufWriter<std::fs::File>>>,
109}
110
111#[cfg(feature = "parquet")]
112impl ReadStatParquetWriter {
113 fn new(wtr: ParquetArrowWriter<BufWriter<std::fs::File>>) -> Self {
114 Self { wtr: Some(wtr) }
115 }
116}
117
118#[cfg(all(any(feature = "csv", feature = "ndjson"), not(target_arch = "wasm32")))]
126pub struct ParallelTextWriter {
127 writer: Option<BufWriter<File>>,
128 schema: SchemaRef,
129 format: OutFormat,
130 wrote_batch: bool,
131 rows_written: usize,
132 staging_path: Option<PathBuf>,
133 destination: PathBuf,
134 overwrite: bool,
135}
136
137#[cfg(all(any(feature = "csv", feature = "ndjson"), not(target_arch = "wasm32")))]
138impl ParallelTextWriter {
139 pub fn new(config: WriteConfig, schema: SchemaRef) -> Result<Self, ReadStatError> {
146 config.validate()?;
147 let supported = match config.format {
148 #[cfg(feature = "csv")]
149 OutFormat::Csv => true,
150 #[cfg(feature = "ndjson")]
151 OutFormat::Ndjson => true,
152 _ => false,
153 };
154 if !supported {
155 return Err(ReadStatError::InvalidOutputConfig(
156 "parallel text writer requires CSV or NDJSON output".into(),
157 ));
158 }
159
160 let destination = config.out_path.clone().ok_or_else(|| {
161 ReadStatError::InvalidOutputConfig(
162 "parallel text writer requires an output file".into(),
163 )
164 })?;
165 let (file, staging_path) = crate::rs_write_config::open_output(&config)?;
166 let mut staging = StagingGuard(Some(staging_path));
167
168 Ok(Self {
169 writer: Some(BufWriter::new(file)),
170 schema,
171 format: config.format,
172 wrote_batch: false,
173 rows_written: 0,
174 staging_path: staging.0.take(),
175 destination,
176 overwrite: config.overwrite,
177 })
178 }
179
180 pub fn write(&mut self, batches: &[RecordBatch]) -> Result<(), ReadStatError> {
188 if batches.is_empty() {
189 return Ok(());
190 }
191 if batches.iter().any(|batch| batch.schema() != self.schema) {
192 return Err(ReadStatError::SchemaMismatch);
193 }
194 let next_rows = batches.iter().try_fold(self.rows_written, |rows, batch| {
195 rows.checked_add(batch.num_rows())
196 .ok_or_else(|| ReadStatError::Other("writer row count overflow".into()))
197 })?;
198
199 let include_header = !self.wrote_batch && matches!(self.format, OutFormat::Csv);
200 let format = self.format;
201 let encoded = batches
202 .par_iter()
203 .enumerate()
204 .map(|(index, batch)| encode_text_batch(format, batch, include_header && index == 0))
205 .collect::<Result<Vec<_>, _>>()?;
206
207 let write_result = {
208 let writer = self
209 .writer
210 .as_mut()
211 .ok_or_else(|| ReadStatError::Other("text writer is already closed".into()))?;
212 encoded
213 .into_iter()
214 .try_for_each(|bytes| writer.write_all(&bytes))
215 };
216 if let Err(error) = write_result {
217 self.writer = None;
221 return Err(error.into());
222 }
223 self.wrote_batch = true;
224 self.rows_written = next_rows;
225 Ok(())
226 }
227
228 pub fn finish(mut self) -> Result<usize, ReadStatError> {
236 if !self.wrote_batch && matches!(self.format, OutFormat::Csv) {
237 let empty = RecordBatch::new_empty(self.schema.clone());
238 self.write(std::slice::from_ref(&empty))?;
239 }
240 self.writer
241 .take()
242 .ok_or_else(|| ReadStatError::Other("text writer is already closed".into()))?
243 .flush()?;
244 let staging = self
245 .staging_path
246 .as_ref()
247 .expect("parallel text staging path is armed");
248 crate::rs_write_config::publish_staging(staging, &self.destination, self.overwrite)?;
249 self.staging_path = None;
250 Ok(self.rows_written)
251 }
252}
253
254#[cfg(all(any(feature = "csv", feature = "ndjson"), not(target_arch = "wasm32")))]
255impl Drop for ParallelTextWriter {
256 fn drop(&mut self) {
257 if let Some(path) = self.staging_path.take() {
258 self.writer = None;
259 let _ = std::fs::remove_file(path);
260 }
261 }
262}
263
264#[cfg(all(any(feature = "csv", feature = "ndjson"), not(target_arch = "wasm32")))]
265fn encode_text_batch(
266 format: OutFormat,
267 batch: &RecordBatch,
268 include_header: bool,
269) -> Result<Vec<u8>, ReadStatError> {
270 #[cfg(not(feature = "csv"))]
271 let _ = include_header;
272 let mut bytes = Vec::new();
273 match format {
274 #[cfg(feature = "csv")]
275 OutFormat::Csv => {
276 let mut writer = CsvWriterBuilder::new()
277 .with_header(include_header)
278 .build(&mut bytes);
279 writer.write(batch)?;
280 }
281 #[cfg(feature = "ndjson")]
282 OutFormat::Ndjson => {
283 let mut writer = JsonLineDelimitedWriter::new(&mut bytes);
284 writer.write(batch)?;
285 writer.finish()?;
286 }
287 _ => {
288 return Err(ReadStatError::InvalidOutputConfig(
289 "parallel text writer requires CSV or NDJSON output".into(),
290 ));
291 }
292 }
293 Ok(bytes)
294}
295
296#[cfg(feature = "parquet")]
303pub struct ParallelParquetWriter {
304 writer: Option<SerializedFileWriter<BufWriter<File>>>,
305 factory: ArrowRowGroupWriterFactory,
306 schema: SchemaRef,
307 pending: Vec<RecordBatch>,
308 pending_rows: usize,
309 row_group_rows: usize,
310 row_group_index: usize,
311 rows_written: usize,
312 staging_path: Option<PathBuf>,
313 destination: PathBuf,
314 overwrite: bool,
315}
316
317#[cfg(feature = "parquet")]
318impl ParallelParquetWriter {
319 pub fn new(
326 config: WriteConfig,
327 schema: SchemaRef,
328 row_group_rows: usize,
329 ) -> Result<Self, ReadStatError> {
330 config.validate()?;
331 if !matches!(config.format, OutFormat::Parquet) {
332 return Err(ReadStatError::InvalidOutputConfig(
333 "parallel Parquet writer requires Parquet output".into(),
334 ));
335 }
336 if row_group_rows == 0 {
337 return Err(ReadStatError::Other(
338 "Parquet row-group rows must be greater than zero".into(),
339 ));
340 }
341
342 let destination = config
343 .out_path
344 .clone()
345 .ok_or_else(|| ReadStatError::InvalidOutputConfig("Parquet requires output".into()))?;
346 let compression = crate::rs_write_config::resolve_parquet_compression(
347 config.compression,
348 config.compression_level,
349 )?;
350 let (file, staging_path) = crate::rs_write_config::open_output(&config)?;
351 let mut staging = StagingGuard(Some(staging_path));
352 let properties = WriterProperties::builder()
353 .set_compression(compression)
354 .set_statistics_enabled(parquet::file::properties::EnabledStatistics::Page)
355 .set_writer_version(parquet::file::properties::WriterVersion::PARQUET_2_0)
356 .build();
357 let (writer, factory) =
358 ParquetArrowWriter::try_new(BufWriter::new(file), schema.clone(), Some(properties))?
359 .into_serialized_writer()?;
360
361 Ok(Self {
362 writer: Some(writer),
363 factory,
364 schema,
365 pending: Vec::new(),
366 pending_rows: 0,
367 row_group_rows,
368 row_group_index: 0,
369 rows_written: 0,
370 staging_path: staging.0.take(),
371 destination,
372 overwrite: config.overwrite,
373 })
374 }
375
376 pub fn write(&mut self, batch: &RecordBatch) -> Result<(), ReadStatError> {
385 if batch.schema() != self.schema {
386 return Err(ReadStatError::SchemaMismatch);
387 }
388 if self.schema.fields().is_empty() && batch.num_rows() != 0 {
389 return Err(ReadStatError::Other(
390 "Parquet cannot represent rows without columns".into(),
391 ));
392 }
393
394 let mut offset = 0;
395 while offset < batch.num_rows() {
396 let available = self.row_group_rows - self.pending_rows;
397 let rows = available.min(batch.num_rows() - offset);
398 self.pending.push(batch.slice(offset, rows));
399 self.pending_rows += rows;
400 offset += rows;
401 if self.pending_rows == self.row_group_rows {
402 self.flush_row_group()?;
403 }
404 }
405 self.rows_written = self
406 .rows_written
407 .checked_add(batch.num_rows())
408 .ok_or_else(|| ReadStatError::Other("writer row count overflow".into()))?;
409 Ok(())
410 }
411
412 fn flush_row_group(&mut self) -> Result<(), ReadStatError> {
413 if self.pending_rows == 0 {
414 return Ok(());
415 }
416
417 let column_writers = self.factory.create_column_writers(self.row_group_index)?;
418 let mut inputs: Vec<Vec<ArrowLeafColumn>> = (0..column_writers.len())
419 .map(|_| Vec::with_capacity(self.pending.len()))
420 .collect();
421
422 for batch in &self.pending {
423 let mut leaf_index = 0;
424 for (field, array) in self.schema.fields().iter().zip(batch.columns()) {
425 for leaf in compute_leaves(field.as_ref(), array)? {
426 inputs[leaf_index].push(leaf);
427 leaf_index += 1;
428 }
429 }
430 if leaf_index != column_writers.len() {
431 return Err(ReadStatError::Other(
432 "computed Parquet leaf count does not match column writers".into(),
433 ));
434 }
435 }
436
437 #[cfg(not(target_arch = "wasm32"))]
438 let column_writers = column_writers.into_par_iter();
439 #[cfg(target_arch = "wasm32")]
440 let column_writers = column_writers.into_iter();
441 #[cfg(not(target_arch = "wasm32"))]
442 let inputs = inputs.into_par_iter();
443 #[cfg(target_arch = "wasm32")]
444 let inputs = inputs.into_iter();
445
446 let chunks: Vec<ArrowColumnChunk> = column_writers
447 .zip(inputs)
448 .map(|(mut writer, leaves)| {
449 for leaf in &leaves {
450 writer.write(leaf)?;
451 }
452 writer.close()
453 })
454 .collect::<Result<Vec<_>, _>>()?;
455
456 let writer = self
457 .writer
458 .as_mut()
459 .ok_or_else(|| ReadStatError::Other("Parquet writer is already closed".into()))?;
460 let mut row_group = writer.next_row_group()?;
461 for chunk in chunks {
462 chunk.append_to_row_group(&mut row_group)?;
463 }
464 row_group.close()?;
465
466 self.pending.clear();
467 self.pending_rows = 0;
468 self.row_group_index += 1;
469 Ok(())
470 }
471
472 pub fn finish(mut self) -> Result<usize, ReadStatError> {
479 self.flush_row_group()?;
480 self.writer
481 .take()
482 .ok_or_else(|| ReadStatError::Other("Parquet writer is already closed".into()))?
483 .close()?;
484 let staging = self
485 .staging_path
486 .as_ref()
487 .expect("parallel Parquet staging path is armed");
488 crate::rs_write_config::publish_staging(staging, &self.destination, self.overwrite)?;
489 self.staging_path = None;
490 Ok(self.rows_written)
491 }
492}
493
494#[cfg(feature = "parquet")]
495impl Drop for ParallelParquetWriter {
496 fn drop(&mut self) {
497 if let Some(path) = self.staging_path.take() {
498 self.writer = None;
499 let _ = std::fs::remove_file(path);
500 }
501 }
502}
503
504pub(crate) enum ReadStatWriterFormat {
506 #[cfg(feature = "csv")]
508 Csv(BufWriter<std::fs::File>),
509 #[cfg(feature = "csv")]
511 CsvStdout(std::io::Stdout),
512 #[cfg(feature = "feather")]
514 Feather(IpcFileWriter<BufWriter<std::fs::File>>),
515 #[cfg(feature = "ndjson")]
517 Ndjson(BufWriter<std::fs::File>),
518 #[cfg(feature = "parquet")]
520 Parquet(ReadStatParquetWriter),
521}
522
523#[cfg_attr(
530 not(any(
531 feature = "csv",
532 feature = "parquet",
533 feature = "feather",
534 feature = "ndjson"
535 )),
536 allow(dead_code)
537)]
538pub struct ReadStatWriter {
539 pub(crate) wtr: Option<ReadStatWriterFormat>,
541 #[cfg(feature = "csv")]
543 pub(crate) wrote_header: bool,
544 pub(crate) wrote_start: bool,
546 config: WriteConfig,
547 schema: SchemaRef,
548 rows_written: usize,
549 #[cfg(any(
550 feature = "csv",
551 feature = "feather",
552 feature = "ndjson",
553 feature = "parquet"
554 ))]
555 staging_path: Option<PathBuf>,
556}
557
558impl ReadStatWriter {
559 pub fn new(config: WriteConfig, schema: SchemaRef) -> Result<Self, ReadStatError> {
561 config.validate()?;
562 Ok(Self {
563 wtr: None,
564 #[cfg(feature = "csv")]
565 wrote_header: false,
566 wrote_start: false,
567 config,
568 schema,
569 rows_written: 0,
570 #[cfg(any(
571 feature = "csv",
572 feature = "feather",
573 feature = "ndjson",
574 feature = "parquet"
575 ))]
576 staging_path: None,
577 })
578 }
579
580 #[cfg(any(
583 feature = "csv",
584 feature = "feather",
585 feature = "ndjson",
586 feature = "parquet"
587 ))]
588 fn open_output(&mut self, wc: &WriteConfig) -> Result<File, ReadStatError> {
589 debug_assert!(!self.wrote_start, "output file opened twice");
590 let (file, staging_path) = crate::rs_write_config::open_output(wc)?;
591 self.staging_path = Some(staging_path);
592 Ok(file)
593 }
594
595 #[cfg(feature = "parquet")]
596 fn resolve_compression(
597 compression: Option<ParquetCompression>,
598 compression_level: Option<u32>,
599 ) -> Result<ParquetCompressionCodec, ReadStatError> {
600 crate::rs_write_config::resolve_parquet_compression(compression, compression_level)
601 }
602
603 #[allow(unused_variables)]
614 pub fn finish(mut self) -> Result<usize, ReadStatError> {
615 if !self.wrote_start {
617 self.write(&RecordBatch::new_empty(self.schema.clone()))?;
618 }
619 match self.config.format {
620 #[cfg(feature = "csv")]
621 OutFormat::Csv => {
622 self.flush_buffered()?;
626 self.publish()
627 }
628 #[cfg(feature = "feather")]
629 OutFormat::Feather => {
630 self.finish_feather()?;
631 self.publish()
632 }
633 #[cfg(feature = "ndjson")]
634 OutFormat::Ndjson => {
635 self.flush_buffered()?;
636 self.publish()
637 }
638 #[cfg(feature = "parquet")]
639 OutFormat::Parquet => {
640 self.finish_parquet()?;
641 self.publish()
642 }
643 #[allow(unreachable_patterns)]
644 _ => Err(ReadStatError::Other(format!(
645 "Output format {:?} is not enabled. Enable the corresponding feature flag.",
646 self.config.format
647 ))),
648 }
649 }
650
651 #[cfg(any(
652 feature = "csv",
653 feature = "feather",
654 feature = "ndjson",
655 feature = "parquet"
656 ))]
657 fn publish(&mut self) -> Result<usize, ReadStatError> {
658 let Some(staging) = self.staging_path.take() else {
659 return Ok(self.rows_written);
660 };
661 self.wtr = None;
663 let destination = self
664 .config
665 .out_path
666 .as_ref()
667 .expect("staging has destination");
668 let result =
669 crate::rs_write_config::publish_staging(&staging, destination, self.config.overwrite);
670 if result.is_err() {
671 let _ = std::fs::remove_file(&staging);
672 }
673 result?;
674 Ok(self.rows_written)
675 }
676
677 #[cfg(any(feature = "csv", feature = "ndjson"))]
680 fn flush_buffered(&mut self) -> Result<(), ReadStatError> {
681 use std::io::Write;
682 match &mut self.wtr {
683 #[cfg(feature = "csv")]
684 Some(ReadStatWriterFormat::Csv(f)) => f.flush()?,
685 #[cfg(feature = "csv")]
686 Some(ReadStatWriterFormat::CsvStdout(f)) => f.flush()?,
687 #[cfg(feature = "ndjson")]
688 Some(ReadStatWriterFormat::Ndjson(f)) => f.flush()?,
689 _ => {}
690 }
691 Ok(())
692 }
693
694 #[cfg(feature = "feather")]
695 fn finish_feather(&mut self) -> Result<(), ReadStatError> {
696 if let Some(ReadStatWriterFormat::Feather(wtr)) = &mut self.wtr {
697 wtr.finish()?;
698 Ok(())
699 } else {
700 Err(ReadStatError::Other(
701 "Error writing feather as associated writer is not for the feather format"
702 .to_string(),
703 ))
704 }
705 }
706
707 #[cfg(feature = "parquet")]
708 fn finish_parquet(&mut self) -> Result<(), ReadStatError> {
709 if let Some(ReadStatWriterFormat::Parquet(pwtr)) = &mut self.wtr {
710 if let Some(wtr) = pwtr.wtr.take() {
711 wtr.close()?;
712 }
713 Ok(())
714 } else {
715 Err(ReadStatError::Other(
716 "Error writing parquet as associated writer is not for the parquet format"
717 .to_string(),
718 ))
719 }
720 }
721
722 #[allow(unused_variables)]
731 pub fn write(&mut self, batch: &RecordBatch) -> Result<(), ReadStatError> {
732 if batch.schema() != self.schema {
733 return Err(ReadStatError::SchemaMismatch);
734 }
735 let wc = self.config.clone();
736 match wc.format {
737 #[cfg(feature = "csv")]
738 OutFormat::Csv => {
739 if wc.out_path.is_none() {
740 if self.wrote_header {
741 self.write_data_to_stdout(batch)
742 } else {
743 self.write_header_to_stdout()?;
744 self.write_data_to_stdout(batch)
745 }
746 } else {
747 self.write_data_to_csv(batch, &wc)
748 }
749 }
750 #[cfg(feature = "feather")]
751 OutFormat::Feather => self.write_data_to_feather(batch, &wc),
752 #[cfg(feature = "ndjson")]
753 OutFormat::Ndjson => self.write_data_to_ndjson(batch, &wc),
754 #[cfg(feature = "parquet")]
755 OutFormat::Parquet => self.write_data_to_parquet(batch, &wc),
756 #[allow(unreachable_patterns)]
757 _ => Err(ReadStatError::Other(format!(
758 "Output format {:?} is not enabled. Enable the corresponding feature flag.",
759 wc.format
760 ))),
761 }?;
762 self.rows_written = self
763 .rows_written
764 .checked_add(batch.num_rows())
765 .ok_or_else(|| ReadStatError::Other("writer row count overflow".into()))?;
766 Ok(())
767 }
768
769 #[cfg(feature = "csv")]
770 fn write_data_to_csv(
771 &mut self,
772 batch: &RecordBatch,
773 wc: &WriteConfig,
774 ) -> Result<(), ReadStatError> {
775 if wc.out_path.is_some() {
776 if !self.wrote_start {
780 let f = self.open_output(wc)?;
781 self.wtr = Some(ReadStatWriterFormat::Csv(BufWriter::new(f)));
782 }
783
784 if let Some(ReadStatWriterFormat::Csv(f)) = &mut self.wtr {
786 let include_header = !self.wrote_header;
787 let mut writer = CsvWriterBuilder::new().with_header(include_header).build(f);
788 writer.write(batch)?;
789 self.wrote_header = true;
790
791 self.wrote_start = true;
792 Ok(())
793 } else {
794 Err(ReadStatError::Other(
795 "Error writing csv as associated writer is not for the csv format".to_string(),
796 ))
797 }
798 } else {
799 Err(ReadStatError::Other(
800 "Error writing csv as output path is set to None".to_string(),
801 ))
802 }
803 }
804
805 #[cfg(feature = "feather")]
806 fn write_data_to_feather(
807 &mut self,
808 batch: &RecordBatch,
809 wc: &WriteConfig,
810 ) -> Result<(), ReadStatError> {
811 if wc.out_path.is_some() {
812 if !self.wrote_start {
814 let f = self.open_output(wc)?;
815 let wtr = IpcFileWriter::try_new(BufWriter::new(f), &self.schema)?;
816 self.wtr = Some(ReadStatWriterFormat::Feather(wtr));
817 }
818
819 if let Some(ReadStatWriterFormat::Feather(wtr)) = &mut self.wtr {
821 wtr.write(batch)?;
822
823 self.wrote_start = true;
824
825 Ok(())
826 } else {
827 Err(ReadStatError::Other(
828 "Error writing feather as associated writer is not for the feather format"
829 .to_string(),
830 ))
831 }
832 } else {
833 Err(ReadStatError::Other(
834 "Error writing feather file as output path is set to None".to_string(),
835 ))
836 }
837 }
838
839 #[cfg(feature = "ndjson")]
840 fn write_data_to_ndjson(
841 &mut self,
842 batch: &RecordBatch,
843 wc: &WriteConfig,
844 ) -> Result<(), ReadStatError> {
845 if wc.out_path.is_some() {
846 if !self.wrote_start {
848 let f = self.open_output(wc)?;
849 self.wtr = Some(ReadStatWriterFormat::Ndjson(BufWriter::new(f)));
850 }
851
852 if let Some(ReadStatWriterFormat::Ndjson(f)) = &mut self.wtr {
854 let mut writer = JsonLineDelimitedWriter::new(f);
855 writer.write(batch)?;
856 writer.finish()?;
857
858 self.wrote_start = true;
859
860 Ok(())
861 } else {
862 Err(ReadStatError::Other(
863 "Error writing ndjson as associated writer is not for the ndjson format"
864 .to_string(),
865 ))
866 }
867 } else {
868 Err(ReadStatError::Other(
869 "Error writing ndjson file as output path is set to None".to_string(),
870 ))
871 }
872 }
873
874 #[cfg(feature = "parquet")]
875 fn write_data_to_parquet(
876 &mut self,
877 batch: &RecordBatch,
878 wc: &WriteConfig,
879 ) -> Result<(), ReadStatError> {
880 if self.schema.fields().is_empty() && batch.num_rows() != 0 {
881 return Err(ReadStatError::Other(
882 "Parquet cannot represent rows without columns".into(),
883 ));
884 }
885 if wc.out_path.is_some() {
886 if !self.wrote_start {
889 let f = self.open_output(wc)?;
890 let compression_codec =
891 Self::resolve_compression(wc.compression, wc.compression_level)?;
892
893 let props = WriterProperties::builder()
894 .set_compression(compression_codec)
895 .set_statistics_enabled(parquet::file::properties::EnabledStatistics::Page)
896 .set_writer_version(parquet::file::properties::WriterVersion::PARQUET_2_0)
897 .build();
898
899 let wtr = ParquetArrowWriter::try_new(
900 BufWriter::new(f),
901 self.schema.clone(),
902 Some(props),
903 )?;
904
905 self.wtr = Some(ReadStatWriterFormat::Parquet(ReadStatParquetWriter::new(
906 wtr,
907 )));
908 }
909
910 if let Some(ReadStatWriterFormat::Parquet(pwtr)) = &mut self.wtr {
912 if let Some(ref mut wtr) = pwtr.wtr {
913 wtr.write(batch)?;
914 }
915
916 self.wrote_start = true;
917
918 Ok(())
919 } else {
920 Err(ReadStatError::Other(
921 "Error writing parquet as associated writer is not for the parquet format"
922 .to_string(),
923 ))
924 }
925 } else {
926 Err(ReadStatError::Other(
927 "Error writing parquet file as output path is set to None".to_string(),
928 ))
929 }
930 }
931
932 #[cfg(feature = "csv")]
933 fn write_data_to_stdout(&mut self, batch: &RecordBatch) -> Result<(), ReadStatError> {
934 if !self.wrote_start {
936 self.wtr = Some(ReadStatWriterFormat::CsvStdout(stdout()));
937 }
938
939 if let Some(ReadStatWriterFormat::CsvStdout(f)) = &mut self.wtr {
941 let mut writer = CsvWriterBuilder::new().with_header(false).build(f);
942 writer.write(batch)?;
943
944 self.wrote_start = true;
945
946 Ok(())
947 } else {
948 Err(ReadStatError::Other(
949 "Error writing to csv as associated writer is not for the csv format".to_string(),
950 ))
951 }
952 }
953
954 #[cfg(feature = "csv")]
955 fn write_header_to_stdout(&mut self) -> Result<(), ReadStatError> {
956 use std::io::Write;
957
958 let header = self
962 .schema
963 .fields()
964 .iter()
965 .map(|field| csv_escape_field(field.name()))
966 .collect::<Vec<_>>()
967 .join(",");
968
969 writeln!(stdout(), "{header}")?;
972
973 self.wrote_header = true;
974
975 Ok(())
976 }
977}
978
979impl Drop for ReadStatWriter {
980 fn drop(&mut self) {
981 #[cfg(any(
982 feature = "csv",
983 feature = "feather",
984 feature = "ndjson",
985 feature = "parquet"
986 ))]
987 if let Some(path) = self.staging_path.take() {
988 self.wtr = None;
990 let _ = std::fs::remove_file(path);
991 }
992 }
993}
994
995#[cfg(feature = "csv")]
998fn csv_escape_field(field: &str) -> String {
999 if field.contains([',', '"', '\n', '\r']) {
1000 format!("\"{}\"", field.replace('"', "\"\""))
1001 } else {
1002 field.to_string()
1003 }
1004}
1005
1006#[cfg(feature = "csv")]
1012pub fn write_batch_to_csv_bytes(
1013 batch: &arrow_array::RecordBatch,
1014) -> Result<Vec<u8>, ReadStatError> {
1015 let mut buf = Vec::new();
1016 let mut writer = CsvWriterBuilder::new().with_header(true).build(&mut buf);
1017 writer.write(batch)?;
1018 drop(writer);
1019 Ok(buf)
1020}
1021
1022#[cfg(feature = "ndjson")]
1028pub fn write_batch_to_ndjson_bytes(
1029 batch: &arrow_array::RecordBatch,
1030) -> Result<Vec<u8>, ReadStatError> {
1031 let mut buf = Vec::new();
1032 let mut writer = JsonLineDelimitedWriter::new(&mut buf);
1033 writer.write(batch)?;
1034 writer.finish()?;
1035 Ok(buf)
1036}
1037
1038#[cfg(feature = "parquet")]
1044pub fn write_batch_to_parquet_bytes(batch: &RecordBatch) -> Result<Vec<u8>, ReadStatError> {
1045 let mut buf = Vec::new();
1046 let props = WriterProperties::builder()
1047 .set_compression(ParquetCompressionCodec::SNAPPY)
1048 .build();
1049 let mut writer = ParquetArrowWriter::try_new(&mut buf, batch.schema(), Some(props))?;
1050 writer.write(batch)?;
1051 writer.close()?;
1052 Ok(buf)
1053}
1054
1055#[cfg(feature = "feather")]
1061pub fn write_batch_to_feather_bytes(
1062 batch: &arrow_array::RecordBatch,
1063) -> Result<Vec<u8>, ReadStatError> {
1064 let mut buf = Vec::new();
1065 let mut writer = IpcFileWriter::try_new(&mut buf, &batch.schema())?;
1066 writer.write(batch)?;
1067 writer.finish()?;
1068 Ok(buf)
1069}
1070
1071#[cfg(all(
1075 test,
1076 feature = "csv",
1077 feature = "feather",
1078 feature = "ndjson",
1079 feature = "parquet"
1080))]
1081mod tests {
1082 use super::*;
1083
1084 #[test]
1087 fn resolve_compression_none_defaults_to_snappy() {
1088 let codec = ReadStatWriter::resolve_compression(None, None).unwrap();
1089 assert!(matches!(codec, ParquetCompressionCodec::SNAPPY));
1090 }
1091
1092 #[test]
1093 fn resolve_compression_uncompressed() {
1094 let codec =
1095 ReadStatWriter::resolve_compression(Some(ParquetCompression::Uncompressed), None)
1096 .unwrap();
1097 assert!(matches!(codec, ParquetCompressionCodec::UNCOMPRESSED));
1098 }
1099
1100 #[test]
1101 fn resolve_compression_snappy() {
1102 let codec =
1103 ReadStatWriter::resolve_compression(Some(ParquetCompression::Snappy), None).unwrap();
1104 assert!(matches!(codec, ParquetCompressionCodec::SNAPPY));
1105 }
1106
1107 #[test]
1108 fn resolve_compression_lz4raw() {
1109 let codec =
1110 ReadStatWriter::resolve_compression(Some(ParquetCompression::Lz4Raw), None).unwrap();
1111 assert!(matches!(codec, ParquetCompressionCodec::LZ4_RAW));
1112 }
1113
1114 #[test]
1115 fn resolve_compression_gzip_default() {
1116 let codec =
1117 ReadStatWriter::resolve_compression(Some(ParquetCompression::Gzip), None).unwrap();
1118 assert!(matches!(codec, ParquetCompressionCodec::GZIP(_)));
1119 }
1120
1121 #[test]
1122 fn resolve_compression_gzip_with_level() {
1123 let codec =
1124 ReadStatWriter::resolve_compression(Some(ParquetCompression::Gzip), Some(5)).unwrap();
1125 assert!(matches!(codec, ParquetCompressionCodec::GZIP(_)));
1126 }
1127
1128 #[test]
1129 fn resolve_compression_brotli_default() {
1130 let codec =
1131 ReadStatWriter::resolve_compression(Some(ParquetCompression::Brotli), None).unwrap();
1132 assert!(matches!(codec, ParquetCompressionCodec::BROTLI(_)));
1133 }
1134
1135 #[test]
1136 fn resolve_compression_brotli_with_level() {
1137 let codec =
1138 ReadStatWriter::resolve_compression(Some(ParquetCompression::Brotli), Some(8)).unwrap();
1139 assert!(matches!(codec, ParquetCompressionCodec::BROTLI(_)));
1140 }
1141
1142 #[test]
1143 fn resolve_compression_zstd_default() {
1144 let codec =
1145 ReadStatWriter::resolve_compression(Some(ParquetCompression::Zstd), None).unwrap();
1146 assert!(matches!(codec, ParquetCompressionCodec::ZSTD(_)));
1147 }
1148
1149 #[test]
1150 fn resolve_compression_zstd_with_level() {
1151 let codec =
1152 ReadStatWriter::resolve_compression(Some(ParquetCompression::Zstd), Some(15)).unwrap();
1153 assert!(matches!(codec, ParquetCompressionCodec::ZSTD(_)));
1154 }
1155
1156 #[test]
1159 fn new_writer_defaults() {
1160 let wtr = ReadStatWriter::new(WriteConfig::new(OutFormat::Csv), Arc::new(Schema::empty()))
1161 .unwrap();
1162 assert!(wtr.wtr.is_none());
1163 assert!(!wtr.wrote_header);
1164 assert!(!wtr.wrote_start);
1165 }
1166
1167 fn test_batch(schema: SchemaRef, values: &[&str]) -> RecordBatch {
1168 RecordBatch::try_new(
1169 schema,
1170 vec![Arc::new(arrow_array::StringArray::from(values.to_vec()))],
1171 )
1172 .unwrap()
1173 }
1174
1175 #[test]
1176 fn multi_batch_rows_and_consuming_finish() {
1177 let dir = tempfile::tempdir().unwrap();
1178 let schema = Arc::new(Schema::new(vec![arrow_schema::Field::new(
1179 "x",
1180 arrow_schema::DataType::Utf8,
1181 false,
1182 )]));
1183 let config = WriteConfig::new(OutFormat::Csv)
1184 .output(dir.path().join("rows.csv"))
1185 .unwrap();
1186 let mut writer = ReadStatWriter::new(config, schema.clone()).unwrap();
1187 writer
1188 .write(&test_batch(schema.clone(), &["a", "b"]))
1189 .unwrap();
1190 writer.write(&test_batch(schema.clone(), &["c"])).unwrap();
1191 assert_eq!(writer.finish().unwrap(), 3);
1192 }
1193
1194 #[test]
1195 fn parallel_csv_matches_serial_bytes_and_row_count() {
1196 let dir = tempfile::tempdir().unwrap();
1197 let serial_path = dir.path().join("serial.csv");
1198 let parallel_path = dir.path().join("parallel.csv");
1199 let schema = Arc::new(Schema::new(vec![arrow_schema::Field::new(
1200 "x",
1201 arrow_schema::DataType::Utf8,
1202 false,
1203 )]));
1204 let empty = test_batch(schema.clone(), &[]);
1205 let first = test_batch(schema.clone(), &["a", "b"]);
1206 let second = test_batch(schema.clone(), &["c"]);
1207
1208 let mut serial = ReadStatWriter::new(
1209 WriteConfig::new(OutFormat::Csv)
1210 .output(&serial_path)
1211 .unwrap(),
1212 schema.clone(),
1213 )
1214 .unwrap();
1215 for batch in [&empty, &first, &second] {
1216 serial.write(batch).unwrap();
1217 }
1218 assert_eq!(serial.finish().unwrap(), 3);
1219
1220 let mut parallel = ParallelTextWriter::new(
1221 WriteConfig::new(OutFormat::Csv)
1222 .output(¶llel_path)
1223 .unwrap(),
1224 schema,
1225 )
1226 .unwrap();
1227 parallel.write(std::slice::from_ref(&empty)).unwrap();
1228 parallel.write(std::slice::from_ref(&first)).unwrap();
1229 parallel.write(std::slice::from_ref(&second)).unwrap();
1230 assert_eq!(parallel.finish().unwrap(), 3);
1231
1232 assert_eq!(
1233 std::fs::read(serial_path).unwrap(),
1234 std::fs::read(parallel_path).unwrap()
1235 );
1236 }
1237
1238 #[test]
1239 fn parallel_csv_schema_error_preserves_destination_and_cleans_staging() {
1240 let dir = tempfile::tempdir().unwrap();
1241 let path = dir.path().join("preserve.csv");
1242 std::fs::write(&path, "sentinel").unwrap();
1243 let schema = Arc::new(Schema::new(vec![arrow_schema::Field::new(
1244 "x",
1245 arrow_schema::DataType::Utf8,
1246 false,
1247 )]));
1248 let other = Arc::new(Schema::new(vec![arrow_schema::Field::new(
1249 "y",
1250 arrow_schema::DataType::Utf8,
1251 false,
1252 )]));
1253 let config = WriteConfig::new(OutFormat::Csv)
1254 .output(&path)
1255 .unwrap()
1256 .overwrite(true);
1257 let mut writer = ParallelTextWriter::new(config, schema.clone()).unwrap();
1258 writer.write(&[test_batch(schema, &["staged"])]).unwrap();
1259 assert!(matches!(
1260 writer.write(&[test_batch(other, &["new"])]),
1261 Err(ReadStatError::SchemaMismatch)
1262 ));
1263 drop(writer);
1264
1265 assert_eq!(std::fs::read_to_string(&path).unwrap(), "sentinel");
1266 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
1267 }
1268
1269 #[test]
1270 fn parallel_text_empty_outputs_match_serial() {
1271 let dir = tempfile::tempdir().unwrap();
1272 let schema = Arc::new(Schema::new(vec![arrow_schema::Field::new(
1273 "x",
1274 arrow_schema::DataType::Utf8,
1275 false,
1276 )]));
1277 for format in [OutFormat::Csv, OutFormat::Ndjson] {
1278 let serial_path = dir.path().join(format!("serial.{format}"));
1279 let parallel_path = dir.path().join(format!("parallel.{format}"));
1280 let serial = ReadStatWriter::new(
1281 WriteConfig::new(format).output(&serial_path).unwrap(),
1282 schema.clone(),
1283 )
1284 .unwrap();
1285 assert_eq!(serial.finish().unwrap(), 0);
1286
1287 let parallel = ParallelTextWriter::new(
1288 WriteConfig::new(format).output(¶llel_path).unwrap(),
1289 schema.clone(),
1290 )
1291 .unwrap();
1292 assert_eq!(parallel.finish().unwrap(), 0);
1293
1294 assert_eq!(
1295 std::fs::read(serial_path).unwrap(),
1296 std::fs::read(parallel_path).unwrap()
1297 );
1298 }
1299 }
1300
1301 #[test]
1302 fn rejects_schema_mismatch() {
1303 let schema = Arc::new(Schema::empty());
1304 let mut writer = ReadStatWriter::new(WriteConfig::new(OutFormat::Csv), schema).unwrap();
1305 let other = Arc::new(Schema::new(vec![arrow_schema::Field::new(
1306 "x",
1307 arrow_schema::DataType::Utf8,
1308 true,
1309 )]));
1310 assert!(matches!(
1311 writer.write(&RecordBatch::new_empty(other)),
1312 Err(ReadStatError::SchemaMismatch)
1313 ));
1314 }
1315
1316 #[test]
1317 fn empty_output_for_each_enabled_format() {
1318 let dir = tempfile::tempdir().unwrap();
1319 let schema = Arc::new(Schema::empty());
1320 let formats = [
1321 OutFormat::Csv,
1322 OutFormat::Feather,
1323 OutFormat::Ndjson,
1324 OutFormat::Parquet,
1325 ];
1326 for format in formats {
1327 let path = dir.path().join(format!("empty.{format}"));
1328 let config = WriteConfig::new(format).output(&path).unwrap();
1329 let writer = ReadStatWriter::new(config, schema.clone()).unwrap();
1330 assert_eq!(writer.finish().unwrap(), 0);
1331 assert!(path.exists());
1332 }
1333 }
1334
1335 #[test]
1336 fn output_open_race_and_overwrite() {
1337 let dir = tempfile::tempdir().unwrap();
1338 let path = dir.path().join("race.csv");
1339 let config = WriteConfig::new(OutFormat::Csv).output(&path).unwrap();
1340 std::fs::write(&path, "sentinel").unwrap();
1341 let schema = Arc::new(Schema::empty());
1342 let writer = ReadStatWriter::new(config, schema.clone()).unwrap();
1343 assert!(matches!(
1344 writer.finish(),
1345 Err(ReadStatError::OutputFileExists(_))
1346 ));
1347 assert_eq!(std::fs::read_to_string(&path).unwrap(), "sentinel");
1348
1349 let config = WriteConfig::new(OutFormat::Csv)
1350 .output(&path)
1351 .unwrap()
1352 .overwrite(true);
1353 let writer = ReadStatWriter::new(config, schema).unwrap();
1354 writer.finish().unwrap();
1355 assert_ne!(std::fs::read_to_string(path).unwrap(), "sentinel");
1356 }
1357
1358 #[test]
1359 fn drop_before_finish_preserves_destination_and_cleans_staging() {
1360 let dir = tempfile::tempdir().unwrap();
1361 let path = dir.path().join("preserve.csv");
1362 std::fs::write(&path, "sentinel").unwrap();
1363 let schema = Arc::new(Schema::new(vec![arrow_schema::Field::new(
1364 "x",
1365 arrow_schema::DataType::Utf8,
1366 false,
1367 )]));
1368 let config = WriteConfig::new(OutFormat::Csv)
1369 .output(&path)
1370 .unwrap()
1371 .overwrite(true);
1372 let mut writer = ReadStatWriter::new(config, schema.clone()).unwrap();
1373 writer.write(&test_batch(schema, &["new"])).unwrap();
1374 drop(writer);
1375 assert_eq!(std::fs::read_to_string(&path).unwrap(), "sentinel");
1376 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
1377 }
1378
1379 #[test]
1380 fn no_overwrite_destination_raced_before_publication() {
1381 let dir = tempfile::tempdir().unwrap();
1382 let path = dir.path().join("raced.csv");
1383 let schema = Arc::new(Schema::new(vec![arrow_schema::Field::new(
1384 "x",
1385 arrow_schema::DataType::Utf8,
1386 false,
1387 )]));
1388 let config = WriteConfig::new(OutFormat::Csv).output(&path).unwrap();
1389 let mut writer = ReadStatWriter::new(config, schema.clone()).unwrap();
1390 writer.write(&test_batch(schema, &["new"])).unwrap();
1391 std::fs::write(&path, "racer").unwrap();
1392 assert!(matches!(
1393 writer.finish(),
1394 Err(ReadStatError::OutputFileExists(_))
1395 ));
1396 assert_eq!(std::fs::read_to_string(&path).unwrap(), "racer");
1397 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
1398 }
1399
1400 #[cfg(feature = "csv")]
1403 #[test]
1404 fn csv_escape_field_cases() {
1405 assert_eq!(csv_escape_field("Brand"), "Brand");
1407 assert_eq!(csv_escape_field("a,b"), "\"a,b\"");
1409 assert_eq!(csv_escape_field("a\"b"), "\"a\"\"b\"");
1411 assert_eq!(csv_escape_field("a\nb"), "\"a\nb\"");
1413 assert_eq!(csv_escape_field("a\rb"), "\"a\rb\"");
1414 }
1415}