This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new f83bec422 fix: arrow schema to schema supports uuids (#2930)
f83bec422 is described below

commit f83bec42298a1cf51a42ea2aee26c041a15db5b4
Author: Joseph Lenton <[email protected]>
AuthorDate: Thu Oct 15 20:04:13 2026 +0000

    fix: arrow schema to schema supports uuids (#2930)
    
    * chore: add a test capturing the change
    
    * fix: add uuid support to arrow_schema_to_schema
    
    * chore: add uuid to the large arrow_schema_to_schema_auto_assign_ids test
    
    * chore: update the public api
    
    * feat: add a type check on the uuid conversion
    
    * chore: add a test for schema_to_arrow_schema for uuid
    
    * feat: add UUID support for schema_to_arrow_schema
    
    * docs: add uuid extension to visit_field documentation
    
    * refactor: remove duplicate UUID extension import from test
    
    * chore: add tests for fixed size binary of wrong size
    
    * feat: enable parquet writing uuid with uuid extension
    
    * chore: fmt Cargo.toml
---
 Cargo.lock                                         |   2 +
 crates/iceberg/Cargo.toml                          |   8 +-
 crates/iceberg/public-api.txt                      |   1 +
 crates/iceberg/src/arrow/schema.rs                 | 215 ++++++++++++++++++---
 .../src/writer/file_writer/parquet_writer.rs       |  73 +++++++
 5 files changed, 273 insertions(+), 26 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 3bb94e339..9e92c31b3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -423,6 +423,8 @@ source = 
"registry+https://github.com/rust-lang/crates.io-index";
 checksum = "10fab8d4563491417ba801fab29d205104d20d4bdf37bda6cd1cf425cff598cd"
 dependencies = [
  "bitflags 2.13.0",
+ "serde_core",
+ "serde_json",
 ]
 
 [[package]]
diff --git a/crates/iceberg/Cargo.toml b/crates/iceberg/Cargo.toml
index f22ed59b9..99ec5f433 100644
--- a/crates/iceberg/Cargo.toml
+++ b/crates/iceberg/Cargo.toml
@@ -42,7 +42,7 @@ arrow-array = { workspace = true }
 arrow-buffer = { workspace = true }
 arrow-cast = { workspace = true }
 arrow-ord = { workspace = true }
-arrow-schema = { workspace = true }
+arrow-schema = { workspace = true, features = ["canonical_extension_types"] }
 arrow-select = { workspace = true }
 arrow-string = { workspace = true }
 as-any = { workspace = true }
@@ -65,7 +65,11 @@ moka = { version = "0.12.10", features = ["future"] }
 murmur3 = { workspace = true }
 once_cell = { workspace = true }
 ordered-float = { workspace = true }
-parquet = { workspace = true, features = ["async", "encryption"] }
+parquet = { workspace = true, features = [
+  "async",
+  "encryption",
+  "arrow_canonical_extension_types",
+] }
 rand = { workspace = true }
 reqwest = { workspace = true }
 roaring = { workspace = true }
diff --git a/crates/iceberg/public-api.txt b/crates/iceberg/public-api.txt
index 00040053a..ad1af4389 100644
--- a/crates/iceberg/public-api.txt
+++ b/crates/iceberg/public-api.txt
@@ -123,6 +123,7 @@ pub fn iceberg::arrow::ArrowSchemaVisitor::map(&mut self, 
map: &arrow_schema::da
 pub fn iceberg::arrow::ArrowSchemaVisitor::primitive(&mut self, p: 
&arrow_schema::datatype::DataType) -> iceberg::Result<Self::T>
 pub fn iceberg::arrow::ArrowSchemaVisitor::schema(&mut self, schema: 
&arrow_schema::schema::Schema, values: alloc::vec::Vec<Self::T>) -> 
iceberg::Result<Self::U>
 pub fn iceberg::arrow::ArrowSchemaVisitor::struct(&mut self, fields: 
&arrow_schema::fields::Fields, results: alloc::vec::Vec<Self::T>) -> 
iceberg::Result<Self::T>
+pub fn iceberg::arrow::ArrowSchemaVisitor::uuid(&mut self, field: 
&arrow_schema::field::FieldRef) -> iceberg::Result<Self::T> where Self: 
core::marker::Sized
 pub fn iceberg::arrow::ArrowSchemaVisitor::variant(&mut self, field: 
&arrow_schema::field::FieldRef) -> iceberg::Result<Self::T> where Self: 
core::marker::Sized
 pub fn iceberg::arrow::arrow_primitive_to_literal(primitive_array: 
&arrow_array::array::ArrayRef, ty: &iceberg::spec::Type) -> 
iceberg::Result<alloc::vec::Vec<core::option::Option<iceberg::spec::Literal>>>
 pub fn iceberg::arrow::arrow_schema_to_schema(schema: 
&arrow_schema::schema::Schema) -> iceberg::Result<iceberg::spec::Schema>
diff --git a/crates/iceberg/src/arrow/schema.rs 
b/crates/iceberg/src/arrow/schema.rs
index 0c673c6bc..748b97b3a 100644
--- a/crates/iceberg/src/arrow/schema.rs
+++ b/crates/iceberg/src/arrow/schema.rs
@@ -26,7 +26,7 @@ use arrow_array::{
     FixedSizeBinaryArray, Float32Array, Float64Array, Int32Array, Int64Array, 
Scalar, StringArray,
     TimestampMicrosecondArray, TimestampNanosecondArray,
 };
-use arrow_schema::extension::ExtensionType;
+use arrow_schema::extension::{ExtensionType, Uuid as UuidExtensionType};
 use arrow_schema::{
     ArrowError, DataType, Field, FieldRef, Fields, Schema as ArrowSchema, 
TimeUnit,
 };
@@ -184,6 +184,20 @@ pub trait ArrowSchemaVisitor {
     where Self: Sized {
         visit_type(field.data_type(), self)
     }
+
+    /// Called when a field carries the `arrow.uuid` extension type.
+    ///
+    /// The default treats the field as its underlying Arrow storage, 
preserving
+    /// the behavior of visitors that don't special-case UUIDs. A visitor that
+    /// produces Iceberg types should override this to fold the storage into a
+    /// single logical UUID.
+    ///
+    /// Takes the `&FieldRef` rather than a `&DataType` because the UUID signal
+    /// lives on the field's metadata, not on its data type.
+    fn uuid(&mut self, field: &FieldRef) -> Result<Self::T>
+    where Self: Sized {
+        visit_type(field.data_type(), self)
+    }
 }
 
 /// Visiting a type in post order.
@@ -241,11 +255,15 @@ fn visit_type<V: ArrowSchemaVisitor>(r#type: &DataType, 
visitor: &mut V) -> Resu
     }
 }
 
-/// Dispatch a field: fold it into a variant when it carries the
-/// `arrow.parquet.variant` extension type, otherwise visit its data type.
+/// Dispatch a field, and fold it into following extension types when carried:
+///  - `arrow.parquet.variant` -- fold into a variant,
+///  - `arrow.uuid` -- fold it into a UUID type,
+///  - and in all other cases we visit it's data.
 fn visit_field<V: ArrowSchemaVisitor>(field: &FieldRef, visitor: &mut V) -> 
Result<V::T> {
     if field.extension_type_name() == Some(VariantExtensionType::NAME) {
         visitor.variant(field)
+    } else if field.extension_type_name() == Some(UuidExtensionType::NAME) {
+        visitor.uuid(field)
     } else {
         visit_type(field.data_type(), visitor)
     }
@@ -562,6 +580,23 @@ impl ArrowSchemaVisitor for ArrowSchemaConverter {
         // fail. The enclosing field's own id is read by the caller.
         Ok(Type::Variant(VariantType))
     }
+
+    fn uuid(&mut self, field: &FieldRef) -> Result<Self::T> {
+        // The extension expects a fixed size binary.
+        // See: Apache Arrow spec 
https://arrow.apache.org/docs/format/CanonicalExtensions.html#uuid
+        let field_data_type = field.data_type();
+        let expected_data_type = DataType::FixedSizeBinary(16);
+        if *field_data_type != expected_data_type {
+            let error_message = format!(
+                "{} extension requires {expected_data_type} storage, found 
{field_data_type}",
+                UuidExtensionType::NAME,
+            );
+
+            return Err(Error::new(ErrorKind::DataInvalid, error_message));
+        }
+
+        Ok(Type::Primitive(PrimitiveType::Uuid))
+    }
 }
 
 struct ToArrowSchemaConverter;
@@ -612,9 +647,17 @@ impl SchemaVisitor for ToArrowSchemaConverter {
         // `arrow.parquet.variant` extension type so consumers read it as a 
Variant, not a struct.
         let arrow_field = if field.field_type.is_variant() {
             arrow_field.with_extension_type(VariantExtensionType)
+        } else if matches!(
+            field.field_type.as_ref(),
+            Type::Primitive(PrimitiveType::Uuid)
+        ) {
+            // A uuid column's storage is FixedSizeBinary(16); tag the field 
with the canonical
+            // `arrow.uuid` extension type so consumers read it as a UUID, not 
opaque bytes.
+            arrow_field.with_extension_type(UuidExtensionType)
         } else {
             arrow_field
         };
+
         Ok(ArrowSchemaOrFieldOrType::Field(arrow_field))
     }
 
@@ -1695,6 +1738,105 @@ mod tests {
         pretty_assertions::assert_eq!(converted_schema, schema);
     }
 
+    #[test]
+    fn test_converting_uuid_from_arrow_to_iceberg_to_arrow_should_give_uuid() {
+        let arrow_schema = ArrowSchema::new(vec![
+            simple_field("uuid_field", DataType::FixedSizeBinary(16), false, 
"1")
+                .with_extension_type(UuidExtensionType),
+        ]);
+        let output =
+            
schema_to_arrow_schema(&arrow_schema_to_schema(&arrow_schema).unwrap()).unwrap();
+
+        assert_eq!(arrow_schema, output);
+    }
+
+    #[test]
+    fn 
test_converting_uuid_from_iceberg_to_arrow_to_iceberg_should_give_uuid() {
+        let iceberg_schema = Schema::builder()
+            .with_fields(vec![
+                NestedField::optional(1, "uuid_field", 
Type::Primitive(PrimitiveType::Uuid)).into(),
+            ])
+            .build()
+            .unwrap();
+        let output =
+            
arrow_schema_to_schema(&schema_to_arrow_schema(&iceberg_schema).unwrap()).unwrap();
+
+        assert_eq!(iceberg_schema, output);
+    }
+
+    #[test]
+    fn 
test_arrow_schema_to_schema_should_convert_uuid_when_fixed_size_binary() {
+        let converted_schema = arrow_schema_to_schema(&ArrowSchema::new(vec![
+            simple_field("uuid_field", DataType::FixedSizeBinary(16), false, 
"1")
+                .with_extension_type(UuidExtensionType),
+        ]))
+        .unwrap();
+
+        let expected = Schema::builder()
+            .with_fields([NestedField::required(
+                1,
+                "uuid_field",
+                Type::Primitive(PrimitiveType::Uuid),
+            )
+            .into()])
+            .build()
+            .unwrap();
+
+        pretty_assertions::assert_eq!(expected, converted_schema);
+    }
+
+    #[test]
+    fn 
test_arrow_schema_to_schema_should_reject_uuid_when_not_a_fixed_size_binary() {
+        // The field must be built correctly, and then changed to the wrong 
type,
+        // to avoid Arrow's own validation and panic.
+        let field = simple_field_with_uuid_ext(DataType::Utf8);
+
+        let error = 
arrow_schema_to_schema(&ArrowSchema::new(vec![field])).unwrap_err();
+
+        pretty_assertions::assert_eq!(
+            "arrow.uuid extension requires FixedSizeBinary(16) storage, found 
Utf8",
+            error.message()
+        );
+    }
+
+    #[test]
+    fn 
test_arrow_schema_to_schema_should_reject_uuid_when_fixed_size_binary_that_is_less_than_16()
+    {
+        let field = simple_field_with_uuid_ext(DataType::FixedSizeBinary(8));
+        let error = 
arrow_schema_to_schema(&ArrowSchema::new(vec![field])).unwrap_err();
+
+        pretty_assertions::assert_eq!(
+            "arrow.uuid extension requires FixedSizeBinary(16) storage, found 
FixedSizeBinary(8)",
+            error.message()
+        );
+    }
+
+    #[test]
+    fn 
test_arrow_schema_to_schema_should_reject_uuid_when_fixed_size_binary_that_is_more_than_16()
+    {
+        let field = simple_field_with_uuid_ext(DataType::FixedSizeBinary(17));
+        let error = 
arrow_schema_to_schema(&ArrowSchema::new(vec![field])).unwrap_err();
+
+        pretty_assertions::assert_eq!(
+            "arrow.uuid extension requires FixedSizeBinary(16) storage, found 
FixedSizeBinary(17)",
+            error.message()
+        );
+    }
+
+    fn simple_field_with_uuid_ext(field_data_type: DataType) -> Field {
+        let mut field = simple_field("uuid_field", 
DataType::FixedSizeBinary(16), false, "1")
+            .with_extension_type(UuidExtensionType);
+
+        // We want to allow setting a UUID extension to the wrong data type.
+        // This is to test our logic against bad schemas.
+        //
+        // To avoid Arrow's own internal validation and panic, we need to build
+        // the field correctly above, and then manually override the type here.
+        field = field.with_data_type(field_data_type);
+
+        field
+    }
+
     fn arrow_schema_for_schema_to_arrow_schema_test() -> ArrowSchema {
         let fields = Fields::from(vec![
             simple_field("key", DataType::Int32, false, "28"),
@@ -1787,7 +1929,8 @@ mod tests {
             ),
             simple_field("map", map, false, "16"),
             simple_field("struct", r#struct, false, "17"),
-            simple_field("uuid", DataType::FixedSizeBinary(16), false, "30"),
+            simple_field("uuid", DataType::FixedSizeBinary(16), false, "30")
+                .with_extension_type(UuidExtensionType),
             Field::new(
                 "v",
                 DataType::Struct(Fields::from(vec![
@@ -2003,10 +2146,10 @@ mod tests {
 
     #[test]
     fn test_schema_to_arrow_schema() {
-        let arrow_schema = arrow_schema_for_schema_to_arrow_schema_test();
+        let expected_arrow_schema = 
arrow_schema_for_schema_to_arrow_schema_test();
         let schema = iceberg_schema_for_schema_to_arrow_schema();
         let converted_arrow_schema = schema_to_arrow_schema(&schema).unwrap();
-        assert_eq!(converted_arrow_schema, arrow_schema);
+        assert_eq!(converted_arrow_schema, expected_arrow_schema);
     }
 
     #[test]
@@ -2052,6 +2195,27 @@ mod tests {
         );
     }
 
+    #[test]
+    fn test_schema_to_arrow_schema_should_convert_uuid_to_arrow_uuid() {
+        let schema = Schema::builder()
+            .with_fields(vec![
+                NestedField::optional(1, "uuid_field", 
Type::Primitive(PrimitiveType::Uuid)).into(),
+            ])
+            .build()
+            .unwrap();
+
+        let arrow_schema = schema_to_arrow_schema(&schema).unwrap();
+        let field = arrow_schema.field_with_name("uuid_field").unwrap();
+
+        assert_eq!(field.extension_type_name(), Some(UuidExtensionType::NAME));
+        // Attaching the extension type must not clobber the Iceberg field id.
+        assert_eq!(
+            field.metadata().get(PARQUET_FIELD_ID_META_KEY),
+            Some(&"1".to_string())
+        );
+        assert_eq!(*field.data_type(), DataType::FixedSizeBinary(16));
+    }
+
     #[test]
     fn test_variant_nested_in_list_and_map_carries_arrow_extension_type() {
         // A variant nested in a list element or map value keeps the 
arrow.parquet.variant
@@ -2490,6 +2654,8 @@ mod tests {
             Field::new("id", DataType::Int64, false),
             Field::new("name", DataType::Utf8, true),
             Field::new("price", DataType::Decimal128(10, 2), false),
+            Field::new("uuid", DataType::FixedSizeBinary(16), false)
+                .with_extension_type(UuidExtensionType),
             Field::new(
                 "created_at",
                 DataType::Timestamp(TimeUnit::Microsecond, 
Some("+00:00".into())),
@@ -2541,9 +2707,9 @@ mod tests {
         let schema = 
arrow_schema_to_schema_auto_assign_ids(&arrow_schema).unwrap();
 
         // Build expected schema with exact field IDs following level-order 
assignment:
-        // Level 0: id=1, name=2, price=3, created_at=4, tags=5, address=6, 
attributes=7, orders=8
-        // Level 1: tags.element=9, address.{street=10,city=11,zip=12}, 
attributes.{key=13,value=14}, orders.element=15
-        // Level 2: orders.element.{order_id=16,amount=17}
+        // Level 0: id=1, name=2, price=3, uuid=4, created_at=5, tags=6, 
address=7, attributes=8, orders=9
+        // Level 1: tags.element=10, address.{street=11,city=12,zip=13}, 
attributes.{key=14,value=15}, orders.element=16
+        // Level 2: orders.element.{order_id=17,amount=18}
         let expected = Schema::builder()
             .with_fields(vec![
                 NestedField::required(1, "id", 
Type::Primitive(PrimitiveType::Long)).into(),
@@ -2557,14 +2723,15 @@ mod tests {
                     }),
                 )
                 .into(),
-                NestedField::optional(4, "created_at", 
Type::Primitive(PrimitiveType::Timestamptz))
+                NestedField::required(4, "uuid", 
Type::Primitive(PrimitiveType::Uuid)).into(),
+                NestedField::optional(5, "created_at", 
Type::Primitive(PrimitiveType::Timestamptz))
                     .into(),
                 NestedField::optional(
-                    5,
+                    6,
                     "tags",
                     Type::List(ListType {
                         element_field: NestedField::list_element(
-                            9,
+                            10,
                             Type::Primitive(PrimitiveType::String),
                             false,
                         )
@@ -2573,29 +2740,29 @@ mod tests {
                 )
                 .into(),
                 NestedField::optional(
-                    6,
+                    7,
                     "address",
                     Type::Struct(StructType::new(vec![
-                        NestedField::optional(10, "street", 
Type::Primitive(PrimitiveType::String))
+                        NestedField::optional(11, "street", 
Type::Primitive(PrimitiveType::String))
                             .into(),
-                        NestedField::required(11, "city", 
Type::Primitive(PrimitiveType::String))
+                        NestedField::required(12, "city", 
Type::Primitive(PrimitiveType::String))
                             .into(),
-                        NestedField::optional(12, "zip", 
Type::Primitive(PrimitiveType::Int))
+                        NestedField::optional(13, "zip", 
Type::Primitive(PrimitiveType::Int))
                             .into(),
                     ])),
                 )
                 .into(),
                 NestedField::optional(
-                    7,
+                    8,
                     "attributes",
                     Type::Map(MapType {
                         key_field: NestedField::map_key_element(
-                            13,
+                            14,
                             Type::Primitive(PrimitiveType::String),
                         )
                         .into(),
                         value_field: NestedField::map_value_element(
-                            14,
+                            15,
                             Type::Primitive(PrimitiveType::String),
                             false,
                         )
@@ -2604,20 +2771,20 @@ mod tests {
                 )
                 .into(),
                 NestedField::optional(
-                    8,
+                    9,
                     "orders",
                     Type::List(ListType {
                         element_field: NestedField::list_element(
-                            15,
+                            16,
                             Type::Struct(StructType::new(vec![
                                 NestedField::required(
-                                    16,
+                                    17,
                                     "order_id",
                                     Type::Primitive(PrimitiveType::Long),
                                 )
                                 .into(),
                                 NestedField::required(
-                                    17,
+                                    18,
                                     "amount",
                                     Type::Primitive(PrimitiveType::Double),
                                 )
@@ -2634,7 +2801,7 @@ mod tests {
             .unwrap();
 
         pretty_assertions::assert_eq!(schema, expected);
-        assert_eq!(schema.highest_field_id(), 17);
+        assert_eq!(schema.highest_field_id(), 18);
     }
 
     fn decimal_byte_array_stats(min: Vec<u8>, max: Vec<u8>) -> Statistics {
diff --git a/crates/iceberg/src/writer/file_writer/parquet_writer.rs 
b/crates/iceberg/src/writer/file_writer/parquet_writer.rs
index 7cdc48888..a471aea5c 100644
--- a/crates/iceberg/src/writer/file_writer/parquet_writer.rs
+++ b/crates/iceberg/src/writer/file_writer/parquet_writer.rs
@@ -2497,6 +2497,79 @@ mod tests {
         assert_eq!(std::fs::read_dir(temp_dir.path()).unwrap().count(), 0);
     }
 
+    #[tokio::test]
+    async fn test_parquet_writer_uuid_logical_type() {
+        let temp_dir = TempDir::new().unwrap();
+        let file_io = FileIO::new_with_fs();
+        let location_gen = DefaultLocationGenerator::with_data_location(
+            temp_dir.path().to_str().unwrap().to_string(),
+        );
+        let file_name_gen =
+            DefaultFileNameGenerator::new("test".to_string(), None, 
DataFileFormat::Parquet);
+        let output_file = file_io
+            .new_output(location_gen.generate_location(None, 
&file_name_gen.generate_file_name()))
+            .unwrap();
+
+        let schema = Arc::new(
+            Schema::builder()
+                .with_schema_id(1)
+                .with_fields(vec![
+                    NestedField::required(0, "uuid", 
Type::Primitive(PrimitiveType::Uuid)).into(),
+                ])
+                .build()
+                .unwrap(),
+        );
+        let arrow_schema: ArrowSchemaRef = 
Arc::new(schema_to_arrow_schema(&schema).unwrap());
+        let col = Arc::new(
+            arrow_array::FixedSizeBinaryArray::try_from_iter(
+                [Uuid::from_u128(0), Uuid::from_u128(1)]
+                    .into_iter()
+                    .map(|uuid| uuid.into_bytes()),
+            )
+            .unwrap(),
+        ) as ArrayRef;
+        let to_write = RecordBatch::try_new(arrow_schema, vec![col]).unwrap();
+
+        let mut pw = 
ParquetWriterBuilder::new(WriterProperties::builder().build(), schema)
+            .build(output_file)
+            .await
+            .unwrap();
+        pw.write(&to_write).await.unwrap();
+        let data_file = pw
+            .close()
+            .await
+            .unwrap()
+            .into_iter()
+            .next()
+            .unwrap()
+            .content(DataContentType::Data)
+            .partition(Struct::empty())
+            .partition_spec_id(0)
+            .build()
+            .unwrap();
+
+        let raw = file_io
+            .new_input(data_file.file_path.clone())
+            .unwrap()
+            .read()
+            .await
+            .unwrap();
+        let reader = ParquetRecordBatchReaderBuilder::try_new(raw).unwrap();
+        let column = 
reader.metadata().file_metadata().schema_descr().column(0);
+        assert_eq!(
+            (
+                column.physical_type(),
+                column.type_length(),
+                column.logical_type_ref()
+            ),
+            (
+                parquet::basic::Type::FIXED_LEN_BYTE_ARRAY,
+                16,
+                Some(&parquet::basic::LogicalType::Uuid)
+            )
+        );
+    }
+
     #[test]
     fn test_min_max_aggregator() {
         let schema = Arc::new(

Reply via email to