Repository: apache/arrow-rs
Pull request #5782 (2024-05-17 20:00:51)

feat(arrow-csv): support encoding of binary in CSV writer
Allows for writing binary (Binary, LargeBinary, and FixedSizeBinary) to CSV. Note: FixedSizeBinary was already being supported in this way.

Values are encoded as HEX, by using the default Arrow formatter.

A test was added that accounts for null values when encoding all three binary types in CSV.

# Which issue does this PR close?

<!--
We generally require a GitHub issue to be filed for all bug fixes and enhancements and this helps us generate change logs for our releases. You can link an issue to this PR using the GitHub syntax. For example `Closes #123` indicates that this PR will close issue #123.
-->

Closes #3921.

# Rationale for this change
 
<!--
Why are you proposing this change? If this is already explained clearly in the issue then this section is not needed.
Explaining clearly why changes are proposed helps reviewers understand your changes and offer better suggestions for fixes.
-->

Currently, `FixedSizeBinary` is encoded in CSV (and JSON) as HEX (see https://github.com/apache/arrow-rs/issues/3291#issuecomment-2118244809). Therefore, it seemed reasonable to lift the restriction that was preventing writing `Binary` and `LargeBinary` arrays.

# What changes are included in this PR?

<!--
There is no need to duplicate the description in the issue here but it is sometimes worth providing a summary of the individual changes in this PR.
-->

The condition that checks if the datatype on the array is `Binary` or `LargeBinary` was removed, and a test was added to check writing to CSV of the three binary types: `Binary`, `LargeBinary`, and `FixedSizeBinary`. The test also checks for null values.

# Are there any user-facing changes?

<!--
If there are user-facing changes then we may require documentation to be updated before approving the PR.
-->

<!---
If there are any breaking changes to public APIs, please add the `breaking change` label.
-->
This will change the behaviour of the writer for users, but should not be a breaking change, since previously, an error was being thrown when attempting to write these data types to CSV.

We may need to document that such types are encoded as HEX in the CSV writer, as they are with the standard Arrow formatter.

hiltontj: It is worth noting the discussion here: https://github.com/apache/arrow-rs/pull/3292 where, at the time, I think there was some contention about encoding binary in CSV based on other Arrow implementations (pyarrow).

Patch:
diff --git a/README.md b/README.md
index bc4add930..4005f66f6 100644
--- a/README.md
+++ b/README.md
@@ -86,7 +86,7 @@ There are two related crates in different repositories
 | [`ballista`]   | Distributed query execution             | [(README)][ballista-readme]   |
 
 [`datafusion`]: https://crates.io/crates/datafusion
-[`ballista`]: https://crates.io/crates/datafusion-ballista
+[`ballista`]: https://crates.io/crates/ballista
 
 Collectively, these crates support a wider array of functionality for analytic computations in Rust.
 
diff --git a/arrow-array/src/array/fixed_size_list_array.rs b/arrow-array/src/array/fixed_size_list_array.rs
index 1d07daf4c..7bb4e0c5e 100644
--- a/arrow-array/src/array/fixed_size_list_array.rs
+++ b/arrow-array/src/array/fixed_size_list_array.rs
@@ -183,7 +183,8 @@ impl FixedSizeListArray {
                 || nulls
                     .as_ref()
                     .map(|n| n.expand(size as _).contains(&a))
-                    .unwrap_or_default();
+                    .unwrap_or_default()
+                || (nulls.is_none() && a.null_count() == 0);
 
             if !nulls_valid {
                 return Err(ArrowError::InvalidArgumentError(format!(
diff --git a/arrow-cast/src/cast/list.rs b/arrow-cast/src/cast/list.rs
index 2ceab1d0c..ec7a5c57d 100644
--- a/arrow-cast/src/cast/list.rs
+++ b/arrow-cast/src/cast/list.rs
@@ -41,6 +41,15 @@ pub(crate) fn cast_values_to_fixed_size_list(
     Ok(Arc::new(list))
 }
 
+pub(crate) fn cast_single_element_fixed_size_list_to_values(
+    array: &dyn Array,
+    to: &DataType,
+    cast_options: &CastOptions,
+) -> Result<ArrayRef, ArrowError> {
+    let values = array.as_fixed_size_list().values();
+    cast_with_options(values, to, cast_options)
+}
+
 pub(crate) fn cast_fixed_size_list_to_list<OffsetSize>(
     array: &dyn Array,
 ) -> Result<ArrayRef, ArrowError>
diff --git a/arrow-cast/src/cast/mod.rs b/arrow-cast/src/cast/mod.rs
index ccbc8727f..f2fdfd044 100644
--- a/arrow-cast/src/cast/mod.rs
+++ b/arrow-cast/src/cast/mod.rs
@@ -157,6 +157,8 @@ pub fn can_cast_types(from_type: &DataType, to_type: &DataType) -> bool {
         (_, LargeList(list_to)) => can_cast_types(from_type, list_to.data_type()),
         (_, FixedSizeList(list_to,size)) if *size == 1 => {
             can_cast_types(from_type, list_to.data_type())},
+        (FixedSizeList(list_from,size), _) if *size == 1 => {
+            can_cast_types(list_from.data_type(), to_type)},
         // cast one decimal type to another decimal type
         (Decimal128(_, _), Decimal128(_, _)) => true,
         (Decimal256(_, _), Decimal256(_, _)) => true,
@@ -712,6 +714,9 @@ pub fn cast_with_options(
         (_, FixedSizeList(ref to, size)) if *size == 1 => {
             cast_values_to_fixed_size_list(array, to, *size, cast_options)
         }
+        (FixedSizeList(_, size), _) if *size == 1 => {
+            cast_single_element_fixed_size_list_to_values(array, to_type, cast_options)
+        }
         (Decimal128(_, s1), Decimal128(p2, s2)) => {
             cast_decimal_to_decimal_same_type::<Decimal128Type>(
                 array.as_primitive(),
@@ -6895,6 +6900,85 @@ mod tests {
         assert_eq!(&expect.value(0), &actual.value(0));
     }
 
+    #[test]
+    fn test_cast_single_element_fixed_size_list() {
+        // FixedSizeList<T>[1] => T
+        let from_array = Arc::new(FixedSizeListArray::from_iter_primitive::<Int16Type, _, _>(
+            [(Some([Some(5)]))],
+            1,
+        )) as ArrayRef;
+        let casted_array = cast(&from_array, &DataType::Int32).unwrap();
+        let actual: &Int32Array = casted_array.as_primitive();
+        let expected = Int32Array::from(vec![Some(5)]);
+        assert_eq!(&expected, actual);
+
+        // FixedSizeList<T>[1] => FixedSizeList<U>[1]
+        let from_array = Arc::new(FixedSizeListArray::from_iter_primitive::<Int16Type, _, _>(
+            [(Some([Some(5)]))],
+            1,
+        )) as ArrayRef;
+        let to_field = Arc::new(Field::new("dummy", DataType::Float32, false));
+        let actual = cast(&from_array, &DataType::FixedSizeList(to_field.clone(), 1)).unwrap();
+        let expected = Arc::new(FixedSizeListArray::new(
+            to_field.clone(),
+            1,
+            Arc::new(Float32Array::from(vec![Some(5.0)])) as ArrayRef,
+            None,
+        )) as ArrayRef;
+        assert_eq!(*expected, *actual);
+
+        // FixedSizeList<T>[1] => FixedSizeList<FixdSizedList<U>[1]>[1]
+        let from_array = Arc::new(FixedSizeListArray::from_iter_primitive::<Int16Type, _, _>(
+            [(Some([Some(5)]))],
+            1,
+        )) as ArrayRef;
+        let to_field_inner = Arc::new(Field::new("item", DataType::Float32, false));
+        let to_field = Arc::new(Field::new(
+            "dummy",
+            DataType::FixedSizeList(to_field_inner.clone(), 1),
+            false,
+        ));
+        let actual = cast(&from_array, &DataType::FixedSizeList(to_field.clone(), 1)).unwrap();
+        let expected = Arc::new(FixedSizeListArray::new(
+            to_field.clone(),
+            1,
+            Arc::new(FixedSizeListArray::new(
+                to_field_inner.clone(),
+                1,
+                Arc::new(Float32Array::from(vec![Some(5.0)])) as ArrayRef,
+                None,
+            )) as ArrayRef,
+            None,
+        )) as ArrayRef;
+        assert_eq!(*expected, *actual);
+
+        // T => FixedSizeList<T>[1] (non-nullable)
+        let field = Arc::new(Field::new("dummy", DataType::Float32, false));
+        let from_array = Arc::new(Int8Array::from(vec![Some(5)])) as ArrayRef;
+        let casted_array = cast(&from_array, &DataType::FixedSizeList(field.clone(), 1)).unwrap();
+        let actual = casted_array.as_fixed_size_list();
+        let expected = Arc::new(FixedSizeListArray::new(
+            field.clone(),
+            1,
+            Arc::new(Float32Array::from(vec![Some(5.0)])) as ArrayRef,
+            None,
+        )) as ArrayRef;
+        assert_eq!(expected.as_ref(), actual);
+
+        // T => FixedSizeList<T>[1] (nullable)
+        let field = Arc::new(Field::new("nullable", DataType::Float32, true));
+        let from_array = Arc::new(Int8Array::from(vec![None])) as ArrayRef;
+        let casted_array = cast(&from_array, &DataType::FixedSizeList(field.clone(), 1)).unwrap();
+        let actual = casted_array.as_fixed_size_list();
+        let expected = Arc::new(FixedSizeListArray::new(
+            field.clone(),
+            1,
+            Arc::new(Float32Array::from(vec![None])) as ArrayRef,
+            None,
+        )) as ArrayRef;
+        assert_eq!(expected.as_ref(), actual);
+    }
+
     #[test]
     fn test_cast_list_containers() {
         // large-list to list
diff --git a/arrow-csv/src/writer.rs b/arrow-csv/src/writer.rs
index a31a1d5e8..5edb93bcb 100644
--- a/arrow-csv/src/writer.rs
+++ b/arrow-csv/src/writer.rs
@@ -131,15 +131,15 @@ impl<W: Write> Writer<W> {
         let converters = batch
             .columns()
             .iter()
-            .map(|a| match a.data_type() {
-                d if d.is_nested() => Err(ArrowError::CsvError(format!(
-                    "Nested type {} is not supported in CSV",
-                    a.data_type()
-                ))),
-                DataType::Binary | DataType::LargeBinary => Err(ArrowError::CsvError(
-                    "Binary data cannot be written to CSV".to_string(),
-                )),
-                _ => ArrayFormatter::try_new(a.as_ref(), &options),
+            .map(|a| {
+                if a.data_type().is_nested() {
+                    Err(ArrowError::CsvError(format!(
+                        "Nested type {} is not supported in CSV",
+                        a.data_type()
+                    )))
+                } else {
+                    ArrayFormatter::try_new(a.as_ref(), &options)
+                }
             })
             .collect::<Result<Vec<_>, ArrowError>>()?;
 
@@ -425,7 +425,10 @@ mod tests {
     use super::*;
 
     use crate::ReaderBuilder;
-    use arrow_array::builder::{Decimal128Builder, Decimal256Builder};
+    use arrow_array::builder::{
+        BinaryBuilder, Decimal128Builder, Decimal256Builder, FixedSizeBinaryBuilder,
+        LargeBinaryBuilder,
+    };
     use arrow_array::types::*;
     use arrow_buffer::i256;
     use std::io::{Cursor, Read, Seek};
@@ -759,4 +762,55 @@ sed do eiusmod tempor,-556132.25,1,,2019-04-18T02:45:55.555,23:46:03,foo
             String::from_utf8(buffer).unwrap()
         );
     }
+
+    #[test]
+    fn test_write_csv_binary() {
+        let fixed_size = 8;
+        let schema = SchemaRef::new(Schema::new(vec![
+            Field::new("c1", DataType::Binary, true),
+            Field::new("c2", DataType::FixedSizeBinary(fixed_size), true),
+            Field::new("c3", DataType::LargeBinary, true),
+        ]));
+        let mut c1_builder = BinaryBuilder::new();
+        c1_builder.append_value(b"Homer");
+        c1_builder.append_value(b"Bart");
+        c1_builder.append_null();
+        c1_builder.append_value(b"Ned");
+        let mut c2_builder = FixedSizeBinaryBuilder::new(fixed_size);
+        c2_builder.append_value(b"Simpson ").unwrap();
+        c2_builder.append_value(b"Simpson ").unwrap();
+        c2_builder.append_null();
+        c2_builder.append_value(b"Flanders").unwrap();
+        let mut c3_builder = LargeBinaryBuilder::new();
+        c3_builder.append_null();
+        c3_builder.append_null();
+        c3_builder.append_value(b"Comic Book Guy");
+        c3_builder.append_null();
+
+        let batch = RecordBatch::try_new(
+            schema,
+            vec![
+                Arc::new(c1_builder.finish()) as ArrayRef,
+                Arc::new(c2_builder.finish()) as ArrayRef,
+                Arc::new(c3_builder.finish()) as ArrayRef,
+            ],
+        )
+        .unwrap();
+
+        let mut buf = Vec::new();
+        let builder = WriterBuilder::new();
+        let mut writer = builder.build(&mut buf);
+        writer.write(&batch).unwrap();
+        drop(writer);
+        assert_eq!(
+            "\
+            c1,c2,c3\n\
+            486f6d6572,53696d70736f6e20,\n\
+            42617274,53696d70736f6e20,\n\
+            ,,436f6d696320426f6f6b20477579\n\
+            4e6564,466c616e64657273,\n\
+            ",
+            String::from_utf8(buf).unwrap()
+        );
+    }
 }
diff --git a/parquet_derive/src/parquet_field.rs b/parquet_derive/src/parquet_field.rs
index 3ab85a897..9fff76c42 100644
--- a/parquet_derive/src/parquet_field.rs
+++ b/parquet_derive/src/parquet_field.rs
@@ -136,7 +136,7 @@ impl Field {
                 Type::Option(_) => unimplemented!("Unsupported nesting encountered"),
                 Type::Reference(_, ref second_type)
                 | Type::Vec(ref second_type)
-                | Type::Array(ref second_type)
+                | Type::Array(ref second_type, _)
                 | Type::Slice(ref second_type) => match **second_type {
                     Type::TypePath(_) => Some(self.optional_definition_levels()),
                     _ => unimplemented!("Unsupported nesting encountered"),
@@ -144,11 +144,11 @@ impl Field {
             },
             Type::Reference(_, ref first_type)
             | Type::Vec(ref first_type)
-            | Type::Array(ref first_type)
+            | Type::Array(ref first_type, _)
             | Type::Slice(ref first_type) => match **first_type {
                 Type::TypePath(_) => None,
                 Type::Vec(ref second_type)
-                | Type::Array(ref second_type)
+                | Type::Array(ref second_type, _)
                 | Type::Slice(ref second_type) => match **second_type {
                     Type::TypePath(_) => None,
                     Type::Reference(_, ref third_type) => match **third_type {
@@ -161,7 +161,7 @@ impl Field {
                     match **second_type {
                         Type::TypePath(_) => Some(self.optional_definition_levels()),
                         Type::Vec(ref third_type)
-                        | Type::Array(ref third_type)
+                        | Type::Array(ref third_type, _)
                         | Type::Slice(ref third_type) => match **third_type {
                             Type::TypePath(_) => Some(self.optional_definition_levels()),
                             Type::Reference(_, ref fourth_type) => match **fourth_type {
@@ -316,25 +316,23 @@ impl Field {
         let logical_type = self.ty.logical_type();
         let repetition = self.ty.repetition();
         let converted_type = self.ty.converted_type();
+        let length = self.ty.length();
+
+        let mut builder = quote! {
+            ParquetType::primitive_type_builder(#field_name, #physical_type)
+                .with_logical_type(#logical_type)
+                .with_repetition(#repetition)
+        };
 
         if let Some(converted_type) = converted_type {
-            quote! {
-                fields.push(ParquetType::primitive_type_builder(#field_name, #physical_type)
-                    .with_logical_type(#logical_type)
-                    .with_repetition(#repetition)
-                    .with_converted_type(#converted_type)
-                    .build().unwrap().into()
-                )
-            }
-        } else {
-            quote! {
-                fields.push(ParquetType::primitive_type_builder(#field_name, #physical_type)
-                    .with_logical_type(#logical_type)
-                    .with_repetition(#repetition)
-                    .build().unwrap().into()
-                )
-            }
+            builder = quote! { #builder.with_converted_type(#converted_type) };
+        }
+
+        if let Some(length) = length {
+            builder = quote! { #builder.with_length(#length) };
         }
+
+        quote! {  fields.push(#builder.build().unwrap().into()) }
     }
 
     fn option_into_vals(&self) -> proc_macro2::TokenStream {
@@ -394,7 +392,7 @@ impl Field {
                 quote! { rec.#field_name.signed_duration_since(::chrono::NaiveDate::from_ymd(1970, 1, 1)).num_days() as i32 }
             }
             Some(ThirdPartyType::Uuid) => {
-                quote! { (&rec.#field_name.to_string()[..]).into() }
+                quote! { rec.#field_name.as_bytes().to_vec().into() }
             }
             _ => {
                 if self.is_a_byte_buf {
@@ -430,7 +428,7 @@ impl Field {
                 }
             }
             Some(ThirdPartyType::Uuid) => {
-                quote! { ::uuid::Uuid::parse_str(vals[i].data().convert()).unwrap() }
+                quote! { ::uuid::Uuid::from_bytes(vals[i].data().try_into().unwrap()) }
             }
             _ => match &self.ty {
                 Type::TypePath(_) => match self.ty.last_part().as_str() {
@@ -469,7 +467,7 @@ impl Field {
 #[allow(clippy::large_enum_variant)]
 #[derive(Debug, PartialEq)]
 enum Type {
-    Array(Box<Type>),
+    Array(Box<Type>, syn::Expr),
     Option(Box<Type>),
     Slice(Box<Type>),
     Vec(Box<Type>),
@@ -542,7 +540,7 @@ impl Type {
             Type::TypePath(_) => parent_ty.unwrap_or(ty),
             Type::Option(ref first_type)
             | Type::Vec(ref first_type)
-            | Type::Array(ref first_type)
+            | Type::Array(ref first_type, _)
             | Type::Slice(ref first_type)
             | Type::Reference(_, ref first_type) => {
                 Type::leaf_type_recursive_helper(first_type, Some(ty))
@@ -560,7 +558,7 @@ impl Type {
             Type::TypePath(ref type_) => type_,
             Type::Option(ref first_type)
             | Type::Vec(ref first_type)
-            | Type::Array(ref first_type)
+            | Type::Array(ref first_type, _)
             | Type::Slice(ref first_type)
             | Type::Reference(_, ref first_type) => match **first_type {
                 Type::TypePath(ref type_) => type_,
@@ -607,7 +605,7 @@ impl Type {
         let leaf_type = self.leaf_type_recursive();
 
         match leaf_type {
-            Type::Array(ref first_type) => {
+            Type::Array(ref first_type, _length) => {
                 if let Type::TypePath(_) = **first_type {
                     if last_part == "u8" {
                         return BasicType::FIXED_LEN_BYTE_ARRAY;
@@ -638,17 +636,38 @@ impl Type {
             }
             "f32" => BasicType::FLOAT,
             "f64" => BasicType::DOUBLE,
-            "String" | "str" | "Uuid" => BasicType::BYTE_ARRAY,
+            "String" | "str" => BasicType::BYTE_ARRAY,
+            "Uuid" => BasicType::FIXED_LEN_BYTE_ARRAY,
             f => unimplemented!("{} currently is not supported", f),
         }
     }
 
+    fn length(&self) -> Option<syn::Expr> {
+        let last_part = self.last_part();
+        let leaf_type = self.leaf_type_recursive();
+
+        // `[u8; N]` => Some(N)
+        if let Type::Array(ref first_type, length) = leaf_type {
+            if let Type::TypePath(_) = **first_type {
+                if last_part == "u8" {
+                    return Some(length.clone());
+                }
+            }
+        }
+
+        match last_part.trim() {
+            // Uuid => [u8; 16] => Some(16)
+            "Uuid" => Some(syn::parse_quote!(16)),
+            _ => None,
+        }
+    }
+
     fn logical_type(&self) -> proc_macro2::TokenStream {
         let last_part = self.last_part();
         let leaf_type = self.leaf_type_recursive();
 
         match leaf_type {
-            Type::Array(ref first_type) => {
+            Type::Array(ref first_type, _length) => {
                 if let Type::TypePath(_) = **first_type {
                     if last_part == "u8" {
                         return quote! { None };
@@ -789,7 +808,7 @@ impl Type {
 
     fn from_type_array(f: &syn::Field, ta: &syn::TypeArray) -> Self {
         let inner_type = Type::from_type(f, ta.elem.as_ref());
-        Type::Array(Box::new(inner_type))
+        Type::Array(Box::new(inner_type), ta.len.clone())
     }
 
     fn from_type_slice(f: &syn::Field, ts: &syn::TypeSlice) -> Self {
@@ -1091,6 +1110,7 @@ mod test {
             a_fix_byte_buf: [u8; 10],
             a_complex_option: ::std::option::Option<&Vec<u8>>,
             a_complex_vec: &::std::vec::Vec<&Option<u8>>,
+            a_uuid: ::uuid::Uuid,
           }
         };
 
@@ -1110,7 +1130,42 @@ mod test {
                 BasicType::BYTE_ARRAY,
                 BasicType::FIXED_LEN_BYTE_ARRAY,
                 BasicType::BYTE_ARRAY,
-                BasicType::INT32
+                BasicType::INT32,
+                BasicType::FIXED_LEN_BYTE_ARRAY,
+            ]
+        )
+    }
+
+    #[test]
+    fn test_type_length() {
+        let snippet: proc_macro2::TokenStream = quote! {
+          struct LotsOfInnerTypes {
+            a_buf: ::std::vec::Vec<u8>,
+            a_number: i32,
+            a_verbose_option: ::std::option::Option<bool>,
+            a_silly_string: String,
+            a_fix_byte_buf: [u8; 10],
+            a_complex_option: ::std::option::Option<&Vec<u8>>,
+            a_complex_vec: &::std::vec::Vec<&Option<u8>>,
+            a_uuid: ::uuid::Uuid,
+          }
+        };
+
+        let fields = extract_fields(snippet);
+        let converted_fields: Vec<_> = fields.iter().map(Type::from).collect();
+        let lengths: Vec<_> = converted_fields.iter().map(|ty| ty.length()).collect();
+
+        assert_eq!(
+            lengths,
+            vec![
+                None,
+                None,
+                None,
+                None,
+                Some(syn::parse_quote!(10)),
+                None,
+                None,
+                Some(syn::parse_quote!(16)),
             ]
         )
     }
@@ -1328,8 +1383,8 @@ mod test {
         let when = Field::from(&fields[0]);
         assert_eq!(when.writer_snippet().to_string(),(quote!{
             {
-                let vals : Vec<_> = records.iter().map(|rec| (&rec.unique_id.to_string()[..]).into() ).collect();
-                if let ColumnWriter::ByteArrayColumnWriter(ref mut typed) = column_writer.untyped() {
+                let vals : Vec<_> = records.iter().map(|rec| rec.unique_id.as_bytes().to_vec().into() ).collect();
+                if let ColumnWriter::FixedLenByteArrayColumnWriter(ref mut typed) = column_writer.untyped() {
                     typed.write_batch(&vals[..], None, None) ?;
                 } else {
                     panic!("Schema and struct disagree on type for {}" , stringify!{ unique_id })
@@ -1349,7 +1404,7 @@ mod test {
                     }
                 }).collect();
 
-                if let ColumnWriter::ByteArrayColumnWriter(ref mut typed) = column_writer.untyped() {
+                if let ColumnWriter::FixedLenByteArrayColumnWriter(ref mut typed) = column_writer.untyped() {
                     typed.write_batch(&vals[..], Some(&definition_levels[..]), None) ?;
                 } else {
                     panic!("Schema and struct disagree on type for {}" , stringify!{ maybe_unique_id })
@@ -1371,13 +1426,13 @@ mod test {
         assert_eq!(when.reader_snippet().to_string(),(quote!{
             {
                 let mut vals = Vec::new();
-                if let ColumnReader::ByteArrayColumnReader(mut typed) = column_reader {
+                if let ColumnReader::FixedLenByteArrayColumnReader(mut typed) = column_reader {
                     typed.read_records(num_records, None, None, &mut vals)?;
                 } else {
                     panic!("Schema and struct disagree on type for {}", stringify!{ unique_id });
                 }
                 for (i, r) in &mut records[..num_records].iter_mut().enumerate() {
-                    r.unique_id = ::uuid::Uuid::parse_str(vals[i].data().convert()).unwrap();
+                    r.unique_id = ::uuid::Uuid::from_bytes(vals[i].data().try_into().unwrap());
                 }
             }
         }).to_string());


Test patch:
diff --git a/parquet_derive_test/Cargo.toml b/parquet_derive_test/Cargo.toml
index a5d2e76d4..a3a4b58fe 100644
--- a/parquet_derive_test/Cargo.toml
+++ b/parquet_derive_test/Cargo.toml
@@ -32,3 +32,4 @@ rust-version = { workspace = true }
 parquet = { workspace = true }
 parquet_derive = { path = "../parquet_derive", default-features = false }
 chrono = { workspace = true }
+uuid = { version = "1", features = ["v4"] }
diff --git a/parquet_derive_test/src/lib.rs b/parquet_derive_test/src/lib.rs
index 25734813a..3743c6b55 100644
--- a/parquet_derive_test/src/lib.rs
+++ b/parquet_derive_test/src/lib.rs
@@ -42,6 +42,7 @@ struct ACompleteRecord<'a> {
     pub borrowed_maybe_a_string: &'a Option<String>,
     pub borrowed_maybe_a_str: &'a Option<&'a str>,
     pub now: chrono::NaiveDateTime,
+    pub uuid: uuid::Uuid,
     pub byte_vec: Vec<u8>,
     pub maybe_byte_vec: Option<Vec<u8>>,
     pub borrowed_byte_vec: &'a [u8],
@@ -61,6 +62,7 @@ struct APartiallyCompleteRecord {
     pub double: f64,
     pub now: chrono::NaiveDateTime,
     pub date: chrono::NaiveDate,
+    pub uuid: uuid::Uuid,
     pub byte_vec: Vec<u8>,
 }
 
@@ -105,6 +107,7 @@ mod tests {
             OPTIONAL BINARY          borrowed_maybe_a_string (STRING);
             OPTIONAL BINARY          borrowed_maybe_a_str (STRING);
             REQUIRED INT64           now (TIMESTAMP_MILLIS);
+            REQUIRED FIXED_LEN_BYTE_ARRAY (16) uuid (UUID);
             REQUIRED BINARY          byte_vec;
             OPTIONAL BINARY          maybe_byte_vec;
             REQUIRED BINARY          borrowed_byte_vec;
@@ -144,6 +147,7 @@ mod tests {
             borrowed_maybe_a_string: &maybe_a_string,
             borrowed_maybe_a_str: &maybe_a_str,
             now: chrono::Utc::now().naive_local(),
+            uuid: uuid::Uuid::new_v4(),
             byte_vec: vec![0x65, 0x66, 0x67],
             maybe_byte_vec: Some(vec![0x88, 0x89, 0x90]),
             borrowed_byte_vec: &borrowed_byte_vec,
@@ -179,6 +183,7 @@ mod tests {
             double: std::f64::NAN,
             now: chrono::Utc::now().naive_local(),
             date: chrono::naive::NaiveDate::from_ymd_opt(2015, 3, 14).unwrap(),
+            uuid: uuid::Uuid::new_v4(),
             byte_vec: vec![0x65, 0x66, 0x67],
         }];