Skip to main content

datui_lib/export/
mod.rs

1//! Writing the view to an export file. [`run`] takes the plan to a committed file:
2//! uncompressed CSV and Parquet stream through Polars' sink into the [`OutputFile`];
3//! everything else (compressed CSV, JSON, NDJSON, IPC, Avro, or no streaming engine)
4//! collects and [`encode`]s. The destination changes only at commit, after the last
5//! byte, encoder finish and flush succeed. Streaming bounds the export, not the plan: a
6//! sort, group-by or join still gathers its input first.
7
8pub mod avro_types;
9pub(crate) mod export_keys;
10pub mod export_modal;
11pub mod nested_json;
12pub mod output_file;
13pub mod python_script;
14
15use std::io::{self, BufWriter, Write};
16use std::path::PathBuf;
17use std::time::{Duration, Instant};
18
19use color_eyre::Result;
20use polars::prelude::{
21    CsvWriter, DataFrame, IpcWriter, JsonFormat, JsonWriter, LazyFrame, ParquetWriter, SerWriter,
22};
23
24use crate::CompressionFormat;
25use crate::export::export_modal::ExportFormat;
26use crate::export::output_file::{OutputFile, Overwrite};
27
28#[derive(Debug, Clone)]
29pub struct ExportOptions {
30    pub csv_delimiter: u8,
31    pub csv_include_header: bool,
32    /// Add a column naming the file each row came from, so a cell that is absent
33    /// rather than null can still be told apart once the data has left datui.
34    pub source_file: bool,
35    pub csv_compression: Option<CompressionFormat>,
36    pub json_compression: Option<CompressionFormat>,
37    pub ndjson_compression: Option<CompressionFormat>,
38}
39
40impl ExportOptions {
41    /// The compression chosen for `format`; None for the formats without one.
42    pub fn compression(&self, format: ExportFormat) -> Option<CompressionFormat> {
43        match format {
44            ExportFormat::Csv | ExportFormat::Tsv | ExportFormat::Psv => self.csv_compression,
45            ExportFormat::Json => self.json_compression,
46            ExportFormat::Ndjson => self.ndjson_compression,
47            ExportFormat::Parquet | ExportFormat::Ipc | ExportFormat::Avro => None,
48        }
49    }
50
51    /// The delimiter `format` writes: a preset's own, else the one chosen for CSV.
52    pub fn delimiter(&self, format: ExportFormat) -> u8 {
53        format.preset_delimiter().unwrap_or(self.csv_delimiter)
54    }
55}
56
57/// One export: the file, its form, and whether it may replace a file there.
58#[derive(Debug, Clone)]
59pub struct ExportRequest {
60    pub path: PathBuf,
61    pub format: ExportFormat,
62    pub options: ExportOptions,
63    pub overwrite: Overwrite,
64}
65
66/// How an export's rows reach its file.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum Route {
69    /// Sunk from the plan in batches by the streaming engine.
70    Streamed,
71    /// Collected into one frame, then encoded.
72    Collected,
73}
74
75impl ExportRequest {
76    /// The route this export takes. `polars_streaming` is the user's engine
77    /// setting: with it off, nothing streams.
78    pub fn route(&self, polars_streaming: bool) -> Route {
79        let sinkable = (self.format.is_delimited() || self.format == ExportFormat::Parquet)
80            && self.options.compression(self.format).is_none();
81        if cfg!(feature = "streaming") && polars_streaming && sinkable {
82            Route::Streamed
83        } else {
84            Route::Collected
85        }
86    }
87}
88
89/// How often a running export reports the bytes it has written.
90const PROGRESS_EVERY: Duration = Duration::from_millis(250);
91
92/// Run an export: plan `lf` for the format ([`ExportFormat::prepare`]), write by its
93/// [`Route`], commit. `written` hears bytes so far (0 at start, then a few times a
94/// second). The destination changes only if everything 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::analysis::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::analysis::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 (drain buffers, write the last block and trailer,
231/// flush), so failures surface here rather than at drop.
232pub fn encode<W: Write>(
233    df: &mut DataFrame,
234    format: ExportFormat,
235    options: &ExportOptions,
236    sink: W,
237) -> Result<()> {
238    // The buffer sits in front of the encoder, which batches its own output.
239    let mut sink = match options.compression(format) {
240        None => {
241            let mut buffered = BufWriter::new(sink);
242            serialize(df, format, options, &mut buffered)?;
243            buffered
244                .into_inner()
245                .map_err(io::IntoInnerError::into_error)?
246        }
247        Some(compression) => {
248            let mut buffered = BufWriter::new(Encoder::new(compression, sink)?);
249            serialize(df, format, options, &mut buffered)?;
250            let encoder = buffered
251                .into_inner()
252                .map_err(io::IntoInnerError::into_error)?;
253            encoder.finish()?
254        }
255    };
256    sink.flush()?;
257    Ok(())
258}
259
260fn serialize(
261    df: &mut DataFrame,
262    format: ExportFormat,
263    options: &ExportOptions,
264    out: &mut impl Write,
265) -> Result<()> {
266    match format {
267        ExportFormat::Csv | ExportFormat::Tsv | ExportFormat::Psv => CsvWriter::new(out)
268            .with_separator(options.delimiter(format))
269            .include_header(options.csv_include_header)
270            .finish(df)?,
271        ExportFormat::Parquet => {
272            ParquetWriter::new(out).finish(df)?;
273        }
274        ExportFormat::Json => JsonWriter::new(out)
275            .with_json_format(JsonFormat::Json)
276            .finish(df)?,
277        ExportFormat::Ndjson => JsonWriter::new(out)
278            .with_json_format(JsonFormat::JsonLines)
279            .finish(df)?,
280        ExportFormat::Ipc => IpcWriter::new(out).finish(df)?,
281        ExportFormat::Avro => crate::export::avro_types::write(df, out)?,
282    }
283    Ok(())
284}
285
286/// A compression encoder owned as its concrete type, so its `finish` — which
287/// writes the final block and trailer — is called and its error kept. Boxed as
288/// `dyn Write`, the finish ran in a drop that discards errors.
289enum Encoder<W: Write> {
290    Gzip(flate2::write::GzEncoder<W>),
291    Zstd(zstd::Encoder<'static, W>),
292    Bzip2(bzip2::write::BzEncoder<W>),
293    Xz(xz2::write::XzEncoder<W>),
294}
295
296impl<W: Write> Encoder<W> {
297    fn new(compression: CompressionFormat, out: W) -> io::Result<Self> {
298        Ok(match compression {
299            CompressionFormat::Gzip => Self::Gzip(flate2::write::GzEncoder::new(
300                out,
301                flate2::Compression::default(),
302            )),
303            CompressionFormat::Zstd => Self::Zstd(zstd::Encoder::new(out, 0)?),
304            CompressionFormat::Bzip2 => Self::Bzip2(bzip2::write::BzEncoder::new(
305                out,
306                bzip2::Compression::default(),
307            )),
308            CompressionFormat::Xz => Self::Xz(xz2::write::XzEncoder::new(out, 6)),
309        })
310    }
311
312    fn finish(self) -> io::Result<W> {
313        match self {
314            Self::Gzip(e) => e.finish(),
315            Self::Zstd(e) => e.finish(),
316            Self::Bzip2(e) => e.finish(),
317            Self::Xz(e) => e.finish(),
318        }
319    }
320}
321
322impl<W: Write> Write for Encoder<W> {
323    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
324        match self {
325            Self::Gzip(e) => e.write(buf),
326            Self::Zstd(e) => e.write(buf),
327            Self::Bzip2(e) => e.write(buf),
328            Self::Xz(e) => e.write(buf),
329        }
330    }
331
332    fn flush(&mut self) -> io::Result<()> {
333        match self {
334            Self::Gzip(e) => e.flush(),
335            Self::Zstd(e) => e.flush(),
336            Self::Bzip2(e) => e.flush(),
337            Self::Xz(e) => e.flush(),
338        }
339    }
340}
341
342#[cfg(test)]
343mod tests;