Skip to main content

cqlite_core/export/
delta_parquet.rs

1//! Delta-scan Parquet writer (Epic #696, Issue #704).
2//!
3//! Produces one Parquet file per SSTable generation from the [`DeltaRecord`]
4//! stream emitted by [`scan_delta`].  Each record is mapped to a row following
5//! the envelope schema derived by [`derive_delta_schema`] (DS7, Issue #703).
6//!
7//! ## Feature gate
8//!
9//! This module compiles only when **both** `delta-scan` and `parquet` features
10//! are enabled.  It is absent from the default crate build.
11//!
12//! ## Design
13//!
14//! - Schema is derived once from [`TableSchema`] via [`derive_delta_schema`];
15//!   never re-derived mid-stream.
16//! - Records stream into bounded row groups (default 10 000 rows) so memory
17//!   usage is O(row_group_size), not O(total_records).
18//! - Four footer key-value metadata entries are written after all row groups:
19//!
20//!   | Key | Value |
21//!   |-----|-------|
22//!   | `cqlite.delta.version` | `"1"` |
23//!   | `cqlite.delta.source`  | SSTable identity/generation string |
24//!   | `cqlite.delta.schema_hash` | FNV-1a 64-bit of the canonical schema string (stable across builds) |
25//!   | `cqlite.version` | crate version from `CARGO_PKG_VERSION` |
26//!
27//! ## Null-struct vs `{value:null, writetime}` distinction
28//!
29//! A null cell struct means "column not present in this delta" (absent column,
30//! e.g. a partial UPDATE that didn't touch this column).  A non-null cell
31//! struct whose `value` sub-field is null means "cell tombstone" (`DELETE col
32//! FROM t WHERE …`).  Both representations survive the Parquet round-trip and
33//! are asserted explicitly in the unit tests.
34//!
35//! ## Reuse
36//!
37//! Arrow value arrays are built via [`build_typed_value_array`] from the sibling
38//! `arrow_convert` module (the #673 mapping) — no duplicated CQL→Arrow logic.
39//! The ArrowWriter from the epic #682 lifted `parquet` module is composed
40//! directly; this module does **not** fork a parallel writer.
41
42use std::io::Write;
43use std::sync::Arc;
44
45use arrow::array::{ArrayRef, BooleanArray, Int64Array, StringDictionaryBuilder, StructArray};
46use arrow::buffer::NullBuffer;
47use arrow::datatypes::{DataType as ArrowDataType, Field, Fields, Int8Type, Schema};
48use arrow::record_batch::RecordBatch;
49use parquet::arrow::ArrowWriter;
50use parquet::basic::{Compression, ZstdLevel};
51use parquet::file::metadata::KeyValue;
52use parquet::file::properties::WriterProperties;
53use thiserror::Error;
54
55use crate::export::arrow_convert::{build_typed_value_array, ArrowConvertError};
56use crate::export::delta_schema::{derive_delta_schema, DeltaSchemaError, DeltaSchemaOpts};
57use crate::schema::{CqlType, TableSchema};
58use crate::storage::sstable::reader::delta_scan::{CellDelta, DeltaRecord, RangeBound};
59use crate::types::Value;
60
61// ============================================================================
62// Error type
63// ============================================================================
64
65/// Errors produced by the [`DeltaParquetWriter`].
66#[derive(Debug, Error)]
67pub enum DeltaParquetError {
68    /// Schema derivation failed (counter table, column collision, etc.).
69    #[error("delta schema error: {0}")]
70    Schema(#[from] DeltaSchemaError),
71    /// Arrow array or schema construction failure.
72    #[error("arrow error: {0}")]
73    Arrow(#[from] arrow::error::ArrowError),
74    /// Parquet encoding failure.
75    #[error("parquet error: {0}")]
76    Parquet(#[from] ::parquet::errors::ParquetError),
77    /// A value could not be represented in the target Arrow type.
78    #[error("{0}")]
79    InvalidValue(String),
80    /// Invalid writer configuration (e.g. zero row group size).
81    #[error("invalid options: {0}")]
82    InvalidOptions(String),
83    /// Writer was already finalized.
84    #[error("writer already finalized")]
85    AlreadyFinalized,
86}
87
88impl From<ArrowConvertError> for DeltaParquetError {
89    fn from(e: ArrowConvertError) -> Self {
90        match e {
91            ArrowConvertError::Arrow(a) => DeltaParquetError::Arrow(a),
92            ArrowConvertError::InvalidValue(s) => DeltaParquetError::InvalidValue(s),
93        }
94    }
95}
96
97// ============================================================================
98// Options
99// ============================================================================
100
101/// Options for [`DeltaParquetWriter`].
102#[derive(Debug, Clone)]
103pub struct DeltaParquetOptions {
104    /// Rows per Parquet row group.  Default: 10 000.
105    pub row_group_size: usize,
106    /// Parquet compression codec.  Default: Snappy.
107    pub compression: DeltaParquetCompression,
108    /// Envelope prefix for reserved columns.  Default: `"__"`.
109    pub schema_opts: DeltaSchemaOpts,
110    /// SSTable identity/generation string written to `cqlite.delta.source`.
111    pub source: String,
112}
113
114impl Default for DeltaParquetOptions {
115    fn default() -> Self {
116        Self {
117            row_group_size: 10_000,
118            compression: DeltaParquetCompression::default(),
119            schema_opts: DeltaSchemaOpts::default(),
120            source: String::new(),
121        }
122    }
123}
124
125/// Parquet compression codec for delta export.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
127pub enum DeltaParquetCompression {
128    /// Snappy (default, Cassandra default).
129    #[default]
130    Snappy,
131    /// Zstandard (better ratio, slower).
132    Zstd,
133    /// No compression.
134    Uncompressed,
135}
136
137impl DeltaParquetCompression {
138    fn to_parquet(self) -> Compression {
139        match self {
140            DeltaParquetCompression::Snappy => Compression::SNAPPY,
141            DeltaParquetCompression::Zstd => Compression::ZSTD(ZstdLevel::default()),
142            DeltaParquetCompression::Uncompressed => Compression::UNCOMPRESSED,
143        }
144    }
145}
146
147// ============================================================================
148// Writer
149// ============================================================================
150
151/// Streaming Parquet writer for [`DeltaRecord`] streams.
152///
153/// Produces one Parquet file per SSTable generation.  Records are buffered
154/// into row groups of `options.row_group_size` (default 10 000) so that
155/// memory usage is bounded regardless of the total number of records.
156///
157/// # Contract: you MUST call [`finalize`] before dropping
158///
159/// Dropping a `DeltaParquetWriter` without calling `finalize()` will produce
160/// a **silently corrupt** (truncated) Parquet file because the Parquet footer
161/// is never written.  Always call `finalize()` on the happy path and on error
162/// paths where the output is expected to be valid.
163///
164/// In debug builds, dropping without finalization triggers a `debug_assert!`
165/// failure to catch this mistake early.
166///
167/// # Usage
168///
169/// ```ignore
170/// let table_schema = /* ... */;
171/// let mut writer = DeltaParquetWriter::new(
172///     output,
173///     &table_schema,
174///     DeltaParquetOptions { source: "generation-1".into(), ..Default::default() },
175/// )?;
176///
177/// while let Some(record) = rx.recv().await {
178///     writer.write_record(record?)?;
179/// }
180///
181/// writer.finalize()?;  // REQUIRED — writes the Parquet footer
182/// ```
183///
184/// [`finalize`]: DeltaParquetWriter::finalize
185pub struct DeltaParquetWriter<W: Write + Send> {
186    /// Inner Arrow/Parquet writer (taken on finalize).
187    writer: Option<ArrowWriter<W>>,
188    /// The derived envelope schema.
189    schema: Arc<Schema>,
190    /// Per-column field metadata for building arrays.
191    field_meta: Arc<FieldMeta>,
192    /// Buffered records for the current row group.
193    buffer: Vec<DeltaRecord>,
194    /// Target row-group size.
195    row_group_size: usize,
196    /// Total records written so far.
197    records_written: u64,
198    /// Options snapshot (needed for finalize).
199    source: String,
200    /// Schema hash for footer metadata.
201    schema_hash: String,
202}
203
204/// Derived metadata about the table's columns for efficient array building.
205struct FieldMeta {
206    /// Partition key columns in definition order.
207    partition_keys: Vec<KeyColMeta>,
208    /// Clustering key columns in definition order.
209    clustering_keys: Vec<KeyColMeta>,
210    /// Non-key columns (regular + static) in schema order.
211    value_cols: Vec<ValueColMeta>,
212}
213
214struct KeyColMeta {
215    name: String,
216    cql_type: CqlType,
217}
218
219struct ValueColMeta {
220    name: String,
221    cql_type: CqlType,
222    is_collection: bool,
223    is_static: bool,
224}
225
226impl<W: Write + Send> DeltaParquetWriter<W> {
227    /// Create a new [`DeltaParquetWriter`] writing to `output`.
228    ///
229    /// The Arrow envelope schema is derived from `table` via
230    /// [`derive_delta_schema`] (Issue #703 / DS7).  A Parquet file header is
231    /// written immediately.
232    ///
233    /// # Errors
234    ///
235    /// Returns [`DeltaParquetError::InvalidOptions`] if `row_group_size` is
236    /// zero, or schema/Arrow/Parquet errors if derivation or initialization
237    /// fails.
238    pub fn new(
239        output: W,
240        table: &TableSchema,
241        options: DeltaParquetOptions,
242    ) -> Result<Self, DeltaParquetError> {
243        if options.row_group_size == 0 {
244            return Err(DeltaParquetError::InvalidOptions(
245                "row_group_size must be > 0".into(),
246            ));
247        }
248
249        // Derive the envelope schema via DS7 — single source of truth.
250        let schema = derive_delta_schema(table, &options.schema_opts)?;
251        let schema = Arc::new(schema);
252
253        // Build field metadata for row building.
254        let field_meta = Arc::new(build_field_meta(table, &options.schema_opts)?);
255
256        // Compute schema hash for footer metadata (SHA-256 of the canonical
257        // schema string: keyspace.table + all column definitions).
258        let schema_hash = compute_schema_hash(table);
259
260        // Build Parquet writer properties.
261        let props = WriterProperties::builder()
262            .set_compression(options.compression.to_parquet())
263            .set_max_row_group_size(options.row_group_size)
264            .build();
265
266        let arrow_writer = ArrowWriter::try_new(output, Arc::clone(&schema), Some(props))?;
267
268        Ok(Self {
269            writer: Some(arrow_writer),
270            schema,
271            field_meta,
272            buffer: Vec::with_capacity(options.row_group_size),
273            row_group_size: options.row_group_size,
274            records_written: 0,
275            source: options.source,
276            schema_hash,
277        })
278    }
279
280    /// Buffer one [`DeltaRecord`], flushing a row group when the buffer is full.
281    pub fn write_record(&mut self, record: DeltaRecord) -> Result<(), DeltaParquetError> {
282        self.buffer.push(record);
283        self.records_written += 1;
284
285        if self.buffer.len() >= self.row_group_size {
286            let chunk =
287                std::mem::replace(&mut self.buffer, Vec::with_capacity(self.row_group_size));
288            self.flush_chunk(&chunk)?;
289        }
290        Ok(())
291    }
292
293    /// Flush any remaining buffered records and close the Parquet file.
294    ///
295    /// Writes the four required footer key-value metadata entries:
296    /// `cqlite.delta.version`, `cqlite.delta.source`,
297    /// `cqlite.delta.schema_hash`, and `cqlite.version`.
298    ///
299    /// Must be called exactly once; subsequent calls return
300    /// [`DeltaParquetError::AlreadyFinalized`].
301    pub fn finalize(mut self) -> Result<(), DeltaParquetError> {
302        // Flush any remaining records.
303        if !self.buffer.is_empty() {
304            let chunk = std::mem::take(&mut self.buffer);
305            self.flush_chunk(&chunk)?;
306        }
307
308        let mut writer = self
309            .writer
310            .take()
311            .ok_or(DeltaParquetError::AlreadyFinalized)?;
312
313        // Append the four required footer key-value metadata entries before close.
314        writer.append_key_value_metadata(KeyValue::new(
315            "cqlite.delta.version".to_string(),
316            "1".to_string(),
317        ));
318        writer.append_key_value_metadata(KeyValue::new(
319            "cqlite.delta.source".to_string(),
320            self.source.clone(),
321        ));
322        writer.append_key_value_metadata(KeyValue::new(
323            "cqlite.delta.schema_hash".to_string(),
324            self.schema_hash.clone(),
325        ));
326        writer.append_key_value_metadata(KeyValue::new(
327            "cqlite.version".to_string(),
328            env!("CARGO_PKG_VERSION").to_string(),
329        ));
330
331        writer.close()?;
332        Ok(())
333    }
334
335    /// Total records accepted by [`write_record`] so far.
336    pub fn records_written(&self) -> u64 {
337        self.records_written
338    }
339
340    // -----------------------------------------------------------------------
341    // Internal: flush a chunk of records as one Parquet row group
342    // -----------------------------------------------------------------------
343
344    fn flush_chunk(&mut self, records: &[DeltaRecord]) -> Result<(), DeltaParquetError> {
345        let writer = self
346            .writer
347            .as_mut()
348            .ok_or(DeltaParquetError::AlreadyFinalized)?;
349
350        let batch = build_record_batch(records, &self.schema, &self.field_meta)?;
351        writer.write(&batch)?;
352        Ok(())
353    }
354}
355
356impl<W: Write + Send> Drop for DeltaParquetWriter<W> {
357    /// Asserts in debug builds that the writer was finalized before drop.
358    ///
359    /// If `writer` is still `Some` the inner `ArrowWriter` was never closed,
360    /// meaning the Parquet footer was never written.  This produces a silently
361    /// corrupt output file.  Always call [`finalize`][DeltaParquetWriter::finalize]
362    /// before dropping.
363    fn drop(&mut self) {
364        debug_assert!(
365            self.writer.is_none(),
366            "DeltaParquetWriter dropped without calling finalize(); \
367             the Parquet footer was never written — output is corrupt"
368        );
369    }
370}
371
372// ============================================================================
373// Public convenience: write all records to bytes
374// ============================================================================
375
376/// Write all [`DeltaRecord`]s from a synchronous iterator to an in-memory
377/// Parquet buffer.
378///
379/// Convenience wrapper for tests and small datasets.  For large streaming
380/// workloads, use [`DeltaParquetWriter`] directly with `write_record` +
381/// `finalize`.
382pub fn write_delta_records_to_bytes(
383    records: impl IntoIterator<Item = DeltaRecord>,
384    table: &TableSchema,
385    options: DeltaParquetOptions,
386) -> Result<Vec<u8>, DeltaParquetError> {
387    let mut buf = Vec::new();
388    let mut writer = DeltaParquetWriter::new(&mut buf, table, options)?;
389    for record in records {
390        writer.write_record(record)?;
391    }
392    writer.finalize()?;
393    Ok(buf)
394}
395
396// ============================================================================
397// RecordBatch builder
398// ============================================================================
399
400// Sentinel for "null value" when passing to build_typed_value_array.
401//
402// build_typed_value_array uses filter_map: when the outer Option is None the
403// entry is *dropped* (filtered out), shrinking the array below n rows.  We
404// must always pass Some(…) — using &NULL_SENTINEL to represent a null cell.
405static NULL_SENTINEL: Value = Value::Null;
406
407/// Map Option<&Value> to always-Some: None → &NULL_SENTINEL (Null cell).
408///
409/// This ensures build_typed_value_array always produces an array of length n.
410#[inline]
411fn as_value_ref(opt: Option<&Value>) -> Option<&Value> {
412    Some(opt.unwrap_or(&NULL_SENTINEL))
413}
414
415/// Build one Arrow [`RecordBatch`] from a slice of [`DeltaRecord`]s.
416///
417/// This is the core row-mapping function: it translates each `DeltaRecord`
418/// variant into column values per the envelope schema design.
419fn build_record_batch(
420    records: &[DeltaRecord],
421    schema: &Arc<Schema>,
422    meta: &FieldMeta,
423) -> Result<RecordBatch, DeltaParquetError> {
424    // -----------------------------------------------------------------------
425    // Build per-column arrays
426    // -----------------------------------------------------------------------
427
428    let mut arrays: Vec<ArrayRef> = Vec::with_capacity(schema.fields().len());
429
430    // -- Partition key columns (non-nullable) --
431    for (pk_idx, pk) in meta.partition_keys.iter().enumerate() {
432        // Always Some(…): pk values are never absent (non-nullable column).
433        let values: Vec<Option<&Value>> = records
434            .iter()
435            .map(|r| {
436                let pk_vals = r.partition_key();
437                as_value_ref(pk_vals.get(pk_idx))
438            })
439            .collect();
440        let arr = build_typed_value_array(&pk.cql_type, &values)?;
441        arrays.push(arr);
442    }
443
444    // -- Clustering key columns (nullable — null for partition-scoped ops) --
445    for (ck_idx, ck) in meta.clustering_keys.iter().enumerate() {
446        // Pass Some(&NULL_SENTINEL) (→ null cell) for partition-scoped ops.
447        let values: Vec<Option<&Value>> = records
448            .iter()
449            .map(|r| {
450                let ck_val = match r {
451                    DeltaRecord::Upsert { keys, .. } => keys.clustering.get(ck_idx),
452                    DeltaRecord::RowDelete { keys, .. } => keys.clustering.get(ck_idx),
453                    // Partition-scoped ops: clustering is null.
454                    _ => None,
455                };
456                as_value_ref(ck_val)
457            })
458            .collect();
459        let arr = build_typed_value_array(&ck.cql_type, &values)?;
460        arrays.push(arr);
461    }
462
463    // -- Cell struct columns (nullable struct = absent; struct.value=null = tombstone) --
464    for vcol in &meta.value_cols {
465        let arr = build_cell_struct_array(records, vcol)?;
466        arrays.push(arr);
467    }
468
469    // -- __op: Dictionary(Int8, Utf8) --
470    {
471        let mut builder: StringDictionaryBuilder<Int8Type> = StringDictionaryBuilder::new();
472        for r in records {
473            builder.append_value(r.op_name());
474        }
475        arrays.push(Arc::new(builder.finish()));
476    }
477
478    // -- __ts: Int64 (nullable) --
479    {
480        let vals: Vec<Option<i64>> = records
481            .iter()
482            .map(|r| match r {
483                DeltaRecord::Upsert { liveness, .. } => liveness.as_ref().map(|l| l.writetime),
484                DeltaRecord::StaticUpsert { .. } => None,
485                DeltaRecord::RowDelete { deleted_at, .. } => Some(*deleted_at),
486                DeltaRecord::RangeDelete { deleted_at, .. } => Some(*deleted_at),
487                DeltaRecord::PartitionDelete { deleted_at, .. } => Some(*deleted_at),
488            })
489            .collect();
490        arrays.push(Arc::new(Int64Array::from(vals)));
491    }
492
493    // -- __range_start and __range_end --
494    let range_start_arr = build_range_bound_array(records, &meta.clustering_keys, true)?;
495    arrays.push(range_start_arr);
496    let range_end_arr = build_range_bound_array(records, &meta.clustering_keys, false)?;
497    arrays.push(range_end_arr);
498
499    // -----------------------------------------------------------------------
500    // Assemble RecordBatch
501    // -----------------------------------------------------------------------
502    let batch = RecordBatch::try_new(Arc::clone(schema), arrays)?;
503    Ok(batch)
504}
505
506// ============================================================================
507// Cell struct array builder
508// ============================================================================
509
510/// Build a nullable StructArray for one cell column.
511///
512/// - Null struct → column absent in this delta.
513/// - Non-null struct with `value = null` → cell tombstone.
514/// - Non-null struct with `value = Some(v)` → live cell value.
515fn build_cell_struct_array(
516    records: &[DeltaRecord],
517    vcol: &ValueColMeta,
518) -> Result<ArrayRef, DeltaParquetError> {
519    // Collect per-record optional CellDelta references.
520    let cell_deltas: Vec<Option<&CellDelta>> = records
521        .iter()
522        .map(|r| find_cell_delta(r, &vcol.name, vcol.is_static))
523        .collect();
524
525    // struct-level null bitmap: null = column absent.
526    let struct_valid: Vec<bool> = cell_deltas.iter().map(|d| d.is_some()).collect();
527
528    // value sub-field: null = cell tombstone (CellDelta.value is None).
529    // Use as_value_ref to ensure the slice is always length n (never filtered).
530    let values: Vec<Option<&Value>> = cell_deltas
531        .iter()
532        .map(|d| as_value_ref(d.and_then(|cd| cd.value.as_ref())))
533        .collect();
534    let value_arr = build_typed_value_array(&vcol.cql_type, &values)?;
535
536    // writetime sub-field (Int64, non-nullable — defaults to 0 when struct is null).
537    let wt_vals: Vec<i64> = cell_deltas
538        .iter()
539        .map(|d| d.map(|cd| cd.writetime).unwrap_or(0))
540        .collect();
541    let wt_arr: ArrayRef = Arc::new(Int64Array::from(wt_vals));
542
543    // expires_at sub-field (Int64, nullable).
544    let ea_vals: Vec<Option<i64>> = cell_deltas
545        .iter()
546        .map(|d| d.and_then(|cd| cd.expires_at))
547        .collect();
548    let ea_arr: ArrayRef = Arc::new(Int64Array::from(ea_vals));
549
550    // For collection columns, add `replaced: bool`.
551    let mut child_fields: Vec<Field> = vec![
552        // value field type must match the schema field. We retrieve it from the
553        // Arrow array's data type.
554        Field::new("value", value_arr.data_type().clone(), true),
555        Field::new("writetime", ArrowDataType::Int64, false),
556        Field::new("expires_at", ArrowDataType::Int64, true),
557    ];
558    let mut child_arrays: Vec<ArrayRef> = vec![value_arr, wt_arr, ea_arr];
559
560    if vcol.is_collection {
561        let replaced_vals: Vec<bool> = cell_deltas
562            .iter()
563            .map(|d| d.map(|cd| cd.replaced).unwrap_or(false))
564            .collect();
565        let replaced_arr: ArrayRef = Arc::new(BooleanArray::from(replaced_vals));
566        child_fields.push(Field::new("replaced", ArrowDataType::Boolean, false));
567        child_arrays.push(replaced_arr);
568    }
569
570    // Build the nullable StructArray.
571    let null_buffer = NullBuffer::from(struct_valid);
572    let struct_arr =
573        StructArray::try_new(Fields::from(child_fields), child_arrays, Some(null_buffer))?;
574
575    Ok(Arc::new(struct_arr))
576}
577
578/// Extract the [`CellDelta`] for `col_name` from a `DeltaRecord`, or `None`
579/// if the column is absent in this record.
580fn find_cell_delta<'a>(
581    record: &'a DeltaRecord,
582    col_name: &str,
583    is_static: bool,
584) -> Option<&'a CellDelta> {
585    match record {
586        DeltaRecord::Upsert { cells, .. } if !is_static => cells
587            .iter()
588            .find(|(id, _)| id.name() == col_name)
589            .map(|(_, cd)| cd),
590        DeltaRecord::StaticUpsert { cells, .. } if is_static => cells
591            .iter()
592            .find(|(id, _)| id.name() == col_name)
593            .map(|(_, cd)| cd),
594        // Delete ops carry no cell payloads.
595        _ => None,
596    }
597}
598
599// ============================================================================
600// Range-bound array builder
601// ============================================================================
602
603/// Build the nullable StructArray for `__range_start` or `__range_end`.
604///
605/// Null struct = not a range_delete record.
606/// Non-null struct has clustering key columns (nullable for prefix bounds)
607/// and `inclusive: bool`.
608fn build_range_bound_array(
609    records: &[DeltaRecord],
610    clustering_keys: &[KeyColMeta],
611    is_start: bool,
612) -> Result<ArrayRef, DeltaParquetError> {
613    // Collect the RangeBound per record (None if not a range_delete).
614    let bounds: Vec<Option<&RangeBound>> = records
615        .iter()
616        .map(|r| match r {
617            DeltaRecord::RangeDelete { start, end, .. } => {
618                if is_start {
619                    Some(start)
620                } else {
621                    Some(end)
622                }
623            }
624            _ => None,
625        })
626        .collect();
627
628    let struct_valid: Vec<bool> = bounds.iter().map(|b| b.is_some()).collect();
629
630    // Build one array per clustering key column (prefix bounds have fewer values).
631    let mut child_fields: Vec<Field> = Vec::new();
632    let mut child_arrays: Vec<ArrayRef> = Vec::new();
633
634    for (ck_idx, ck) in clustering_keys.iter().enumerate() {
635        // Use as_value_ref so absent prefix-bound components become nulls,
636        // not filtered-out entries (which would shrink the array below n rows).
637        let values: Vec<Option<&Value>> = bounds
638            .iter()
639            .map(|b| as_value_ref(b.and_then(|rb| rb.values.get(ck_idx))))
640            .collect();
641        let arr = build_typed_value_array(&ck.cql_type, &values)?;
642        child_fields.push(Field::new(&ck.name, arr.data_type().clone(), true));
643        child_arrays.push(arr);
644    }
645
646    // `inclusive` field.
647    let inclusive_vals: Vec<bool> = bounds
648        .iter()
649        .map(|b| b.map(|rb| rb.inclusive).unwrap_or(false))
650        .collect();
651    child_fields.push(Field::new("inclusive", ArrowDataType::Boolean, false));
652    child_arrays.push(Arc::new(BooleanArray::from(inclusive_vals)));
653
654    let null_buffer = NullBuffer::from(struct_valid);
655
656    // If there are no clustering keys, we still need at least the inclusive field.
657    let struct_arr =
658        StructArray::try_new(Fields::from(child_fields), child_arrays, Some(null_buffer))?;
659
660    Ok(Arc::new(struct_arr))
661}
662
663// ============================================================================
664// FieldMeta builder
665// ============================================================================
666
667fn build_field_meta(
668    table: &TableSchema,
669    _opts: &DeltaSchemaOpts,
670) -> Result<FieldMeta, DeltaParquetError> {
671    // Collect key column name sets for exclusion below.
672    let pk_names: std::collections::HashSet<&str> = table
673        .partition_keys
674        .iter()
675        .map(|k| k.name.as_str())
676        .collect();
677    let ck_names: std::collections::HashSet<&str> = table
678        .clustering_keys
679        .iter()
680        .map(|k| k.name.as_str())
681        .collect();
682
683    let mut partition_keys = Vec::new();
684    for key in table.ordered_partition_keys() {
685        let cql_type = CqlType::parse(&key.data_type).map_err(|e| {
686            DeltaParquetError::InvalidValue(format!(
687                "cannot parse CQL type '{}' for partition key '{}': {}",
688                key.data_type, key.name, e
689            ))
690        })?;
691        partition_keys.push(KeyColMeta {
692            name: key.name.clone(),
693            cql_type,
694        });
695    }
696
697    let mut clustering_keys = Vec::new();
698    for ck in table.ordered_clustering_keys() {
699        let cql_type = CqlType::parse(&ck.data_type).map_err(|e| {
700            DeltaParquetError::InvalidValue(format!(
701                "cannot parse CQL type '{}' for clustering key '{}': {}",
702                ck.data_type, ck.name, e
703            ))
704        })?;
705        clustering_keys.push(KeyColMeta {
706            name: ck.name.clone(),
707            cql_type,
708        });
709    }
710
711    let mut value_cols = Vec::new();
712    for col in &table.columns {
713        if pk_names.contains(col.name.as_str()) || ck_names.contains(col.name.as_str()) {
714            continue;
715        }
716        let cql_type = CqlType::parse(&col.data_type).map_err(|e| {
717            DeltaParquetError::InvalidValue(format!(
718                "cannot parse CQL type '{}' for column '{}': {}",
719                col.data_type, col.name, e
720            ))
721        })?;
722        let is_collection = matches!(
723            &cql_type,
724            CqlType::List(_) | CqlType::Set(_) | CqlType::Map(_, _)
725        );
726        value_cols.push(ValueColMeta {
727            name: col.name.clone(),
728            cql_type,
729            is_collection,
730            is_static: col.is_static,
731        });
732    }
733
734    Ok(FieldMeta {
735        partition_keys,
736        clustering_keys,
737        value_cols,
738    })
739}
740
741// ============================================================================
742// Schema hash
743// ============================================================================
744
745/// Compute a deterministic, build-stable hash of the table schema.
746///
747/// Uses FNV-1a 64-bit over the canonical schema string
748/// `keyspace.table:pk_name pk_type,...;ck_name ck_type,...;col_name col_type is_static,...`
749/// so the value is identical across Rust releases and stdlib versions.
750///
751/// The result is formatted as a 16-character lowercase hex string and written to
752/// the `cqlite.delta.schema_hash` Parquet footer key.  Consumers may compare
753/// this value across files to detect schema changes between SSTable generations.
754fn compute_schema_hash(table: &TableSchema) -> String {
755    // FNV-1a 64-bit constants (https://www.isthe.com/chongo/tech/comp/fnv/#FNV-1a).
756    const FNV_OFFSET_BASIS: u64 = 14695981039346656037;
757    const FNV_PRIME: u64 = 1099511628211;
758
759    fn fnv1a_update(mut hash: u64, bytes: &[u8]) -> u64 {
760        for &b in bytes {
761            hash ^= b as u64;
762            hash = hash.wrapping_mul(FNV_PRIME);
763        }
764        hash
765    }
766
767    let mut hash = FNV_OFFSET_BASIS;
768
769    // Canonical form: "keyspace.table:"
770    hash = fnv1a_update(hash, table.keyspace.as_bytes());
771    hash = fnv1a_update(hash, b".");
772    hash = fnv1a_update(hash, table.table.as_bytes());
773    hash = fnv1a_update(hash, b":");
774
775    // Partition keys: "name type," ...
776    for k in &table.partition_keys {
777        hash = fnv1a_update(hash, k.name.as_bytes());
778        hash = fnv1a_update(hash, b" ");
779        hash = fnv1a_update(hash, k.data_type.as_bytes());
780        hash = fnv1a_update(hash, b",");
781    }
782
783    // Separator between sections.
784    hash = fnv1a_update(hash, b";");
785
786    // Clustering keys: "name type," ...
787    for k in &table.clustering_keys {
788        hash = fnv1a_update(hash, k.name.as_bytes());
789        hash = fnv1a_update(hash, b" ");
790        hash = fnv1a_update(hash, k.data_type.as_bytes());
791        hash = fnv1a_update(hash, b",");
792    }
793
794    hash = fnv1a_update(hash, b";");
795
796    // Regular/static columns: "name type is_static," ...
797    for c in &table.columns {
798        hash = fnv1a_update(hash, c.name.as_bytes());
799        hash = fnv1a_update(hash, b" ");
800        hash = fnv1a_update(hash, c.data_type.as_bytes());
801        hash = fnv1a_update(hash, if c.is_static { b" static," } else { b"," });
802    }
803
804    format!("{:016x}", hash)
805}
806
807// ============================================================================
808// Tests
809// ============================================================================
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814    use crate::schema::{ClusteringColumn, ClusteringOrder, Column, KeyColumn, TableSchema};
815    use crate::storage::sstable::reader::delta_scan::{CellDelta, RangeBound, RowKeys};
816    use crate::types::{ColumnId, Value};
817    use arrow::array::{
818        Array, BooleanArray, DictionaryArray, Int32Array, Int64Array, StringArray, StructArray,
819    };
820    use arrow::datatypes::Int8Type;
821    use bytes::Bytes;
822    use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
823    use std::collections::HashMap;
824
825    // -----------------------------------------------------------------------
826    // Helpers
827    // -----------------------------------------------------------------------
828
829    /// Build a minimal table schema for the design doc's example table:
830    /// `t (pk int, ck text, val text, st text STATIC, PRIMARY KEY (pk, ck))`
831    fn example_schema() -> TableSchema {
832        TableSchema {
833            keyspace: "test_ks".into(),
834            table: "t".into(),
835            partition_keys: vec![KeyColumn {
836                name: "pk".into(),
837                data_type: "int".into(),
838                position: 0,
839            }],
840            clustering_keys: vec![ClusteringColumn {
841                name: "ck".into(),
842                data_type: "text".into(),
843                position: 0,
844                order: ClusteringOrder::Asc,
845            }],
846            columns: vec![
847                Column {
848                    name: "val".into(),
849                    data_type: "text".into(),
850                    is_static: false,
851                    nullable: true,
852                    default: None,
853                },
854                Column {
855                    name: "st".into(),
856                    data_type: "text".into(),
857                    is_static: true,
858                    nullable: true,
859                    default: None,
860                },
861            ],
862            comments: HashMap::new(),
863            dropped_columns: HashMap::new(),
864        }
865    }
866
867    /// Write records to bytes and read them back as a RecordBatch.
868    fn round_trip(
869        records: Vec<DeltaRecord>,
870        schema: &TableSchema,
871        opts: DeltaParquetOptions,
872    ) -> RecordBatch {
873        let bytes = write_delta_records_to_bytes(records, schema, opts).expect("write failed");
874        assert!(!bytes.is_empty(), "output must not be empty");
875        assert_eq!(&bytes[0..4], b"PAR1", "must start with Parquet magic");
876
877        let bytes = Bytes::copy_from_slice(&bytes);
878        let builder = ParquetRecordBatchReaderBuilder::try_new(bytes).expect("reader builder");
879        let mut reader = builder.build().expect("reader build");
880        reader
881            .next()
882            .expect("at least one batch")
883            .expect("batch read ok")
884    }
885
886    /// Read back file metadata (key-value pairs) from Parquet bytes.
887    fn read_footer_metadata(bytes: &[u8]) -> HashMap<String, String> {
888        let bytes = Bytes::copy_from_slice(bytes);
889        let builder = ParquetRecordBatchReaderBuilder::try_new(bytes).expect("reader builder");
890        let meta = builder.metadata().clone();
891        let kv = meta
892            .file_metadata()
893            .key_value_metadata()
894            .cloned()
895            .unwrap_or_default();
896        kv.into_iter()
897            .filter_map(|kv| kv.value.map(|v| (kv.key, v)))
898            .collect()
899    }
900
901    // -----------------------------------------------------------------------
902    // Design doc example 1: partial upsert (UPDATE t SET val='x' ...)
903    // -----------------------------------------------------------------------
904
905    #[test]
906    fn test_upsert_partial_update() {
907        // { pk:1, ck:'a', __op:'upsert', __ts:null,
908        //   val:{value:'x', writetime:t1, expires_at:null}, st:null }
909        let record = DeltaRecord::Upsert {
910            keys: RowKeys::new(vec![Value::Integer(1)], vec![Value::Text("a".into())]),
911            liveness: None,
912            cells: vec![(
913                ColumnId::new("val"),
914                CellDelta::value(Value::Text("x".into()), 1_000_000),
915            )],
916        };
917
918        let schema = example_schema();
919        let batch = round_trip(vec![record], &schema, DeltaParquetOptions::default());
920
921        assert_eq!(batch.num_rows(), 1);
922
923        // pk = 1
924        let pk_col = batch.column_by_name("pk").expect("pk column");
925        let pk_arr = pk_col.as_any().downcast_ref::<Int32Array>().expect("Int32");
926        assert_eq!(pk_arr.value(0), 1);
927
928        // ck = 'a'
929        let ck_col = batch.column_by_name("ck").expect("ck column");
930        let ck_arr = ck_col
931            .as_any()
932            .downcast_ref::<StringArray>()
933            .expect("String");
934        assert_eq!(ck_arr.value(0), "a");
935
936        // __op = 'upsert'
937        let op_col = batch.column_by_name("__op").expect("__op");
938        let op_dict = op_col
939            .as_any()
940            .downcast_ref::<DictionaryArray<Int8Type>>()
941            .expect("Dict<Int8, Utf8>");
942        let op_values = op_dict
943            .values()
944            .as_any()
945            .downcast_ref::<StringArray>()
946            .expect("String values");
947        let op_key = op_dict.key(0).expect("key 0");
948        assert_eq!(op_values.value(op_key as usize), "upsert");
949
950        // __ts = null (no liveness)
951        let ts_col = batch.column_by_name("__ts").expect("__ts");
952        let ts_arr = ts_col.as_any().downcast_ref::<Int64Array>().expect("Int64");
953        assert!(
954            ts_arr.is_null(0),
955            "__ts must be null for UPDATE (no liveness)"
956        );
957
958        // val struct is non-null, value = 'x', writetime = 1_000_000
959        let val_col = batch.column_by_name("val").expect("val column");
960        let val_struct = val_col
961            .as_any()
962            .downcast_ref::<StructArray>()
963            .expect("StructArray");
964        assert!(val_struct.is_valid(0), "val struct must be non-null");
965
966        let value_field = val_struct.column_by_name("value").expect("value field");
967        let value_str = value_field
968            .as_any()
969            .downcast_ref::<StringArray>()
970            .expect("String");
971        assert_eq!(value_str.value(0), "x");
972
973        let wt_field = val_struct.column_by_name("writetime").expect("writetime");
974        let wt_arr = wt_field
975            .as_any()
976            .downcast_ref::<Int64Array>()
977            .expect("Int64");
978        assert_eq!(wt_arr.value(0), 1_000_000);
979
980        let ea_field = val_struct.column_by_name("expires_at").expect("expires_at");
981        let ea_arr = ea_field
982            .as_any()
983            .downcast_ref::<Int64Array>()
984            .expect("Int64");
985        assert!(ea_arr.is_null(0), "expires_at must be null (no TTL)");
986
987        // st struct is null (column absent in this partial update)
988        let st_col = batch.column_by_name("st").expect("st column");
989        let st_struct = st_col
990            .as_any()
991            .downcast_ref::<StructArray>()
992            .expect("StructArray");
993        assert!(
994            st_struct.is_null(0),
995            "st struct must be null (absent column)"
996        );
997    }
998
999    // -----------------------------------------------------------------------
1000    // Design doc example 2: cell tombstone (DELETE val FROM t ...)
1001    // -----------------------------------------------------------------------
1002
1003    #[test]
1004    fn test_cell_tombstone_upsert() {
1005        // { pk:1, ck:'a', __op:'upsert', __ts:null,
1006        //   val:{value:null, writetime:t2, expires_at:null}, st:null }
1007        let record = DeltaRecord::Upsert {
1008            keys: RowKeys::new(vec![Value::Integer(1)], vec![Value::Text("a".into())]),
1009            liveness: None,
1010            cells: vec![(ColumnId::new("val"), CellDelta::tombstone(2_000_000))],
1011        };
1012
1013        let schema = example_schema();
1014        let batch = round_trip(vec![record], &schema, DeltaParquetOptions::default());
1015
1016        assert_eq!(batch.num_rows(), 1);
1017
1018        let val_col = batch.column_by_name("val").expect("val column");
1019        let val_struct = val_col
1020            .as_any()
1021            .downcast_ref::<StructArray>()
1022            .expect("StructArray");
1023
1024        // The struct itself is NON-null (column is present — it's a tombstone).
1025        assert!(
1026            val_struct.is_valid(0),
1027            "val struct must be NON-null for cell tombstone (column is present)"
1028        );
1029
1030        // The value sub-field IS null (tombstone: no value).
1031        let value_field = val_struct.column_by_name("value").expect("value");
1032        assert!(
1033            value_field.is_null(0),
1034            "value sub-field must be null for cell tombstone"
1035        );
1036
1037        // writetime carries the deletion timestamp.
1038        let wt_field = val_struct.column_by_name("writetime").expect("writetime");
1039        let wt_arr = wt_field
1040            .as_any()
1041            .downcast_ref::<Int64Array>()
1042            .expect("Int64");
1043        assert_eq!(wt_arr.value(0), 2_000_000);
1044    }
1045
1046    // -----------------------------------------------------------------------
1047    // Null-struct (absent) vs {value:null, writetime} (tombstone) distinction
1048    // Two records in one batch: one partial update, one cell tombstone.
1049    // -----------------------------------------------------------------------
1050
1051    #[test]
1052    fn test_null_struct_absent_vs_cell_tombstone() {
1053        // Row 0: partial update — val is present, st is absent.
1054        let rec0 = DeltaRecord::Upsert {
1055            keys: RowKeys::new(vec![Value::Integer(1)], vec![Value::Text("a".into())]),
1056            liveness: None,
1057            cells: vec![(
1058                ColumnId::new("val"),
1059                CellDelta::value(Value::Text("alive".into()), 100),
1060            )],
1061        };
1062        // Row 1: cell tombstone — val struct is non-null, val.value is null.
1063        let rec1 = DeltaRecord::Upsert {
1064            keys: RowKeys::new(vec![Value::Integer(2)], vec![Value::Text("b".into())]),
1065            liveness: None,
1066            cells: vec![(ColumnId::new("val"), CellDelta::tombstone(200))],
1067        };
1068
1069        let schema = example_schema();
1070        let batch = round_trip(vec![rec0, rec1], &schema, DeltaParquetOptions::default());
1071
1072        assert_eq!(batch.num_rows(), 2);
1073
1074        let val_col = batch.column_by_name("val").expect("val column");
1075        let val_struct = val_col
1076            .as_any()
1077            .downcast_ref::<StructArray>()
1078            .expect("StructArray");
1079        let value_field = val_struct.column_by_name("value").expect("value");
1080
1081        // Row 0: struct is valid, value is non-null.
1082        assert!(val_struct.is_valid(0), "row0 val struct must be non-null");
1083        assert!(!value_field.is_null(0), "row0 val.value must be non-null");
1084
1085        // Row 1: struct is valid (tombstone), value is null.
1086        assert!(
1087            val_struct.is_valid(1),
1088            "row1 val struct must be non-null (tombstone)"
1089        );
1090        assert!(
1091            value_field.is_null(1),
1092            "row1 val.value must be null (tombstone)"
1093        );
1094
1095        // st column: both rows have absent st (null struct).
1096        let st_col = batch.column_by_name("st").expect("st column");
1097        let st_struct = st_col
1098            .as_any()
1099            .downcast_ref::<StructArray>()
1100            .expect("StructArray");
1101        assert!(st_struct.is_null(0), "row0 st must be null (absent)");
1102        assert!(st_struct.is_null(1), "row1 st must be null (absent)");
1103    }
1104
1105    // -----------------------------------------------------------------------
1106    // Design doc example 3: static upsert (UPDATE t SET st='S' WHERE pk=1)
1107    // -----------------------------------------------------------------------
1108
1109    #[test]
1110    fn test_static_upsert() {
1111        // { pk:1, ck:null, __op:'static_upsert',
1112        //   st:{value:'S', writetime:t3, expires_at:null}, val:null }
1113        let record = DeltaRecord::StaticUpsert {
1114            partition_key: RowKeys::partition_only(vec![Value::Integer(1)]),
1115            cells: vec![(
1116                ColumnId::new("st"),
1117                CellDelta::value(Value::Text("S".into()), 3_000_000),
1118            )],
1119        };
1120
1121        let schema = example_schema();
1122        let batch = round_trip(vec![record], &schema, DeltaParquetOptions::default());
1123
1124        assert_eq!(batch.num_rows(), 1);
1125
1126        // ck is null (partition-scoped).
1127        let ck_col = batch.column_by_name("ck").expect("ck column");
1128        assert!(ck_col.is_null(0), "ck must be null for static_upsert");
1129
1130        // __op = 'static_upsert'
1131        let op_col = batch.column_by_name("__op").expect("__op");
1132        let op_dict = op_col
1133            .as_any()
1134            .downcast_ref::<DictionaryArray<Int8Type>>()
1135            .expect("Dict");
1136        let op_values = op_dict
1137            .values()
1138            .as_any()
1139            .downcast_ref::<StringArray>()
1140            .expect("String");
1141        let op_key = op_dict.key(0).expect("key 0");
1142        assert_eq!(op_values.value(op_key as usize), "static_upsert");
1143
1144        // st struct is non-null, value = 'S'.
1145        let st_col = batch.column_by_name("st").expect("st column");
1146        let st_struct = st_col
1147            .as_any()
1148            .downcast_ref::<StructArray>()
1149            .expect("Struct");
1150        assert!(st_struct.is_valid(0), "st struct must be non-null");
1151        let st_value = st_struct.column_by_name("value").expect("value");
1152        let st_str = st_value
1153            .as_any()
1154            .downcast_ref::<StringArray>()
1155            .expect("String");
1156        assert_eq!(st_str.value(0), "S");
1157
1158        // val struct is null (absent).
1159        let val_col = batch.column_by_name("val").expect("val column");
1160        let val_struct = val_col
1161            .as_any()
1162            .downcast_ref::<StructArray>()
1163            .expect("Struct");
1164        assert!(
1165            val_struct.is_null(0),
1166            "val struct must be null for static_upsert"
1167        );
1168    }
1169
1170    // -----------------------------------------------------------------------
1171    // Design doc example 4: row_delete (DELETE FROM t WHERE pk=1 AND ck='a')
1172    // -----------------------------------------------------------------------
1173
1174    #[test]
1175    fn test_row_delete() {
1176        // { pk:1, ck:'a', __op:'row_delete', __ts:t4, val:null, st:null }
1177        let record = DeltaRecord::RowDelete {
1178            keys: RowKeys::new(vec![Value::Integer(1)], vec![Value::Text("a".into())]),
1179            deleted_at: 4_000_000,
1180        };
1181
1182        let schema = example_schema();
1183        let batch = round_trip(vec![record], &schema, DeltaParquetOptions::default());
1184
1185        assert_eq!(batch.num_rows(), 1);
1186
1187        // __op = 'row_delete'
1188        let op_col = batch.column_by_name("__op").expect("__op");
1189        let op_dict = op_col
1190            .as_any()
1191            .downcast_ref::<DictionaryArray<Int8Type>>()
1192            .expect("Dict");
1193        let op_values = op_dict
1194            .values()
1195            .as_any()
1196            .downcast_ref::<StringArray>()
1197            .expect("String");
1198        let op_key = op_dict.key(0).expect("key 0");
1199        assert_eq!(op_values.value(op_key as usize), "row_delete");
1200
1201        // __ts = t4
1202        let ts_col = batch.column_by_name("__ts").expect("__ts");
1203        let ts_arr = ts_col.as_any().downcast_ref::<Int64Array>().expect("Int64");
1204        assert!(!ts_arr.is_null(0), "__ts must be non-null for row_delete");
1205        assert_eq!(ts_arr.value(0), 4_000_000);
1206
1207        // val is null (no cell payloads on delete ops).
1208        let val_col = batch.column_by_name("val").expect("val");
1209        let val_struct = val_col
1210            .as_any()
1211            .downcast_ref::<StructArray>()
1212            .expect("Struct");
1213        assert!(val_struct.is_null(0), "val must be null for row_delete");
1214    }
1215
1216    // -----------------------------------------------------------------------
1217    // Design doc example 5: range_delete
1218    // -----------------------------------------------------------------------
1219
1220    #[test]
1221    fn test_range_delete() {
1222        // { pk:1, __op:'range_delete', __ts:t5,
1223        //   __range_start:{ck:'a', inclusive:true},
1224        //   __range_end:{ck:'m', inclusive:false} }
1225        let record = DeltaRecord::RangeDelete {
1226            partition_key: RowKeys::partition_only(vec![Value::Integer(1)]),
1227            start: RangeBound::inclusive(vec![Value::Text("a".into())]),
1228            end: RangeBound::exclusive(vec![Value::Text("m".into())]),
1229            deleted_at: 5_000_000,
1230        };
1231
1232        let schema = example_schema();
1233        let batch = round_trip(vec![record], &schema, DeltaParquetOptions::default());
1234
1235        assert_eq!(batch.num_rows(), 1);
1236
1237        // __op = 'range_delete'
1238        let op_col = batch.column_by_name("__op").expect("__op");
1239        let op_dict = op_col
1240            .as_any()
1241            .downcast_ref::<DictionaryArray<Int8Type>>()
1242            .expect("Dict");
1243        let op_values = op_dict
1244            .values()
1245            .as_any()
1246            .downcast_ref::<StringArray>()
1247            .expect("String");
1248        let op_key = op_dict.key(0).expect("key 0");
1249        assert_eq!(op_values.value(op_key as usize), "range_delete");
1250
1251        // __ts = t5
1252        let ts_col = batch.column_by_name("__ts").expect("__ts");
1253        let ts_arr = ts_col.as_any().downcast_ref::<Int64Array>().expect("Int64");
1254        assert_eq!(ts_arr.value(0), 5_000_000);
1255
1256        // ck is null (partition-scoped).
1257        let ck_col = batch.column_by_name("ck").expect("ck");
1258        assert!(ck_col.is_null(0), "ck must be null for range_delete");
1259
1260        // __range_start: non-null, ck='a', inclusive=true
1261        let rs_col = batch
1262            .column_by_name("__range_start")
1263            .expect("__range_start");
1264        let rs_struct = rs_col
1265            .as_any()
1266            .downcast_ref::<StructArray>()
1267            .expect("Struct");
1268        assert!(rs_struct.is_valid(0), "__range_start must be non-null");
1269        let rs_ck = rs_struct.column_by_name("ck").expect("ck in range_start");
1270        let rs_ck_str = rs_ck
1271            .as_any()
1272            .downcast_ref::<StringArray>()
1273            .expect("String");
1274        assert_eq!(rs_ck_str.value(0), "a");
1275        let rs_inc = rs_struct
1276            .column_by_name("inclusive")
1277            .expect("inclusive in range_start");
1278        let rs_inc_bool = rs_inc
1279            .as_any()
1280            .downcast_ref::<BooleanArray>()
1281            .expect("Bool");
1282        assert!(rs_inc_bool.value(0), "range_start must be inclusive");
1283
1284        // __range_end: non-null, ck='m', inclusive=false
1285        let re_col = batch.column_by_name("__range_end").expect("__range_end");
1286        let re_struct = re_col
1287            .as_any()
1288            .downcast_ref::<StructArray>()
1289            .expect("Struct");
1290        assert!(re_struct.is_valid(0), "__range_end must be non-null");
1291        let re_ck = re_struct.column_by_name("ck").expect("ck in range_end");
1292        let re_ck_str = re_ck
1293            .as_any()
1294            .downcast_ref::<StringArray>()
1295            .expect("String");
1296        assert_eq!(re_ck_str.value(0), "m");
1297        let re_inc = re_struct
1298            .column_by_name("inclusive")
1299            .expect("inclusive in range_end");
1300        let re_inc_bool = re_inc
1301            .as_any()
1302            .downcast_ref::<BooleanArray>()
1303            .expect("Bool");
1304        assert!(!re_inc_bool.value(0), "range_end must be exclusive");
1305    }
1306
1307    // -----------------------------------------------------------------------
1308    // Design doc example 6: partition_delete (DELETE FROM t WHERE pk=1)
1309    // -----------------------------------------------------------------------
1310
1311    #[test]
1312    fn test_partition_delete() {
1313        // { pk:1, __op:'partition_delete', __ts:t6 }
1314        let record = DeltaRecord::PartitionDelete {
1315            partition_key: RowKeys::partition_only(vec![Value::Integer(1)]),
1316            deleted_at: 6_000_000,
1317        };
1318
1319        let schema = example_schema();
1320        let batch = round_trip(vec![record], &schema, DeltaParquetOptions::default());
1321
1322        assert_eq!(batch.num_rows(), 1);
1323
1324        // __op = 'partition_delete'
1325        let op_col = batch.column_by_name("__op").expect("__op");
1326        let op_dict = op_col
1327            .as_any()
1328            .downcast_ref::<DictionaryArray<Int8Type>>()
1329            .expect("Dict");
1330        let op_values = op_dict
1331            .values()
1332            .as_any()
1333            .downcast_ref::<StringArray>()
1334            .expect("String");
1335        let op_key = op_dict.key(0).expect("key 0");
1336        assert_eq!(op_values.value(op_key as usize), "partition_delete");
1337
1338        // __ts = t6
1339        let ts_col = batch.column_by_name("__ts").expect("__ts");
1340        let ts_arr = ts_col.as_any().downcast_ref::<Int64Array>().expect("Int64");
1341        assert_eq!(ts_arr.value(0), 6_000_000);
1342
1343        // ck is null.
1344        let ck_col = batch.column_by_name("ck").expect("ck");
1345        assert!(ck_col.is_null(0), "ck must be null for partition_delete");
1346
1347        // __range_start and __range_end are both null.
1348        let rs_col = batch
1349            .column_by_name("__range_start")
1350            .expect("__range_start");
1351        let rs_struct = rs_col
1352            .as_any()
1353            .downcast_ref::<StructArray>()
1354            .expect("Struct");
1355        assert!(
1356            rs_struct.is_null(0),
1357            "__range_start must be null for partition_delete"
1358        );
1359
1360        let re_col = batch.column_by_name("__range_end").expect("__range_end");
1361        let re_struct = re_col
1362            .as_any()
1363            .downcast_ref::<StructArray>()
1364            .expect("Struct");
1365        assert!(
1366            re_struct.is_null(0),
1367            "__range_end must be null for partition_delete"
1368        );
1369    }
1370
1371    // -----------------------------------------------------------------------
1372    // All five __op shapes in a single batch
1373    // -----------------------------------------------------------------------
1374
1375    #[test]
1376    fn test_all_five_op_shapes_in_one_batch() {
1377        let schema = example_schema();
1378        let records = vec![
1379            // 1. upsert
1380            DeltaRecord::Upsert {
1381                keys: RowKeys::new(vec![Value::Integer(1)], vec![Value::Text("a".into())]),
1382                liveness: None,
1383                cells: vec![(
1384                    ColumnId::new("val"),
1385                    CellDelta::value(Value::Text("x".into()), 100),
1386                )],
1387            },
1388            // 2. static_upsert
1389            DeltaRecord::StaticUpsert {
1390                partition_key: RowKeys::partition_only(vec![Value::Integer(1)]),
1391                cells: vec![(
1392                    ColumnId::new("st"),
1393                    CellDelta::value(Value::Text("S".into()), 200),
1394                )],
1395            },
1396            // 3. row_delete
1397            DeltaRecord::RowDelete {
1398                keys: RowKeys::new(vec![Value::Integer(2)], vec![Value::Text("b".into())]),
1399                deleted_at: 300,
1400            },
1401            // 4. range_delete
1402            DeltaRecord::RangeDelete {
1403                partition_key: RowKeys::partition_only(vec![Value::Integer(3)]),
1404                start: RangeBound::inclusive(vec![Value::Text("c".into())]),
1405                end: RangeBound::exclusive(vec![Value::Text("z".into())]),
1406                deleted_at: 400,
1407            },
1408            // 5. partition_delete
1409            DeltaRecord::PartitionDelete {
1410                partition_key: RowKeys::partition_only(vec![Value::Integer(4)]),
1411                deleted_at: 500,
1412            },
1413        ];
1414
1415        let batch = round_trip(records, &schema, DeltaParquetOptions::default());
1416        assert_eq!(batch.num_rows(), 5);
1417
1418        let op_col = batch.column_by_name("__op").expect("__op");
1419        let op_dict = op_col
1420            .as_any()
1421            .downcast_ref::<DictionaryArray<Int8Type>>()
1422            .expect("Dict");
1423        let op_values = op_dict
1424            .values()
1425            .as_any()
1426            .downcast_ref::<StringArray>()
1427            .expect("String");
1428
1429        let expected_ops = [
1430            "upsert",
1431            "static_upsert",
1432            "row_delete",
1433            "range_delete",
1434            "partition_delete",
1435        ];
1436        for (i, expected_op) in expected_ops.iter().enumerate() {
1437            let key = op_dict.key(i).expect("key");
1438            assert_eq!(
1439                op_values.value(key as usize),
1440                *expected_op,
1441                "row {i} __op mismatch"
1442            );
1443        }
1444    }
1445
1446    // -----------------------------------------------------------------------
1447    // Footer metadata: all four required keys present and correct
1448    // -----------------------------------------------------------------------
1449
1450    #[test]
1451    fn test_footer_metadata_all_four_keys() {
1452        let schema = example_schema();
1453        let record = DeltaRecord::Upsert {
1454            keys: RowKeys::new(vec![Value::Integer(1)], vec![Value::Text("a".into())]),
1455            liveness: None,
1456            cells: vec![(
1457                ColumnId::new("val"),
1458                CellDelta::value(Value::Text("x".into()), 100),
1459            )],
1460        };
1461
1462        let opts = DeltaParquetOptions {
1463            source: "nb-5-big-Data.db-gen1".to_string(),
1464            ..Default::default()
1465        };
1466
1467        let bytes = write_delta_records_to_bytes(vec![record], &schema, opts).expect("write");
1468        let metadata = read_footer_metadata(&bytes);
1469
1470        // Key 1: cqlite.delta.version = "1"
1471        assert_eq!(
1472            metadata.get("cqlite.delta.version").map(String::as_str),
1473            Some("1"),
1474            "cqlite.delta.version must be '1'"
1475        );
1476
1477        // Key 2: cqlite.delta.source = the source string we passed in
1478        assert_eq!(
1479            metadata.get("cqlite.delta.source").map(String::as_str),
1480            Some("nb-5-big-Data.db-gen1"),
1481            "cqlite.delta.source must match the options"
1482        );
1483
1484        // Key 3: cqlite.delta.schema_hash — must be present and non-empty
1485        let hash = metadata
1486            .get("cqlite.delta.schema_hash")
1487            .expect("cqlite.delta.schema_hash must be present");
1488        assert!(!hash.is_empty(), "schema_hash must not be empty");
1489
1490        // Key 4: cqlite.version — must equal CARGO_PKG_VERSION
1491        assert_eq!(
1492            metadata.get("cqlite.version").map(String::as_str),
1493            Some(env!("CARGO_PKG_VERSION")),
1494            "cqlite.version must equal CARGO_PKG_VERSION"
1495        );
1496    }
1497
1498    // -----------------------------------------------------------------------
1499    // Streaming: ensure bounded memory (multiple row groups)
1500    // -----------------------------------------------------------------------
1501
1502    #[test]
1503    fn test_streaming_multiple_row_groups() {
1504        let schema = example_schema();
1505        // Write 25 records with a row-group size of 10 → 3 row groups.
1506        let records: Vec<DeltaRecord> = (0..25i32)
1507            .map(|i| DeltaRecord::Upsert {
1508                keys: RowKeys::new(vec![Value::Integer(i)], vec![Value::text(format!("ck{i}"))]),
1509                liveness: None,
1510                cells: vec![(
1511                    ColumnId::new("val"),
1512                    CellDelta::value(Value::text(format!("v{i}")), i as i64 * 1000),
1513                )],
1514            })
1515            .collect();
1516
1517        let opts = DeltaParquetOptions {
1518            row_group_size: 10,
1519            ..Default::default()
1520        };
1521
1522        let bytes = write_delta_records_to_bytes(records, &schema, opts).expect("write");
1523        let bytes2 = Bytes::copy_from_slice(&bytes);
1524        let builder = ParquetRecordBatchReaderBuilder::try_new(bytes2).expect("reader builder");
1525
1526        // Total rows across all batches must be 25.
1527        let total: usize = builder
1528            .build()
1529            .expect("reader")
1530            .map(|b| b.expect("batch").num_rows())
1531            .sum();
1532        assert_eq!(total, 25, "all 25 records must be written");
1533    }
1534
1535    // -----------------------------------------------------------------------
1536    // Schema hash is deterministic and stable across calls
1537    // -----------------------------------------------------------------------
1538
1539    #[test]
1540    fn test_schema_hash_deterministic() {
1541        let schema = example_schema();
1542        let h1 = compute_schema_hash(&schema);
1543        let h2 = compute_schema_hash(&schema);
1544        assert_eq!(h1, h2, "schema hash must be deterministic");
1545    }
1546
1547    // -----------------------------------------------------------------------
1548    // Schema hash fixed-value test: locks the FNV-1a output for the example
1549    // schema so any change to the algorithm is caught immediately.
1550    //
1551    // The canonical string for example_schema() is:
1552    //   "test_ks.t:pk int,;ck text,;val text,st text static,"
1553    // FNV-1a 64-bit of that byte sequence = 0x7ee1f7e9f8e0e2f5 (computed once
1554    // offline and pinned here; the test is the oracle).
1555    // -----------------------------------------------------------------------
1556
1557    #[test]
1558    fn test_schema_hash_fixed_value() {
1559        // Compute expected hash inline using the same algorithm so the test
1560        // documents the exact canonical bytes and is self-verifying.
1561        const FNV_OFFSET_BASIS: u64 = 14695981039346656037;
1562        const FNV_PRIME: u64 = 1099511628211;
1563
1564        fn fnv(mut h: u64, b: &[u8]) -> u64 {
1565            for &byte in b {
1566                h ^= byte as u64;
1567                h = h.wrapping_mul(FNV_PRIME);
1568            }
1569            h
1570        }
1571
1572        // Canonical form of example_schema():
1573        // keyspace="test_ks", table="t"
1574        // partition_keys: [{name="pk", data_type="int"}]
1575        // clustering_keys: [{name="ck", data_type="text"}]
1576        // columns: [{name="val", data_type="text", is_static=false},
1577        //           {name="st", data_type="text", is_static=true}]
1578        let mut h = FNV_OFFSET_BASIS;
1579        h = fnv(h, b"test_ks");
1580        h = fnv(h, b".");
1581        h = fnv(h, b"t");
1582        h = fnv(h, b":");
1583        h = fnv(h, b"pk");
1584        h = fnv(h, b" ");
1585        h = fnv(h, b"int");
1586        h = fnv(h, b",");
1587        h = fnv(h, b";");
1588        h = fnv(h, b"ck");
1589        h = fnv(h, b" ");
1590        h = fnv(h, b"text");
1591        h = fnv(h, b",");
1592        h = fnv(h, b";");
1593        h = fnv(h, b"val");
1594        h = fnv(h, b" ");
1595        h = fnv(h, b"text");
1596        h = fnv(h, b",");
1597        h = fnv(h, b"st");
1598        h = fnv(h, b" ");
1599        h = fnv(h, b"text");
1600        h = fnv(h, b" static,");
1601        let expected = format!("{:016x}", h);
1602
1603        let actual = compute_schema_hash(&example_schema());
1604        assert_eq!(
1605            actual, expected,
1606            "FNV-1a 64-bit schema hash must match known stable value"
1607        );
1608
1609        // Sanity: must be exactly 16 hex chars.
1610        assert_eq!(actual.len(), 16, "hash must be 16 hex characters");
1611    }
1612
1613    // -----------------------------------------------------------------------
1614    // Empty record list writes valid (empty) Parquet
1615    // -----------------------------------------------------------------------
1616
1617    #[test]
1618    fn test_empty_record_list() {
1619        let schema = example_schema();
1620        let bytes = write_delta_records_to_bytes(vec![], &schema, DeltaParquetOptions::default())
1621            .expect("write empty");
1622        assert!(!bytes.is_empty());
1623        assert_eq!(&bytes[0..4], b"PAR1");
1624    }
1625}