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

jyothsnakonisa pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/cassandra-analytics.git


The following commit(s) were added to refs/heads/trunk by this push:
     new 6ca4f1cd CASSANALYTICS-203: Fix UDT column deserialization in CDC 
(#252)
6ca4f1cd is described below

commit 6ca4f1cdb04e68826abebed1a6a16bda55b4044b
Author: Jyothsna konisa <[email protected]>
AuthorDate: Wed Oct 14 16:40:54 2026 -0700

    CASSANALYTICS-203: Fix UDT column deserialization in CDC (#252)
    
    Patch by Jyothsna Konisa; Reviewed by Yifan Cai for CASSANALYTICS-203
---
 CHANGES.txt                                        |   1 +
 .../java/org/apache/cassandra/cdc/CdcLogMode.java  |   3 +-
 .../cdc/avro/AvroBaseRecordTransformer.java        |   2 +-
 .../apache/cassandra/cdc/avro/CdcEventUtils.java   |   2 +-
 .../cassandra/cdc/avro/JavaTypeConverter.java      | 140 +++++++++++
 .../cassandra/cdc/json/AvroJsonTransformer.java    |   5 +-
 .../cassandra/cdc/avro/AvroUdtTransformerTest.java | 255 +++++++++++++++++++++
 .../cassandra/cdc/avro/JavaTypeConverterTest.java  |  86 +++++++
 8 files changed, 489 insertions(+), 5 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 092926ae..1198a67e 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,6 @@
 0.5.0
 -----
+ * Fix UDT column deserialization in CDC (CASSANALYTICS-203)
  * Skip CDC-irrelevant mutations on deserialization failure (CASSANALYTICS-196)
  * Opening one keyspace instance rebuilds every keyspace instance in the 
Cassandra 6.0 bridge (CASSANALYTICS-202)
  * CDC logs NPE for deleted column values (CASSANALYTICS-178)
diff --git 
a/cassandra-analytics-cdc-codec/src/main/java/org/apache/cassandra/cdc/CdcLogMode.java
 
b/cassandra-analytics-cdc-codec/src/main/java/org/apache/cassandra/cdc/CdcLogMode.java
index e89e2738..b6aea613 100644
--- 
a/cassandra-analytics-cdc-codec/src/main/java/org/apache/cassandra/cdc/CdcLogMode.java
+++ 
b/cassandra-analytics-cdc-codec/src/main/java/org/apache/cassandra/cdc/CdcLogMode.java
@@ -28,6 +28,7 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import org.apache.cassandra.cdc.api.KeyspaceTypeKey;
+import org.apache.cassandra.cdc.avro.JavaTypeConverter;
 import org.apache.cassandra.cdc.msg.CdcEvent;
 import org.apache.cassandra.cdc.msg.Value;
 import org.apache.cassandra.spark.data.CqlField;
@@ -166,7 +167,7 @@ public enum CdcLogMode implements CdcLogger
         try
         {
             CqlField.CqlType type = 
typeLookup.apply(KeyspaceTypeKey.of(fieldValue.keyspace, 
fieldValue.columnType));
-            Object javaValue = type.deserializeToJavaType(value);
+            Object javaValue = JavaTypeConverter.deserialize(type, value);
             return String.format("[%s : %s]", fieldValue.columnName, 
javaValue);
         }
         catch (Throwable t)
diff --git 
a/cassandra-analytics-cdc-codec/src/main/java/org/apache/cassandra/cdc/avro/AvroBaseRecordTransformer.java
 
b/cassandra-analytics-cdc-codec/src/main/java/org/apache/cassandra/cdc/avro/AvroBaseRecordTransformer.java
index 7c0ac173..c4cbd702 100644
--- 
a/cassandra-analytics-cdc-codec/src/main/java/org/apache/cassandra/cdc/avro/AvroBaseRecordTransformer.java
+++ 
b/cassandra-analytics-cdc-codec/src/main/java/org/apache/cassandra/cdc/avro/AvroBaseRecordTransformer.java
@@ -78,7 +78,7 @@ extends CdcEventAvroEncoder
 
             GenericData.Record update = new GenericData.Record(tableSchema);
             CqlField.CqlType type = 
typeLookup.apply(KeyspaceTypeKey.of(event.keyspace, field.columnType));
-            Object javaValue = type.deserializeToJavaType(fieldValue);
+            Object javaValue = JavaTypeConverter.deserialize(type, fieldValue);
             update.put(field.columnName, AvroDataUtils.toAvro(javaValue, 
column.schema()));
             return ByteBuffer.wrap(encode(store.getWriter(event.keyspace + '.' 
+ event.table, null), update));
         };
diff --git 
a/cassandra-analytics-cdc-codec/src/main/java/org/apache/cassandra/cdc/avro/CdcEventUtils.java
 
b/cassandra-analytics-cdc-codec/src/main/java/org/apache/cassandra/cdc/avro/CdcEventUtils.java
index fef91ab6..7c54df4c 100644
--- 
a/cassandra-analytics-cdc-codec/src/main/java/org/apache/cassandra/cdc/avro/CdcEventUtils.java
+++ 
b/cassandra-analytics-cdc-codec/src/main/java/org/apache/cassandra/cdc/avro/CdcEventUtils.java
@@ -323,7 +323,7 @@ public final class CdcEventUtils
                                        "Encountered an unknown field during 
event encoding. " +
                                        "Field: %s. Avro schema: %s", 
field.columnName, tableSchema.getFullName());
             CqlField.CqlType type = 
