use std::path::{Path, PathBuf};
use graphar::FileType;
use crate::error::Result;
use crate::falkor::FalkorExecutor;
#[derive(Debug, Clone, Default)]
pub struct ExportReport {
pub rows: usize,
pub columns: usize,
pub chunks: Vec<PathBuf>,
}
pub async fn export_cypher(
executor: &mut FalkorExecutor,
cypher: &str,
path: impl AsRef<Path>,
file_type: FileType,
) -> Result<ExportReport> {
let batch = executor.query_auto(cypher).await?;
let rows = batch.num_rows();
let columns = batch.num_columns();
graphar::io::write_chunk(path, &[batch], &file_type)?;
Ok(ExportReport {
rows,
columns,
chunks: Vec::new(),
})
}
pub async fn export_cypher_chunked(
executor: &mut FalkorExecutor,
cypher: &str,
dir: impl AsRef<Path>,
file_type: FileType,
chunk_rows: usize,
) -> Result<ExportReport> {
let batch = executor.query_auto(cypher).await?;
write_batch_chunked(&batch, dir, &file_type, chunk_rows)
}
pub fn write_batch_chunked(
batch: &arrow_array::RecordBatch,
dir: impl AsRef<Path>,
file_type: &FileType,
chunk_rows: usize,
) -> Result<ExportReport> {
let dir = dir.as_ref();
let ext = file_type.extension();
let total = batch.num_rows();
let columns = batch.num_columns();
let step = chunk_rows.max(1);
let mut chunks = Vec::new();
if total == 0 {
let path = dir.join(format!("chunk0.{ext}"));
graphar::io::write_chunk(&path, std::slice::from_ref(batch), file_type)?;
chunks.push(path);
return Ok(ExportReport {
rows: 0,
columns,
chunks,
});
}
let mut offset = 0;
let mut idx = 0;
while offset < total {
let len = step.min(total - offset);
let slice = batch.slice(offset, len);
let path = dir.join(format!("chunk{idx}.{ext}"));
graphar::io::write_chunk(&path, &[slice], file_type)?;
chunks.push(path);
offset += len;
idx += 1;
}
Ok(ExportReport {
rows: total,
columns,
chunks,
})
}
#[cfg(feature = "skade")]
pub async fn export_cypher_to_iceberg(
executor: &mut FalkorExecutor,
cypher: &str,
warehouse_dir: impl AsRef<Path>,
table_name: &str,
) -> Result<ExportReport> {
let batch = executor.query_auto(cypher).await?;
let rows = batch.num_rows();
let columns = batch.num_columns();
append_arrow_to_iceberg(warehouse_dir.as_ref(), table_name, batch).await?;
Ok(ExportReport {
rows,
columns,
chunks: Vec::new(),
})
}
#[cfg(feature = "skade")]
pub async fn append_arrow_to_iceberg(
warehouse_dir: &Path,
table_name: &str,
batch: arrow_array::RecordBatch,
) -> Result<usize> {
let rows = batch.num_rows();
if rows == 0 {
return Ok(0);
}
let batch57 = bridge_56_to_57(&batch)?;
append_batch57(warehouse_dir, table_name, &batch57).await?;
Ok(rows)
}
#[cfg(feature = "skade")]
pub async fn append_arrow_to_iceberg_via_parquet(
warehouse_dir: &Path,
table_name: &str,
batch: arrow_array::RecordBatch,
) -> Result<usize> {
use crate::error::FlightSqlError;
let rows = batch.num_rows();
let tmp = tempfile::Builder::new()
.prefix("knut-iceberg-")
.suffix(".parquet")
.tempfile()
.map_err(|e| FlightSqlError::Skade(format!("temp file: {e}")))?;
graphar::io::write_chunk(tmp.path(), &[batch], &FileType::Parquet)?;
let batches57 = read_parquet_as_skade(tmp.path())?;
drop(tmp);
let Some(first) = batches57.first() else {
return Ok(0);
};
let schema = skade::arrow_array::RecordBatch::schema(first);
let wh = skade::Warehouse::open(warehouse_dir)
.await
.map_err(|e| FlightSqlError::Skade(e.to_string()))?;
let mut table = wh
.table_or_create(table_name, &schema)
.await
.map_err(|e| FlightSqlError::Skade(e.to_string()))?;
table
.append(&batches57)
.await
.map_err(|e| FlightSqlError::Skade(e.to_string()))?;
Ok(rows)
}
#[cfg(feature = "skade")]
async fn append_batch57(
warehouse_dir: &Path,
table_name: &str,
batch57: &skade::arrow_array::RecordBatch,
) -> Result<()> {
use crate::error::FlightSqlError;
let schema = skade::arrow_array::RecordBatch::schema(batch57);
let wh = skade::Warehouse::open(warehouse_dir)
.await
.map_err(|e| FlightSqlError::Skade(e.to_string()))?;
let mut table = wh
.table_or_create(table_name, &schema)
.await
.map_err(|e| FlightSqlError::Skade(e.to_string()))?;
table
.append(std::slice::from_ref(batch57))
.await
.map_err(|e| FlightSqlError::Skade(e.to_string()))?;
Ok(())
}
#[cfg(feature = "skade")]
pub fn bridge_56_to_57(
batch: &arrow_array::RecordBatch,
) -> Result<skade::arrow_array::RecordBatch> {
Ok(batch.clone())
}
#[cfg(feature = "skade")]
fn read_parquet_as_skade(path: &Path) -> Result<Vec<skade::arrow_array::RecordBatch>> {
use crate::error::FlightSqlError;
use skade::parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
let file =
std::fs::File::open(path).map_err(|e| FlightSqlError::Skade(format!("open: {e}")))?;
let reader = ParquetRecordBatchReaderBuilder::try_new(file)
.map_err(|e| FlightSqlError::Skade(format!("parquet: {e}")))?
.build()
.map_err(|e| FlightSqlError::Skade(format!("parquet: {e}")))?;
let mut out = Vec::new();
for b in reader {
out.push(b.map_err(|e| FlightSqlError::Skade(format!("parquet read: {e}")))?);
}
Ok(out)
}
#[cfg(test)]
mod chunk_tests {
use super::*;
use std::sync::Arc;
use arrow_array::{Array, Int64Array, RecordBatch, StringArray};
use arrow_schema::{DataType, Field, Schema};
fn sample_batch() -> RecordBatch {
RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("id", DataType::Int64, false),
Field::new("name", DataType::Utf8, false),
])),
vec![
Arc::new(Int64Array::from((0..7).collect::<Vec<i64>>())),
Arc::new(StringArray::from(
(0..7).map(|i| format!("n{i}")).collect::<Vec<_>>(),
)),
],
)
.unwrap()
}
#[test]
fn chunked_export_round_trips_and_files_are_bounded() {
let batch = sample_batch();
let dir = tempfile::tempdir().unwrap();
let report = write_batch_chunked(&batch, dir.path(), &FileType::Parquet, 3).unwrap();
assert_eq!(report.rows, 7);
assert_eq!(report.columns, 2);
assert_eq!(report.chunks.len(), 3, "ceil(7/3) chunk files");
let mut total = 0usize;
let mut all_ids = Vec::new();
let mut all_names = Vec::new();
for (i, path) in report.chunks.iter().enumerate() {
assert_eq!(
path.file_name().unwrap().to_str().unwrap(),
format!("chunk{i}.parquet"),
);
let batches = graphar::io::read_chunk(path, &FileType::Parquet).unwrap();
let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
assert!(rows <= 3, "chunk {i} bounded by chunk_rows (got {rows})");
total += rows;
for b in &batches {
let ids = b
.column_by_name("id")
.unwrap()
.as_any()
.downcast_ref::<Int64Array>()
.unwrap();
let names = b
.column_by_name("name")
.unwrap()
.as_any()
.downcast_ref::<StringArray>()
.unwrap();
for r in 0..b.num_rows() {
all_ids.push(ids.value(r));
all_names.push(names.value(r).to_string());
}
}
}
assert_eq!(total, 7, "total rows read back == input rows");
assert_eq!(all_ids, (0..7).collect::<Vec<i64>>());
assert_eq!(
all_names,
(0..7).map(|i| format!("n{i}")).collect::<Vec<String>>(),
);
}
#[test]
fn chunk_rows_at_least_total_writes_single_file() {
let batch = sample_batch();
let dir = tempfile::tempdir().unwrap();
let report = write_batch_chunked(&batch, dir.path(), &FileType::Parquet, 100).unwrap();
assert_eq!(report.chunks.len(), 1, "one chunk when chunk_rows >= total");
assert_eq!(report.rows, 7);
}
#[test]
fn empty_result_writes_one_empty_chunk_preserving_schema() {
let empty = RecordBatch::new_empty(sample_batch().schema());
let dir = tempfile::tempdir().unwrap();
let report = write_batch_chunked(&empty, dir.path(), &FileType::Parquet, 3).unwrap();
assert_eq!(report.rows, 0);
assert_eq!(report.chunks.len(), 1, "empty result still emits chunk0");
let batches = graphar::io::read_chunk(&report.chunks[0], &FileType::Parquet).unwrap();
let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(rows, 0);
assert!(report.chunks[0].exists());
}
#[test]
fn chunk_rows_zero_is_treated_as_one() {
let batch = sample_batch();
let dir = tempfile::tempdir().unwrap();
let report = write_batch_chunked(&batch, dir.path(), &FileType::Parquet, 0).unwrap();
assert_eq!(report.chunks.len(), 7, "chunk_rows 0 → 1 row per file");
}
}
#[cfg(all(test, feature = "skade"))]
mod skade_tests {
use super::*;
use std::sync::Arc;
#[test]
fn ffi_bridge_preserves_values_and_nulls() {
use arrow_array::{Float64Array, Int64Array, StringArray};
use arrow_schema::{DataType, Field, Schema};
let batch56 = arrow_array::RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("id", DataType::Int64, false),
Field::new("score", DataType::Float64, false),
Field::new("name", DataType::Utf8, true),
])),
vec![
Arc::new(Int64Array::from(vec![10, 20, 30])),
Arc::new(Float64Array::from(vec![1.5, 2.5, 3.5])),
Arc::new(StringArray::from(vec![Some("a"), None, Some("c")])),
],
)
.unwrap();
let batch57 = bridge_56_to_57(&batch56).unwrap();
assert_eq!(batch57.num_rows(), 3);
assert_eq!(batch57.num_columns(), 3);
let ids = batch57
.column(0)
.as_any()
.downcast_ref::<skade::arrow_array::Int64Array>()
.unwrap();
assert_eq!(ids.values(), &[10, 20, 30]);
let scores = batch57
.column(1)
.as_any()
.downcast_ref::<skade::arrow_array::Float64Array>()
.unwrap();
assert_eq!(scores.values(), &[1.5, 2.5, 3.5]);
use skade::arrow_array::Array;
let names = batch57
.column(2)
.as_any()
.downcast_ref::<skade::arrow_array::StringArray>()
.unwrap();
assert_eq!(names.value(0), "a");
assert!(names.is_null(1), "the null survived the bridge");
assert_eq!(names.value(2), "c");
}
#[tokio::test]
async fn append_and_read_back_via_iceberg() {
use arrow_array::{Int64Array, StringArray};
use arrow_schema::{DataType, Field, Schema};
let batch = arrow_array::RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("id", DataType::Int64, false),
Field::new("name", DataType::Utf8, false),
])),
vec![
Arc::new(Int64Array::from(vec![1, 2, 3])),
Arc::new(StringArray::from(vec!["a", "b", "c"])),
],
)
.unwrap();
let dir = tempfile::tempdir().unwrap();
let n = append_arrow_to_iceberg(dir.path(), "people", batch)
.await
.unwrap();
assert_eq!(n, 3);
let wh = skade::Warehouse::open(dir.path()).await.unwrap();
let table = wh.table("people").await.unwrap();
let count = table.count().await.unwrap();
assert_eq!(count, 3, "3 rows landed in the Iceberg table");
}
}