1use 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#[derive(Debug, Error)]
67pub enum DeltaParquetError {
68 #[error("delta schema error: {0}")]
70 Schema(#[from] DeltaSchemaError),
71 #[error("arrow error: {0}")]
73 Arrow(#[from] arrow::error::ArrowError),
74 #[error("parquet error: {0}")]
76 Parquet(#[from] ::parquet::errors::ParquetError),
77 #[error("{0}")]
79 InvalidValue(String),
80 #[error("invalid options: {0}")]
82 InvalidOptions(String),
83 #[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#[derive(Debug, Clone)]
103pub struct DeltaParquetOptions {
104 pub row_group_size: usize,
106 pub compression: DeltaParquetCompression,
108 pub schema_opts: DeltaSchemaOpts,
110 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
127pub enum DeltaParquetCompression {
128 #[default]
130 Snappy,
131 Zstd,
133 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
147pub struct DeltaParquetWriter<W: Write + Send> {
186 writer: Option<ArrowWriter<W>>,
188 schema: Arc<Schema>,
190 field_meta: Arc<FieldMeta>,
192 buffer: Vec<DeltaRecord>,
194 row_group_size: usize,
196 records_written: u64,
198 source: String,
200 schema_hash: String,
202}
203
204struct FieldMeta {
206 partition_keys: Vec<KeyColMeta>,
208 clustering_keys: Vec<KeyColMeta>,
210 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 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 let schema = derive_delta_schema(table, &options.schema_opts)?;
251 let schema = Arc::new(schema);
252
253 let field_meta = Arc::new(build_field_meta(table, &options.schema_opts)?);
255
256 let schema_hash = compute_schema_hash(table);
259
260 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 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 pub fn finalize(mut self) -> Result<(), DeltaParquetError> {
302 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 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 pub fn records_written(&self) -> u64 {
337 self.records_written
338 }
339
340 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 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
372pub 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
396static NULL_SENTINEL: Value = Value::Null;
406
407#[inline]
411fn as_value_ref(opt: Option<&Value>) -> Option<&Value> {
412 Some(opt.unwrap_or(&NULL_SENTINEL))
413}
414
415fn build_record_batch(
420 records: &[DeltaRecord],
421 schema: &Arc<Schema>,
422 meta: &FieldMeta,
423) -> Result<RecordBatch, DeltaParquetError> {
424 let mut arrays: Vec<ArrayRef> = Vec::with_capacity(schema.fields().len());
429
430 for (pk_idx, pk) in meta.partition_keys.iter().enumerate() {
432 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 for (ck_idx, ck) in meta.clustering_keys.iter().enumerate() {
446 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 _ => 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 for vcol in &meta.value_cols {
465 let arr = build_cell_struct_array(records, vcol)?;
466 arrays.push(arr);
467 }
468
469 {
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 {
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 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 let batch = RecordBatch::try_new(Arc::clone(schema), arrays)?;
503 Ok(batch)
504}
505
506fn build_cell_struct_array(
516 records: &[DeltaRecord],
517 vcol: &ValueColMeta,
518) -> Result<ArrayRef, DeltaParquetError> {
519 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 let struct_valid: Vec<bool> = cell_deltas.iter().map(|d| d.is_some()).collect();
527
528 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 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 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 let mut child_fields: Vec<Field> = vec![
552 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 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
578fn 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 _ => None,
596 }
597}
598
599fn build_range_bound_array(
609 records: &[DeltaRecord],
610 clustering_keys: &[KeyColMeta],
611 is_start: bool,
612) -> Result<ArrayRef, DeltaParquetError> {
613 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 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 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 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 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
663fn build_field_meta(
668 table: &TableSchema,
669 _opts: &DeltaSchemaOpts,
670) -> Result<FieldMeta, DeltaParquetError> {
671 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
741fn compute_schema_hash(table: &TableSchema) -> String {
755 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 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 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 hash = fnv1a_update(hash, b";");
785
786 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 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#[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 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 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 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 #[test]
906 fn test_upsert_partial_update() {
907 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 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 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 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 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 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 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 #[test]
1004 fn test_cell_tombstone_upsert() {
1005 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 assert!(
1026 val_struct.is_valid(0),
1027 "val struct must be NON-null for cell tombstone (column is present)"
1028 );
1029
1030 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 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 #[test]
1052 fn test_null_struct_absent_vs_cell_tombstone() {
1053 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 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 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 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 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 #[test]
1110 fn test_static_upsert() {
1111 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 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 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 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 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 #[test]
1175 fn test_row_delete() {
1176 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 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 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 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 #[test]
1221 fn test_range_delete() {
1222 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 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 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 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 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 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 #[test]
1312 fn test_partition_delete() {
1313 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 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 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 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 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 #[test]
1376 fn test_all_five_op_shapes_in_one_batch() {
1377 let schema = example_schema();
1378 let records = vec![
1379 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 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 DeltaRecord::RowDelete {
1398 keys: RowKeys::new(vec![Value::Integer(2)], vec![Value::Text("b".into())]),
1399 deleted_at: 300,
1400 },
1401 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 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 #[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 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 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 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 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 #[test]
1503 fn test_streaming_multiple_row_groups() {
1504 let schema = example_schema();
1505 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 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 #[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 #[test]
1558 fn test_schema_hash_fixed_value() {
1559 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 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 assert_eq!(actual.len(), 16, "hash must be 16 hex characters");
1611 }
1612
1613 #[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}