use crate::export::arrow_convert::{build_arrow_schema, convert_to_arrays, ArrowConvertError};
use crate::query::{ColumnInfo, QueryMetadata, QueryResult, QueryRow};
use arrow::datatypes::Schema;
use arrow::record_batch::RecordBatch;
use parquet::arrow::ArrowWriter;
use parquet::basic::{Compression, ZstdLevel};
use parquet::file::properties::WriterProperties;
use std::fs::File;
use std::io::Write;
use std::sync::Arc;
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ParquetCompression {
#[default]
Snappy,
Zstd,
Uncompressed,
}
impl ParquetCompression {
fn to_parquet(self) -> Compression {
match self {
ParquetCompression::Snappy => Compression::SNAPPY,
ParquetCompression::Zstd => Compression::ZSTD(ZstdLevel::default()),
ParquetCompression::Uncompressed => Compression::UNCOMPRESSED,
}
}
}
#[derive(Debug, Clone)]
pub struct ParquetExportOptions {
pub row_limit: Option<usize>,
pub row_group_size: usize,
pub compression: ParquetCompression,
}
impl Default for ParquetExportOptions {
fn default() -> Self {
Self {
row_limit: None,
row_group_size: 10_000,
compression: ParquetCompression::default(),
}
}
}
#[derive(Debug, Error)]
pub enum ParquetExportError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Arrow error: {0}")]
Arrow(#[from] arrow::error::ArrowError),
#[error("Parquet error: {0}")]
Parquet(#[from] ::parquet::errors::ParquetError),
#[error("{0}")]
InvalidValue(String),
#[error("invalid Parquet export options: {0}")]
InvalidOptions(String),
}
impl From<ArrowConvertError> for ParquetExportError {
fn from(e: ArrowConvertError) -> Self {
match e {
ArrowConvertError::Arrow(a) => ParquetExportError::Arrow(a),
ArrowConvertError::InvalidValue(s) => ParquetExportError::InvalidValue(s),
}
}
}
pub struct ParquetWriter;
impl ParquetWriter {
pub fn write(
result: &QueryResult,
options: &ParquetExportOptions,
) -> Result<Vec<u8>, ParquetExportError> {
if result.metadata.columns.is_empty() {
return Self::write_empty_parquet(options);
}
let schema = build_arrow_schema(&result.metadata.columns)?;
let rows_to_process = if let Some(limit) = options.row_limit {
&result.rows[..result.rows.len().min(limit)]
} else {
&result.rows
};
let arrays = convert_to_arrays(&result.metadata.columns, rows_to_process)?;
let batch = RecordBatch::try_new(Arc::new(schema), arrays)?;
Self::write_parquet(&batch, options.compression)
}
fn write_empty_parquet(options: &ParquetExportOptions) -> Result<Vec<u8>, ParquetExportError> {
let schema = Schema::empty();
let batch = RecordBatch::new_empty(Arc::new(schema));
Self::write_parquet(&batch, options.compression)
}
pub(crate) fn build_schema(columns: &[ColumnInfo]) -> Result<Schema, ParquetExportError> {
build_arrow_schema(columns).map_err(Into::into)
}
pub(crate) fn convert_to_arrays(
columns: &[ColumnInfo],
rows: &[QueryRow],
) -> Result<Vec<arrow::array::ArrayRef>, ParquetExportError> {
convert_to_arrays(columns, rows).map_err(Into::into)
}
fn write_parquet(
batch: &RecordBatch,
compression: ParquetCompression,
) -> Result<Vec<u8>, ParquetExportError> {
let mut buffer = Vec::new();
let props = WriterProperties::builder()
.set_compression(compression.to_parquet())
.build();
let mut writer = ArrowWriter::try_new(&mut buffer, batch.schema(), Some(props))?;
writer.write(batch)?;
writer.close()?;
Ok(buffer)
}
}
pub struct StreamingParquetWriter<W: Write + Send> {
writer: Option<ArrowWriter<W>>,
schema: Arc<Schema>,
columns: Vec<ColumnInfo>,
row_buffer: Vec<QueryRow>,
row_group_size: usize,
rows_written: u64,
}
impl<W: Write + Send> StreamingParquetWriter<W> {
pub fn new(
output: W,
metadata: &QueryMetadata,
options: &ParquetExportOptions,
) -> Result<Self, ParquetExportError> {
if options.row_group_size == 0 {
return Err(ParquetExportError::InvalidOptions(
"row_group_size must be greater than 0".to_string(),
));
}
let schema = Arc::new(ParquetWriter::build_schema(&metadata.columns)?);
let props = WriterProperties::builder()
.set_compression(options.compression.to_parquet())
.set_max_row_group_size(options.row_group_size)
.build();
let arrow_writer = ArrowWriter::try_new(output, Arc::clone(&schema), Some(props))?;
Ok(Self {
writer: Some(arrow_writer),
schema,
columns: metadata.columns.clone(),
row_buffer: Vec::with_capacity(options.row_group_size),
row_group_size: options.row_group_size,
rows_written: 0,
})
}
pub fn write_chunk(&mut self, rows: &[QueryRow]) -> Result<usize, ParquetExportError> {
self.row_buffer.extend(rows.iter().cloned());
self.rows_written += rows.len() as u64;
let mut flushed = 0;
while self.row_buffer.len() >= self.row_group_size {
let chunk: Vec<QueryRow> = self.row_buffer.drain(..self.row_group_size).collect();
self.write_row_group(&chunk)?;
flushed += self.row_group_size;
}
Ok(flushed)
}
pub fn finalize(&mut self) -> Result<(), ParquetExportError> {
if !self.row_buffer.is_empty() {
let remaining = std::mem::take(&mut self.row_buffer);
self.write_row_group(&remaining)?;
}
if let Some(writer) = self.writer.take() {
writer.close()?;
}
Ok(())
}
pub fn rows_written(&self) -> u64 {
self.rows_written
}
fn write_row_group(&mut self, rows: &[QueryRow]) -> Result<(), ParquetExportError> {
let writer = self.writer.as_mut().ok_or_else(|| {
ParquetExportError::InvalidOptions(
"writer already finalized - cannot write more rows".to_string(),
)
})?;
let arrays = ParquetWriter::convert_to_arrays(&self.columns, rows)?;
let batch = RecordBatch::try_new(Arc::clone(&self.schema), arrays)?;
writer.write(&batch)?;
Ok(())
}
}
pub fn create_streaming_parquet_writer(
file: File,
metadata: &QueryMetadata,
options: &ParquetExportOptions,
) -> Result<StreamingParquetWriter<File>, ParquetExportError> {
StreamingParquetWriter::new(file, metadata, options)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::query::{ColumnInfo, QueryRow};
use crate::{RowKey, Value};
use arrow::array::BinaryArray;
use arrow::array::{Array, FixedSizeBinaryArray, StringArray};
use bytes::Bytes;
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
use std::collections::HashMap;
use std::error::Error as StdError;
fn default_options() -> ParquetExportOptions {
ParquetExportOptions::default()
}
fn read_parquet_back(bytes: &[u8]) -> Result<RecordBatch, Box<dyn StdError>> {
let bytes = Bytes::copy_from_slice(bytes);
let builder = ParquetRecordBatchReaderBuilder::try_new(bytes)?;
let mut reader = builder.build()?;
reader
.next()
.ok_or_else(|| "No batches in Parquet file".to_string())?
.map_err(|e| Box::new(e) as Box<dyn StdError>)
}
#[test]
fn test_empty_result() {
let result = QueryResult::new();
let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
assert!(!bytes.is_empty());
assert_eq!(&bytes[0..4], b"PAR1");
}
#[test]
fn test_boolean_values() {
use crate::types::DataType;
let mut result = QueryResult::new();
result.metadata.columns = vec![ColumnInfo::new(
"bool_col".to_string(),
DataType::Boolean,
true,
0,
)];
let mut values = HashMap::new();
values.insert("bool_col".to_string(), Value::Boolean(true));
let row = QueryRow::with_values(RowKey::new(vec![1]), values);
result.rows.push(row);
let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
let batch = read_parquet_back(&bytes).unwrap();
assert_eq!(batch.num_rows(), 1);
assert_eq!(batch.num_columns(), 1);
}
#[test]
fn test_integer_types() {
use crate::types::DataType;
let mut result = QueryResult::new();
result.metadata.columns = vec![
ColumnInfo::new("tiny".to_string(), DataType::TinyInt, false, 0),
ColumnInfo::new("small".to_string(), DataType::SmallInt, false, 1),
ColumnInfo::new("int".to_string(), DataType::Integer, false, 2),
ColumnInfo::new("big".to_string(), DataType::BigInt, false, 3),
];
let mut values = HashMap::new();
values.insert("tiny".to_string(), Value::TinyInt(127));
values.insert("small".to_string(), Value::SmallInt(32767));
values.insert("int".to_string(), Value::Integer(2147483647));
values.insert("big".to_string(), Value::BigInt(9223372036854775807));
let row = QueryRow::with_values(RowKey::new(vec![1]), values);
result.rows.push(row);
let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
let batch = read_parquet_back(&bytes).unwrap();
assert_eq!(batch.num_rows(), 1);
assert_eq!(batch.num_columns(), 4);
}
#[test]
fn test_float_types() {
use crate::types::DataType;
let mut result = QueryResult::new();
result.metadata.columns = vec![
ColumnInfo::new("f32".to_string(), DataType::Float32, false, 0),
ColumnInfo::new("f64".to_string(), DataType::Float, false, 1),
];
let mut values = HashMap::new();
values.insert("f32".to_string(), Value::Float32(3.5));
values.insert("f64".to_string(), Value::Float(2.75));
let row = QueryRow::with_values(RowKey::new(vec![1]), values);
result.rows.push(row);
let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
let batch = read_parquet_back(&bytes).unwrap();
assert_eq!(batch.num_rows(), 1);
assert_eq!(batch.num_columns(), 2);
}
#[test]
fn test_text_values() {
use crate::types::DataType;
let mut result = QueryResult::new();
result.metadata.columns = vec![ColumnInfo::new(
"text_col".to_string(),
DataType::Text,
false,
0,
)];
let mut values = HashMap::new();
values.insert(
"text_col".to_string(),
Value::text("Hello, Parquet!".to_string()),
);
let row = QueryRow::with_values(RowKey::new(vec![1]), values);
result.rows.push(row);
let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
let batch = read_parquet_back(&bytes).unwrap();
assert_eq!(batch.num_rows(), 1);
let col = batch.column(0);
let string_array = col.as_any().downcast_ref::<StringArray>().unwrap();
assert_eq!(string_array.value(0), "Hello, Parquet!");
}
#[test]
fn test_blob_values() {
use crate::types::DataType;
let mut result = QueryResult::new();
result.metadata.columns = vec![ColumnInfo::new(
"blob_col".to_string(),
DataType::Blob,
false,
0,
)];
let mut values = HashMap::new();
values.insert(
"blob_col".to_string(),
Value::blob(vec![0xDE, 0xAD, 0xBE, 0xEF]),
);
let row = QueryRow::with_values(RowKey::new(vec![1]), values);
result.rows.push(row);
let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
let batch = read_parquet_back(&bytes).unwrap();
assert_eq!(batch.num_rows(), 1);
let col = batch.column(0);
let binary_array = col.as_any().downcast_ref::<BinaryArray>().unwrap();
assert_eq!(binary_array.value(0), &[0xDE, 0xAD, 0xBE, 0xEF]);
}
#[test]
fn test_timestamp_values() {
use crate::types::DataType;
let mut result = QueryResult::new();
result.metadata.columns = vec![ColumnInfo::new(
"ts_col".to_string(),
DataType::Timestamp,
false,
0,
)];
let mut values = HashMap::new();
values.insert("ts_col".to_string(), Value::Timestamp(1673778645123));
let row = QueryRow::with_values(RowKey::new(vec![1]), values);
result.rows.push(row);
let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
let batch = read_parquet_back(&bytes).unwrap();
assert_eq!(batch.num_rows(), 1);
}
#[test]
fn test_uuid_values() {
use crate::types::DataType;
let mut result = QueryResult::new();
result.metadata.columns = vec![ColumnInfo::new(
"uuid_col".to_string(),
DataType::Uuid,
false,
0,
)];
let uuid_bytes = [
0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66,
0x77, 0x88,
];
let mut values = HashMap::new();
values.insert("uuid_col".to_string(), Value::Uuid(uuid_bytes));
let row = QueryRow::with_values(RowKey::new(vec![1]), values);
result.rows.push(row);
let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
let batch = read_parquet_back(&bytes).unwrap();
assert_eq!(batch.num_rows(), 1);
let col = batch.column(0);
let uuid_array = col.as_any().downcast_ref::<FixedSizeBinaryArray>().unwrap();
assert_eq!(uuid_array.value(0), uuid_bytes);
}
#[test]
fn test_null_values() {
use crate::types::DataType;
let mut result = QueryResult::new();
result.metadata.columns = vec![ColumnInfo::new(
"nullable_col".to_string(),
DataType::Text,
true,
0,
)];
let mut values1 = HashMap::new();
values1.insert(
"nullable_col".to_string(),
Value::text("present".to_string()),
);
result
.rows
.push(QueryRow::with_values(RowKey::new(vec![1]), values1));
let mut values2 = HashMap::new();
values2.insert("nullable_col".to_string(), Value::Null);
result
.rows
.push(QueryRow::with_values(RowKey::new(vec![2]), values2));
let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
let batch = read_parquet_back(&bytes).unwrap();
assert_eq!(batch.num_rows(), 2);
let col = batch.column(0);
let string_array = col.as_any().downcast_ref::<StringArray>().unwrap();
assert!(string_array.is_valid(0));
assert!(!string_array.is_valid(1)); }
#[test]
fn test_list_values() {
use crate::types::DataType;
let mut result = QueryResult::new();
result.metadata.columns = vec![ColumnInfo::new(
"list_col".to_string(),
DataType::List,
false,
0,
)];
let mut values = HashMap::new();
values.insert(
"list_col".to_string(),
Value::List(vec![
Value::Integer(1),
Value::Integer(2),
Value::Integer(3),
]),
);
let row = QueryRow::with_values(RowKey::new(vec![1]), values);
result.rows.push(row);
let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
let batch = read_parquet_back(&bytes).unwrap();
assert_eq!(batch.num_rows(), 1);
}
#[test]
fn test_map_values() {
use crate::types::DataType;
let mut result = QueryResult::new();
result.metadata.columns = vec![ColumnInfo::new(
"map_col".to_string(),
DataType::Map,
false,
0,
)];
let mut values = HashMap::new();
values.insert(
"map_col".to_string(),
Value::Map(vec![
(Value::text("key1".to_string()), Value::Integer(1)),
(Value::text("key2".to_string()), Value::Integer(2)),
]),
);
let row = QueryRow::with_values(RowKey::new(vec![1]), values);
result.rows.push(row);
let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
let batch = read_parquet_back(&bytes).unwrap();
assert_eq!(batch.num_rows(), 1);
}
#[test]
fn test_config_limit() {
use crate::types::DataType;
let mut result = QueryResult::new();
result.metadata.columns = vec![ColumnInfo::new(
"id".to_string(),
DataType::Integer,
false,
0,
)];
for i in 1..=10 {
let mut values = HashMap::new();
values.insert("id".to_string(), Value::Integer(i));
let row = QueryRow::with_values(RowKey::new(vec![i as u8]), values);
result.rows.push(row);
}
let options = ParquetExportOptions {
row_limit: Some(3),
..Default::default()
};
let bytes = ParquetWriter::write(&result, &options).unwrap();
let batch = read_parquet_back(&bytes).unwrap();
assert_eq!(
batch.num_rows(),
3,
"Limit should restrict output to 3 rows"
);
}
#[test]
fn test_multiple_rows() {
use crate::types::DataType;
let mut result = QueryResult::new();
result.metadata.columns = vec![
ColumnInfo::new("id".to_string(), DataType::Integer, false, 0),
ColumnInfo::new("name".to_string(), DataType::Text, false, 1),
];
for i in 1..=5 {
let mut values = HashMap::new();
values.insert("id".to_string(), Value::Integer(i));
values.insert("name".to_string(), Value::text(format!("row_{i}")));
let row = QueryRow::with_values(RowKey::new(vec![i as u8]), values);
result.rows.push(row);
}
let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
let batch = read_parquet_back(&bytes).unwrap();
assert_eq!(batch.num_rows(), 5);
assert_eq!(batch.num_columns(), 2);
}
#[test]
fn test_counter_values() {
use crate::types::DataType;
let mut result = QueryResult::new();
result.metadata.columns = vec![ColumnInfo::new(
"counter_col".to_string(),
DataType::BigInt, false,
0,
)];
let mut values = HashMap::new();
values.insert("counter_col".to_string(), Value::Counter(1000000));
let row = QueryRow::with_values(RowKey::new(vec![1]), values);
result.rows.push(row);
let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
let batch = read_parquet_back(&bytes).unwrap();
assert_eq!(batch.num_rows(), 1);
}
#[test]
fn test_parquet_magic_bytes() {
use crate::types::DataType;
let mut result = QueryResult::new();
result.metadata.columns = vec![ColumnInfo::new(
"col".to_string(),
DataType::Integer,
false,
0,
)];
let mut values = HashMap::new();
values.insert("col".to_string(), Value::Integer(42));
result
.rows
.push(QueryRow::with_values(RowKey::new(vec![1]), values));
let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
assert_eq!(&bytes[0..4], b"PAR1", "Should start with PAR1 magic bytes");
assert_eq!(
&bytes[bytes.len() - 4..],
b"PAR1",
"Should end with PAR1 magic bytes"
);
}
}