1use std::sync::Arc;
2
3use arrow::array::{ArrayRef, Int64Array, TimestampMicrosecondArray};
4use arrow::datatypes::{DataType, Field, Schema, SchemaRef, TimeUnit};
5use arrow::record_batch::RecordBatch;
6
7use crate::config::{MetaColumns, RowHash};
8use crate::error::Result;
9
10pub const COL_EXPORTED_AT: &str = "_rivet_exported_at";
11pub const COL_ROW_HASH: &str = "_rivet_row_hash";
12
13pub const ROW_HASH_RENDER_ID: &str = "xxh3-128-i64-arrow-display-us-v2";
37
38#[derive(
51 Debug, serde::Deserialize, serde::Serialize, schemars::JsonSchema, Clone, PartialEq, Eq,
52)]
53pub struct RowHashContract {
54 pub column: String,
57 pub covered: RowHash,
59 pub render: String,
61}
62
63impl RowHashContract {
64 pub fn of(spec: &RowHash) -> Option<Self> {
67 spec.enabled().then(|| Self {
68 column: COL_ROW_HASH.to_string(),
69 covered: spec.clone(),
70 render: ROW_HASH_RENDER_ID.to_string(),
71 })
72 }
73}
74
75pub fn enrich_schema(schema: &SchemaRef, meta: &MetaColumns) -> Result<SchemaRef> {
84 if !meta.exported_at && !meta.row_hash.enabled() {
85 return Ok(schema.clone());
86 }
87 row_hash_columns(schema, &meta.row_hash)?;
90 let mut fields: Vec<Arc<Field>> = schema.fields().iter().cloned().collect();
91 if meta.exported_at {
92 fields.push(Arc::new(Field::new(
93 COL_EXPORTED_AT,
94 DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
95 false,
96 )));
97 }
98 if meta.row_hash.enabled() {
99 fields.push(Arc::new(Field::new(COL_ROW_HASH, DataType::Int64, false)));
100 }
101 Ok(Arc::new(Schema::new(fields)))
102}
103
104pub fn enrich_batch(
107 batch: &RecordBatch,
108 meta: &MetaColumns,
109 enriched_schema: &SchemaRef,
110 exported_at_us: i64,
111) -> Result<RecordBatch> {
112 if !meta.exported_at && !meta.row_hash.enabled() {
113 return Ok(batch.clone());
114 }
115
116 let n = batch.num_rows();
117 let mut columns: Vec<ArrayRef> = batch.columns().to_vec();
118
119 if meta.exported_at {
120 let ts_array =
121 TimestampMicrosecondArray::from(vec![Some(exported_at_us); n]).with_timezone("UTC");
122 columns.push(Arc::new(ts_array));
123 }
124
125 if meta.row_hash.enabled() {
126 let names = row_hash_columns(&batch.schema(), &meta.row_hash)?;
131 columns.push(row_hash_array(batch, &names)?);
132 }
133
134 Ok(RecordBatch::try_new(enriched_schema.clone(), columns)?)
135}
136
137pub fn row_hash_columns(schema: &SchemaRef, spec: &RowHash) -> Result<Vec<String>> {
145 let available: Vec<String> = schema
146 .fields()
147 .iter()
148 .map(|f| f.name().to_string())
149 .collect();
150 row_hash_columns_of(&available, spec)
151}
152
153pub fn row_hash_columns_of(available: &[String], spec: &RowHash) -> Result<Vec<String>> {
162 let Some(declared) = spec.declared() else {
163 return Ok(available.to_vec());
164 };
165 for name in declared {
166 if !available.iter().any(|a| a == name) {
167 anyhow::bail!(
168 "meta_columns.row_hash names column '{name}', which this export does not \
169 project. Available: {}",
170 available.join(", ")
171 );
172 }
173 }
174 Ok(declared.to_vec())
175}
176
177pub fn row_hash_array(batch: &RecordBatch, cols: &[String]) -> Result<ArrayRef> {
181 let idx: Vec<usize> = cols
182 .iter()
183 .map(|c| {
184 batch.schema().index_of(c).map_err(|_| {
185 anyhow::anyhow!("row_hash: column '{c}' vanished from the batch schema")
186 })
187 })
188 .collect::<Result<Vec<_>>>()?;
189 hash_column(batch, batch.num_rows(), &idx).map(|a| Arc::new(a) as ArrayRef)
190}
191
192const TAG_NULL: u8 = 0x00;
196const TAG_PRESENT: u8 = 0x01;
197
198fn is_container(dt: &DataType) -> bool {
212 matches!(
213 dt,
214 DataType::List(_)
215 | DataType::LargeList(_)
216 | DataType::ListView(_)
217 | DataType::LargeListView(_)
218 | DataType::FixedSizeList(_, _)
219 | DataType::Struct(_)
220 | DataType::Map(_, _)
221 | DataType::Union(_, _)
222 | DataType::RunEndEncoded(_, _)
223 )
224}
225
226fn write_leaf(
233 buf: &mut Vec<u8>,
234 array: &dyn arrow::array::Array,
235 fmt: &arrow::util::display::ArrayFormatter,
236 row: usize,
237) {
238 use std::io::Write as IoWrite;
239 if array.is_null(row) {
240 buf.push(TAG_NULL);
241 return;
242 }
243 buf.push(TAG_PRESENT);
244 let len_at = buf.len();
247 buf.extend_from_slice(&0u32.to_le_bytes());
248 let _ = write!(buf, "{}", fmt.value(row));
250 let len = (buf.len() - len_at - 4) as u32;
251 buf[len_at..len_at + 4].copy_from_slice(&len.to_le_bytes());
252}
253
254fn write_canon(
259 buf: &mut Vec<u8>,
260 array: &dyn arrow::array::Array,
261 row: usize,
262 name: &str,
263 options: &arrow::util::display::FormatOptions,
264) -> Result<()> {
265 use arrow::array::{FixedSizeListArray, LargeListArray, ListArray, MapArray, StructArray};
266
267 if !is_container(array.data_type()) {
268 let fmt = leaf_formatter(array, name, options)?;
269 write_leaf(buf, array, &fmt, row);
270 return Ok(());
271 }
272 if array.is_null(row) {
273 buf.push(TAG_NULL);
277 return Ok(());
278 }
279 buf.push(TAG_PRESENT);
280 match array.data_type() {
281 DataType::List(_) => {
282 let a = downcast::<ListArray>(array, name)?;
283 write_elements(buf, a.value(row).as_ref(), name, options)
284 }
285 DataType::LargeList(_) => {
286 let a = downcast::<LargeListArray>(array, name)?;
287 write_elements(buf, a.value(row).as_ref(), name, options)
288 }
289 DataType::FixedSizeList(_, _) => {
290 let a = downcast::<FixedSizeListArray>(array, name)?;
291 write_elements(buf, a.value(row).as_ref(), name, options)
292 }
293 DataType::Map(_, _) => {
294 let a = downcast::<MapArray>(array, name)?;
297 let entries = a.value(row);
298 write_elements(buf, &entries, name, options)
299 }
300 DataType::Struct(_) => {
301 let a = downcast::<StructArray>(array, name)?;
305 for child in a.columns() {
306 write_canon(buf, child.as_ref(), row, name, options)?;
307 }
308 Ok(())
309 }
310 other => anyhow::bail!(
315 "row_hash: column '{name}' has type {other}, for which rivet has no canonical \
316 form, so hashing it could attest that different rows are equal. Declare the \
317 covered set without it — `meta_columns.row_hash: [col, ...]` — or leave \
318 `row_hash` off for this export."
319 ),
320 }
321}
322
323fn write_elements(
331 buf: &mut Vec<u8>,
332 elements: &dyn arrow::array::Array,
333 name: &str,
334 options: &arrow::util::display::FormatOptions,
335) -> Result<()> {
336 buf.extend_from_slice(&(elements.len() as u32).to_le_bytes());
337 if is_container(elements.data_type()) {
338 for i in 0..elements.len() {
339 write_canon(buf, elements, i, name, options)?;
340 }
341 return Ok(());
342 }
343 let fmt = leaf_formatter(elements, name, options)?;
346 for i in 0..elements.len() {
347 write_leaf(buf, elements, &fmt, i);
348 }
349 Ok(())
350}
351
352fn leaf_formatter<'a>(
353 array: &'a dyn arrow::array::Array,
354 name: &str,
355 options: &'a arrow::util::display::FormatOptions,
356) -> Result<arrow::util::display::ArrayFormatter<'a>> {
357 arrow::util::display::ArrayFormatter::try_new(array, options).map_err(|e| {
358 anyhow::anyhow!(
359 "row_hash: column '{name}' has type {} which Arrow cannot render, so it cannot \
360 be hashed ({e}). Declare the covered set without it — `meta_columns.row_hash: \
361 [col, ...]`.",
362 array.data_type()
363 )
364 })
365}
366
367fn downcast<'a, T: 'static>(array: &'a dyn arrow::array::Array, name: &str) -> Result<&'a T> {
368 array.as_any().downcast_ref::<T>().ok_or_else(|| {
369 anyhow::anyhow!(
370 "row_hash: column '{name}' declares {} but its array is a different kind — \
371 refusing rather than hashing a value read as the wrong shape",
372 array.data_type()
373 )
374 })
375}
376
377fn hash_column(batch: &RecordBatch, n: usize, cols: &[usize]) -> Result<Int64Array> {
392 use xxhash_rust::xxh3::xxh3_128;
393
394 enum Top<'a> {
398 Scalar(arrow::util::display::ArrayFormatter<'a>),
399 Container,
400 }
401
402 let options = arrow::util::display::FormatOptions::default();
403 let names: Vec<String> = cols
404 .iter()
405 .map(|&i| batch.schema().field(i).name().to_string())
406 .collect();
407 let tops: Vec<Top> = cols
408 .iter()
409 .zip(&names)
410 .map(|(&i, name)| {
411 let array = batch.column(i);
412 if is_container(array.data_type()) {
413 Ok(Top::Container)
414 } else {
415 leaf_formatter(array.as_ref(), name, &options).map(Top::Scalar)
416 }
417 })
418 .collect::<Result<Vec<_>>>()?;
419
420 let mut buf = Vec::with_capacity(256);
421 let mut hashes = Vec::with_capacity(n);
422 for row in 0..n {
423 buf.clear();
424 for ((&col_idx, top), name) in cols.iter().zip(&tops).zip(&names) {
425 let array = batch.column(col_idx);
426 match top {
427 Top::Scalar(fmt) => write_leaf(&mut buf, array.as_ref(), fmt, row),
428 Top::Container => write_canon(&mut buf, array.as_ref(), row, name, &options)?,
429 }
430 }
431 let h = xxh3_128(&buf);
432 hashes.push(h as i64);
433 }
434 Ok(Int64Array::from(hashes))
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use arrow::array::StringArray;
441 use arrow::datatypes::Field;
442
443 fn sample_batch() -> (SchemaRef, RecordBatch) {
444 let schema = Arc::new(Schema::new(vec![
445 Field::new("id", DataType::Int64, false),
446 Field::new("name", DataType::Utf8, true),
447 ]));
448 let batch = RecordBatch::try_new(
449 schema.clone(),
450 vec![
451 Arc::new(Int64Array::from(vec![1, 2, 3])),
452 Arc::new(StringArray::from(vec![
453 Some("alice"),
454 None,
455 Some("charlie"),
456 ])),
457 ],
458 )
459 .unwrap();
460 (schema, batch)
461 }
462
463 #[test]
468 fn meta_column_order_is_pinned() {
469 let (schema, batch) = sample_batch();
470 let meta = MetaColumns {
471 exported_at: true,
472 row_hash: RowHash::All(true),
473 };
474 let enriched = enrich_schema(&schema, &meta).unwrap();
475 assert_eq!(
476 enriched
477 .fields()
478 .iter()
479 .map(|f| f.name().as_str())
480 .collect::<Vec<_>>(),
481 vec!["id", "name", COL_EXPORTED_AT, COL_ROW_HASH]
482 );
483 enrich_batch(&batch, &meta, &enriched, 0).unwrap();
485 }
486
487 #[test]
492 fn the_row_hash_ignores_the_meta_columns_it_ships_beside() {
493 let (_, batch) = sample_batch();
494 let without = row_hashes(
495 &MetaColumns {
496 exported_at: false,
497 row_hash: RowHash::All(true),
498 },
499 &batch,
500 );
501 let with = row_hashes(
502 &MetaColumns {
503 exported_at: true,
504 row_hash: RowHash::All(true),
505 },
506 &batch,
507 );
508 assert_eq!(without, with);
509 }
510
511 #[test]
517 fn the_contract_records_the_spec_and_a_render_id() {
518 assert_eq!(RowHashContract::of(&RowHash::All(false)), None);
519 let c = RowHashContract::of(&RowHash::All(true)).unwrap();
520 assert_eq!(c.column, COL_ROW_HASH);
521 assert_eq!(c.covered, RowHash::All(true));
522 assert_eq!(c.render, ROW_HASH_RENDER_ID);
523
524 let declared = RowHash::Columns(vec!["status".into(), "amount".into()]);
525 let c = RowHashContract::of(&declared).unwrap();
526 assert_eq!(c.covered, declared);
527 assert_ne!(
528 RowHashContract::of(&RowHash::Columns(vec!["a".into(), "b".into()])),
529 RowHashContract::of(&RowHash::Columns(vec!["b".into(), "a".into()])),
530 "column order changes every hash, so it must survive into the contract"
531 );
532 }
533
534 fn row_hashes(meta: &MetaColumns, batch: &RecordBatch) -> Vec<i64> {
535 let schema = enrich_schema(&batch.schema(), meta).unwrap();
536 let out = enrich_batch(batch, meta, &schema, 0).unwrap();
537 let a = out
538 .column_by_name(COL_ROW_HASH)
539 .unwrap()
540 .as_any()
541 .downcast_ref::<Int64Array>()
542 .unwrap();
543 (0..a.len()).map(|i| a.value(i)).collect()
544 }
545
546 fn meta_rh(spec: RowHash) -> MetaColumns {
547 MetaColumns {
548 exported_at: false,
549 row_hash: spec,
550 }
551 }
552
553 #[test]
556 fn row_hash_true_still_covers_every_column() {
557 let (schema, batch) = sample_batch();
558 assert_eq!(
559 row_hash_columns(&schema, &RowHash::All(true)).unwrap(),
560 vec!["id", "name"]
561 );
562 assert_eq!(
563 row_hashes(&meta_rh(RowHash::All(true)), &batch),
564 row_hashes(
565 &meta_rh(RowHash::Columns(vec!["id".into(), "name".into()])),
566 &batch
567 ),
568 "naming every column must equal asking for all of them"
569 );
570 }
571
572 #[test]
577 fn a_declared_set_narrows_what_the_hash_attests() {
578 let schema = Arc::new(Schema::new(vec![
579 Field::new("id", DataType::Int64, false),
580 Field::new("name", DataType::Utf8, true),
581 ]));
582 let mk = |name: &str| {
583 RecordBatch::try_new(
584 schema.clone(),
585 vec![
586 Arc::new(Int64Array::from(vec![1])),
587 Arc::new(StringArray::from(vec![Some(name)])),
588 ],
589 )
590 .unwrap()
591 };
592 let id_only = meta_rh(RowHash::Columns(vec!["id".into()]));
593 assert_eq!(
594 row_hashes(&id_only, &mk("alice")),
595 row_hashes(&id_only, &mk("bob")),
596 "a column outside the declared set must not move the hash"
597 );
598 let both = meta_rh(RowHash::All(true));
599 assert_ne!(
600 row_hashes(&both, &mk("alice")),
601 row_hashes(&both, &mk("bob")),
602 "...and inside it, it must"
603 );
604 }
605
606 #[test]
609 fn declared_order_changes_the_hash() {
610 let (_, batch) = sample_batch();
611 assert_ne!(
612 row_hashes(
613 &meta_rh(RowHash::Columns(vec!["id".into(), "name".into()])),
614 &batch
615 ),
616 row_hashes(
617 &meta_rh(RowHash::Columns(vec!["name".into(), "id".into()])),
618 &batch
619 ),
620 );
621 }
622
623 #[test]
626 fn an_unprojected_column_is_refused_with_the_available_list() {
627 let (schema, _) = sample_batch();
628 let err = row_hash_columns(&schema, &RowHash::Columns(vec!["nope".into()]))
629 .unwrap_err()
630 .to_string();
631 assert!(err.contains("nope") && err.contains("id, name"), "{err}");
632 }
633
634 #[test]
635 fn empty_and_duplicate_declared_sets_are_refused() {
636 let err = RowHash::Columns(vec![])
637 .validate("e")
638 .unwrap_err()
639 .to_string();
640 assert!(err.contains("attests nothing"), "{err}");
641 let err = RowHash::Columns(vec!["a".into(), "a".into()])
642 .validate("e")
643 .unwrap_err()
644 .to_string();
645 assert!(err.contains("twice"), "{err}");
646 RowHash::Columns(vec!["a".into()]).validate("e").unwrap();
647 RowHash::All(true).validate("e").unwrap();
648 }
649
650 #[test]
660 fn cdc_meta_columns_do_not_enter_the_row_hash() {
661 let data_only = Arc::new(Schema::new(vec![
662 Field::new("id", DataType::Int64, false),
663 Field::new("name", DataType::Utf8, true),
664 ]));
665 let snapshot = RecordBatch::try_new(
666 data_only,
667 vec![
668 Arc::new(Int64Array::from(vec![1])),
669 Arc::new(StringArray::from(vec![Some("alice")])),
670 ],
671 )
672 .unwrap();
673
674 let with_meta = Arc::new(Schema::new(vec![
676 Field::new("__op", DataType::Utf8, false),
677 Field::new("__pos", DataType::Utf8, false),
678 Field::new("__seq", DataType::Int64, false),
679 Field::new("id", DataType::Int64, false),
680 Field::new("name", DataType::Utf8, true),
681 ]));
682 let drain = RecordBatch::try_new(
683 with_meta,
684 vec![
685 Arc::new(StringArray::from(vec![Some("insert")])),
686 Arc::new(StringArray::from(vec![Some("{\"pos\":7}")])),
687 Arc::new(Int64Array::from(vec![7])),
688 Arc::new(Int64Array::from(vec![1])),
689 Arc::new(StringArray::from(vec![Some("alice")])),
690 ],
691 )
692 .unwrap();
693
694 let data: Vec<String> = vec!["id".into(), "name".into()];
695 let read = |b: &RecordBatch| {
696 let a = row_hash_array(b, &data).unwrap();
697 a.as_any().downcast_ref::<Int64Array>().unwrap().value(0)
698 };
699 assert_eq!(read(&snapshot), read(&drain));
700
701 assert_eq!(
705 row_hash_columns_of(&data, &RowHash::All(true)).unwrap(),
706 data
707 );
708 }
709
710 #[test]
711 fn enrich_disabled_is_noop() {
712 let (schema, batch) = sample_batch();
713 let meta = MetaColumns {
714 exported_at: false,
715 row_hash: RowHash::All(false),
716 };
717 let enriched_schema = enrich_schema(&schema, &meta).unwrap();
718 assert_eq!(enriched_schema.fields().len(), 2);
719 let result = enrich_batch(&batch, &meta, &enriched_schema, 0).unwrap();
720 assert_eq!(result.num_columns(), 2);
721 }
722
723 #[test]
724 fn enrich_exported_at_only() {
725 let (schema, batch) = sample_batch();
726 let meta = MetaColumns {
727 exported_at: true,
728 row_hash: RowHash::All(false),
729 };
730 let enriched_schema = enrich_schema(&schema, &meta).unwrap();
731 assert_eq!(enriched_schema.fields().len(), 3);
732 assert_eq!(enriched_schema.field(2).name(), COL_EXPORTED_AT);
733
734 let ts = 1_711_612_800_000_000i64;
735 let result = enrich_batch(&batch, &meta, &enriched_schema, ts).unwrap();
736 assert_eq!(result.num_columns(), 3);
737 assert_eq!(result.num_rows(), 3);
738
739 let ts_col = result
740 .column(2)
741 .as_any()
742 .downcast_ref::<TimestampMicrosecondArray>()
743 .unwrap();
744 assert_eq!(ts_col.value(0), ts);
745 assert_eq!(ts_col.value(2), ts);
746 }
747
748 #[test]
749 fn enrich_row_hash_only() {
750 let (schema, batch) = sample_batch();
751 let meta = MetaColumns {
752 exported_at: false,
753 row_hash: RowHash::All(true),
754 };
755 let enriched_schema = enrich_schema(&schema, &meta).unwrap();
756 assert_eq!(enriched_schema.field(2).name(), COL_ROW_HASH);
757 assert_eq!(*enriched_schema.field(2).data_type(), DataType::Int64);
758
759 let result = enrich_batch(&batch, &meta, &enriched_schema, 0).unwrap();
760 let hash_col = result
761 .column(2)
762 .as_any()
763 .downcast_ref::<Int64Array>()
764 .unwrap();
765
766 assert_ne!(hash_col.value(0), hash_col.value(1));
768 assert_ne!(hash_col.value(1), hash_col.value(2));
769 }
770
771 #[test]
772 fn enrich_both_columns() {
773 let (schema, batch) = sample_batch();
774 let meta = MetaColumns {
775 exported_at: true,
776 row_hash: RowHash::All(true),
777 };
778 let enriched_schema = enrich_schema(&schema, &meta).unwrap();
779 assert_eq!(enriched_schema.fields().len(), 4);
780 assert_eq!(enriched_schema.field(2).name(), COL_EXPORTED_AT);
781 assert_eq!(enriched_schema.field(3).name(), COL_ROW_HASH);
782
783 let result = enrich_batch(&batch, &meta, &enriched_schema, 123456).unwrap();
784 assert_eq!(result.num_columns(), 4);
785 assert_eq!(result.num_rows(), 3);
786 }
787
788 #[test]
789 fn hash_is_deterministic() {
790 let (schema, batch) = sample_batch();
791 let meta = MetaColumns {
792 exported_at: false,
793 row_hash: RowHash::All(true),
794 };
795 let enriched_schema = enrich_schema(&schema, &meta).unwrap();
796
797 let r1 = enrich_batch(&batch, &meta, &enriched_schema, 0).unwrap();
798 let r2 = enrich_batch(&batch, &meta, &enriched_schema, 0).unwrap();
799
800 let h1 = r1.column(2).as_any().downcast_ref::<Int64Array>().unwrap();
801 let h2 = r2.column(2).as_any().downcast_ref::<Int64Array>().unwrap();
802 for i in 0..3 {
803 assert_eq!(
804 h1.value(i),
805 h2.value(i),
806 "hash should be deterministic for row {i}"
807 );
808 }
809 }
810
811 #[test]
812 fn hash_distinguishes_null_from_empty() {
813 let schema = Arc::new(Schema::new(vec![Field::new("val", DataType::Utf8, true)]));
814 let batch = RecordBatch::try_new(
815 schema.clone(),
816 vec![Arc::new(StringArray::from(vec![None, Some("")]))],
817 )
818 .unwrap();
819
820 let meta = MetaColumns {
821 exported_at: false,
822 row_hash: RowHash::All(true),
823 };
824 let enriched_schema = enrich_schema(&schema, &meta).unwrap();
825 let result = enrich_batch(&batch, &meta, &enriched_schema, 0).unwrap();
826 let hashes = result
827 .column(1)
828 .as_any()
829 .downcast_ref::<Int64Array>()
830 .unwrap();
831 assert_ne!(
832 hashes.value(0),
833 hashes.value(1),
834 "NULL and empty string should hash differently"
835 );
836 }
837
838 fn two_column_hashes(a: Vec<Option<&str>>, b: Vec<Option<&str>>) -> Vec<i64> {
844 let schema = Arc::new(Schema::new(vec![
845 Field::new("a", DataType::Utf8, true),
846 Field::new("b", DataType::Utf8, true),
847 ]));
848 let batch = RecordBatch::try_new(
849 schema.clone(),
850 vec![
851 Arc::new(StringArray::from(a)) as ArrayRef,
852 Arc::new(StringArray::from(b)) as ArrayRef,
853 ],
854 )
855 .unwrap();
856 let meta = meta_rh(RowHash::All(true));
857 let es = enrich_schema(&schema, &meta).unwrap();
858 let out = enrich_batch(&batch, &meta, &es, 0).unwrap();
859 let h = out
860 .column(out.num_columns() - 1)
861 .as_any()
862 .downcast_ref::<Int64Array>()
863 .unwrap();
864 (0..h.len()).map(|i| h.value(i)).collect()
865 }
866
867 #[test]
875 fn a_value_containing_the_old_separator_cannot_forge_a_field_boundary() {
876 let h = two_column_hashes(
877 vec![Some("a\u{1f}"), Some("a")],
878 vec![Some("b"), Some("\u{1f}b")],
879 );
880 assert_ne!(
881 h[0], h[1],
882 "('a\\x1f','b') and ('a','\\x1fb') are DIFFERENT rows and must not share a hash"
883 );
884 }
885
886 #[test]
890 fn hash_distinguishes_null_from_a_value_rendering_as_the_null_marker() {
891 let h = two_column_hashes(vec![None, Some("\u{0}")], vec![Some("x"), Some("x")]);
892 assert_ne!(
893 h[0], h[1],
894 "NULL and the one-NUL-byte string must not share a hash"
895 );
896 }
897
898 #[test]
902 fn hash_distinguishes_where_the_field_boundary_falls() {
903 let h = two_column_hashes(vec![Some("ab"), Some("a")], vec![Some("c"), Some("bc")]);
904 assert_ne!(
905 h[0], h[1],
906 "('ab','c') and ('a','bc') are DIFFERENT rows and must not share a hash"
907 );
908 }
909
910 #[test]
928 fn an_array_column_hashes_injectively_not_by_its_display_text() {
929 use arrow::array::{ListBuilder, StringBuilder};
930 let mut b = ListBuilder::new(StringBuilder::new());
931 b.values().append_null(); b.append(true);
933 b.values().append_value(""); b.append(true);
935 b.append(true); b.values().append_value("a, b"); b.append(true);
938 b.values().append_value("a"); b.values().append_value("b");
940 b.append(true);
941 b.append(false); let arr = Arc::new(b.finish()) as ArrayRef;
943
944 let schema = Arc::new(Schema::new(vec![Field::new(
945 "tags",
946 arr.data_type().clone(),
947 true,
948 )]));
949 let batch = RecordBatch::try_new(schema.clone(), vec![arr]).unwrap();
950 let meta = meta_rh(RowHash::All(true));
951 let es = enrich_schema(&schema, &meta).unwrap();
952 let out = enrich_batch(&batch, &meta, &es, 0).expect("an array column is hashable");
953 let h = out
954 .column(out.num_columns() - 1)
955 .as_any()
956 .downcast_ref::<Int64Array>()
957 .unwrap();
958 let got: Vec<i64> = (0..6).map(|i| h.value(i)).collect();
959
960 let mut uniq = got.clone();
961 uniq.sort_unstable();
962 uniq.dedup();
963 assert_eq!(
964 uniq.len(),
965 6,
966 "all six array shapes are DIFFERENT values and must hash differently — \
967 [NULL], [\"\"], [], [\"a, b\"], [\"a\",\"b\"], NULL; got {got:?}"
968 );
969 }
970
971 #[test]
975 fn a_struct_column_distinguishes_its_fields_and_their_nulls() {
976 use arrow::array::StructArray;
977 let f = |name: &str, v: Vec<Option<&str>>| {
978 (
979 Arc::new(Field::new(name, DataType::Utf8, true)),
980 Arc::new(StringArray::from(v)) as ArrayRef,
981 )
982 };
983 let arr = Arc::new(StructArray::from(vec![
985 f("a", vec![Some("x"), Some("x"), Some("")]),
986 f("b", vec![Some(""), None, Some("x")]),
987 ])) as ArrayRef;
988
989 let schema = Arc::new(Schema::new(vec![Field::new(
990 "meta",
991 arr.data_type().clone(),
992 true,
993 )]));
994 let batch = RecordBatch::try_new(schema.clone(), vec![arr]).unwrap();
995 let meta = meta_rh(RowHash::All(true));
996 let es = enrich_schema(&schema, &meta).unwrap();
997 let out = enrich_batch(&batch, &meta, &es, 0).expect("a struct column is hashable");
998 let h = out
999 .column(out.num_columns() - 1)
1000 .as_any()
1001 .downcast_ref::<Int64Array>()
1002 .unwrap();
1003 let got: Vec<i64> = (0..3).map(|i| h.value(i)).collect();
1004 let mut uniq = got.clone();
1005 uniq.sort_unstable();
1006 uniq.dedup();
1007 assert_eq!(
1008 uniq.len(),
1009 3,
1010 "an empty field, a NULL field and content moved between fields are three \
1011 different rows; got {got:?}"
1012 );
1013 }
1014
1015 #[test]
1024 fn the_rendering_is_pinned_to_literals_and_to_its_render_id() {
1025 let h = two_column_hashes(vec![Some("a"), None], vec![Some("b"), Some("")]);
1026 assert_eq!(
1027 h,
1028 vec![
1029 -7_527_003_277_948_829_337_i64,
1030 -5_099_907_639_937_407_564_i64
1031 ],
1032 "the row-hash rendering changed — bump ROW_HASH_RENDER_ID and re-pin these \
1033 literals together, never one without the other"
1034 );
1035 assert_eq!(
1036 ROW_HASH_RENDER_ID, "xxh3-128-i64-arrow-display-us-v2",
1037 "the render id must change whenever the rendering above does"
1038 );
1039 }
1040}