Skip to main content

cqlite_core/export/
parquet.rs

1//! Parquet export writer for QueryResult (feature = "parquet")
2//!
3//! Converts CQL query results to Apache Parquet format with proper type mapping.
4//! Uses Snappy compression by default (Cassandra default, good speed/size balance).
5//!
6//! This module is compiled only when the `parquet` cargo feature is enabled
7//! (off by default, Epic #682).  CQLite produces Parquet *files*; committing
8//! those files to Iceberg/Delta table formats is an external committer's job
9//! and is deliberately out of scope (see `export` module docs).
10//!
11//! The CQL → Arrow type mapping and array-building logic lives in the sibling
12//! `arrow_convert` module (feature = "arrow"), which this module re-exports.
13//! That separation allows a third-party Arrow IPC writer to reuse the
14//! conversion without depending on the `parquet` crate.
15//!
16//! # CQL → Arrow type mapping
17//!
18//! See [`super::arrow_convert`] for the full mapping table, decimal strategy,
19//! and recursive builder design.
20
21use crate::export::arrow_convert::{build_arrow_schema, convert_to_arrays, ArrowConvertError};
22use crate::query::{ColumnInfo, QueryMetadata, QueryResult, QueryRow};
23use arrow::datatypes::Schema;
24use arrow::record_batch::RecordBatch;
25use parquet::arrow::ArrowWriter;
26use parquet::basic::{Compression, ZstdLevel};
27use parquet::file::properties::WriterProperties;
28use std::fs::File;
29use std::io::Write;
30use std::sync::Arc;
31use thiserror::Error;
32
33// ============================================================================
34// Export options and error type (Issues #683, #684)
35// ============================================================================
36
37/// Compression codec for Parquet output.
38///
39/// Snappy is the default (Cassandra default, good speed/size balance).
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub enum ParquetCompression {
42    /// Snappy compression (default).
43    #[default]
44    Snappy,
45    /// Zstandard compression (better ratio, slower).
46    Zstd,
47    /// No compression.
48    Uncompressed,
49}
50
51impl ParquetCompression {
52    /// Map to the `parquet` crate's compression enum.
53    fn to_parquet(self) -> Compression {
54        match self {
55            ParquetCompression::Snappy => Compression::SNAPPY,
56            ParquetCompression::Zstd => Compression::ZSTD(ZstdLevel::default()),
57            ParquetCompression::Uncompressed => Compression::UNCOMPRESSED,
58        }
59    }
60}
61
62/// Writer-owned options for Parquet export.
63///
64/// Replaces the CLI-only `OutputConfig` parameter (Issue #683) so the writer
65/// has no dependency on CLI types.  The CLI constructs this from its own
66/// configuration; library consumers construct it directly.
67#[derive(Debug, Clone)]
68pub struct ParquetExportOptions {
69    /// Maximum number of rows to write (batch writer only). `None` = all rows.
70    pub row_limit: Option<usize>,
71    /// Rows per Parquet row group (streaming writer). Default: 10,000.
72    pub row_group_size: usize,
73    /// Compression codec. Default: Snappy.
74    pub compression: ParquetCompression,
75}
76
77impl Default for ParquetExportOptions {
78    fn default() -> Self {
79        Self {
80            row_limit: None,
81            row_group_size: 10_000,
82            compression: ParquetCompression::default(),
83        }
84    }
85}
86
87/// Errors produced by the Parquet export writers.
88///
89/// A dedicated `thiserror` enum (Issue #683) so the writer does not depend on
90/// the CLI's `OutputError`.  The CLI maps these to its own error type at the
91/// boundary.
92#[derive(Debug, Error)]
93pub enum ParquetExportError {
94    /// Underlying I/O failure.
95    #[error("I/O error: {0}")]
96    Io(#[from] std::io::Error),
97    /// Arrow array or schema construction failure.
98    #[error("Arrow error: {0}")]
99    Arrow(#[from] arrow::error::ArrowError),
100    /// Parquet encoding failure.
101    #[error("Parquet error: {0}")]
102    Parquet(#[from] ::parquet::errors::ParquetError),
103    /// A value could not be represented in the target Arrow/Parquet type.
104    #[error("{0}")]
105    InvalidValue(String),
106    /// Invalid writer configuration (e.g. zero row group size).
107    #[error("invalid Parquet export options: {0}")]
108    InvalidOptions(String),
109}
110
111impl From<ArrowConvertError> for ParquetExportError {
112    fn from(e: ArrowConvertError) -> Self {
113        match e {
114            ArrowConvertError::Arrow(a) => ParquetExportError::Arrow(a),
115            ArrowConvertError::InvalidValue(s) => ParquetExportError::InvalidValue(s),
116        }
117    }
118}
119
120/// Parquet writer for QueryResult
121///
122/// Converts query results to Apache Parquet binary format.
123/// Unlike JSON/CSV writers, this returns `Vec<u8>` (binary data).
124pub struct ParquetWriter;
125
126impl ParquetWriter {
127    /// Write QueryResult to Parquet binary format
128    ///
129    /// # Arguments
130    ///
131    /// * `result` - The query result to convert to Parquet
132    /// * `options` - Export options (row limit, compression)
133    ///
134    /// # Returns
135    ///
136    /// Binary Parquet data or error
137    pub fn write(
138        result: &QueryResult,
139        options: &ParquetExportOptions,
140    ) -> Result<Vec<u8>, ParquetExportError> {
141        // Handle empty results
142        if result.metadata.columns.is_empty() {
143            return Self::write_empty_parquet(options);
144        }
145
146        // Build Arrow schema from column metadata (delegates to arrow_convert)
147        let schema = build_arrow_schema(&result.metadata.columns)?;
148
149        // Apply row limit if specified in the options
150        let rows_to_process = if let Some(limit) = options.row_limit {
151            &result.rows[..result.rows.len().min(limit)]
152        } else {
153            &result.rows
154        };
155
156        // Convert rows to Arrow arrays (one per column) — delegates to arrow_convert
157        let arrays = convert_to_arrays(&result.metadata.columns, rows_to_process)?;
158
159        // Create RecordBatch
160        let batch = RecordBatch::try_new(Arc::new(schema), arrays)?;
161
162        // Write to Parquet with the configured compression
163        Self::write_parquet(&batch, options.compression)
164    }
165
166    /// Write an empty Parquet file
167    fn write_empty_parquet(options: &ParquetExportOptions) -> Result<Vec<u8>, ParquetExportError> {
168        let schema = Schema::empty();
169        let batch = RecordBatch::new_empty(Arc::new(schema));
170        Self::write_parquet(&batch, options.compression)
171    }
172
173    /// Build Arrow schema from CQL column metadata.
174    ///
175    /// Delegates to [`arrow_convert::build_arrow_schema`].  This associated
176    /// function is kept for callers inside this module (e.g. `StreamingParquetWriter`)
177    /// that hold a `&[ColumnInfo]` directly.
178    pub(crate) fn build_schema(columns: &[ColumnInfo]) -> Result<Schema, ParquetExportError> {
179        build_arrow_schema(columns).map_err(Into::into)
180    }
181
182    /// Convert all rows to Arrow arrays (column-oriented).
183    ///
184    /// Delegates to [`arrow_convert::convert_to_arrays`].
185    pub(crate) fn convert_to_arrays(
186        columns: &[ColumnInfo],
187        rows: &[QueryRow],
188    ) -> Result<Vec<arrow::array::ArrayRef>, ParquetExportError> {
189        convert_to_arrays(columns, rows).map_err(Into::into)
190    }
191
192    /// Write RecordBatch to Parquet bytes
193    fn write_parquet(
194        batch: &RecordBatch,
195        compression: ParquetCompression,
196    ) -> Result<Vec<u8>, ParquetExportError> {
197        let mut buffer = Vec::new();
198
199        let props = WriterProperties::builder()
200            .set_compression(compression.to_parquet())
201            .build();
202
203        let mut writer = ArrowWriter::try_new(&mut buffer, batch.schema(), Some(props))?;
204        writer.write(batch)?;
205        writer.close()?;
206
207        Ok(buffer)
208    }
209}
210
211// ============================================================================
212// Streaming Parquet Writer (Issue #280)
213// ============================================================================
214
215/// Streaming Parquet writer for memory-efficient export of large datasets
216///
217/// Unlike the batch `ParquetWriter`, this writer processes data incrementally
218/// using Parquet row groups. Each chunk is converted to a row group, allowing
219/// export of arbitrarily large result sets within memory constraints.
220///
221/// # Row Group Strategy
222///
223/// The writer buffers rows until `row_group_size` is reached (default: 10,000),
224/// then writes a complete row group to the output. This balances memory usage
225/// against I/O efficiency.
226///
227/// # Schema parity
228///
229/// The constructor uses the same [`arrow_convert::build_arrow_schema`] call as
230/// the batch writer, so the schema produced by the streaming path is always
231/// identical to the schema produced by the batch `ParquetWriter`.
232///
233/// # Example
234///
235/// ```ignore
236/// let file = File::create("output.parquet")?;
237/// let mut writer = StreamingParquetWriter::new(
238///     file,
239///     &metadata,
240///     &ParquetExportOptions::default(),
241/// )?;
242///
243/// for chunk in result_iterator.chunks(10_000) {
244///     writer.write_chunk(&chunk)?;
245/// }
246///
247/// writer.finalize()?;
248/// ```
249pub struct StreamingParquetWriter<W: Write + Send> {
250    /// Inner Arrow/Parquet writer (`None` after `finalize`)
251    writer: Option<ArrowWriter<W>>,
252    /// Arrow schema
253    schema: Arc<Schema>,
254    /// Column metadata
255    columns: Vec<ColumnInfo>,
256    /// Buffered rows for current row group
257    row_buffer: Vec<QueryRow>,
258    /// Row group size (rows per group)
259    row_group_size: usize,
260    /// Total rows written
261    rows_written: u64,
262}
263
264impl<W: Write + Send> StreamingParquetWriter<W> {
265    /// Create a streaming Parquet writer over any `W: Write + Send`.
266    ///
267    /// The Arrow schema is built from `metadata.columns` using the same
268    /// mapping as the batch [`ParquetWriter`], and the Parquet file header
269    /// is initialized immediately.
270    ///
271    /// # Errors
272    ///
273    /// Returns [`ParquetExportError::InvalidOptions`] if
274    /// `options.row_group_size` is zero, or an Arrow/Parquet error if the
275    /// schema cannot be built or the writer cannot be initialized.
276    pub fn new(
277        output: W,
278        metadata: &QueryMetadata,
279        options: &ParquetExportOptions,
280    ) -> Result<Self, ParquetExportError> {
281        if options.row_group_size == 0 {
282            return Err(ParquetExportError::InvalidOptions(
283                "row_group_size must be greater than 0".to_string(),
284            ));
285        }
286
287        // Build Arrow schema — same helper used by the batch ParquetWriter.
288        let schema = Arc::new(ParquetWriter::build_schema(&metadata.columns)?);
289
290        // set_max_row_group_size makes `row_group_size` authoritative: without
291        // it, ArrowWriter coalesces written batches into ~1M-row groups, which
292        // both ignored the documented "row group per chunk" behavior and let
293        // memory grow unbounded on large streaming exports.
294        let props = WriterProperties::builder()
295            .set_compression(options.compression.to_parquet())
296            .set_max_row_group_size(options.row_group_size)
297            .build();
298
299        let arrow_writer = ArrowWriter::try_new(output, Arc::clone(&schema), Some(props))?;
300
301        Ok(Self {
302            writer: Some(arrow_writer),
303            schema,
304            columns: metadata.columns.clone(),
305            row_buffer: Vec::with_capacity(options.row_group_size),
306            row_group_size: options.row_group_size,
307            rows_written: 0,
308        })
309    }
310
311    /// Buffer rows and flush complete row groups.
312    ///
313    /// Returns the number of rows flushed to the output in this call (rows
314    /// remaining in the buffer are flushed by [`finalize`](Self::finalize)).
315    pub fn write_chunk(&mut self, rows: &[QueryRow]) -> Result<usize, ParquetExportError> {
316        self.row_buffer.extend(rows.iter().cloned());
317        self.rows_written += rows.len() as u64;
318
319        let mut flushed = 0;
320        while self.row_buffer.len() >= self.row_group_size {
321            let chunk: Vec<QueryRow> = self.row_buffer.drain(..self.row_group_size).collect();
322            self.write_row_group(&chunk)?;
323            flushed += self.row_group_size;
324        }
325
326        Ok(flushed)
327    }
328
329    /// Flush any buffered rows and close the Parquet file.
330    ///
331    /// Must be called exactly once after all chunks are written; dropping the
332    /// writer without calling `finalize` produces a truncated file.
333    pub fn finalize(&mut self) -> Result<(), ParquetExportError> {
334        if !self.row_buffer.is_empty() {
335            let remaining = std::mem::take(&mut self.row_buffer);
336            self.write_row_group(&remaining)?;
337        }
338
339        if let Some(writer) = self.writer.take() {
340            writer.close()?;
341        }
342
343        Ok(())
344    }
345
346    /// Total number of rows accepted by `write_chunk` so far.
347    pub fn rows_written(&self) -> u64 {
348        self.rows_written
349    }
350
351    /// Convert a slice of rows to a RecordBatch and write it as a row group.
352    fn write_row_group(&mut self, rows: &[QueryRow]) -> Result<(), ParquetExportError> {
353        let writer = self.writer.as_mut().ok_or_else(|| {
354            ParquetExportError::InvalidOptions(
355                "writer already finalized - cannot write more rows".to_string(),
356            )
357        })?;
358
359        let arrays = ParquetWriter::convert_to_arrays(&self.columns, rows)?;
360        let batch = RecordBatch::try_new(Arc::clone(&self.schema), arrays)?;
361        writer.write(&batch)?;
362
363        Ok(())
364    }
365}
366
367/// Create a StreamingParquetWriter that writes to a file.
368///
369/// Convenience wrapper around [`StreamingParquetWriter::new`].
370pub fn create_streaming_parquet_writer(
371    file: File,
372    metadata: &QueryMetadata,
373    options: &ParquetExportOptions,
374) -> Result<StreamingParquetWriter<File>, ParquetExportError> {
375    StreamingParquetWriter::new(file, metadata, options)
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use crate::query::{ColumnInfo, QueryRow};
382    use crate::{RowKey, Value};
383    use arrow::array::BinaryArray;
384    use arrow::array::{Array, FixedSizeBinaryArray, StringArray};
385    use bytes::Bytes;
386    use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
387    use std::collections::HashMap;
388    use std::error::Error as StdError;
389
390    fn default_options() -> ParquetExportOptions {
391        ParquetExportOptions::default()
392    }
393
394    /// Helper to verify Parquet output by reading it back
395    fn read_parquet_back(bytes: &[u8]) -> Result<RecordBatch, Box<dyn StdError>> {
396        // Use Bytes which implements ChunkReader
397        let bytes = Bytes::copy_from_slice(bytes);
398        let builder = ParquetRecordBatchReaderBuilder::try_new(bytes)?;
399        let mut reader = builder.build()?;
400        reader
401            .next()
402            .ok_or_else(|| "No batches in Parquet file".to_string())?
403            .map_err(|e| Box::new(e) as Box<dyn StdError>)
404    }
405
406    #[test]
407    fn test_empty_result() {
408        let result = QueryResult::new();
409        let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
410
411        // Should produce valid (empty) Parquet
412        assert!(!bytes.is_empty());
413        // Parquet magic bytes: PAR1
414        assert_eq!(&bytes[0..4], b"PAR1");
415    }
416
417    #[test]
418    fn test_boolean_values() {
419        use crate::types::DataType;
420        let mut result = QueryResult::new();
421        result.metadata.columns = vec![ColumnInfo::new(
422            "bool_col".to_string(),
423            DataType::Boolean,
424            true,
425            0,
426        )];
427
428        let mut values = HashMap::new();
429        values.insert("bool_col".to_string(), Value::Boolean(true));
430        let row = QueryRow::with_values(RowKey::new(vec![1]), values);
431        result.rows.push(row);
432
433        let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
434        let batch = read_parquet_back(&bytes).unwrap();
435
436        assert_eq!(batch.num_rows(), 1);
437        assert_eq!(batch.num_columns(), 1);
438    }
439
440    #[test]
441    fn test_integer_types() {
442        use crate::types::DataType;
443        let mut result = QueryResult::new();
444        result.metadata.columns = vec![
445            ColumnInfo::new("tiny".to_string(), DataType::TinyInt, false, 0),
446            ColumnInfo::new("small".to_string(), DataType::SmallInt, false, 1),
447            ColumnInfo::new("int".to_string(), DataType::Integer, false, 2),
448            ColumnInfo::new("big".to_string(), DataType::BigInt, false, 3),
449        ];
450
451        let mut values = HashMap::new();
452        values.insert("tiny".to_string(), Value::TinyInt(127));
453        values.insert("small".to_string(), Value::SmallInt(32767));
454        values.insert("int".to_string(), Value::Integer(2147483647));
455        values.insert("big".to_string(), Value::BigInt(9223372036854775807));
456        let row = QueryRow::with_values(RowKey::new(vec![1]), values);
457        result.rows.push(row);
458
459        let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
460        let batch = read_parquet_back(&bytes).unwrap();
461
462        assert_eq!(batch.num_rows(), 1);
463        assert_eq!(batch.num_columns(), 4);
464    }
465
466    #[test]
467    fn test_float_types() {
468        use crate::types::DataType;
469        let mut result = QueryResult::new();
470        result.metadata.columns = vec![
471            ColumnInfo::new("f32".to_string(), DataType::Float32, false, 0),
472            ColumnInfo::new("f64".to_string(), DataType::Float, false, 1),
473        ];
474
475        let mut values = HashMap::new();
476        values.insert("f32".to_string(), Value::Float32(3.5));
477        values.insert("f64".to_string(), Value::Float(2.75));
478        let row = QueryRow::with_values(RowKey::new(vec![1]), values);
479        result.rows.push(row);
480
481        let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
482        let batch = read_parquet_back(&bytes).unwrap();
483
484        assert_eq!(batch.num_rows(), 1);
485        assert_eq!(batch.num_columns(), 2);
486    }
487
488    #[test]
489    fn test_text_values() {
490        use crate::types::DataType;
491        let mut result = QueryResult::new();
492        result.metadata.columns = vec![ColumnInfo::new(
493            "text_col".to_string(),
494            DataType::Text,
495            false,
496            0,
497        )];
498
499        let mut values = HashMap::new();
500        values.insert(
501            "text_col".to_string(),
502            Value::Text("Hello, Parquet!".to_string()),
503        );
504        let row = QueryRow::with_values(RowKey::new(vec![1]), values);
505        result.rows.push(row);
506
507        let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
508        let batch = read_parquet_back(&bytes).unwrap();
509
510        assert_eq!(batch.num_rows(), 1);
511        let col = batch.column(0);
512        let string_array = col.as_any().downcast_ref::<StringArray>().unwrap();
513        assert_eq!(string_array.value(0), "Hello, Parquet!");
514    }
515
516    #[test]
517    fn test_blob_values() {
518        use crate::types::DataType;
519        let mut result = QueryResult::new();
520        result.metadata.columns = vec![ColumnInfo::new(
521            "blob_col".to_string(),
522            DataType::Blob,
523            false,
524            0,
525        )];
526
527        let mut values = HashMap::new();
528        values.insert(
529            "blob_col".to_string(),
530            Value::Blob(vec![0xDE, 0xAD, 0xBE, 0xEF]),
531        );
532        let row = QueryRow::with_values(RowKey::new(vec![1]), values);
533        result.rows.push(row);
534
535        let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
536        let batch = read_parquet_back(&bytes).unwrap();
537
538        assert_eq!(batch.num_rows(), 1);
539        let col = batch.column(0);
540        let binary_array = col.as_any().downcast_ref::<BinaryArray>().unwrap();
541        assert_eq!(binary_array.value(0), &[0xDE, 0xAD, 0xBE, 0xEF]);
542    }
543
544    #[test]
545    fn test_timestamp_values() {
546        use crate::types::DataType;
547        let mut result = QueryResult::new();
548        result.metadata.columns = vec![ColumnInfo::new(
549            "ts_col".to_string(),
550            DataType::Timestamp,
551            false,
552            0,
553        )];
554
555        // 2023-01-15 10:30:45.123 UTC = 1673778645123 milliseconds
556        let mut values = HashMap::new();
557        values.insert("ts_col".to_string(), Value::Timestamp(1673778645123));
558        let row = QueryRow::with_values(RowKey::new(vec![1]), values);
559        result.rows.push(row);
560
561        let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
562        let batch = read_parquet_back(&bytes).unwrap();
563
564        assert_eq!(batch.num_rows(), 1);
565    }
566
567    #[test]
568    fn test_uuid_values() {
569        use crate::types::DataType;
570        let mut result = QueryResult::new();
571        result.metadata.columns = vec![ColumnInfo::new(
572            "uuid_col".to_string(),
573            DataType::Uuid,
574            false,
575            0,
576        )];
577
578        let uuid_bytes = [
579            0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66,
580            0x77, 0x88,
581        ];
582
583        let mut values = HashMap::new();
584        values.insert("uuid_col".to_string(), Value::Uuid(uuid_bytes));
585        let row = QueryRow::with_values(RowKey::new(vec![1]), values);
586        result.rows.push(row);
587
588        let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
589        let batch = read_parquet_back(&bytes).unwrap();
590
591        assert_eq!(batch.num_rows(), 1);
592        let col = batch.column(0);
593        let uuid_array = col.as_any().downcast_ref::<FixedSizeBinaryArray>().unwrap();
594        assert_eq!(uuid_array.value(0), uuid_bytes);
595    }
596
597    #[test]
598    fn test_null_values() {
599        use crate::types::DataType;
600        let mut result = QueryResult::new();
601        result.metadata.columns = vec![ColumnInfo::new(
602            "nullable_col".to_string(),
603            DataType::Text,
604            true,
605            0,
606        )];
607
608        // First row with value, second row with null
609        let mut values1 = HashMap::new();
610        values1.insert(
611            "nullable_col".to_string(),
612            Value::Text("present".to_string()),
613        );
614        result
615            .rows
616            .push(QueryRow::with_values(RowKey::new(vec![1]), values1));
617
618        let mut values2 = HashMap::new();
619        values2.insert("nullable_col".to_string(), Value::Null);
620        result
621            .rows
622            .push(QueryRow::with_values(RowKey::new(vec![2]), values2));
623
624        let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
625        let batch = read_parquet_back(&bytes).unwrap();
626
627        assert_eq!(batch.num_rows(), 2);
628        let col = batch.column(0);
629        let string_array = col.as_any().downcast_ref::<StringArray>().unwrap();
630        assert!(string_array.is_valid(0));
631        assert!(!string_array.is_valid(1)); // Null
632    }
633
634    #[test]
635    fn test_list_values() {
636        use crate::types::DataType;
637        let mut result = QueryResult::new();
638        result.metadata.columns = vec![ColumnInfo::new(
639            "list_col".to_string(),
640            DataType::List,
641            false,
642            0,
643        )];
644
645        let mut values = HashMap::new();
646        values.insert(
647            "list_col".to_string(),
648            Value::List(vec![
649                Value::Integer(1),
650                Value::Integer(2),
651                Value::Integer(3),
652            ]),
653        );
654        let row = QueryRow::with_values(RowKey::new(vec![1]), values);
655        result.rows.push(row);
656
657        let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
658        let batch = read_parquet_back(&bytes).unwrap();
659
660        assert_eq!(batch.num_rows(), 1);
661    }
662
663    #[test]
664    fn test_map_values() {
665        use crate::types::DataType;
666        let mut result = QueryResult::new();
667        result.metadata.columns = vec![ColumnInfo::new(
668            "map_col".to_string(),
669            DataType::Map,
670            false,
671            0,
672        )];
673
674        let mut values = HashMap::new();
675        values.insert(
676            "map_col".to_string(),
677            Value::Map(vec![
678                (Value::Text("key1".to_string()), Value::Integer(1)),
679                (Value::Text("key2".to_string()), Value::Integer(2)),
680            ]),
681        );
682        let row = QueryRow::with_values(RowKey::new(vec![1]), values);
683        result.rows.push(row);
684
685        let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
686        let batch = read_parquet_back(&bytes).unwrap();
687
688        assert_eq!(batch.num_rows(), 1);
689    }
690
691    #[test]
692    fn test_config_limit() {
693        use crate::types::DataType;
694        let mut result = QueryResult::new();
695        result.metadata.columns = vec![ColumnInfo::new(
696            "id".to_string(),
697            DataType::Integer,
698            false,
699            0,
700        )];
701
702        // Add 10 rows
703        for i in 1..=10 {
704            let mut values = HashMap::new();
705            values.insert("id".to_string(), Value::Integer(i));
706            let row = QueryRow::with_values(RowKey::new(vec![i as u8]), values);
707            result.rows.push(row);
708        }
709
710        // Limit to 3 rows
711        let options = ParquetExportOptions {
712            row_limit: Some(3),
713            ..Default::default()
714        };
715        let bytes = ParquetWriter::write(&result, &options).unwrap();
716        let batch = read_parquet_back(&bytes).unwrap();
717
718        assert_eq!(
719            batch.num_rows(),
720            3,
721            "Limit should restrict output to 3 rows"
722        );
723    }
724
725    #[test]
726    fn test_multiple_rows() {
727        use crate::types::DataType;
728        let mut result = QueryResult::new();
729        result.metadata.columns = vec![
730            ColumnInfo::new("id".to_string(), DataType::Integer, false, 0),
731            ColumnInfo::new("name".to_string(), DataType::Text, false, 1),
732        ];
733
734        for i in 1..=5 {
735            let mut values = HashMap::new();
736            values.insert("id".to_string(), Value::Integer(i));
737            values.insert("name".to_string(), Value::Text(format!("row_{i}")));
738            let row = QueryRow::with_values(RowKey::new(vec![i as u8]), values);
739            result.rows.push(row);
740        }
741
742        let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
743        let batch = read_parquet_back(&bytes).unwrap();
744
745        assert_eq!(batch.num_rows(), 5);
746        assert_eq!(batch.num_columns(), 2);
747    }
748
749    #[test]
750    fn test_counter_values() {
751        use crate::types::DataType;
752        let mut result = QueryResult::new();
753        result.metadata.columns = vec![ColumnInfo::new(
754            "counter_col".to_string(),
755            DataType::BigInt, // Counters map to BigInt in DataType
756            false,
757            0,
758        )];
759
760        let mut values = HashMap::new();
761        values.insert("counter_col".to_string(), Value::Counter(1000000));
762        let row = QueryRow::with_values(RowKey::new(vec![1]), values);
763        result.rows.push(row);
764
765        let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
766        let batch = read_parquet_back(&bytes).unwrap();
767
768        assert_eq!(batch.num_rows(), 1);
769    }
770
771    #[test]
772    fn test_parquet_magic_bytes() {
773        use crate::types::DataType;
774        let mut result = QueryResult::new();
775        result.metadata.columns = vec![ColumnInfo::new(
776            "col".to_string(),
777            DataType::Integer,
778            false,
779            0,
780        )];
781
782        let mut values = HashMap::new();
783        values.insert("col".to_string(), Value::Integer(42));
784        result
785            .rows
786            .push(QueryRow::with_values(RowKey::new(vec![1]), values));
787
788        let bytes = ParquetWriter::write(&result, &default_options()).unwrap();
789
790        // Parquet files start and end with PAR1 magic bytes
791        assert_eq!(&bytes[0..4], b"PAR1", "Should start with PAR1 magic bytes");
792        assert_eq!(
793            &bytes[bytes.len() - 4..],
794            b"PAR1",
795            "Should end with PAR1 magic bytes"
796        );
797    }
798}