This is an automated email from the ASF dual-hosted git repository.
RyanSkraba pushed a commit to branch branch-1.12
in repository https://gitbox.apache.org/repos/asf/avro.git
The following commit(s) were added to refs/heads/branch-1.12 by this push:
new be1443838f AVRO-4322: [Java] Only apply java-class annotations to
SpecificData models. (#3917)
be1443838f is described below
commit be1443838fe84dd4d3b7032568c6c4d8ef6028d6
Author: Ryan Skraba <[email protected]>
AuthorDate: Wed Aug 5 11:18:38 2026 +0200
AVRO-4322: [Java] Only apply java-class annotations to SpecificData models.
(#3917)
---
.../java/org/apache/avro/io/FastReaderBuilder.java | 7 +-
.../avro/io/FastReaderBuilderJavaClassTest.java | 203 +++++++++++++++------
.../avro/io/TestFastReaderBuilderClassLoading.java | 14 +-
3 files changed, 152 insertions(+), 72 deletions(-)
diff --git
a/lang/java/avro/src/main/java/org/apache/avro/io/FastReaderBuilder.java
b/lang/java/avro/src/main/java/org/apache/avro/io/FastReaderBuilder.java
index 8c61acf201..e169dfa82f 100644
--- a/lang/java/avro/src/main/java/org/apache/avro/io/FastReaderBuilder.java
+++ b/lang/java/avro/src/main/java/org/apache/avro/io/FastReaderBuilder.java
@@ -96,7 +96,7 @@ public class FastReaderBuilder {
}
public boolean isKeyClassEnabled() {
- return this.keyClassEnabled;
+ return this.keyClassEnabled && data.getClass() == SpecificData.class;
}
public FastReaderBuilder withClassPropEnabled(boolean enabled) {
@@ -105,7 +105,7 @@ public class FastReaderBuilder {
}
public boolean isClassPropEnabled() {
- return this.classPropEnabled;
+ return this.classPropEnabled && data.getClass() == SpecificData.class;
}
public <D> DatumReader<D> createDatumReader(Schema schema) throws
IOException {
@@ -438,8 +438,7 @@ public class FastReaderBuilder {
private FieldReader createMapKeyReader(Schema readerSchema) {
FieldReader stringReader = createSimpleStringReader(readerSchema);
if (isKeyClassEnabled()) {
- return
getTransformingStringReader(readerSchema.getProp(SpecificData.KEY_CLASS_PROP),
- createSimpleStringReader(readerSchema));
+ return
getTransformingStringReader(readerSchema.getProp(SpecificData.KEY_CLASS_PROP),
stringReader);
} else {
return stringReader;
}
diff --git
a/lang/java/avro/src/test/java/org/apache/avro/io/FastReaderBuilderJavaClassTest.java
b/lang/java/avro/src/test/java/org/apache/avro/io/FastReaderBuilderJavaClassTest.java
index 8a23133366..0cb745d931 100644
---
a/lang/java/avro/src/test/java/org/apache/avro/io/FastReaderBuilderJavaClassTest.java
+++
b/lang/java/avro/src/test/java/org/apache/avro/io/FastReaderBuilderJavaClassTest.java
@@ -18,99 +18,190 @@
package org.apache.avro.io;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
+import java.math.BigDecimal;
+import java.util.Map;
import org.apache.avro.Schema;
+import org.apache.avro.SchemaBuilder;
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.generic.GenericRecordBuilder;
+import org.apache.avro.specific.SpecificData;
+import org.apache.avro.util.Utf8;
import org.junit.jupiter.api.Test;
/**
- * Tests for FastReaderBuilder behavior with schemas containing "java-class"
- * attributes.
+ * Tests for FastReaderBuilder behavior with schemas containing
+ * {@link SpecificData#CLASS_PROP} and {@link SpecificData#KEY_CLASS_PROP}
+ * attributes. Note that {@link SpecificData#ELEMENT_PROP} isn't tested because
+ * it is only used by ReflectData.
*/
public class FastReaderBuilderJavaClassTest {
+ private static final Schema SCHEMA_RECORD_WITH_NULLABLE_CLASS_PROP =
SchemaBuilder.record("NullableStringRecord")
+
.fields().requiredString("id").name("price").type().unionOf().nullType().and()
+
.type(SchemaBuilder.builder().stringBuilder().prop(SpecificData.CLASS_PROP,
"java.math.BigDecimal").endString())
+ .endUnion().noDefault().endRecord();
+
+ private static final GenericRecord RECORD_WITH_NULLABLE_CLASS_PROP = new
GenericRecordBuilder(
+ SCHEMA_RECORD_WITH_NULLABLE_CLASS_PROP).set("id", "123").set("price",
"-0.0002").build();
+
+ private static final Schema SCHEMA_RECORD_WITH_CLASS_PROP =
SchemaBuilder.record("StringRecord").fields()
+ .requiredString("id").name("price")
+
.type(SchemaBuilder.builder().stringBuilder().prop(SpecificData.CLASS_PROP,
"java.math.BigDecimal").endString())
+ .noDefault().endRecord();
+
+ private static final GenericRecord RECORD_WITH_CLASS_PROP = new
GenericRecordBuilder(SCHEMA_RECORD_WITH_CLASS_PROP)
+ .set("id", "123").set("price", "-0.0002").build();
+
+ private static final Schema SCHEMA_RECORD_WITH_MAP_KEY_CLASS_PROP =
SchemaBuilder.record("MapRecord").fields()
+
.requiredString("id").name("prices").type().map().prop(SpecificData.KEY_CLASS_PROP,
"java.math.BigDecimal")
+ .values().stringType().noDefault().endRecord();
+
+ private static final GenericRecord RECORD_WITH_MAP_KEY_CLASS_PROP = new
GenericRecordBuilder(
+ SCHEMA_RECORD_WITH_MAP_KEY_CLASS_PROP).set("id", "123")
+ .set("prices", Map.of("-0.0002", "cheap", "12345.678",
"expensive")).build();
+
+ /**
+ * Reusable round-trip logic for a record, using the given model.
+ */
+ public static GenericRecord roundTrip(GenericRecord record, GenericData
model) throws IOException {
+ byte[] serialized;
+
+ try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+ GenericDatumWriter<GenericRecord> writer = new
GenericDatumWriter<>(record.getSchema());
+ BinaryEncoder encoder = EncoderFactory.get().binaryEncoder(baos, null);
+ writer.write(record, encoder);
+ encoder.flush();
+ serialized = baos.toByteArray();
+ }
+
+ GenericDatumReader<GenericRecord> reader = new
GenericDatumReader<>(record.getSchema(), record.getSchema(), model);
+ BinaryDecoder decoder = DecoderFactory.get().binaryDecoder(serialized,
null);
+ return reader.read(null, decoder);
+ }
+
/**
- * Tests that GenericDatumReader can deserialize records with string fields
that
- * have a "java-class" attribute (e.g., BigDecimal).
- *
- * This test reproduces a bug where
+ * Tests that a plain GenericDatumReader (GenericData model) ignores the
+ * {@link SpecificData#CLASS_PROP} attribute on a string field inside a
union,
+ * matching the classic (non fast-reader) behavior of GenericData.
+ * <p>
+ * This test also reproduces a bug (AVRO-4225) where
* FastReaderBuilder.getTransformingStringReader() casts the result of
- * stringReader.read() directly to String, but in GenericData mode the reader
- * returns Utf8, causing a ClassCastException.
+ * stringReader.read() directly to String, but GenericData returns Utf8,
causing
+ * a ClassCastException
*/
@Test
- void genericDatumReaderWithJavaClassAttribute() throws IOException {
- // Schema with a string field that has "java-class": "java.math.BigDecimal"
- // This is a common pattern for representing decimal values as strings
- String schemaJson = "{\n" + " \"type\": \"record\",\n" + " \"name\":
\"TestRecord\",\n" + " \"fields\": [\n"
- + " {\"name\": \"id\", \"type\": \"string\"},\n" + " {\"name\":
\"price\", \"type\": [\"null\", {\n"
- + " \"type\": \"string\",\n" + " \"java-class\":
\"java.math.BigDecimal\"\n" + " }]}\n" + " ]\n"
- + "}";
-
- Schema schema = new Schema.Parser().parse(schemaJson);
-
- GenericRecord record = new GenericData.Record(schema);
- record.put("id", "123");
- record.put("price", "-0.0002");
-
- ByteArrayOutputStream out = new ByteArrayOutputStream();
- GenericDatumWriter<GenericRecord> writer = new
GenericDatumWriter<>(schema);
- BinaryEncoder encoder = EncoderFactory.get().binaryEncoder(out, null);
- writer.write(record, encoder);
- encoder.flush();
-
- byte[] serialized = out.toByteArray();
-
- // Deserialize using GenericDatumReader (which uses FastReaderBuilder by
- // default)
- GenericDatumReader<GenericRecord> reader = new
GenericDatumReader<>(schema);
- BinaryDecoder decoder = DecoderFactory.get().binaryDecoder(serialized,
null);
+ void genericDataModelIgnoresJavaClassPropWithStringUnion() throws
IOException {
+ // This round trip shouldn't cause a ClassCastException (AVRO-4225)
+ GenericRecord result = roundTrip(RECORD_WITH_NULLABLE_CLASS_PROP,
GenericData.get());
+
+ assertNotNull(result);
+ assertInstanceOf(Utf8.class, result.get("id"));
+ assertEquals("123", result.get("id").toString());
+ assertInstanceOf(Utf8.class, result.get("price"), "GenericData should
ignore 'java-class'");
+ assertEquals("-0.0002", result.get("price").toString());
+ }
- // AVRO-4225 this should not throw ClassCastException: Utf8 cannot be cast
- // to String
- GenericRecord result = reader.read(null, decoder);
+ /**
+ * Tests that a plain GenericDatumReader (GenericData model) ignores the
+ * {@link SpecificData#CLASS_PROP} attribute on a direct (non-union) string
+ * field.
+ */
+ @Test
+ void genericDataModelIgnoresJavaClassPropWithString() throws IOException {
+ GenericRecord result = roundTrip(RECORD_WITH_CLASS_PROP,
GenericData.get());
assertNotNull(result);
+ assertInstanceOf(Utf8.class, result.get("id"));
assertEquals("123", result.get("id").toString());
+ assertInstanceOf(Utf8.class, result.get("price"), "GenericData should
ignore 'java-class'");
assertEquals("-0.0002", result.get("price").toString());
}
/**
- * Tests that GenericDatumReader can deserialize records with a direct string
- * field (not in a union) that has a "java-class" attribute.
+ * Tests that a GenericDatumReader built on the SpecificData model uses
+ * {@link SpecificData#CLASS_PROP} on a string field inside a union,
+ * transforming it into the named class without throwing ClassCastException.
*/
@Test
- void genericDatumReaderWithDirectJavaClassString() throws IOException {
- String schemaJson = "{\n" + " \"type\": \"record\",\n" + " \"name\":
\"TestRecord\",\n" + " \"fields\": [\n"
- + " {\"name\": \"amount\", \"type\": {\n" + " \"type\":
\"string\",\n"
- + " \"java-class\": \"java.math.BigDecimal\"\n" + " }}\n" + "
]\n" + "}";
+ void specificDataModelUsesJavaClassProp() throws IOException {
+ GenericRecord result = roundTrip(RECORD_WITH_NULLABLE_CLASS_PROP,
SpecificData.get());
+
+ assertNotNull(result);
+ assertInstanceOf(Utf8.class, result.get("id"));
+ assertEquals("123", result.get("id").toString());
+ assertInstanceOf(BigDecimal.class, result.get("price"), "SpecificData
should use the class in 'java-class'");
+ assertEquals(new BigDecimal("-0.0002"), result.get("price"));
+ }
- Schema schema = new Schema.Parser().parse(schemaJson);
+ /**
+ * Tests that a GenericDatumReader built on the SpecificData model uses
+ * {@link SpecificData#CLASS_PROP} on a direct (non-union) string field,
+ * transforming it into the named class without throwing ClassCastException.
+ */
+ @Test
+ void specificDataModelUsesJavaClassPropWithDirectString() throws IOException
{
+ GenericRecord result = roundTrip(RECORD_WITH_CLASS_PROP,
SpecificData.get());
- GenericRecord record = new GenericData.Record(schema);
- record.put("amount", "123.45");
+ assertNotNull(result);
+ assertInstanceOf(Utf8.class, result.get("id"));
+ assertEquals("123", result.get("id").toString());
+ assertInstanceOf(BigDecimal.class, result.get("price"), "SpecificData
should use the class in 'java-class'");
+ assertEquals(new BigDecimal("-0.0002"), result.get("price"));
+ }
- ByteArrayOutputStream out = new ByteArrayOutputStream();
- GenericDatumWriter<GenericRecord> writer = new
GenericDatumWriter<>(schema);
- BinaryEncoder encoder = EncoderFactory.get().binaryEncoder(out, null);
- writer.write(record, encoder);
- encoder.flush();
+ /**
+ * Tests that a plain GenericDatumReader (GenericData model) ignores the
+ * {@link SpecificData#KEY_CLASS_PROP} property on a map schema, matching the
+ * classic (non-fast-reader) behavior of GenericData, and leaves the map
keys as
+ * Utf8/String.
+ */
+ @Test
+ void genericDataModelIgnoresJavaKeyClassPropWithMap() throws IOException {
+ GenericRecord result = roundTrip(RECORD_WITH_MAP_KEY_CLASS_PROP,
GenericData.get());
- byte[] serialized = out.toByteArray();
+ assertNotNull(result);
+ assertInstanceOf(Utf8.class, result.get("id"));
+ assertEquals("123", result.get("id").toString());
- GenericDatumReader<GenericRecord> reader = new
GenericDatumReader<>(schema);
- BinaryDecoder decoder = DecoderFactory.get().binaryDecoder(serialized,
null);
+ @SuppressWarnings("unchecked")
+ Map<Object, Object> prices = (Map<Object, Object>) result.get("prices");
+ assertEquals(2, prices.size());
+ for (Object key : prices.keySet()) {
+ assertInstanceOf(Utf8.class, key, "GenericData should ignore
'java-key-class'");
+ }
+ assertEquals("cheap", prices.get(new Utf8("-0.0002")).toString());
+ assertEquals("expensive", prices.get(new Utf8("12345.678")).toString());
+ }
- GenericRecord result = reader.read(null, decoder);
+ /**
+ * Tests that a GenericDatumReader built on the SpecificData model uses
+ * {@link SpecificData#KEY_CLASS_PROP} property on a map schema, transforming
+ * the map keys into the named class.
+ */
+ @Test
+ void specificDataModelUsesJavaKeyClassProp() throws IOException {
+ GenericRecord result = roundTrip(RECORD_WITH_MAP_KEY_CLASS_PROP,
SpecificData.get());
assertNotNull(result);
- assertEquals("123.45", result.get("amount").toString());
+ assertInstanceOf(Utf8.class, result.get("id"));
+ assertEquals("123", result.get("id").toString());
+
+ @SuppressWarnings("unchecked")
+ Map<Object, Object> prices = (Map<Object, Object>) result.get("prices");
+ assertEquals(2, prices.size());
+ for (Object key : prices.keySet()) {
+ assertInstanceOf(BigDecimal.class, key, "SpecificData should use the
class in 'java-key-class'");
+ }
+ assertEquals("cheap", prices.get(new BigDecimal("-0.0002")).toString());
+ assertEquals("expensive", prices.get(new
BigDecimal("12345.678")).toString());
}
}
diff --git
a/lang/java/avro/src/test/java/org/apache/avro/io/TestFastReaderBuilderClassLoading.java
b/lang/java/avro/src/test/java/org/apache/avro/io/TestFastReaderBuilderClassLoading.java
index 2feedaedaa..ea87513050 100644
---
a/lang/java/avro/src/test/java/org/apache/avro/io/TestFastReaderBuilderClassLoading.java
+++
b/lang/java/avro/src/test/java/org/apache/avro/io/TestFastReaderBuilderClassLoading.java
@@ -19,16 +19,12 @@ package org.apache.avro.io;
import static org.junit.jupiter.api.Assertions.*;
-import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URI;
import java.util.Collections;
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.generic.GenericRecordBuilder;
import org.apache.avro.specific.SpecificData;
@@ -101,18 +97,12 @@ public class TestFastReaderBuilderClassLoading {
Schema recordSchema = Schema.createRecord("TestRecord", null, "test",
false);
recordSchema.setFields(Collections.singletonList(new
Schema.Field("value", stringSchema, null, null)));
- // Encode
GenericRecord record = new
GenericRecordBuilder(recordSchema).set("value", TEST_VALUE).build();
- ByteArrayOutputStream out = new ByteArrayOutputStream();
- Encoder encoder = EncoderFactory.get().binaryEncoder(out, null);
- new GenericDatumWriter<GenericRecord>(recordSchema).write(record,
encoder);
- encoder.flush();
// Decode with fast reader enabled
- GenericData data = new GenericData();
+ GenericData data = new SpecificData();
data.setFastReaderEnabled(true);
- GenericDatumReader<GenericRecord> reader = new
GenericDatumReader<>(recordSchema, recordSchema, data);
- return reader.read(null, DecoderFactory.get().binaryDecoder(new
ByteArrayInputStream(out.toByteArray()), null));
+ return FastReaderBuilderJavaClassTest.roundTrip(record, data);
} catch (IOException e) {
return fail("Unexpected IOException during encode/decode", e);
}