Skip to main content

graphar_flight/
export.rs

1//! Graph → lakehouse: export FalkorDB query results back to GraphAr files.
2//!
3//! The reverse of the ingest bridge. [`FalkorDbLoader`](graphar_falkordb) writes
4//! Arrow into FalkorDB; this reads a Cypher query's result rows back out as
5//! Arrow and writes them as a chunked GraphAr file (parquet/csv/orc/json/
6//! arrow-ipc) — so a graph computed in FalkorDB can land back in the lakehouse
7//! (and from there into Iceberg, object storage, etc.). Both directions of the
8//! columnar ↔ graph bridge now exist.
9
10use std::path::{Path, PathBuf};
11
12use graphar::FileType;
13
14use crate::error::Result;
15use crate::falkor::FalkorExecutor;
16
17/// Outcome of an [`export_cypher`] / [`export_cypher_chunked`] call.
18#[derive(Debug, Clone, Default)]
19pub struct ExportReport {
20    /// Rows written.
21    pub rows: usize,
22    /// Columns in the result schema.
23    pub columns: usize,
24    /// Chunk files written, in order (`chunk0.<ext>`, `chunk1.<ext>`, …).
25    ///
26    /// Empty for [`export_cypher`] (single-file, single-path export); populated
27    /// by [`export_cypher_chunked`]. Additive — single-file callers can keep
28    /// ignoring it.
29    pub chunks: Vec<PathBuf>,
30}
31
32/// Run `cypher` against the executor's graph and write the result rows as one
33/// GraphAr chunk file at `path` in `file_type`. Returns what was written.
34///
35/// The schema is inferred from FalkorDB's response (auto-string mode), so any
36/// `RETURN` shape works without pre-registration. For very large results this
37/// writes a single chunk; chunked/streamed export is a future extension.
38///
39/// ```no_run
40/// # async fn demo() -> graphar_flight::Result<()> {
41/// use graphar_flight::{FalkorExecutor, export::export_cypher};
42/// use graphar::FileType;
43///
44/// let mut exec = FalkorExecutor::connect("redis://127.0.0.1:6379", "social").await?;
45/// let report = export_cypher(
46///     &mut exec,
47///     "MATCH (n:Person) RETURN n._gar_id AS id, n.name AS name",
48///     "/tmp/people.parquet",
49///     FileType::Parquet,
50/// ).await?;
51/// println!("exported {} rows", report.rows);
52/// # Ok(()) }
53/// ```
54pub async fn export_cypher(
55    executor: &mut FalkorExecutor,
56    cypher: &str,
57    path: impl AsRef<Path>,
58    file_type: FileType,
59) -> Result<ExportReport> {
60    let batch = executor.query_auto(cypher).await?;
61    let rows = batch.num_rows();
62    let columns = batch.num_columns();
63    graphar::io::write_chunk(path, &[batch], &file_type)?;
64    Ok(ExportReport {
65        rows,
66        columns,
67        chunks: Vec::new(),
68    })
69}
70
71/// Run `cypher` and write the result as **multiple** GraphAr chunk files —
72/// `chunk0.<ext>`, `chunk1.<ext>`, … — under `dir`, each holding at most
73/// `chunk_rows` rows. This is the *chunked* export for huge results: instead of
74/// one monolithic file it lands the result the way GraphAr naturally tiles a
75/// vertex/edge collection (one file per row-range), so a `VertexReader` (or a
76/// plain `read_chunk` per file) can stream the chunks back without ever holding
77/// the whole result in memory.
78///
79/// Returns an [`ExportReport`] whose `chunks` lists the files written in order.
80///
81/// ## Streaming honesty — why this is slice-and-write, not pull-stream
82///
83/// FalkorDB's `GRAPH.QUERY` is a single RESP request/response: the server
84/// serializes the **entire** result set into one reply with no server-side
85/// cursor, and [`FalkorExecutor::query_auto`] therefore materializes it as one
86/// in-memory [`RecordBatch`](arrow_array::RecordBatch). There is no API to pull
87/// the result in pages, so we cannot bound the *read* side below the full
88/// result. (FalkorDB ≥ 4 exposes `GRAPH.QUERY … TIMEOUT`/result-set limits but
89/// no streaming cursor over RESP.)
90///
91/// What this *does* bound is the **write** side and downstream consumption: the
92/// fetched batch is zero-copy-sliced into `chunk_rows`-sized record batches
93/// (`RecordBatch::slice` shares the underlying Arrow buffers — no row copy), and
94/// each slice is written and dropped before the next, so peak *encoder* memory
95/// (parquet row-group buffers, CSV/JSON string buffers, etc.) is bounded by
96/// `chunk_rows` rather than the whole result, and each output file is bounded
97/// too. Reading back with `read_chunk` per file then streams chunk-by-chunk.
98///
99/// When the upstream is the *lakehouse* rather than FalkorDB, the Iceberg export
100/// path ([`export_cypher_to_iceberg`], feature `skade`) already appends in
101/// batches; this function is the GraphAr-file equivalent.
102///
103/// `chunk_rows` must be non-zero; `0` is treated as `1`. An empty result still
104/// writes a single empty `chunk0.<ext>` so consumers always find a chunk file.
105///
106/// ```no_run
107/// # async fn demo() -> graphar_flight::Result<()> {
108/// use graphar_flight::{FalkorExecutor, export::export_cypher_chunked};
109/// use graphar::FileType;
110///
111/// let mut exec = FalkorExecutor::connect("redis://127.0.0.1:6379", "social").await?;
112/// let report = export_cypher_chunked(
113///     &mut exec,
114///     "MATCH (n:Person) RETURN n._gar_id AS id, n.name AS name",
115///     "/tmp/people_chunks",
116///     FileType::Parquet,
117///     100_000, // ≤ 100k rows per chunk file
118/// ).await?;
119/// println!("exported {} rows across {} chunks", report.rows, report.chunks.len());
120/// # Ok(()) }
121/// ```
122pub async fn export_cypher_chunked(
123    executor: &mut FalkorExecutor,
124    cypher: &str,
125    dir: impl AsRef<Path>,
126    file_type: FileType,
127    chunk_rows: usize,
128) -> Result<ExportReport> {
129    let batch = executor.query_auto(cypher).await?;
130    write_batch_chunked(&batch, dir, &file_type, chunk_rows)
131}
132
133/// Slice one in-memory [`RecordBatch`](arrow_array::RecordBatch) into
134/// `chunk_rows`-sized record batches and write each as `chunk{i}.<ext>` under
135/// `dir`. Split out from [`export_cypher_chunked`] so the chunking can be
136/// exercised (round-tripped) without a FalkorDB connection.
137///
138/// The slicing is zero-copy ([`RecordBatch::slice`](arrow_array::RecordBatch::slice)
139/// shares Arrow buffers); peak extra memory is one encoder's worth of
140/// `chunk_rows` rows at a time.
141pub fn write_batch_chunked(
142    batch: &arrow_array::RecordBatch,
143    dir: impl AsRef<Path>,
144    file_type: &FileType,
145    chunk_rows: usize,
146) -> Result<ExportReport> {
147    // `graphar::io::write_chunk` creates each path's parent directory, so `dir`
148    // itself is created on the first write — no explicit `create_dir_all` (and
149    // no need for `FlightSqlError: From<io::Error>`).
150    let dir = dir.as_ref();
151    let ext = file_type.extension();
152    let total = batch.num_rows();
153    let columns = batch.num_columns();
154    let step = chunk_rows.max(1);
155
156    let mut chunks = Vec::new();
157
158    if total == 0 {
159        // Always emit one (empty) chunk so consumers find a chunk0 file.
160        let path = dir.join(format!("chunk0.{ext}"));
161        graphar::io::write_chunk(&path, std::slice::from_ref(batch), file_type)?;
162        chunks.push(path);
163        return Ok(ExportReport {
164            rows: 0,
165            columns,
166            chunks,
167        });
168    }
169
170    let mut offset = 0;
171    let mut idx = 0;
172    while offset < total {
173        let len = step.min(total - offset);
174        // Zero-copy view of rows [offset, offset+len) — shares the source
175        // buffers, so no row data is duplicated to form the slice.
176        let slice = batch.slice(offset, len);
177        let path = dir.join(format!("chunk{idx}.{ext}"));
178        graphar::io::write_chunk(&path, &[slice], file_type)?;
179        chunks.push(path);
180        offset += len;
181        idx += 1;
182    }
183
184    Ok(ExportReport {
185        rows: total,
186        columns,
187        chunks,
188    })
189}
190
191// ── Graph → Iceberg (feature `skade`) ───────────────────────────────────────
192
193/// Export a Cypher query's results **straight into an Iceberg table** (the
194/// graph → lakehouse, Iceberg edition). Runs `cypher`, then appends the rows to
195/// `table_name` in the [`skade`] embedded Iceberg warehouse rooted at
196/// `warehouse_dir`, creating the table on first write.
197///
198/// knut and skade now both build on arrow 58, so the result is handed to skade
199/// directly ([`bridge_56_to_57`] is an identity clone) — no serialization, no
200/// temp file, no FFI. (The parquet-bridge variant
201/// [`append_arrow_to_iceberg_via_parquet`] is retained only for the comparison
202/// bench.)
203///
204/// ```no_run
205/// # #[cfg(feature = "skade")]
206/// # async fn demo() -> graphar_flight::Result<()> {
207/// use graphar_flight::{FalkorExecutor, export::export_cypher_to_iceberg};
208/// let mut exec = FalkorExecutor::connect("redis://127.0.0.1:6379", "social").await?;
209/// let report = export_cypher_to_iceberg(
210///     &mut exec,
211///     "MATCH (n:Person) RETURN n._gar_id AS id, n.name AS name",
212///     "/tmp/knut_warehouse",
213///     "people",
214/// ).await?;
215/// # let _ = report; Ok(()) }
216/// ```
217#[cfg(feature = "skade")]
218pub async fn export_cypher_to_iceberg(
219    executor: &mut FalkorExecutor,
220    cypher: &str,
221    warehouse_dir: impl AsRef<Path>,
222    table_name: &str,
223) -> Result<ExportReport> {
224    let batch = executor.query_auto(cypher).await?;
225    let rows = batch.num_rows();
226    let columns = batch.num_columns();
227    append_arrow_to_iceberg(warehouse_dir.as_ref(), table_name, batch).await?;
228    Ok(ExportReport {
229        rows,
230        columns,
231        chunks: Vec::new(),
232    })
233}
234
235/// Append one arrow-58 `RecordBatch` to an Iceberg table via skade.
236///
237/// knut and skade share arrow 58, so the batch is handed to skade directly
238/// ([`bridge_56_to_57`] is an identity clone — an Arc bump, no buffer copy, no
239/// FFI). Exposed (feature-gated) so the bridge can be exercised without a
240/// FalkorDB connection.
241#[cfg(feature = "skade")]
242pub async fn append_arrow_to_iceberg(
243    warehouse_dir: &Path,
244    table_name: &str,
245    batch: arrow_array::RecordBatch,
246) -> Result<usize> {
247    let rows = batch.num_rows();
248    if rows == 0 {
249        return Ok(0);
250    }
251    let batch57 = bridge_56_to_57(&batch)?;
252    append_batch57(warehouse_dir, table_name, &batch57).await?;
253    Ok(rows)
254}
255
256/// Same as [`append_arrow_to_iceberg`] but bridges through a temp **parquet**
257/// file instead of the C Data Interface. Kept so the `knut.skade_iceberg_export`
258/// (zero-copy) and `knut.skade_iceberg_export_parquet` benchers can quantify the
259/// difference; the zero-copy path is the default everywhere else.
260#[cfg(feature = "skade")]
261pub async fn append_arrow_to_iceberg_via_parquet(
262    warehouse_dir: &Path,
263    table_name: &str,
264    batch: arrow_array::RecordBatch,
265) -> Result<usize> {
266    use crate::error::FlightSqlError;
267
268    let rows = batch.num_rows();
269    let tmp = tempfile::Builder::new()
270        .prefix("knut-iceberg-")
271        .suffix(".parquet")
272        .tempfile()
273        .map_err(|e| FlightSqlError::Skade(format!("temp file: {e}")))?;
274    graphar::io::write_chunk(tmp.path(), &[batch], &FileType::Parquet)?;
275    let batches57 = read_parquet_as_skade(tmp.path())?;
276    drop(tmp);
277    let Some(first) = batches57.first() else {
278        return Ok(0);
279    };
280    let schema = skade::arrow_array::RecordBatch::schema(first);
281    let wh = skade::Warehouse::open(warehouse_dir)
282        .await
283        .map_err(|e| FlightSqlError::Skade(e.to_string()))?;
284    let mut table = wh
285        .table_or_create(table_name, &schema)
286        .await
287        .map_err(|e| FlightSqlError::Skade(e.to_string()))?;
288    table
289        .append(&batches57)
290        .await
291        .map_err(|e| FlightSqlError::Skade(e.to_string()))?;
292    Ok(rows)
293}
294
295/// Open the warehouse and append one arrow-57 batch to `table_name`.
296#[cfg(feature = "skade")]
297async fn append_batch57(
298    warehouse_dir: &Path,
299    table_name: &str,
300    batch57: &skade::arrow_array::RecordBatch,
301) -> Result<()> {
302    use crate::error::FlightSqlError;
303    let schema = skade::arrow_array::RecordBatch::schema(batch57);
304    let wh = skade::Warehouse::open(warehouse_dir)
305        .await
306        .map_err(|e| FlightSqlError::Skade(e.to_string()))?;
307    let mut table = wh
308        .table_or_create(table_name, &schema)
309        .await
310        .map_err(|e| FlightSqlError::Skade(e.to_string()))?;
311    table
312        .append(std::slice::from_ref(batch57))
313        .await
314        .map_err(|e| FlightSqlError::Skade(e.to_string()))?;
315    Ok(())
316}
317
318/// arrow → skade record-batch bridge — now an **identity clone**.
319///
320/// knut and skade both build on Apache Arrow 58, so
321/// `skade::arrow_array::RecordBatch` *is* `arrow_array::RecordBatch` (the same
322/// crate, same major). The old C-Data-Interface FFI transmute bridge
323/// (arrow 56 → arrow 57) is therefore gone: a plain `clone()` (Arc bumps, no
324/// buffer copy) hands skade the batch directly. Kept as a named seam so the
325/// export path and its round-trip test read the same.
326#[cfg(feature = "skade")]
327pub fn bridge_56_to_57(
328    batch: &arrow_array::RecordBatch,
329) -> Result<skade::arrow_array::RecordBatch> {
330    Ok(batch.clone())
331}
332
333/// Read a parquet file as skade's (arrow-57) record batches.
334#[cfg(feature = "skade")]
335fn read_parquet_as_skade(path: &Path) -> Result<Vec<skade::arrow_array::RecordBatch>> {
336    use crate::error::FlightSqlError;
337    use skade::parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
338
339    let file =
340        std::fs::File::open(path).map_err(|e| FlightSqlError::Skade(format!("open: {e}")))?;
341    let reader = ParquetRecordBatchReaderBuilder::try_new(file)
342        .map_err(|e| FlightSqlError::Skade(format!("parquet: {e}")))?
343        .build()
344        .map_err(|e| FlightSqlError::Skade(format!("parquet: {e}")))?;
345    let mut out = Vec::new();
346    for b in reader {
347        out.push(b.map_err(|e| FlightSqlError::Skade(format!("parquet read: {e}")))?);
348    }
349    Ok(out)
350}
351
352#[cfg(test)]
353mod chunk_tests {
354    use super::*;
355    use std::sync::Arc;
356
357    use arrow_array::{Array, Int64Array, RecordBatch, StringArray};
358    use arrow_schema::{DataType, Field, Schema};
359
360    /// A 7-row batch (id 0..6, name "n0".."n6").
361    fn sample_batch() -> RecordBatch {
362        RecordBatch::try_new(
363            Arc::new(Schema::new(vec![
364                Field::new("id", DataType::Int64, false),
365                Field::new("name", DataType::Utf8, false),
366            ])),
367            vec![
368                Arc::new(Int64Array::from((0..7).collect::<Vec<i64>>())),
369                Arc::new(StringArray::from(
370                    (0..7).map(|i| format!("n{i}")).collect::<Vec<_>>(),
371                )),
372            ],
373        )
374        .unwrap()
375    }
376
377    #[test]
378    fn chunked_export_round_trips_and_files_are_bounded() {
379        // 7 rows, chunk_rows = 3 → ceil(7/3) = 3 files: 3 + 3 + 1 rows.
380        let batch = sample_batch();
381        let dir = tempfile::tempdir().unwrap();
382        let report = write_batch_chunked(&batch, dir.path(), &FileType::Parquet, 3).unwrap();
383
384        assert_eq!(report.rows, 7);
385        assert_eq!(report.columns, 2);
386        assert_eq!(report.chunks.len(), 3, "ceil(7/3) chunk files");
387
388        // Each file exists, is named chunk{i}.parquet, and holds ≤ chunk_rows rows.
389        let mut total = 0usize;
390        let mut all_ids = Vec::new();
391        let mut all_names = Vec::new();
392        for (i, path) in report.chunks.iter().enumerate() {
393            assert_eq!(
394                path.file_name().unwrap().to_str().unwrap(),
395                format!("chunk{i}.parquet"),
396            );
397            let batches = graphar::io::read_chunk(path, &FileType::Parquet).unwrap();
398            let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
399            assert!(rows <= 3, "chunk {i} bounded by chunk_rows (got {rows})");
400            total += rows;
401            for b in &batches {
402                let ids = b
403                    .column_by_name("id")
404                    .unwrap()
405                    .as_any()
406                    .downcast_ref::<Int64Array>()
407                    .unwrap();
408                let names = b
409                    .column_by_name("name")
410                    .unwrap()
411                    .as_any()
412                    .downcast_ref::<StringArray>()
413                    .unwrap();
414                for r in 0..b.num_rows() {
415                    all_ids.push(ids.value(r));
416                    all_names.push(names.value(r).to_string());
417                }
418            }
419        }
420
421        // Round trip: every input row came back exactly once, in order.
422        assert_eq!(total, 7, "total rows read back == input rows");
423        assert_eq!(all_ids, (0..7).collect::<Vec<i64>>());
424        assert_eq!(
425            all_names,
426            (0..7).map(|i| format!("n{i}")).collect::<Vec<String>>(),
427        );
428    }
429
430    #[test]
431    fn chunk_rows_at_least_total_writes_single_file() {
432        let batch = sample_batch();
433        let dir = tempfile::tempdir().unwrap();
434        let report = write_batch_chunked(&batch, dir.path(), &FileType::Parquet, 100).unwrap();
435        assert_eq!(report.chunks.len(), 1, "one chunk when chunk_rows >= total");
436        assert_eq!(report.rows, 7);
437    }
438
439    #[test]
440    fn empty_result_writes_one_empty_chunk_preserving_schema() {
441        let empty = RecordBatch::new_empty(sample_batch().schema());
442        let dir = tempfile::tempdir().unwrap();
443        let report = write_batch_chunked(&empty, dir.path(), &FileType::Parquet, 3).unwrap();
444        assert_eq!(report.rows, 0);
445        assert_eq!(report.chunks.len(), 1, "empty result still emits chunk0");
446        let batches = graphar::io::read_chunk(&report.chunks[0], &FileType::Parquet).unwrap();
447        let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
448        assert_eq!(rows, 0);
449        // The file exists and is readable (an empty parquet yields zero record
450        // batches on read-back; the point is the chunk0 file is always present
451        // so a consumer never hits a missing-file error for an empty result).
452        assert!(report.chunks[0].exists());
453    }
454
455    #[test]
456    fn chunk_rows_zero_is_treated_as_one() {
457        let batch = sample_batch();
458        let dir = tempfile::tempdir().unwrap();
459        let report = write_batch_chunked(&batch, dir.path(), &FileType::Parquet, 0).unwrap();
460        assert_eq!(report.chunks.len(), 7, "chunk_rows 0 → 1 row per file");
461    }
462}
463
464#[cfg(all(test, feature = "skade"))]
465mod skade_tests {
466    use super::*;
467    use std::sync::Arc;
468
469    #[test]
470    fn ffi_bridge_preserves_values_and_nulls() {
471        // arrow-58 batch with three types incl. a null → skade via the now
472        // identity bridge (knut + skade share arrow 58) → assert every value
473        // (and the null) survives the hand-off. Reads the result back through
474        // skade's typed arrays, which are the same arrow-58 types.
475        use arrow_array::{Float64Array, Int64Array, StringArray};
476        use arrow_schema::{DataType, Field, Schema};
477
478        let batch56 = arrow_array::RecordBatch::try_new(
479            Arc::new(Schema::new(vec![
480                Field::new("id", DataType::Int64, false),
481                Field::new("score", DataType::Float64, false),
482                Field::new("name", DataType::Utf8, true),
483            ])),
484            vec![
485                Arc::new(Int64Array::from(vec![10, 20, 30])),
486                Arc::new(Float64Array::from(vec![1.5, 2.5, 3.5])),
487                Arc::new(StringArray::from(vec![Some("a"), None, Some("c")])),
488            ],
489        )
490        .unwrap();
491
492        let batch57 = bridge_56_to_57(&batch56).unwrap();
493        assert_eq!(batch57.num_rows(), 3);
494        assert_eq!(batch57.num_columns(), 3);
495
496        let ids = batch57
497            .column(0)
498            .as_any()
499            .downcast_ref::<skade::arrow_array::Int64Array>()
500            .unwrap();
501        assert_eq!(ids.values(), &[10, 20, 30]);
502
503        let scores = batch57
504            .column(1)
505            .as_any()
506            .downcast_ref::<skade::arrow_array::Float64Array>()
507            .unwrap();
508        assert_eq!(scores.values(), &[1.5, 2.5, 3.5]);
509
510        use skade::arrow_array::Array;
511        let names = batch57
512            .column(2)
513            .as_any()
514            .downcast_ref::<skade::arrow_array::StringArray>()
515            .unwrap();
516        assert_eq!(names.value(0), "a");
517        assert!(names.is_null(1), "the null survived the bridge");
518        assert_eq!(names.value(2), "c");
519    }
520
521    #[tokio::test]
522    async fn append_and_read_back_via_iceberg() {
523        // Build an arrow-58 batch (no FalkorDB), append to a fresh warehouse,
524        // then read it back through skade — exercises the (now identity-clone)
525        // arrow bridge and the skade write/read path with no container.
526        use arrow_array::{Int64Array, StringArray};
527        use arrow_schema::{DataType, Field, Schema};
528
529        let batch = arrow_array::RecordBatch::try_new(
530            Arc::new(Schema::new(vec![
531                Field::new("id", DataType::Int64, false),
532                Field::new("name", DataType::Utf8, false),
533            ])),
534            vec![
535                Arc::new(Int64Array::from(vec![1, 2, 3])),
536                Arc::new(StringArray::from(vec!["a", "b", "c"])),
537            ],
538        )
539        .unwrap();
540
541        let dir = tempfile::tempdir().unwrap();
542        let n = append_arrow_to_iceberg(dir.path(), "people", batch)
543            .await
544            .unwrap();
545        assert_eq!(n, 3);
546
547        // Read it back from the Iceberg table.
548        let wh = skade::Warehouse::open(dir.path()).await.unwrap();
549        let table = wh.table("people").await.unwrap();
550        let count = table.count().await.unwrap();
551        assert_eq!(count, 3, "3 rows landed in the Iceberg table");
552    }
553}