Skip to main content

datui_lib/
export.rs

1//! Writing the view to an export file.
2//!
3//! [`run`] takes the view's plan to a committed file. Uncompressed CSV and
4//! Parquet stream: Polars' sink encodes the plan batch by batch into the
5//! [`OutputFile`], and no frame of the whole output is built. Everything else —
6//! compressed CSV, JSON, NDJSON, IPC, Avro, any export with the streaming
7//! engine off, and builds without it — collects the plan and [`encode`]s the
8//! frame. Either way the destination changes only at the commit, after the
9//! last byte, the encoder's finish and the final flush have succeeded.
10//!
11//! Streaming bounds what the export holds, not what the plan needs: a sort, a
12//! group-by or a join still gathers its input before its first row leaves.
13
14use 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    /// Add a column naming the file each row came from, so a cell that is absent
32    /// rather than null can still be told apart once the data has left datui.
33    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    /// The compression chosen for `format`; None for the formats without one.
41    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    /// The delimiter `format` writes: a preset's own, else the one chosen for CSV.
51    pub fn delimiter(&self, format: ExportFormat) -> u8 {
52        format.preset_delimiter().unwrap_or(self.csv_delimiter)
53    }
54}
55
56/// One export: the file, its form, and whether it may replace a file there.
57#[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/// How an export's rows reach its file.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Route {
68    /// Sunk from the plan in batches by the streaming engine.
69    Streamed,
70    /// Collected into one frame, then encoded.
71    Collected,
72}
73
74impl ExportRequest {
75    /// The route this export takes. `polars_streaming` is the user's engine
76    /// setting: with it off, nothing streams.
77    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
88/// How often a running export reports the bytes it has written.
89const PROGRESS_EVERY: Duration = Duration::from_millis(250);
90
91/// Run an export: plan `lf` as the format needs ([`ExportFormat::prepare`]),
92/// write it to the request's path by its [`Route`], and commit. `written` hears
93/// the bytes written so far: 0 as the write starts, then a few times a second.
94/// The destination changes only if every byte was written.
95pub 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    // Before the plan runs, so a destination that cannot be written fails first.
104    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/// Sink `lf` into `writable` with the streaming engine. The sink writes,
124/// finishes and closes the writable before this returns; a failure on the way —
125/// the plan's, the encoder's, a write's or the close's — is its error.
126#[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    // What the frame writers of the collected route write.
140    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        // `ParquetWriter` writes the newest Arrow types (string views); the
152        // sink's default is the oldest.
153        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
169/// A writer that counts what passes through it and reports the count, at most
170/// every [`PROGRESS_EVERY`].
171struct 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/// The sink's handle on the temporary file, counted. Closing flushes, which a
206/// file needs nothing for: the sync is [`OutputFile::commit`]'s, and the
207/// descriptor closes when the handle drops.
208#[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
230/// Encode `df` into `sink` and finish: every buffer is drained, the encoder's
231/// last block and trailer written and `sink` flushed before this returns, so a
232/// failure in any of them is an error here rather than one lost when a writer
233/// is dropped.
234pub fn encode<W: Write>(
235    df: &mut DataFrame,
236    format: ExportFormat,
237    options: &ExportOptions,
238    sink: W,
239) -> Result<()> {
240    // The buffer sits in front of the encoder, which batches its own output.
241    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
288/// A compression encoder owned as its concrete type, so its `finish` — which
289/// writes the final block and trailer — is called and its error kept. Boxed as
290/// `dyn Write`, the finish ran in a drop that discards errors.
291enum 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    /// Every format, and every compression of those that have one.
389    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    /// A sink that takes `capacity` bytes and then fails, and can fail its flush.
455    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    /// Export `df` the way the app does, with the streaming engine on or off.
499    fn write(df: DataFrame, request: &ExportRequest, streaming: bool) -> Result<()> {
500        run(df.lazy(), request, streaming, |_| {})
501    }
502
503    /// Through the file path the app takes, over a file agreed to be replaced, by
504    /// either engine and so by both routes.
505    #[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    /// Fixed records export on the streaming route, decoded a batch at a time.
538    #[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    /// A preset writes its own delimiter, whatever was typed for CSV, on both routes.
559    #[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    /// The serializer itself refuses: CSV has no list type unprepared.
580    #[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    /// The sink fails part way through the body.
597    #[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    /// One byte short: the last byte out is the end of the file. Uncompressed,
609    /// that is the buffer's final flush; compressed, the encoder's trailer,
610    /// written only by its finish. Both were lost at a drop before.
611    #[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    /// A frame CSV cannot write unprepared: a list column.
648    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    /// Rows enough for three of the streaming engine's 100,000-row morsels.
662    const MANY: i64 = 300_000;
663
664    /// `MANY` ids, whose plan fails once it reaches id `at`: after the streamed
665    /// route has written the batches before it.
666    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    /// Every way an export can go, by the route each takes.
682    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    /// A failure part way through the plan, over an approved overwrite, leaves
698    /// the old file's bytes and mode and no temporary file; over a new file, no
699    /// file at all. By every route.
700    #[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    /// A panic in the plan part way through unwinds out of the export, which
740    /// the worker reports as a failure; the destination is as it was, and the
741    /// next export by the same route writes.
742    #[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    /// Uncompressed CSV (and its presets) and Parquet stream when the engine is on;
800    /// every other format and compression, and every export with it off, is collected.
801    #[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    /// A view with what a CSV has to get right: nulls, quotes, separators and
823    /// line breaks inside text, floats, dates, datetimes, booleans, and the list,
824    /// binary and duration columns [`ExportFormat::prepare`] turns into text.
825    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        // Datetimes in the other units and a zone, as the CSV writer's text, and
855        // one past the calendar in a single batch, as its stored number.
856        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    /// The views an export is asked for: as loaded, filtered, sorted with its
886    /// columns reordered, a query's text of its dates, and filtered to nothing.
887    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    /// The streamed CSV is the collected CSV, byte for byte, under each delimiter
930    /// and header choice.
931    #[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    /// Durations reach a CSV as ISO 8601 by every route, the text a JSON export
969    /// of the same view holds; a null is an empty field.
970    #[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    /// The Parquet file's own description of its columns: the column types
1017    /// another reader sees, and the Arrow schema Polars and pyarrow read back.
1018    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    /// The streamed Parquet is the collected one as a reader sees it: the same
1031    /// column types and Arrow schema, and the same rows in the same order.
1032    #[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    /// The streamed route never builds a frame of the whole output: no batch the
1057    /// plan hands on is more than a morsel. The collected route, by contrast, has
1058    /// every row in one frame, which is what shows the probe can see it.
1059    #[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    /// `written` hears 0 as the write starts, then counts that only grow and
1095    /// never pass the file's size.
1096    #[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    /// The streamed route's writer boundary: a write that fails part way and a
1122    /// failed close are the sink's errors, and the file at its exact size is not.
1123    #[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        /// What the sink writes in all.
1153        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}