1use std::io::{self, BufWriter, Write};
15use std::path::PathBuf;
16use std::time::{Duration, Instant};
17
18use color_eyre::Result;
19use polars::prelude::{
20 CsvWriter, DataFrame, IpcWriter, JsonFormat, JsonWriter, LazyFrame, ParquetWriter, SerWriter,
21};
22
23use crate::CompressionFormat;
24use crate::export_modal::ExportFormat;
25use crate::output_file::{OutputFile, Overwrite};
26
27#[derive(Debug, Clone)]
28pub struct ExportOptions {
29 pub csv_delimiter: u8,
30 pub csv_include_header: bool,
31 pub source_file: bool,
34 pub csv_compression: Option<CompressionFormat>,
35 pub json_compression: Option<CompressionFormat>,
36 pub ndjson_compression: Option<CompressionFormat>,
37}
38
39impl ExportOptions {
40 pub fn compression(&self, format: ExportFormat) -> Option<CompressionFormat> {
42 match format {
43 ExportFormat::Csv | ExportFormat::Tsv | ExportFormat::Psv => self.csv_compression,
44 ExportFormat::Json => self.json_compression,
45 ExportFormat::Ndjson => self.ndjson_compression,
46 ExportFormat::Parquet | ExportFormat::Ipc | ExportFormat::Avro => None,
47 }
48 }
49
50 pub fn delimiter(&self, format: ExportFormat) -> u8 {
52 format.preset_delimiter().unwrap_or(self.csv_delimiter)
53 }
54}
55
56#[derive(Debug, Clone)]
58pub struct ExportRequest {
59 pub path: PathBuf,
60 pub format: ExportFormat,
61 pub options: ExportOptions,
62 pub overwrite: Overwrite,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Route {
68 Streamed,
70 Collected,
72}
73
74impl ExportRequest {
75 pub fn route(&self, polars_streaming: bool) -> Route {
78 let sinkable = (self.format.is_delimited() || self.format == ExportFormat::Parquet)
79 && self.options.compression(self.format).is_none();
80 if cfg!(feature = "streaming") && polars_streaming && sinkable {
81 Route::Streamed
82 } else {
83 Route::Collected
84 }
85 }
86}
87
88const PROGRESS_EVERY: Duration = Duration::from_millis(250);
90
91pub fn run(
96 lf: LazyFrame,
97 request: &ExportRequest,
98 polars_streaming: bool,
99 mut written: impl FnMut(u64) + Send + 'static,
100) -> Result<()> {
101 let lf = request.format.prepare(lf)?;
102 let polars_streaming = crate::statistics::may_stream(&lf, polars_streaming);
103 let mut out = OutputFile::create(&request.path, request.overwrite)?;
105 match request.route(polars_streaming) {
106 #[cfg(feature = "streaming")]
107 Route::Streamed => {
108 written(0);
109 let file = Counted::new(out.file().try_clone()?, written);
110 sink(lf, request.format, &request.options, file.into_writable())?;
111 }
112 _ => {
113 let mut df = crate::statistics::collect_lazy(lf, polars_streaming)?;
114 written(0);
115 let file = Counted::new(out.file(), written);
116 encode(&mut df, request.format, &request.options, file)?;
117 }
118 }
119 out.commit()?;
120 Ok(())
121}
122
123#[cfg(feature = "streaming")]
127fn sink(
128 lf: LazyFrame,
129 format: ExportFormat,
130 options: &ExportOptions,
131 writable: polars::io::utils::file::Writable,
132) -> Result<()> {
133 use polars::prelude::{
134 CompatLevel, CsvWriterOptions, Engine, FileWriteFormat, ParquetWriteOptions,
135 SerializeOptions, SinkDestination, SinkTarget, SpecialEq, UnifiedSinkArgs,
136 };
137 use std::sync::{Arc, Mutex};
138
139 let file_format = match format {
141 ExportFormat::Csv | ExportFormat::Tsv | ExportFormat::Psv => {
142 FileWriteFormat::Csv(CsvWriterOptions {
143 include_header: options.csv_include_header,
144 serialize_options: Arc::new(SerializeOptions {
145 separator: options.delimiter(format),
146 ..SerializeOptions::default()
147 }),
148 ..CsvWriterOptions::default()
149 })
150 }
151 ExportFormat::Parquet => FileWriteFormat::Parquet(Arc::new(ParquetWriteOptions {
154 compat_level: Some(CompatLevel::newest()),
155 ..ParquetWriteOptions::default()
156 })),
157 other => unreachable!("{other:?} does not stream"),
158 };
159 let target = SinkTarget::Dyn(SpecialEq::new(Arc::new(Mutex::new(Some(writable)))));
160 lf.sink(
161 SinkDestination::File { target },
162 file_format,
163 UnifiedSinkArgs::default(),
164 )?
165 .collect_with_engine(Engine::Streaming)?;
166 Ok(())
167}
168
169struct Counted<W, F> {
172 inner: W,
173 bytes: u64,
174 reported: Instant,
175 report: F,
176}
177
178impl<W: Write, F: FnMut(u64)> Counted<W, F> {
179 fn new(inner: W, report: F) -> Self {
180 Self {
181 inner,
182 bytes: 0,
183 reported: Instant::now(),
184 report,
185 }
186 }
187}
188
189impl<W: Write, F: FnMut(u64)> Write for Counted<W, F> {
190 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
191 let n = self.inner.write(buf)?;
192 self.bytes += n as u64;
193 if self.reported.elapsed() >= PROGRESS_EVERY {
194 self.reported = Instant::now();
195 (self.report)(self.bytes);
196 }
197 Ok(n)
198 }
199
200 fn flush(&mut self) -> io::Result<()> {
201 self.inner.flush()
202 }
203}
204
205#[cfg(feature = "streaming")]
209impl<F: FnMut(u64) + Send + 'static> Counted<std::fs::File, F> {
210 fn into_writable(self) -> polars::io::utils::file::Writable {
211 polars::io::utils::file::Writable::Dyn(Box::new(self))
212 }
213}
214
215#[cfg(feature = "streaming")]
216impl<F: FnMut(u64)> polars::io::utils::file::WritableTrait for Counted<std::fs::File, F> {
217 fn close(&mut self) -> io::Result<()> {
218 self.inner.flush()
219 }
220
221 fn sync_all(&self) -> io::Result<()> {
222 self.inner.sync_all()
223 }
224
225 fn sync_data(&self) -> io::Result<()> {
226 self.inner.sync_data()
227 }
228}
229
230pub fn encode<W: Write>(
235 df: &mut DataFrame,
236 format: ExportFormat,
237 options: &ExportOptions,
238 sink: W,
239) -> Result<()> {
240 let mut sink = match options.compression(format) {
242 None => {
243 let mut buffered = BufWriter::new(sink);
244 serialize(df, format, options, &mut buffered)?;
245 buffered
246 .into_inner()
247 .map_err(io::IntoInnerError::into_error)?
248 }
249 Some(compression) => {
250 let mut buffered = BufWriter::new(Encoder::new(compression, sink)?);
251 serialize(df, format, options, &mut buffered)?;
252 let encoder = buffered
253 .into_inner()
254 .map_err(io::IntoInnerError::into_error)?;
255 encoder.finish()?
256 }
257 };
258 sink.flush()?;
259 Ok(())
260}
261
262fn serialize(
263 df: &mut DataFrame,
264 format: ExportFormat,
265 options: &ExportOptions,
266 out: &mut impl Write,
267) -> Result<()> {
268 match format {
269 ExportFormat::Csv | ExportFormat::Tsv | ExportFormat::Psv => CsvWriter::new(out)
270 .with_separator(options.delimiter(format))
271 .include_header(options.csv_include_header)
272 .finish(df)?,
273 ExportFormat::Parquet => {
274 ParquetWriter::new(out).finish(df)?;
275 }
276 ExportFormat::Json => JsonWriter::new(out)
277 .with_json_format(JsonFormat::Json)
278 .finish(df)?,
279 ExportFormat::Ndjson => JsonWriter::new(out)
280 .with_json_format(JsonFormat::JsonLines)
281 .finish(df)?,
282 ExportFormat::Ipc => IpcWriter::new(out).finish(df)?,
283 ExportFormat::Avro => crate::avro_types::write(df, out)?,
284 }
285 Ok(())
286}
287
288enum Encoder<W: Write> {
292 Gzip(flate2::write::GzEncoder<W>),
293 Zstd(zstd::Encoder<'static, W>),
294 Bzip2(bzip2::write::BzEncoder<W>),
295 Xz(xz2::write::XzEncoder<W>),
296}
297
298impl<W: Write> Encoder<W> {
299 fn new(compression: CompressionFormat, out: W) -> io::Result<Self> {
300 Ok(match compression {
301 CompressionFormat::Gzip => Self::Gzip(flate2::write::GzEncoder::new(
302 out,
303 flate2::Compression::default(),
304 )),
305 CompressionFormat::Zstd => Self::Zstd(zstd::Encoder::new(out, 0)?),
306 CompressionFormat::Bzip2 => Self::Bzip2(bzip2::write::BzEncoder::new(
307 out,
308 bzip2::Compression::default(),
309 )),
310 CompressionFormat::Xz => Self::Xz(xz2::write::XzEncoder::new(out, 6)),
311 })
312 }
313
314 fn finish(self) -> io::Result<W> {
315 match self {
316 Self::Gzip(e) => e.finish(),
317 Self::Zstd(e) => e.finish(),
318 Self::Bzip2(e) => e.finish(),
319 Self::Xz(e) => e.finish(),
320 }
321 }
322}
323
324impl<W: Write> Write for Encoder<W> {
325 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
326 match self {
327 Self::Gzip(e) => e.write(buf),
328 Self::Zstd(e) => e.write(buf),
329 Self::Bzip2(e) => e.write(buf),
330 Self::Xz(e) => e.write(buf),
331 }
332 }
333
334 fn flush(&mut self) -> io::Result<()> {
335 match self {
336 Self::Gzip(e) => e.flush(),
337 Self::Zstd(e) => e.flush(),
338 Self::Bzip2(e) => e.flush(),
339 Self::Xz(e) => e.flush(),
340 }
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use polars::prelude::*;
348 use std::io::Read;
349 use std::path::Path;
350
351 const COMPRESSIONS: [Option<CompressionFormat>; 5] = [
352 None,
353 Some(CompressionFormat::Gzip),
354 Some(CompressionFormat::Zstd),
355 Some(CompressionFormat::Bzip2),
356 Some(CompressionFormat::Xz),
357 ];
358
359 fn frame() -> DataFrame {
360 df!(
361 "id" => (0..5_000).collect::<Vec<i64>>(),
362 "name" => (0..5_000).map(|i| format!("row {i}")).collect::<Vec<_>>(),
363 "score" => (0..5_000).map(|i| (i % 7 == 0).then_some(i as f64 / 3.0)).collect::<Vec<_>>(),
364 )
365 .unwrap()
366 }
367
368 fn options(format: ExportFormat, compression: Option<CompressionFormat>) -> ExportOptions {
369 let mut options = ExportOptions {
370 csv_delimiter: b',',
371 csv_include_header: true,
372 source_file: false,
373 csv_compression: None,
374 json_compression: None,
375 ndjson_compression: None,
376 };
377 match format {
378 ExportFormat::Csv | ExportFormat::Tsv | ExportFormat::Psv => {
379 options.csv_compression = compression
380 }
381 ExportFormat::Json => options.json_compression = compression,
382 ExportFormat::Ndjson => options.ndjson_compression = compression,
383 _ => assert!(compression.is_none()),
384 }
385 options
386 }
387
388 fn combinations() -> Vec<(ExportFormat, Option<CompressionFormat>)> {
390 ExportFormat::ALL
391 .iter()
392 .flat_map(|&format| {
393 let compressions: &[Option<CompressionFormat>] = if format.supports_compression() {
394 &COMPRESSIONS
395 } else {
396 &[None]
397 };
398 compressions.iter().map(move |&c| (format, c))
399 })
400 .collect()
401 }
402
403 fn decompress(bytes: Vec<u8>, compression: Option<CompressionFormat>) -> Vec<u8> {
404 let mut out = Vec::new();
405 match compression {
406 None => return bytes,
407 Some(CompressionFormat::Gzip) => flate2::read::GzDecoder::new(&bytes[..])
408 .read_to_end(&mut out)
409 .unwrap(),
410 Some(CompressionFormat::Zstd) => zstd::Decoder::new(&bytes[..])
411 .unwrap()
412 .read_to_end(&mut out)
413 .unwrap(),
414 Some(CompressionFormat::Bzip2) => bzip2::read::BzDecoder::new(&bytes[..])
415 .read_to_end(&mut out)
416 .unwrap(),
417 Some(CompressionFormat::Xz) => xz2::read::XzDecoder::new(&bytes[..])
418 .read_to_end(&mut out)
419 .unwrap(),
420 };
421 out
422 }
423
424 fn read_back(bytes: Vec<u8>, format: ExportFormat) -> DataFrame {
425 let cursor = std::io::Cursor::new(bytes);
426 match format {
427 ExportFormat::Csv | ExportFormat::Tsv | ExportFormat::Psv => CsvReadOptions::default()
428 .map_parse_options(|p| p.with_separator(format.preset_delimiter().unwrap_or(b',')))
429 .into_reader_with_file_handle(cursor)
430 .finish(),
431 ExportFormat::Parquet => ParquetReader::new(cursor).finish(),
432 ExportFormat::Json => JsonReader::new(cursor).finish(),
433 ExportFormat::Ndjson => JsonReader::new(cursor)
434 .with_json_format(JsonFormat::JsonLines)
435 .finish(),
436 ExportFormat::Ipc => IpcReader::new(cursor).finish(),
437 ExportFormat::Avro => polars::io::avro::AvroReader::new(cursor).finish(),
438 }
439 .unwrap()
440 }
441
442 fn encoded(format: ExportFormat, compression: Option<CompressionFormat>) -> Vec<u8> {
443 let mut bytes = Vec::new();
444 encode(
445 &mut frame(),
446 format,
447 &options(format, compression),
448 &mut bytes,
449 )
450 .unwrap();
451 bytes
452 }
453
454 struct Faulty {
456 capacity: usize,
457 written: usize,
458 fail_flush: bool,
459 }
460
461 impl Faulty {
462 fn new(capacity: usize) -> Self {
463 Self {
464 capacity,
465 written: 0,
466 fail_flush: false,
467 }
468 }
469 }
470
471 impl Write for Faulty {
472 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
473 let room = self.capacity - self.written;
474 if room == 0 {
475 return Err(io::Error::other("injected write failure"));
476 }
477 let n = buf.len().min(room);
478 self.written += n;
479 Ok(n)
480 }
481
482 fn flush(&mut self) -> io::Result<()> {
483 if self.fail_flush {
484 return Err(io::Error::other("injected flush failure"));
485 }
486 Ok(())
487 }
488 }
489
490 fn encode_into(
491 format: ExportFormat,
492 compression: Option<CompressionFormat>,
493 sink: &mut Faulty,
494 ) -> Result<()> {
495 encode(&mut frame(), format, &options(format, compression), sink)
496 }
497
498 fn write(df: DataFrame, request: &ExportRequest, streaming: bool) -> Result<()> {
500 run(df.lazy(), request, streaming, |_| {})
501 }
502
503 #[test]
506 fn every_format_and_compression_round_trips() {
507 let expected = frame();
508 let dir = tempfile::tempdir().unwrap();
509 for streaming in [true, false] {
510 for (format, compression) in combinations() {
511 let path = dir.path().join("out");
512 std::fs::write(&path, b"old").unwrap();
513 let request = ExportRequest {
514 options: options(format, compression),
515 ..request(&path, format, Overwrite::Replace)
516 };
517 let case = format!("{format:?} {compression:?} streaming={streaming}");
518 write(frame(), &request, streaming).unwrap();
519 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
520 let bytes = decompress(std::fs::read(&path).unwrap(), compression);
521 let back = read_back(bytes, format);
522 assert_eq!(back.shape(), expected.shape(), "{case}");
523 assert_eq!(
524 back.column("id").unwrap().cast(&DataType::Int64).unwrap(),
525 *expected.column("id").unwrap(),
526 "{case}"
527 );
528 assert_eq!(
529 back.column("score").unwrap().null_count(),
530 expected.column("score").unwrap().null_count(),
531 "{case}"
532 );
533 }
534 }
535 }
536
537 #[test]
539 fn fixed_records_export_with_streaming_asked_for() {
540 use crate::fixed_records::{Bytes, ColumnLayout, FixedRecords, Physical};
541 let records = || {
542 let bytes = std::sync::Arc::new(Bytes::Owned((0u8..32).collect()));
543 let column = ColumnLayout::new("a", 0, 4, Physical::Unsigned(4), 4);
544 std::sync::Arc::new(FixedRecords::new(vec![bytes], vec![column], usize::MAX).unwrap())
545 .lazy()
546 };
547 let dir = tempfile::tempdir().unwrap();
548 for format in [ExportFormat::Parquet, ExportFormat::Csv] {
549 let path = dir.path().join("out");
550 let request = request(&path, format, Overwrite::Replace);
551 let lf = records().filter(col("a").gt(lit(0x0302_0100u32)));
552 run(lf, &request, true, |_| {}).unwrap();
553 let back = read_back(std::fs::read(&path).unwrap(), format);
554 assert_eq!(back.height(), 7, "{format:?}");
555 }
556 }
557
558 #[test]
560 fn presets_write_their_delimiter() {
561 let dir = tempfile::tempdir().unwrap();
562 for (format, separator) in [(ExportFormat::Tsv, '\t'), (ExportFormat::Psv, '|')] {
563 for streaming in [false, true] {
564 let path = dir.path().join("out");
565 let request = request(&path, format, Overwrite::Replace);
566 assert_eq!(request.options.csv_delimiter, b',');
567 let lf = df!("a" => [1i64, 2], "b" => ["x", "y"]).unwrap().lazy();
568 run(lf, &request, streaming, |_| {}).unwrap();
569 let text = std::fs::read_to_string(&path).unwrap();
570 assert_eq!(
571 text,
572 format!("a{separator}b\n1{separator}x\n2{separator}y\n"),
573 "{format:?} streaming={streaming}"
574 );
575 }
576 }
577 }
578
579 #[test]
581 fn a_serializer_error_is_an_error() {
582 for compression in COMPRESSIONS {
583 assert!(
584 encode(
585 &mut nested(),
586 ExportFormat::Csv,
587 &options(ExportFormat::Csv, compression),
588 io::sink(),
589 )
590 .is_err(),
591 "{compression:?}"
592 );
593 }
594 }
595
596 #[test]
598 fn a_write_failure_part_way_is_an_error() {
599 for (format, compression) in combinations() {
600 let mut sink = Faulty::new(16);
601 assert!(
602 encode_into(format, compression, &mut sink).is_err(),
603 "{format:?} {compression:?}"
604 );
605 }
606 }
607
608 #[test]
612 fn a_failure_finishing_the_file_is_an_error() {
613 for (format, compression) in combinations() {
614 let size = encoded(format, compression).len();
615 let mut sink = Faulty::new(size - 1);
616 assert!(
617 encode_into(format, compression, &mut sink).is_err(),
618 "{format:?} {compression:?}"
619 );
620 let mut exact = Faulty::new(size);
621 encode_into(format, compression, &mut exact)
622 .unwrap_or_else(|e| panic!("{format:?} {compression:?} at its size: {e}"));
623 }
624 }
625
626 #[test]
627 fn a_failed_final_flush_is_an_error() {
628 for (format, compression) in combinations() {
629 let mut sink = Faulty::new(usize::MAX);
630 sink.fail_flush = true;
631 assert!(
632 encode_into(format, compression, &mut sink).is_err(),
633 "{format:?} {compression:?}"
634 );
635 }
636 }
637
638 fn request(path: &Path, format: ExportFormat, overwrite: Overwrite) -> ExportRequest {
639 ExportRequest {
640 path: path.to_path_buf(),
641 format,
642 options: options(format, None),
643 overwrite,
644 }
645 }
646
647 fn nested() -> DataFrame {
649 let mut df = df!("a" => [1i64, 2]).unwrap();
650 df.with_column(Column::new(
651 "list".into(),
652 [
653 Series::new("".into(), [1i64]),
654 Series::new("".into(), [2i64]),
655 ],
656 ))
657 .unwrap();
658 df
659 }
660
661 const MANY: i64 = 300_000;
663
664 fn failing_at(at: i64) -> LazyFrame {
667 df!("id" => (0..MANY).collect::<Vec<_>>())
668 .unwrap()
669 .lazy()
670 .with_column(col("id").map(
671 move |c| {
672 if c.i64()?.max().is_some_and(|id| id >= at) {
673 polars_bail!(ComputeError: "injected plan failure");
674 }
675 Ok(c)
676 },
677 |_, field| Ok(field.clone()),
678 ))
679 }
680
681 fn routes() -> Vec<(&'static str, ExportFormat, Option<CompressionFormat>, bool)> {
683 vec![
684 ("out.csv", ExportFormat::Csv, None, true),
685 ("out.parquet", ExportFormat::Parquet, None, true),
686 ("out.csv", ExportFormat::Csv, None, false),
687 (
688 "out.csv.gz",
689 ExportFormat::Csv,
690 Some(CompressionFormat::Gzip),
691 true,
692 ),
693 ("out.json", ExportFormat::Json, None, true),
694 ]
695 }
696
697 #[test]
701 fn a_failed_export_keeps_the_destination() {
702 for (name, format, compression, streaming) in routes() {
703 let case = format!("{name} streaming={streaming}");
704 let dir = tempfile::tempdir().unwrap();
705 let path = dir.path().join(name);
706 std::fs::write(&path, b"old").unwrap();
707 #[cfg(unix)]
708 {
709 use std::os::unix::fs::PermissionsExt;
710 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o604)).unwrap();
711 }
712 let replace = ExportRequest {
713 options: options(format, compression),
714 ..request(&path, format, Overwrite::Replace)
715 };
716 let err = run(failing_at(250_000), &replace, streaming, |_| {}).unwrap_err();
717 assert!(format!("{err:?}").contains("injected"), "{case}: {err:?}");
718 assert_eq!(std::fs::read(&path).unwrap(), b"old", "{case}");
719 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1, "{case}");
720 #[cfg(unix)]
721 {
722 use std::os::unix::fs::PermissionsExt;
723 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
724 assert_eq!(mode, 0o604, "{case}");
725 }
726
727 let fresh = dir.path().join(format!("new-{name}"));
728 let forbid = ExportRequest {
729 path: fresh.clone(),
730 overwrite: Overwrite::Forbid,
731 ..replace
732 };
733 assert!(run(failing_at(250_000), &forbid, streaming, |_| {}).is_err());
734 assert!(!fresh.exists(), "{case}: no partial file");
735 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1, "{case}");
736 }
737 }
738
739 #[test]
743 fn a_panic_part_way_keeps_the_destination() {
744 for (name, format, compression, streaming) in routes() {
745 let case = format!("{name} streaming={streaming}");
746 let dir = tempfile::tempdir().unwrap();
747 let path = dir.path().join(name);
748 std::fs::write(&path, b"old").unwrap();
749 let lf = df!("id" => (0..MANY).collect::<Vec<_>>())
750 .unwrap()
751 .lazy()
752 .with_column(col("id").map(
753 |c| {
754 if c.i64()?.max().is_some_and(|id| id >= 250_000) {
755 panic!("injected panic");
756 }
757 Ok(c)
758 },
759 |_, field| Ok(field.clone()),
760 ));
761 let request = ExportRequest {
762 options: options(format, compression),
763 ..request(&path, format, Overwrite::Replace)
764 };
765 let ended = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
766 run(lf, &request, streaming, |_| {})
767 }));
768 assert!(!matches!(ended, Ok(Ok(()))), "{case}");
769 assert_eq!(std::fs::read(&path).unwrap(), b"old", "{case}");
770 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1, "{case}");
771
772 run(frame().lazy(), &request, streaming, |_| {})
773 .unwrap_or_else(|e| panic!("{case}: the next export: {e}"));
774 let bytes = decompress(std::fs::read(&path).unwrap(), compression);
775 assert_eq!(
776 read_back(bytes, format).height(),
777 frame().height(),
778 "{case}"
779 );
780 }
781 }
782
783 #[test]
784 fn a_written_export_replaces_the_destination() {
785 let dir = tempfile::tempdir().unwrap();
786 let path = dir.path().join("out.parquet");
787 std::fs::write(&path, b"old").unwrap();
788 write(
789 frame(),
790 &request(&path, ExportFormat::Parquet, Overwrite::Replace),
791 true,
792 )
793 .unwrap();
794 let back = read_back(std::fs::read(&path).unwrap(), ExportFormat::Parquet);
795 assert_eq!(back.shape(), frame().shape());
796 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
797 }
798
799 #[test]
802 fn only_uncompressed_csv_and_parquet_stream() {
803 for (format, compression) in combinations() {
804 for streaming in [true, false] {
805 let request = ExportRequest {
806 options: options(format, compression),
807 ..request(Path::new("out"), format, Overwrite::Forbid)
808 };
809 let streams = cfg!(feature = "streaming")
810 && streaming
811 && compression.is_none()
812 && (format.is_delimited() || format == ExportFormat::Parquet);
813 assert_eq!(
814 request.route(streaming) == Route::Streamed,
815 streams,
816 "{format:?} {compression:?} streaming={streaming}"
817 );
818 }
819 }
820 }
821
822 fn awkward() -> DataFrame {
826 let n = 2_000;
827 let mut df = df!(
828 "id" => (0..n).collect::<Vec<i64>>(),
829 "text" => (0..n).map(|i| match i % 5 {
830 0 => None,
831 1 => Some("plain".to_string()),
832 2 => Some(format!("a, \"quoted\" {i}")),
833 3 => Some("semi;colon\ttab".to_string()),
834 _ => Some(format!("two\nlines {i}")),
835 }).collect::<Vec<_>>(),
836 "x" => (0..n).map(|i| (i % 3 != 0).then_some(i as f64 / 7.0)).collect::<Vec<_>>(),
837 "flag" => (0..n).map(|i| (i % 4 != 0).then_some(i % 2 == 0)).collect::<Vec<_>>(),
838 "day" => (0..n).map(|i| i as i32).collect::<Vec<_>>(),
839 "at" => (0..n).map(|i| i * 3_600_000).collect::<Vec<i64>>(),
840 "took" => (0..n).map(|i| (i % 7 != 0).then_some((i - 1_000) * 1_234_567)).collect::<Vec<_>>(),
841 )
842 .unwrap();
843 df.apply("took", |c| {
844 c.cast(&DataType::Duration(TimeUnit::Microseconds)).unwrap()
845 })
846 .unwrap();
847 df.apply("day", |c| c.cast(&DataType::Date).unwrap())
848 .unwrap();
849 df.apply("at", |c| {
850 c.cast(&DataType::Datetime(TimeUnit::Milliseconds, None))
851 .unwrap()
852 })
853 .unwrap();
854 let paris = TimeZone::opt_try_new(Some("Europe/Paris")).unwrap();
857 let stamps: Vec<Option<i64>> = (0..n)
858 .map(|i| match i {
859 1_500 => Some(i64::MIN + 1),
860 i if i % 9 == 0 => None,
861 i => Some(i * 3_600_000_123),
862 })
863 .collect();
864 for (name, dtype) in [
865 (
866 "at_us_tz",
867 DataType::Datetime(TimeUnit::Microseconds, paris),
868 ),
869 ("at_ns", DataType::Datetime(TimeUnit::Nanoseconds, None)),
870 ] {
871 let column = Series::new(name.into(), &stamps).cast(&dtype).unwrap();
872 df.with_column(column.into_column()).unwrap();
873 }
874 let tags: Vec<Option<Series>> = (0..n)
875 .map(|i| (i % 6 != 0).then(|| Series::new("".into(), [format!("t{i}"), "x,y".into()])))
876 .collect();
877 df.with_column(Column::new("tags".into(), tags)).unwrap();
878 let raw: Vec<Option<Vec<u8>>> = (0..n)
879 .map(|i| (i % 5 != 0).then(|| vec![0, 0xff, i as u8]))
880 .collect();
881 df.with_column(Column::new("raw".into(), raw)).unwrap();
882 df
883 }
884
885 fn views() -> Vec<(&'static str, LazyFrame)> {
888 let lf = awkward().lazy();
889 vec![
890 ("as loaded", lf.clone()),
891 ("filtered", lf.clone().filter(col("x").gt(lit(100.0)))),
892 (
893 "sorted and reordered",
894 lf.clone()
895 .sort(
896 ["text"],
897 SortMultipleOptions::default().with_nulls_last(true),
898 )
899 .select([
900 col("x"),
901 col("tags"),
902 col("took"),
903 col("text"),
904 col("id"),
905 col("at"),
906 col("at_us_tz"),
907 ]),
908 ),
909 (
910 "a query's text",
911 lf.clone().select([
912 col("id"),
913 crate::past_calendar::guard_expr(col("at_us_tz").cast(DataType::String), None),
914 crate::past_calendar::guard_expr(
915 col("at").dt().to_string("%Y").alias("year"),
916 None,
917 ),
918 ]),
919 ),
920 ("empty", lf.filter(lit(false))),
921 ]
922 }
923
924 fn exported(lf: LazyFrame, request: &ExportRequest, streaming: bool) -> Vec<u8> {
925 run(lf, request, streaming, |_| {}).unwrap();
926 std::fs::read(&request.path).unwrap()
927 }
928
929 #[test]
932 fn a_streamed_csv_is_the_collected_csv() {
933 let dir = tempfile::tempdir().unwrap();
934 for (view, lf) in views() {
935 for (delimiter, header) in [(b',', true), (b';', false), (b'\t', true)] {
936 let mut request = request(
937 &dir.path().join("out.csv"),
938 ExportFormat::Csv,
939 Overwrite::Replace,
940 );
941 request.options.csv_delimiter = delimiter;
942 request.options.csv_include_header = header;
943 let streamed = exported(lf.clone(), &request, true);
944 let collected = exported(lf.clone(), &request, false);
945 let case = format!("{view}, {:?}, header={header}", delimiter as char);
946 assert_eq!(
947 String::from_utf8_lossy(&streamed),
948 String::from_utf8_lossy(&collected),
949 "{case}"
950 );
951 if view == "empty" && header {
952 assert!(
953 !streamed.is_empty(),
954 "{case}: an empty view still has its header"
955 );
956 }
957 if view == "as loaded" || view == "a query's text" {
958 let text = String::from_utf8_lossy(&streamed);
959 assert!(
960 text.contains("-9223372036854775807 us since 1970-01-01 UTC"),
961 "{case}"
962 );
963 }
964 }
965 }
966 }
967
968 #[test]
971 fn durations_export_as_iso_8601_by_every_route() {
972 use crate::nested_json::tests::{duration_text, durations};
973 let rows = duration_text()[0].1.len();
974 let mut expected = String::from("ms,us,ns\n");
975 for row in 0..rows {
976 let cells: Vec<&str> = duration_text()
977 .iter()
978 .map(|(_, text)| text[row].unwrap_or(""))
979 .collect();
980 expected.push_str(&cells.join(","));
981 expected.push('\n');
982 }
983
984 let dir = tempfile::tempdir().unwrap();
985 for (name, compression, streaming) in [
986 ("streamed.csv", None, true),
987 ("collected.csv", None, false),
988 ("compressed.csv.gz", Some(CompressionFormat::Gzip), true),
989 ] {
990 let request = ExportRequest {
991 options: options(ExportFormat::Csv, compression),
992 ..request(&dir.path().join(name), ExportFormat::Csv, Overwrite::Forbid)
993 };
994 let bytes = decompress(
995 exported(durations().lazy(), &request, streaming),
996 compression,
997 );
998 assert_eq!(String::from_utf8(bytes).unwrap(), expected, "{name}");
999 }
1000
1001 let request = request(
1002 &dir.path().join("out.json"),
1003 ExportFormat::Json,
1004 Overwrite::Forbid,
1005 );
1006 let back = read_back(
1007 exported(durations().lazy(), &request, false),
1008 ExportFormat::Json,
1009 );
1010 for (name, text) in duration_text() {
1011 let json = back.column(name).unwrap().str().unwrap();
1012 assert_eq!(json.iter().collect::<Vec<_>>(), text, "{name}");
1013 }
1014 }
1015
1016 fn parquet_schema(bytes: &[u8]) -> (String, Option<String>) {
1019 let meta =
1020 polars_parquet::parquet::read::read_metadata(&mut std::io::Cursor::new(bytes)).unwrap();
1021 let arrow = meta
1022 .key_value_metadata
1023 .iter()
1024 .flatten()
1025 .find(|kv| kv.key == "ARROW:schema")
1026 .and_then(|kv| kv.value.clone());
1027 (format!("{:?}", meta.schema_descr.columns()), arrow)
1028 }
1029
1030 #[test]
1033 fn a_streamed_parquet_reads_as_the_collected_one() {
1034 let dir = tempfile::tempdir().unwrap();
1035 let request = request(
1036 &dir.path().join("out.parquet"),
1037 ExportFormat::Parquet,
1038 Overwrite::Replace,
1039 );
1040 for (view, lf) in views() {
1041 let streamed = exported(lf.clone(), &request, true);
1042 let collected = exported(lf.clone(), &request, false);
1043 assert_eq!(
1044 parquet_schema(&streamed),
1045 parquet_schema(&collected),
1046 "{view}"
1047 );
1048 let streamed = read_back(streamed, ExportFormat::Parquet);
1049 let collected = read_back(collected, ExportFormat::Parquet);
1050 assert_eq!(streamed.schema(), collected.schema(), "{view}");
1051 assert!(streamed.equals_missing(&collected), "{view}");
1052 assert_eq!(streamed.height(), lf.collect().unwrap().height(), "{view}");
1053 }
1054 }
1055
1056 #[test]
1060 fn a_streamed_export_never_holds_the_whole_output() {
1061 use std::sync::Arc;
1062 use std::sync::atomic::{AtomicUsize, Ordering};
1063 let dir = tempfile::tempdir().unwrap();
1064 for format in [ExportFormat::Csv, ExportFormat::Parquet] {
1065 for streaming in [true, false] {
1066 let tallest = Arc::new(AtomicUsize::new(0));
1067 let probe = tallest.clone();
1068 let lf = df!("id" => (0..MANY).collect::<Vec<_>>())
1069 .unwrap()
1070 .lazy()
1071 .with_column(col("id").map(
1072 move |c| {
1073 probe.fetch_max(c.len(), Ordering::Relaxed);
1074 Ok(c)
1075 },
1076 |_, field| Ok(field.clone()),
1077 ));
1078 let request = request(&dir.path().join("out"), format, Overwrite::Replace);
1079 let back = read_back(exported(lf, &request, streaming), format);
1080 assert_eq!(back.height(), MANY as usize);
1081 let tallest = tallest.load(Ordering::Relaxed);
1082 if request.route(streaming) == Route::Streamed {
1083 assert!(
1084 tallest < MANY as usize,
1085 "{format:?}: a batch of {tallest} rows"
1086 );
1087 } else {
1088 assert_eq!(tallest, MANY as usize, "{format:?}");
1089 }
1090 }
1091 }
1092 }
1093
1094 #[test]
1097 fn an_export_reports_what_it_has_written() {
1098 use std::sync::{Arc, Mutex};
1099 let dir = tempfile::tempdir().unwrap();
1100 for streaming in [true, false] {
1101 let heard = Arc::new(Mutex::new(Vec::new()));
1102 let log = heard.clone();
1103 let request = request(
1104 &dir.path().join("out.csv"),
1105 ExportFormat::Csv,
1106 Overwrite::Replace,
1107 );
1108 let lf = df!("id" => (0..MANY).collect::<Vec<_>>()).unwrap().lazy();
1109 run(lf, &request, streaming, move |bytes| {
1110 log.lock().unwrap().push(bytes)
1111 })
1112 .unwrap();
1113 let size = std::fs::metadata(&request.path).unwrap().len();
1114 let heard = heard.lock().unwrap();
1115 assert_eq!(heard.first(), Some(&0), "streaming={streaming}");
1116 assert!(heard.windows(2).all(|w| w[0] <= w[1]), "{heard:?}");
1117 assert!(heard.iter().all(|&b| b <= size), "{heard:?} of {size}");
1118 }
1119 }
1120
1121 #[cfg(feature = "streaming")]
1124 mod sink {
1125 use super::*;
1126 use polars::io::utils::file::{Writable, WritableTrait};
1127
1128 impl WritableTrait for Faulty {
1129 fn close(&mut self) -> io::Result<()> {
1130 self.flush()
1131 }
1132
1133 fn sync_all(&self) -> io::Result<()> {
1134 Ok(())
1135 }
1136
1137 fn sync_data(&self) -> io::Result<()> {
1138 Ok(())
1139 }
1140 }
1141
1142 fn sink_into(format: ExportFormat, faulty: Faulty) -> Result<()> {
1143 let lf = format.prepare(frame().lazy()).unwrap();
1144 sink(
1145 lf,
1146 format,
1147 &options(format, None),
1148 Writable::Dyn(Box::new(faulty)),
1149 )
1150 }
1151
1152 fn size(format: ExportFormat) -> usize {
1154 use std::sync::{Arc, Mutex};
1155 #[derive(Clone, Default)]
1156 struct Tally(Arc<Mutex<usize>>);
1157 impl Write for Tally {
1158 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1159 *self.0.lock().unwrap() += buf.len();
1160 Ok(buf.len())
1161 }
1162 fn flush(&mut self) -> io::Result<()> {
1163 Ok(())
1164 }
1165 }
1166 impl WritableTrait for Tally {
1167 fn close(&mut self) -> io::Result<()> {
1168 Ok(())
1169 }
1170 fn sync_all(&self) -> io::Result<()> {
1171 Ok(())
1172 }
1173 fn sync_data(&self) -> io::Result<()> {
1174 Ok(())
1175 }
1176 }
1177 let tally = Tally::default();
1178 let lf = format.prepare(frame().lazy()).unwrap();
1179 sink(
1180 lf,
1181 format,
1182 &options(format, None),
1183 Writable::Dyn(Box::new(tally.clone())),
1184 )
1185 .unwrap();
1186 *tally.0.lock().unwrap()
1187 }
1188
1189 #[test]
1190 fn a_write_failure_part_way_is_an_error() {
1191 for format in [ExportFormat::Csv, ExportFormat::Parquet] {
1192 assert!(sink_into(format, Faulty::new(16)).is_err(), "{format:?}");
1193 let size = size(format);
1194 assert!(
1195 sink_into(format, Faulty::new(size - 1)).is_err(),
1196 "{format:?} last byte"
1197 );
1198 sink_into(format, Faulty::new(size))
1199 .unwrap_or_else(|e| panic!("{format:?} at its size: {e}"));
1200 }
1201 }
1202
1203 #[test]
1204 fn a_failed_close_is_an_error() {
1205 for format in [ExportFormat::Csv, ExportFormat::Parquet] {
1206 let mut faulty = Faulty::new(usize::MAX);
1207 faulty.fail_flush = true;
1208 assert!(sink_into(format, faulty).is_err(), "{format:?}");
1209 }
1210 }
1211 }
1212}