typeLookup.apply(KeyspaceTypeKey.of(event.keyspace, field.columnType));
-            Object javaValue = type.deserializeToJavaType(value);
+            Object javaValue = JavaTypeConverter.deserialize(type, value);
             update.put(field.columnName, AvroDataUtils.toAvro(javaValue, 
column.schema()));
         }
         return new UpdatedEvent(update, truncatedFields);
diff --git 
a/cassandra-analytics-cdc-codec/src/main/java/org/apache/cassandra/cdc/avro/JavaTypeConverter.java
 
b/cassandra-analytics-cdc-codec/src/main/java/org/apache/cassandra/cdc/avro/JavaTypeConverter.java
new file mode 100644
index 00000000..35715ad7
--- /dev/null
+++ 
b/cassandra-analytics-cdc-codec/src/main/java/org/apache/cassandra/cdc/avro/JavaTypeConverter.java
@@ -0,0 +1,140 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.cassandra.cdc.avro;
+
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.avro.Schema;
+import org.apache.cassandra.spark.data.CqlField;
+import org.apache.cassandra.spark.data.TypeConverter;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * {@link TypeConverter} that converts CQL values into the Java values 
expected by
+ * {@link AvroDataUtils#toAvro(Object, Schema)}.
+ * <p>
+ * Cassandra serializers leave UDTs as raw bytes, including inside collections 
and tuples. This converter
+ * deserializes them into {@code Map<String, Object>} keyed by field name with 
{@link CqlField.CqlUdt#deserializeUdt},
+ * and converts the elements of collections and tuples recursively. All other 
values are returned unchanged.
+ */
+public final class JavaTypeConverter implements TypeConverter
+{
+    public static final JavaTypeConverter INSTANCE = new JavaTypeConverter();
+
+    private JavaTypeConverter()
+    {
+    }
+
+    /**
+     * Deserializes a column value into the Java value expected by {@link 
AvroDataUtils#toAvro(Object, Schema)}.
+     *
+     * @param type  the CQL type of the column
+     * @param value the serialized column value
+     * @return the deserialized value, or null if the value is null
+     */
+    @Nullable
+    public static Object deserialize(CqlField.CqlType type, @Nullable 
ByteBuffer value)
+    {
+        if (value == null)
+        {
+            return null;
+        }
+        // only types containing UDTs need conversion; avoid the overhead for 
everything else
+        if (type.udts().isEmpty())
+        {
+            return type.deserializeToJavaType(value);
+        }
+        return type.deserializeToType(INSTANCE, value);
+    }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public Object convert(CqlField.CqlType cqlType, @NotNull Object value, 
boolean isFrozen)
+    {
+        switch (cqlType.internalType())
+        {
+            case Frozen:
+                return convert(((CqlField.CqlFrozen) cqlType).inner(), value, 
true);
+            case Udt:
+                return value instanceof ByteBuffer
+                       ? ((CqlField.CqlUdt) cqlType).deserializeUdt(this, 
(ByteBuffer) value, isFrozen)
+                       : value;
+            case Tuple:
+                return convertTuple((CqlField.CqlTuple) cqlType, value);
+            case List:
+                CqlField.CqlType listElementType = ((CqlField.CqlCollection) 
cqlType).type();
+                List<Object> list = new ArrayList<>(((Collection<Object>) 
value).size());
+                for (Object element : (Collection<Object>) value)
+                {
+                    list.add(convertNullable(listElementType, element));
+                }
+                return list;
+            case Set:
+                CqlField.CqlType setElementType = ((CqlField.CqlCollection) 
cqlType).type();
+                Set<Object> set = new LinkedHashSet<>();
+                for (Object element : (Collection<Object>) value)
+                {
+                    set.add(convertNullable(setElementType, element));
+                }
+                return set;
+            case Map:
+                CqlField.CqlMap mapType = (CqlField.CqlMap) cqlType;
+                Map<Object, Object> map = new LinkedHashMap<>();
+                for (Map.Entry<Object, Object> entry : ((Map<Object, Object>) 
value).entrySet())
+                {
+                    map.put(convertNullable(mapType.keyType(), entry.getKey()),
+                            convertNullable(mapType.valueType(), 
entry.getValue()));
+                }
+                return map;
+            default:
+                return value;
+        }
+    }
+
+    /**
+     * Tuple elements are deserialized by {@link 
CqlField.CqlTuple#deserializeTuple} without a converter, so each
+     * element is converted here, which also handles collections and UDTs 
nested in the tuple.
+     */
+    private Object[] convertTuple(CqlField.CqlTuple tuple, Object value)
+    {
+        Object[] elements = value instanceof ByteBuffer
+                            ? tuple.deserializeTuple((ByteBuffer) value, true)
+                            : (Object[]) value;
+        Object[] result = new Object[elements.length];
+        for (int index = 0; index < elements.length; index++)
+        {
+            result[index] = convertNullable(tuple.type(index), 
elements[index]);
+        }
+        return result;
+    }
+
+    private Object convertNullable(CqlField.CqlType cqlType, @Nullable Object 
value)
+    {
+        return value == null ? null : convert(cqlType, value, true);
+    }
+}
diff --git 
a/cassandra-analytics-cdc-codec/src/main/java/org/apache/cassandra/cdc/json/AvroJsonTransformer.java
 
b/cassandra-analytics-cdc-codec/src/main/java/org/apache/cassandra/cdc/json/AvroJsonTransformer.java
index 270e6c9c..718d9bd6 100644
--- 
a/cassandra-analytics-cdc-codec/src/main/java/org/apache/cassandra/cdc/json/AvroJsonTransformer.java
+++ 
b/cassandra-analytics-cdc-codec/src/main/java/org/apache/cassandra/cdc/json/AvroJsonTransformer.java
@@ -28,6 +28,7 @@ import org.apache.cassandra.cdc.avro.CdcEventAvroEncoder;
 import org.apache.cassandra.cdc.api.KeyspaceTypeKey;
 import org.apache.cassandra.cdc.avro.AvroDataUtils;
 import org.apache.cassandra.cdc.avro.CdcEventUtils;
+import org.apache.cassandra.cdc.avro.JavaTypeConverter;
 import org.apache.cassandra.cdc.msg.CdcEvent;
 import org.apache.cassandra.cdc.msg.Value;
 import org.apache.cassandra.cdc.schemastore.SchemaStore;
@@ -51,7 +52,7 @@ public class AvroJsonTransformer extends CdcEventAvroEncoder
             Schema tableSchema = store.getSchema(event.keyspace + '.' + 
event.table, null);
             GenericData.Record update = new GenericData.Record(tableSchema);
             CqlField.CqlType type = 
typeLookup.apply(KeyspaceTypeKey.of(event.keyspace, field.columnType));
-            Object javaValue = type.deserializeToJavaType(field.getValue());
+            Object javaValue = JavaTypeConverter.deserialize(type, 
field.getValue());
             update.put(field.columnName, AvroDataUtils.toAvro(javaValue, 
tableSchema.getField(field.columnName).schema()));
             return ByteBuffer.wrap(encode(store.getWriter(event.keyspace + '.' 
+ event.table, null), update));
         };
@@ -67,7 +68,7 @@ public class AvroJsonTransformer extends CdcEventAvroEncoder
         CdcEventUtils.updatedFields(event)
                      .forEach(field -> {
                          CqlField.CqlType type = 
typeLookup.apply(KeyspaceTypeKey.of(event.keyspace, field.columnType));
-                         Object javaValue = 
type.deserializeToJavaType(field.getValue());
+                         Object javaValue = 
JavaTypeConverter.deserialize(type, field.getValue());
                          update.put(field.columnName, 
AvroDataUtils.toAvro(javaValue, 
tableSchema.getField(field.columnName).schema()));
                      });
         return update;
diff --git 
a/cassandra-analytics-cdc-codec/src/test/java/org/apache/cassandra/cdc/avro/AvroUdtTransformerTest.java
 
b/cassandra-analytics-cdc-codec/src/test/java/org/apache/cassandra/cdc/avro/AvroUdtTransformerTest.java
new file mode 100644
index 00000000..5d582f1e
--- /dev/null
+++ 
b/cassandra-analytics-cdc-codec/src/test/java/org/apache/cassandra/cdc/avro/AvroUdtTransformerTest.java
@@ -0,0 +1,255 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.cassandra.cdc.avro;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.avro.Schema;
+import org.apache.avro.generic.GenericData;
+import org.apache.avro.generic.GenericDatumReader;
+import org.apache.avro.generic.GenericDatumWriter;
+import org.apache.avro.generic.GenericRecord;
+import org.apache.avro.io.DecoderFactory;
+import org.apache.cassandra.bridge.CassandraBridgeImplementation;
+import org.apache.cassandra.cdc.msg.CdcEvent;
+import org.apache.cassandra.cdc.msg.CdcEventBuilder;
+import org.apache.cassandra.cdc.msg.Value;
+import org.apache.cassandra.cdc.schemastore.SchemaStore;
+import org.apache.cassandra.spark.data.CqlTable;
+import org.apache.cassandra.spark.data.ReplicationFactor;
+import org.apache.cassandra.spark.data.partitioner.Partitioner;
+
+import static org.apache.cassandra.spark.utils.ArrayUtils.listOf;
+import static org.apache.cassandra.spark.utils.ArrayUtils.setOf;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests that CDC events with UDT columns are transformed into Avro records, 
i.e. UDT values are
+ * published as nested Avro records instead of raw bytes.
+ */
+public class AvroUdtTransformerTest
+{
+    private static final String KEYSPACE = "avro_udt_transformer_ks";
+    private static final ReplicationFactor REPLICATION_FACTOR =
+    new 
ReplicationFactor(ReplicationFactor.ReplicationStrategy.SimpleStrategy, 
Collections.singletonMap("replication_factor", 1));
+
+    private final CassandraBridgeImplementation bridge = new 
CassandraBridgeImplementation();
+
+    @Test
+    public void testListOfFrozenUdts() throws IOException
+    {
+        CqlTable table = registerTable("CREATE TABLE " + KEYSPACE + ".task (id 
uuid PRIMARY KEY, name text, " +
+                                       "results list<frozen<task_result>>);",
+                                       "CREATE TYPE " + KEYSPACE + 
".task_result (status text, message text);");
+        UUID id = UUID.randomUUID();
+        CdcEvent event = insert(table, id,
+                                Value.of(KEYSPACE, "name", "text", 
text("test-task")),
+                                Value.of(KEYSPACE, "results", 
"list<frozen<task_result>>",
+                                         list(fields(text("SUCCESS"), 
text("completed")))));
+
+        GenericRecord payload = transformAndEncode(table, event);
+
+        assertThat(payload.get("id")).hasToString(id.toString());
+        assertThat(payload.get("name")).hasToString("test-task");
+        List<?> results = (List<?>) payload.get("results");
+        assertThat(results).hasSize(1);
+        GenericRecord result = (GenericRecord) results.get(0);
+        assertThat(result.get("status")).hasToString("SUCCESS");
+        assertThat(result.get("message")).hasToString("completed");
+    }
+
+    @Test
+    public void testListOfFrozenUdtsWithNestedUdt() throws IOException
+    {
+        CqlTable table = registerTable("CREATE TABLE " + KEYSPACE + ".command 
(id uuid PRIMARY KEY, command_class text, " +
+                                       "execution_results 
list<frozen<execution_result>>);",
+                                       "CREATE TYPE " + KEYSPACE + 
".transient_event (id uuid, event_class text);",
+                                       "CREATE TYPE " + KEYSPACE + 
".execution_result " +
+                                       "(domain_event frozen<transient_event>, 
status text);");
+        UUID id = UUID.randomUUID();
+        UUID eventId = UUID.randomUUID();
+        CdcEvent event = insert(table, id,
+                                Value.of(KEYSPACE, "command_class", "text", 
text("TestCommand")),
+                                Value.of(KEYSPACE, "execution_results", 
"list<frozen<execution_result>>",
+                                         list(fields(fields(uuid(eventId), 
text("TestEvent")), text("SUCCESS")))));
+
+        GenericRecord payload = transformAndEncode(table, event);
+
+        GenericRecord executionResult = (GenericRecord) ((List<?>) 
payload.get("execution_results")).get(0);
+        GenericRecord domainEvent = (GenericRecord) 
executionResult.get("domain_event");
+        assertThat(domainEvent.get("id")).hasToString(eventId.toString());
+        assertThat(domainEvent.get("event_class")).hasToString("TestEvent");
+        assertThat(executionResult.get("status")).hasToString("SUCCESS");
+    }
+
+    @Test
+    public void testFrozenUdtColumn() throws IOException
+    {
+        CqlTable table = registerTable("CREATE TABLE " + KEYSPACE + ".single 
(id uuid PRIMARY KEY, result frozen<single_result>);",
+                                       "CREATE TYPE " + KEYSPACE + 
".single_result (status text, code int);");
+        CdcEvent event = insert(table, UUID.randomUUID(),
+                                Value.of(KEYSPACE, "result", 
"frozen<single_result>",
+                                         fields(text("SUCCESS"), 
ByteBuffer.allocate(4).putInt(0, 200))));
+
+        GenericRecord result = (GenericRecord) transformAndEncode(table, 
event).get("result");
+
+        assertThat(result.get("status")).hasToString("SUCCESS");
+        assertThat(result.get("code")).isEqualTo(200);
+    }
+
+    @Test
+    public void testNonFrozenUdtColumn() throws IOException
+    {
+        CqlTable table = registerTable("CREATE TABLE " + KEYSPACE + 
".multi_cell (id uuid PRIMARY KEY, result multi_cell_result);",
+                                       "CREATE TYPE " + KEYSPACE + 
".multi_cell_result (status text, code int);");
+        // multi-cell UDT cells are packed with the cell count first
+        CdcEvent event = insert(table, UUID.randomUUID(),
+                                Value.of(KEYSPACE, "result", 
"multi_cell_result",
+                                         list(text("SUCCESS"), 
ByteBuffer.allocate(4).putInt(0, 200))));
+
+        GenericRecord result = (GenericRecord) transformAndEncode(table, 
event).get("result");
+
+        assertThat(result.get("status")).hasToString("SUCCESS");
+        assertThat(result.get("code")).isEqualTo(200);
+    }
+
+    /**
+     * Parses the table and registers its UDTs with the types used for type 
lookup, as the CDC bridge does
+     */
+    private CqlTable registerTable(String createTable, String... createTypes)
+    {
+        CqlTable table = bridge.buildSchema(createTable, KEYSPACE, 
REPLICATION_FACTOR, Partitioner.Murmur3Partitioner, setOf(createTypes));
+        table.udts().forEach(udt -> 
bridge.cassandraTypes().updateUDTs(KEYSPACE, udt));
+        return table;
+    }
+
+    private static CdcEvent insert(CqlTable table, UUID id, Value... 
valueColumns)
+    {
+        CdcEventBuilder builder = CdcEventBuilder.of(CdcEvent.Kind.INSERT, 
KEYSPACE, table.table());
+        builder.setPartitionKeys(listOf(Value.of(KEYSPACE, "id", "uuid", 
uuid(id))));
+        builder.setValueColumns(listOf(valueColumns));
+        
builder.setMaxTimestampMicros(TimeUnit.MILLISECONDS.toMicros(System.currentTimeMillis()));
+        return builder.build();
+    }
+
+    /**
+     * Transforms the event, encodes and decodes the full CDC record, and 
returns the decoded payload
+     */
+    private GenericRecord transformAndEncode(CqlTable table, CdcEvent event) 
throws IOException
+    {
+        Schema tableSchema = new 
CqlToAvroSchemaConverterImplementation(bridge).convert(table);
+        AvroGenericRecordTransformer transformer =
+        new AvroGenericRecordTransformer(new 
SingleTableSchemaStore(tableSchema),
+                                         key -> 
bridge.cassandraTypes().parseType(key.keyspace, key.type),
+                                         null);
+
+        GenericData.Record record = transformer.transform(event);
+
+        // encoding fails with a ClassCastException if UDT values are not Avro 
records
+        byte[] encoded = AvroDataUtils.encode(new 
GenericDatumWriter<>(record.getSchema()), record, null);
+        GenericRecord decoded = new 
GenericDatumReader<GenericRecord>(record.getSchema())
+                                .read(null, 
DecoderFactory.get().binaryDecoder(encoded, null));
+        return (GenericRecord) decoded.get(AvroConstants.PAYLOAD_KEY);
+    }
+
+    private static ByteBuffer text(String value)
+    {
+        return ByteBuffer.wrap(value.getBytes(StandardCharsets.UTF_8));
+    }
+
+    private static ByteBuffer uuid(UUID value)
+    {
+        return ByteBuffer.allocate(16)
+                         .putLong(0, value.getMostSignificantBits())
+                         .putLong(8, value.getLeastSignificantBits());
+    }
+
+    // frozen UDT encoding: [int length][bytes] per field
+    private static ByteBuffer fields(ByteBuffer... values)
+    {
+        return concat(false, values);
+    }
+
+    // collection encoding: [int count] followed by [int length][bytes] per 
element
+    private static ByteBuffer list(ByteBuffer... elements)
+    {
+        return concat(true, elements);
+    }
+
+    private static ByteBuffer concat(boolean withCount, ByteBuffer... values)
+    {
+        int size = withCount ? Integer.BYTES : 0;
+        for (ByteBuffer value : values)
+        {
+            size += Integer.BYTES + value.remaining();
+        }
+        ByteBuffer result = ByteBuffer.allocate(size);
+        if (withCount)
+        {
+            result.putInt(values.length);
+        }
+        for (ByteBuffer value : values)
+        {
+            result.putInt(value.remaining());
+            result.put(value.duplicate());
+        }
+        result.flip();
+        return result;
+    }
+
+    /**
+     * {@link SchemaStore} serving the Avro schema of a single table
+     */
+    private static class SingleTableSchemaStore implements SchemaStore
+    {
+        private final Schema schema;
+
+        SingleTableSchemaStore(Schema schema)
+        {
+            this.schema = schema;
+        }
+
+        @Override
+        public Schema getSchema(String namespace, String name)
+        {
+            return schema;
+        }
+
+        @Override
+        public GenericDatumWriter<GenericRecord> getWriter(String namespace, 
String name)
+        {
+            return new GenericDatumWriter<>(schema);
+        }
+
+        @Override
+        public GenericDatumReader<GenericRecord> getReader(String namespace, 
String name)
+        {
+            return new GenericDatumReader<>(schema);
+        }
+    }
+}
diff --git 
a/cassandra-analytics-cdc-codec/src/test/java/org/apache/cassandra/cdc/avro/JavaTypeConverterTest.java
 
b/cassandra-analytics-cdc-codec/src/test/java/org/apache/cassandra/cdc/avro/JavaTypeConverterTest.java
new file mode 100644
index 00000000..75adeb0d
--- /dev/null
+++ 
b/cassandra-analytics-cdc-codec/src/test/java/org/apache/cassandra/cdc/avro/JavaTypeConverterTest.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.cassandra.cdc.avro;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.cassandra.bridge.CassandraBridgeImplementation;
+import org.apache.cassandra.spark.data.CqlField;
+import org.apache.cassandra.spark.data.CqlTable;
+import org.apache.cassandra.spark.data.ReplicationFactor;
+import org.apache.cassandra.spark.data.partitioner.Partitioner;
+
+import static org.apache.cassandra.spark.utils.ArrayUtils.setOf;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link JavaTypeConverter}
+ */
+public class JavaTypeConverterTest
+{
+    private static final String KEYSPACE = "java_type_converter_ks";
+    private static final ReplicationFactor REPLICATION_FACTOR =
+    new 
ReplicationFactor(ReplicationFactor.ReplicationStrategy.SimpleStrategy, 
Collections.singletonMap("replication_factor", 1));
+
+    @Test
+    public void testUdtInCollectionInTuple()
+    {
+        // tuple elements are deserialized without a converter, so nested 
collections and UDTs must still be converted
+        CqlTable table = new CassandraBridgeImplementation()
+                         .buildSchema("CREATE TABLE " + KEYSPACE + ".t (id int 
PRIMARY KEY, " +
+                                      "c frozen<tuple<int, 
frozen<list<frozen<address>>>>>);",
+                                      KEYSPACE, REPLICATION_FACTOR, 
Partitioner.Murmur3Partitioner,
+                                      setOf("CREATE TYPE " + KEYSPACE + 
".address (city text);"));
+        CqlField.CqlType type = table.getField("c").type();
+        ByteBuffer address = concat(false, 
ByteBuffer.wrap("Paris".getBytes(StandardCharsets.UTF_8)));
+        ByteBuffer serialized = concat(false, ByteBuffer.allocate(4).putInt(0, 
7), concat(true, address));
+
+        Object[] value = (Object[]) JavaTypeConverter.deserialize(type, 
serialized);
+
+        assertThat(value).containsExactly(7, 
Collections.singletonList(Map.of("city", "Paris")));
+    }
+
+    // values encoded as [int length][bytes], optionally prefixed by the 
number of values (collections)
+    private static ByteBuffer concat(boolean withCount, ByteBuffer... values)
+    {
+        int size = withCount ? Integer.BYTES : 0;
+        for (ByteBuffer value : values)
+        {
+            size += Integer.BYTES + value.remaining();
+        }
+        ByteBuffer result = ByteBuffer.allocate(size);
+        if (withCount)
+        {
+            result.putInt(values.length);
+        }
+        for (ByteBuffer value : values)
+        {
+            result.putInt(value.remaining());
+            result.put(value.duplicate());
+        }
+        result.flip();
+        return result;
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to