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

pvillard31 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new 5aa1196f085 NIFI-16074 Added binary, fixed, UUID and geospatial value 
conversion to PutIcebergRecord (#11749)
5aa1196f085 is described below

commit 5aa1196f0851bdc3687adcb9bc2173030544a252
Author: Henri Saksi <[email protected]>
AuthorDate: Wed Oct 7 15:08:05 2026 +0300

    NIFI-16074 Added binary, fixed, UUID and geospatial value conversion to 
PutIcebergRecord (#11749)
---
 .../iceberg/parquet/ParquetIcebergWriterTest.java  | 130 +++++++++++++++
 .../processors/iceberg/record/RecordConverter.java |  70 ++++++++-
 .../iceberg/record/DelegatedRecordTest.java        |  44 ++++++
 .../iceberg/record/RecordConverterTest.java        | 174 +++++++++++++++++++++
 4 files changed, 412 insertions(+), 6 deletions(-)

diff --git 
a/nifi-extension-bundles/nifi-iceberg-bundle/nifi-iceberg-parquet-writer/src/test/java/org/apache/nifi/services/iceberg/parquet/ParquetIcebergWriterTest.java
 
b/nifi-extension-bundles/nifi-iceberg-bundle/nifi-iceberg-parquet-writer/src/test/java/org/apache/nifi/services/iceberg/parquet/ParquetIcebergWriterTest.java
index 1a29da01046..d2fa7cb2b84 100644
--- 
a/nifi-extension-bundles/nifi-iceberg-bundle/nifi-iceberg-parquet-writer/src/test/java/org/apache/nifi/services/iceberg/parquet/ParquetIcebergWriterTest.java
+++ 
b/nifi-extension-bundles/nifi-iceberg-bundle/nifi-iceberg-parquet-writer/src/test/java/org/apache/nifi/services/iceberg/parquet/ParquetIcebergWriterTest.java
@@ -21,15 +21,22 @@ import org.apache.iceberg.FileFormat;
 import org.apache.iceberg.PartitionData;
 import org.apache.iceberg.PartitionSpec;
 import org.apache.iceberg.Schema;
+import org.apache.iceberg.SnapshotSummary;
 import org.apache.iceberg.StructLike;
 import org.apache.iceberg.Table;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.data.GenericRecord;
 import org.apache.iceberg.encryption.EncryptedOutputFile;
 import org.apache.iceberg.encryption.EncryptionManager;
+import org.apache.iceberg.geospatial.GeospatialBound;
+import org.apache.iceberg.inmemory.InMemoryCatalog;
 import org.apache.iceberg.inmemory.InMemoryOutputFile;
 import org.apache.iceberg.io.FileIO;
 import org.apache.iceberg.io.LocationProvider;
 import org.apache.iceberg.io.OutputFile;
+import org.apache.iceberg.types.Conversions;
 import org.apache.iceberg.types.Types;
 import org.apache.iceberg.util.DateTimeUtil;
 import org.apache.nifi.reporting.InitializationException;
@@ -44,11 +51,14 @@ import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 
 import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.LocalTime;
 import java.util.List;
 import java.util.Map;
+import java.util.UUID;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertInstanceOf;
@@ -96,6 +106,48 @@ class ParquetIcebergWriterTest {
 
     private static final Map<String, String> ATTRIBUTES_FIELD_VALUE = 
Map.of("k", "v");
 
+    private static final int DATA_FIELD_ID = 1;
+
+    private static final String DATA_FIELD_NAME = "data";
+
+    private static final int DIGEST_FIELD_ID = 2;
+
+    private static final String DIGEST_FIELD_NAME = "digest";
+
+    private static final int IDENTIFIER_FIELD_ID = 3;
+
+    private static final String IDENTIFIER_FIELD_NAME = "identifier";
+
+    private static final int GEOMETRY_FIELD_ID = 1;
+
+    private static final String GEOMETRY_FIELD_NAME = "geometry";
+
+    private static final int GEOGRAPHY_FIELD_ID = 2;
+
+    private static final String GEOGRAPHY_FIELD_NAME = "geography";
+
+    private static final byte[] DATA_FIELD_VALUE = new byte[] {1, 2, 3, 4};
+
+    private static final UUID IDENTIFIER_FIELD_VALUE = 
UUID.fromString("0f8fad5b-d9cb-469f-a165-70867728950e");
+
+    private static final double POINT_LONGITUDE = 24.9384;
+
+    private static final double POINT_LATITUDE = 60.1699;
+
+    // Well-Known Binary encoding of a Point with little-endian byte order, 
Point geometry type, and X and Y coordinates
+    private static final byte[] POINT_FIELD_VALUE = 
ByteBuffer.allocate(21).order(ByteOrder.LITTLE_ENDIAN)
+            .put((byte) 1)
+            .putInt(1)
+            .putDouble(POINT_LONGITUDE)
+            .putDouble(POINT_LATITUDE)
+            .array();
+
+    private static final String CATALOG_NAME = "memory";
+
+    private static final TableIdentifier GEOSPATIAL_TABLE_IDENTIFIER = 
TableIdentifier.of(Namespace.of("default"), "geospatial");
+
+    private static final String FORMAT_VERSION_3 = "3";
+
     private ParquetIcebergWriter parquetIcebergWriter;
 
     private TestRunner runner;
@@ -251,6 +303,84 @@ class ParquetIcebergWriterTest {
         assertDataFilesFound(dataFiles, serialized);
     }
 
+    /**
+     * Iceberg Parquet writers require ByteBuffer values for binary columns, 
byte arrays for fixed columns, and UUID values
+     * for uuid columns, and record the written values as column bounds.
+     */
+    @Test
+    void testWriteDataFilesBinaryFixedUuidTypes() throws IOException {
+        runner.enableControllerService(parquetIcebergWriter);
+
+        final Types.FixedType fixedType = 
Types.FixedType.ofLength(DATA_FIELD_VALUE.length);
+        final Schema schema = new Schema(
+                Types.NestedField.optional(DATA_FIELD_ID, DATA_FIELD_NAME, 
Types.BinaryType.get()),
+                Types.NestedField.optional(DIGEST_FIELD_ID, DIGEST_FIELD_NAME, 
fixedType),
+                Types.NestedField.optional(IDENTIFIER_FIELD_ID, 
IDENTIFIER_FIELD_NAME, Types.UUIDType.get())
+        );
+        final InMemoryOutputFile outputFile = new InMemoryOutputFile();
+        final PartitionSpec partitionSpec = PartitionSpec.unpartitioned();
+        setTable(schema, partitionSpec, outputFile);
+        
when(locationProvider.newDataLocation(anyString())).thenReturn(LOCATION);
+
+        final IcebergRowWriter rowWriter = 
parquetIcebergWriter.getRowWriter(table);
+
+        final GenericRecord row = GenericRecord.create(schema);
+        row.setField(DATA_FIELD_NAME, ByteBuffer.wrap(DATA_FIELD_VALUE));
+        row.setField(DIGEST_FIELD_NAME, DATA_FIELD_VALUE);
+        row.setField(IDENTIFIER_FIELD_NAME, IDENTIFIER_FIELD_VALUE);
+        rowWriter.write(row);
+
+        final DataFile[] dataFiles = rowWriter.dataFiles();
+        final byte[] serialized = outputFile.toByteArray();
+        assertDataFilesFound(dataFiles, serialized);
+
+        final Map<Integer, ByteBuffer> lowerBounds = 
dataFiles[0].lowerBounds();
+        assertEquals(ByteBuffer.wrap(DATA_FIELD_VALUE), 
Conversions.fromByteBuffer(Types.BinaryType.get(), 
lowerBounds.get(DATA_FIELD_ID)));
+        assertEquals(ByteBuffer.wrap(DATA_FIELD_VALUE), 
Conversions.fromByteBuffer(fixedType, lowerBounds.get(DIGEST_FIELD_ID)));
+        assertEquals(IDENTIFIER_FIELD_VALUE, 
Conversions.fromByteBuffer(Types.UUIDType.get(), 
lowerBounds.get(IDENTIFIER_FIELD_ID)));
+    }
+
+    /**
+     * Iceberg geometry and geography types require table format version 3, 
and Iceberg Parquet writers require Well-Known
+     * Binary values in ByteBuffers for both types, recording the bounding box 
of geometry values as column bounds.
+     */
+    @Test
+    void testWriteDataFilesGeospatialTypes() throws IOException {
+        runner.enableControllerService(parquetIcebergWriter);
+
+        final Types.GeometryType geometryType = Types.GeometryType.crs84();
+        final Schema schema = new Schema(
+                Types.NestedField.optional(GEOMETRY_FIELD_ID, 
GEOMETRY_FIELD_NAME, geometryType),
+                Types.NestedField.optional(GEOGRAPHY_FIELD_ID, 
GEOGRAPHY_FIELD_NAME, Types.GeographyType.crs84())
+        );
+
+        try (InMemoryCatalog catalog = new InMemoryCatalog()) {
+            catalog.initialize(CATALOG_NAME, Map.of());
+            catalog.createNamespace(GEOSPATIAL_TABLE_IDENTIFIER.namespace());
+            final Table geospatialTable = 
catalog.buildTable(GEOSPATIAL_TABLE_IDENTIFIER, schema)
+                    .withProperty(TableProperties.FORMAT_VERSION, 
FORMAT_VERSION_3)
+                    .create();
+
+            final IcebergRowWriter rowWriter = 
parquetIcebergWriter.getRowWriter(geospatialTable);
+
+            final GenericRecord row = GenericRecord.create(schema);
+            row.setField(GEOMETRY_FIELD_NAME, 
ByteBuffer.wrap(POINT_FIELD_VALUE));
+            row.setField(GEOGRAPHY_FIELD_NAME, 
ByteBuffer.wrap(POINT_FIELD_VALUE));
+            rowWriter.write(row);
+
+            final DataFile[] dataFiles = rowWriter.dataFiles();
+            assertEquals(1, dataFiles.length);
+
+            final DataFile dataFile = dataFiles[0];
+            final GeospatialBound pointBound = 
GeospatialBound.createXY(POINT_LONGITUDE, POINT_LATITUDE);
+            assertEquals(pointBound, Conversions.fromByteBuffer(geometryType, 
dataFile.lowerBounds().get(GEOMETRY_FIELD_ID)));
+            assertEquals(pointBound, Conversions.fromByteBuffer(geometryType, 
dataFile.upperBounds().get(GEOMETRY_FIELD_ID)));
+
+            geospatialTable.newAppend().appendFile(dataFile).commit();
+            assertEquals("1", 
geospatialTable.currentSnapshot().summary().get(SnapshotSummary.ADDED_RECORDS_PROP));
+        }
+    }
+
     private void writeRow(final Schema schema, final IcebergRowWriter 
rowWriter) throws IOException {
         final GenericRecord row = GenericRecord.create(schema);
         row.setField(FIRST_FIELD_NAME, FIRST_FIELD_VALUE);
diff --git 
a/nifi-extension-bundles/nifi-iceberg-bundle/nifi-iceberg-processors/src/main/java/org/apache/nifi/processors/iceberg/record/RecordConverter.java
 
b/nifi-extension-bundles/nifi-iceberg-bundle/nifi-iceberg-processors/src/main/java/org/apache/nifi/processors/iceberg/record/RecordConverter.java
index 27dad53acb4..bfa9c6d4c64 100644
--- 
a/nifi-extension-bundles/nifi-iceberg-bundle/nifi-iceberg-processors/src/main/java/org/apache/nifi/processors/iceberg/record/RecordConverter.java
+++ 
b/nifi-extension-bundles/nifi-iceberg-bundle/nifi-iceberg-processors/src/main/java/org/apache/nifi/processors/iceberg/record/RecordConverter.java
@@ -18,13 +18,16 @@ package org.apache.nifi.processors.iceberg.record;
 
 import org.apache.iceberg.types.Type;
 import org.apache.iceberg.types.Types;
+import org.apache.iceberg.util.ByteBuffers;
 import org.apache.nifi.serialization.record.DataType;
 import org.apache.nifi.serialization.record.MapRecord;
 import org.apache.nifi.serialization.record.Record;
 import org.apache.nifi.serialization.record.RecordField;
 import org.apache.nifi.serialization.record.RecordFieldType;
 import org.apache.nifi.serialization.record.RecordSchema;
+import org.apache.nifi.serialization.record.util.DataTypeUtils;
 
+import java.nio.ByteBuffer;
 import java.sql.Date;
 import java.sql.Time;
 import java.sql.Timestamp;
@@ -64,7 +67,7 @@ class RecordConverter {
         final Record convertedRecord;
 
         final RecordSchema recordSchema = inputRecord.getSchema();
-        if (isConversionRequired(recordSchema)) {
+        if (isConversionRequired(recordSchema) || 
isUuidConversionRequired(struct)) {
             final Map<String, Object> values = inputRecord.toMap();
             final Map<String, Object> convertedValues = new 
LinkedHashMap<>(values.size());
             for (final Map.Entry<String, Object> entry : values.entrySet()) {
@@ -86,7 +89,7 @@ class RecordConverter {
             case Timestamp timestamp -> convertTimestamp(timestamp, 
icebergType);
             case Date date -> date.toLocalDate();
             case Time time -> time.toLocalTime();
-            // Recursively convert complex types against the matching Iceberg 
type
+            // Recursively convert complex types and convert binary and UUID 
values against the matching Iceberg type
             case null, default -> convertComplexValue(value, icebergType);
         };
     }
@@ -120,12 +123,13 @@ class RecordConverter {
     }
 
     /**
-     * Recursively convert array, collection, nested record, and map values 
against the matching Iceberg type
+     * Recursively convert array, collection, nested record, and map values 
against the matching Iceberg type, and convert
+     * values for Iceberg primitive types that require specific Java types
      *
      * @param value Field value to be converted
      * @param icebergType Iceberg Type describing the target field type (may 
be null when not resolved)
-     * @return Converted value or the input value when the Iceberg Type is 
unknown or does not describe a complex
-     * type matching the value
+     * @return Converted value or the input value when the Iceberg Type is 
unknown or does not require conversion of the
+     * value
      */
     private static Object convertComplexValue(final Object value, final Type 
icebergType) {
         final Object convertedValue;
@@ -139,7 +143,7 @@ class RecordConverter {
         } else if (icebergType.isMapType() && value instanceof Map<?, ?> map) {
             convertedValue = convertMap(map, icebergType.asMapType());
         } else {
-            convertedValue = value;
+            convertedValue = convertPrimitiveValue(value, icebergType);
         }
 
         return convertedValue;
@@ -181,6 +185,47 @@ class RecordConverter {
         return converted;
     }
 
+    /**
+     * Convert byte array and UUID values to the Java types that Apache 
Iceberg requires for the matching primitive type.
+     * Record Readers provide bytes as byte arrays or as Object arrays of Byte 
elements, and UUIDs as Strings or byte arrays.
+     *
+     * @param value Field value to be converted
+     * @param icebergType Iceberg Type describing the target field type
+     * @return ByteBuffer for binary, geometry, and geography types, byte 
array for fixed types, UUID for uuid types, or the
+     * input value when the Iceberg Type does not require conversion of the 
value
+     */
+    private static Object convertPrimitiveValue(final Object value, final Type 
icebergType) {
+        return switch (icebergType.typeId()) {
+            // Geometry and geography values are encoded as Well-Known Binary
+            case BINARY, GEOMETRY, GEOGRAPHY -> switch (value) {
+                case byte[] bytes -> ByteBuffer.wrap(bytes);
+                case Object[] array -> ByteBuffer.wrap(toByteArray(array));
+                case null, default -> value;
+            };
+            case FIXED -> switch (value) {
+                case Object[] array -> toByteArray(array);
+                case ByteBuffer buffer -> ByteBuffers.toByteArray(buffer);
+                case null, default -> value;
+            };
+            case UUID -> switch (value) {
+                case String string -> DataTypeUtils.toUUID(string);
+                case byte[] bytes -> DataTypeUtils.toUUID(bytes);
+                case Object[] array -> 
DataTypeUtils.toUUID(toByteArray(array));
+                case null, default -> value;
+            };
+            default -> value;
+        };
+    }
+
+    private static byte[] toByteArray(final Object[] array) {
+        final byte[] bytes = new byte[array.length];
+        for (int index = 0; index < array.length; index++) {
+            bytes[index] = (Byte) array[index];
+        }
+
+        return bytes;
+    }
+
     private static Type fieldType(final Types.StructType struct, final String 
fieldName) {
         final Types.NestedField nestedField = struct == null ? null : 
struct.field(fieldName);
         return nestedField == null ? null : nestedField.type();
@@ -192,4 +237,17 @@ class RecordConverter {
                 .map(DataType::getFieldType)
                 .anyMatch(CONVERSION_REQUIRED_FIELD_TYPES::contains);
     }
+
+    /**
+     * Determine whether the Iceberg Struct Type contains uuid fields, which 
require conversion of String values that do
+     * not otherwise require Record conversion
+     *
+     * @param struct Iceberg Struct Type describing the target field types 
(may be null for scalar-only conversion)
+     * @return UUID conversion required status
+     */
+    private static boolean isUuidConversionRequired(final Types.StructType 
struct) {
+        return struct != null && struct.fields().stream()
+                .map(Types.NestedField::type)
+                .anyMatch(type -> type.typeId() == Type.TypeID.UUID);
+    }
 }
diff --git 
a/nifi-extension-bundles/nifi-iceberg-bundle/nifi-iceberg-processors/src/test/java/org/apache/nifi/processors/iceberg/record/DelegatedRecordTest.java
 
b/nifi-extension-bundles/nifi-iceberg-bundle/nifi-iceberg-processors/src/test/java/org/apache/nifi/processors/iceberg/record/DelegatedRecordTest.java
index 3b678521387..5c53983635b 100644
--- 
a/nifi-extension-bundles/nifi-iceberg-bundle/nifi-iceberg-processors/src/test/java/org/apache/nifi/processors/iceberg/record/DelegatedRecordTest.java
+++ 
b/nifi-extension-bundles/nifi-iceberg-bundle/nifi-iceberg-processors/src/test/java/org/apache/nifi/processors/iceberg/record/DelegatedRecordTest.java
@@ -18,6 +18,7 @@ package org.apache.nifi.processors.iceberg.record;
 
 import org.apache.iceberg.types.Types;
 import org.apache.nifi.serialization.SimpleRecordSchema;
+import org.apache.nifi.serialization.record.DataType;
 import org.apache.nifi.serialization.record.MapRecord;
 import org.apache.nifi.serialization.record.Record;
 import org.apache.nifi.serialization.record.RecordField;
@@ -26,6 +27,7 @@ import org.apache.nifi.serialization.record.RecordSchema;
 import org.junit.jupiter.api.Test;
 
 import java.math.BigDecimal;
+import java.nio.ByteBuffer;
 import java.sql.Date;
 import java.sql.Time;
 import java.sql.Timestamp;
@@ -35,7 +37,9 @@ import java.time.LocalTime;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.UUID;
 
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
 
@@ -61,6 +65,13 @@ class DelegatedRecordTest {
     private static final int ID_MODIFIED = 99;
     private static final BigDecimal AMOUNT = new BigDecimal("12.34");
 
+    private static final String DATA_FIELD = "data";
+    private static final String DIGEST_FIELD = "digest";
+    private static final String IDENTIFIER_FIELD = "identifier";
+    private static final byte[] DATA = new byte[] {1, 2, 3, 4};
+    private static final Object[] DATA_ELEMENTS = new Object[] {(byte) 1, 
(byte) 2, (byte) 3, (byte) 4};
+    private static final UUID IDENTIFIER = 
UUID.fromString("0f8fad5b-d9cb-469f-a165-70867728950e");
+
     private static final Types.StructType TABLE_STRUCT = Types.StructType.of(
             Types.NestedField.required(1, ID_FIELD, Types.IntegerType.get()),
             Types.NestedField.optional(2, AMOUNT_FIELD, 
Types.DecimalType.of(10, 2)),
@@ -152,6 +163,39 @@ class DelegatedRecordTest {
         assertEquals(STOPPED_CONVERTED, stopped);
     }
 
+    /**
+     * Record Readers provide bytes as Object arrays of Byte elements and 
UUIDs as Strings, while Iceberg writers require
+     * ByteBuffer values for binary columns, byte arrays for fixed columns, 
and UUID values for uuid columns.
+     */
+    @Test
+    void testGetBinaryFixedUuidFields() {
+        final DataType bytesDataType = 
RecordFieldType.ARRAY.getArrayDataType(RecordFieldType.BYTE.getDataType());
+        final RecordSchema recordSchema = new SimpleRecordSchema(
+                List.of(
+                        new RecordField(DATA_FIELD, bytesDataType),
+                        new RecordField(DIGEST_FIELD, bytesDataType),
+                        new RecordField(IDENTIFIER_FIELD, 
RecordFieldType.STRING.getDataType())
+                )
+        );
+        final Map<String, Object> values = new LinkedHashMap<>();
+        values.put(DATA_FIELD, DATA_ELEMENTS);
+        values.put(DIGEST_FIELD, DATA_ELEMENTS);
+        values.put(IDENTIFIER_FIELD, IDENTIFIER.toString());
+
+        final Record record = new MapRecord(recordSchema, values);
+
+        final Types.StructType structType = Types.StructType.of(
+                Types.NestedField.optional(1, DATA_FIELD, 
Types.BinaryType.get()),
+                Types.NestedField.optional(2, DIGEST_FIELD, 
Types.FixedType.ofLength(DATA.length)),
+                Types.NestedField.optional(3, IDENTIFIER_FIELD, 
Types.UUIDType.get())
+        );
+        final DelegatedRecord delegatedRecord = new DelegatedRecord(record, 
structType);
+
+        assertEquals(ByteBuffer.wrap(DATA), delegatedRecord.get(0, 
ByteBuffer.class));
+        assertArrayEquals(DATA, delegatedRecord.get(1, byte[].class));
+        assertEquals(IDENTIFIER, delegatedRecord.get(2, UUID.class));
+    }
+
     /**
      * Iceberg writers read values positionally against the table struct, so 
position 0 must always return the value of
      * the table's first column ("id"), independent of the field ordering in 
the incoming Record schema.
diff --git 
a/nifi-extension-bundles/nifi-iceberg-bundle/nifi-iceberg-processors/src/test/java/org/apache/nifi/processors/iceberg/record/RecordConverterTest.java
 
b/nifi-extension-bundles/nifi-iceberg-bundle/nifi-iceberg-processors/src/test/java/org/apache/nifi/processors/iceberg/record/RecordConverterTest.java
index 3dcc0fcbf2f..d7cd2c0f643 100644
--- 
a/nifi-extension-bundles/nifi-iceberg-bundle/nifi-iceberg-processors/src/test/java/org/apache/nifi/processors/iceberg/record/RecordConverterTest.java
+++ 
b/nifi-extension-bundles/nifi-iceberg-bundle/nifi-iceberg-processors/src/test/java/org/apache/nifi/processors/iceberg/record/RecordConverterTest.java
@@ -30,6 +30,7 @@ import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
 
+import java.nio.ByteBuffer;
 import java.sql.Timestamp;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
@@ -38,10 +39,13 @@ import java.time.ZoneOffset;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.UUID;
 import java.util.stream.Stream;
 
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
 
 class RecordConverterTest {
 
@@ -57,12 +61,20 @@ class RecordConverterTest {
 
     private static final String ID_FIELD_NAME = "id";
 
+    private static final String DATA_FIELD_NAME = "data";
+
+    private static final String IDENTIFIER_FIELD_NAME = "identifier";
+
     private static final String CITY_FIELD_VALUE = "Berlin";
 
     private static final String NAME_FIELD_VALUE = "widget";
 
     private static final String ID_FIELD_VALUE = "row-1";
 
+    private static final byte[] DATA_FIELD_VALUE = new byte[] {1, 2, 3, 4};
+
+    private static final UUID IDENTIFIER_FIELD_VALUE = 
UUID.fromString("0f8fad5b-d9cb-469f-a165-70867728950e");
+
     private static final LocalDateTime CREATED_LOCAL_DATE_TIME = 
LocalDateTime.of(2026, 1, 1, 12, 30, 45);
 
     @Test
@@ -298,4 +310,166 @@ class RecordConverterTest {
         assertEquals(CITY_FIELD_VALUE, addressStruct.get(0, String.class));
         assertEquals(ID_FIELD_VALUE, converted.getField(ID_FIELD_NAME));
     }
+
+    /**
+     * Record Readers provide bytes as byte arrays or as Object arrays of Byte 
elements, and Iceberg binary, geometry, and
+     * geography columns require ByteBuffer values.
+     */
+    @ParameterizedTest
+    @MethodSource
+    void testConvertBytesToByteBuffer(final Type icebergType, final Object 
value) {
+        final Object converted = RecordConverter.convertValue(value, 
icebergType);
+
+        final ByteBuffer buffer = assertInstanceOf(ByteBuffer.class, 
converted);
+        assertEquals(ByteBuffer.wrap(DATA_FIELD_VALUE), buffer);
+    }
+
+    private static Stream<Arguments> testConvertBytesToByteBuffer() {
+        return Stream.of(Types.BinaryType.get(), Types.GeometryType.crs84(), 
Types.GeographyType.crs84())
+                .flatMap(icebergType -> Stream.of(
+                        Arguments.of(icebergType, DATA_FIELD_VALUE.clone()),
+                        Arguments.of(icebergType, 
getObjectArray(DATA_FIELD_VALUE)),
+                        Arguments.of(icebergType, 
ByteBuffer.wrap(DATA_FIELD_VALUE))
+                ));
+    }
+
+    /**
+     * Iceberg fixed columns require byte arrays.
+     */
+    @ParameterizedTest
+    @MethodSource
+    void testConvertBytesToFixed(final Object value) {
+        final Types.FixedType fixedType = 
Types.FixedType.ofLength(DATA_FIELD_VALUE.length);
+
+        final Object converted = RecordConverter.convertValue(value, 
fixedType);
+
+        final byte[] bytes = assertInstanceOf(byte[].class, converted);
+        assertArrayEquals(DATA_FIELD_VALUE, bytes);
+    }
+
+    private static Stream<Arguments> testConvertBytesToFixed() {
+        return Stream.of(
+                Arguments.of(DATA_FIELD_VALUE.clone()),
+                Arguments.of((Object) getObjectArray(DATA_FIELD_VALUE)),
+                Arguments.of(ByteBuffer.wrap(DATA_FIELD_VALUE))
+        );
+    }
+
+    /**
+     * Iceberg uuid columns require UUID values, which Record Readers can 
provide as Strings or as byte arrays.
+     */
+    @ParameterizedTest
+    @MethodSource
+    void testConvertUuid(final Object value) {
+        final Object converted = RecordConverter.convertValue(value, 
Types.UUIDType.get());
+
+        assertEquals(IDENTIFIER_FIELD_VALUE, converted);
+    }
+
+    private static Stream<Arguments> testConvertUuid() {
+        final byte[] identifierBytes = ByteBuffer.allocate(16)
+                .putLong(IDENTIFIER_FIELD_VALUE.getMostSignificantBits())
+                .putLong(IDENTIFIER_FIELD_VALUE.getLeastSignificantBits())
+                .array();
+
+        return Stream.of(
+                Arguments.of(IDENTIFIER_FIELD_VALUE),
+                Arguments.of(IDENTIFIER_FIELD_VALUE.toString()),
+                Arguments.of(identifierBytes),
+                Arguments.of((Object) getObjectArray(identifierBytes))
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource
+    void testConvertNullBytesUuid(final Type icebergType) {
+        final Object converted = RecordConverter.convertValue(null, 
icebergType);
+
+        assertNull(converted);
+    }
+
+    private static Stream<Arguments> testConvertNullBytesUuid() {
+        return Stream.of(
+                Arguments.of(Types.BinaryType.get()),
+                
Arguments.of(Types.FixedType.ofLength(DATA_FIELD_VALUE.length)),
+                Arguments.of(Types.UUIDType.get()),
+                Arguments.of(Types.GeometryType.crs84()),
+                Arguments.of(Types.GeographyType.crs84())
+        );
+    }
+
+    @Test
+    void testConvertArrayElementBytes() {
+        final Types.ListType listType = Types.ListType.ofOptional(1, 
Types.BinaryType.get());
+        final Object[] array = new Object[] {getObjectArray(DATA_FIELD_VALUE)};
+
+        final Object converted = RecordConverter.convertValue(array, listType);
+
+        final List<?> list = assertInstanceOf(List.class, converted);
+        assertEquals(List.of(ByteBuffer.wrap(DATA_FIELD_VALUE)), list);
+    }
+
+    @Test
+    void testConvertMapBytesValue() {
+        final Types.MapType mapType = Types.MapType.ofOptional(
+                1, 2, Types.StringType.get(), Types.BinaryType.get()
+        );
+        final Map<String, Object> map = new LinkedHashMap<>();
+        map.put(DATA_FIELD_NAME, getObjectArray(DATA_FIELD_VALUE));
+
+        final Object converted = RecordConverter.convertValue(map, mapType);
+
+        final Map<?, ?> resultMap = assertInstanceOf(Map.class, converted);
+        assertEquals(ByteBuffer.wrap(DATA_FIELD_VALUE), 
resultMap.get(DATA_FIELD_NAME));
+    }
+
+    @Test
+    void testConvertNestedRecordBytesField() {
+        final Types.StructType structType = Types.StructType.of(
+                Types.NestedField.optional(1, DATA_FIELD_NAME, 
Types.BinaryType.get())
+        );
+
+        final RecordSchema nestedSchema = new SimpleRecordSchema(List.of(
+                new RecordField(DATA_FIELD_NAME, 
RecordFieldType.ARRAY.getArrayDataType(RecordFieldType.BYTE.getDataType()))
+        ));
+        final Map<String, Object> nestedValues = new LinkedHashMap<>();
+        nestedValues.put(DATA_FIELD_NAME, getObjectArray(DATA_FIELD_VALUE));
+        final Record nestedRecord = new MapRecord(nestedSchema, nestedValues);
+
+        final Object converted = RecordConverter.convertValue(nestedRecord, 
structType);
+
+        final StructLike struct = assertInstanceOf(StructLike.class, 
converted);
+        assertEquals(ByteBuffer.wrap(DATA_FIELD_VALUE), struct.get(0, 
ByteBuffer.class));
+    }
+
+    /**
+     * UUID values read as Strings must be converted for Iceberg uuid columns, 
even when no other Record field requires
+     * conversion.
+     */
+    @Test
+    void testGetConvertedRecordUuidString() {
+        final Types.StructType struct = Types.StructType.of(
+                Types.NestedField.optional(1, IDENTIFIER_FIELD_NAME, 
Types.UUIDType.get())
+        );
+
+        final RecordSchema schema = new SimpleRecordSchema(List.of(
+                new RecordField(IDENTIFIER_FIELD_NAME, 
RecordFieldType.STRING.getDataType())
+        ));
+        final Map<String, Object> values = new LinkedHashMap<>();
+        values.put(IDENTIFIER_FIELD_NAME, IDENTIFIER_FIELD_VALUE.toString());
+        final Record record = new MapRecord(schema, values);
+
+        final Record converted = RecordConverter.getConvertedRecord(record, 
struct);
+
+        assertEquals(IDENTIFIER_FIELD_VALUE, 
converted.getValue(IDENTIFIER_FIELD_NAME));
+    }
+
+    private static Object[] getObjectArray(final byte[] bytes) {
+        final Object[] array = new Object[bytes.length];
+        for (int index = 0; index < bytes.length; index++) {
+            array[index] = bytes[index];
+        }
+
+        return array;
+    }
 }

Reply via email to