Repository: avro-kotlin/avro4k
Pull request #123 (2021-12-11 13:16:56)

Enum default improvements
- https://github.com/avro-kotlin/avro4k/issues/121
- https://github.com/avro-kotlin/avro4k/issues/122

Fixes couple issues related to enum default handling noted by @cgadski


Patch:
diff --git a/src/main/kotlin/com/github/avrokotlin/avro4k/decoder/RecordDecoder.kt b/src/main/kotlin/com/github/avrokotlin/avro4k/decoder/RecordDecoder.kt
index 3fbe1d8..a348510 100644
--- a/src/main/kotlin/com/github/avrokotlin/avro4k/decoder/RecordDecoder.kt
+++ b/src/main/kotlin/com/github/avrokotlin/avro4k/decoder/RecordDecoder.kt
@@ -19,145 +19,162 @@ import org.apache.avro.generic.GenericRecord
 import java.nio.ByteBuffer
 
 interface ExtendedDecoder : Decoder {
-   fun decodeAny(): Any?
+    fun decodeAny(): Any?
 }
 
 interface FieldDecoder : ExtendedDecoder {
-   fun fieldSchema(): Schema
+    fun fieldSchema(): Schema
 }
 
 @ExperimentalSerializationApi
 class RecordDecoder(
-   private val desc: SerialDescriptor,
-   private val record: GenericRecord,
-   override val serializersModule: SerializersModule,
-   private val configuration: AvroConfiguration,
+    private val desc: SerialDescriptor,
+    private val record: GenericRecord,
+    override val serializersModule: SerializersModule,
+    private val configuration: AvroConfiguration,
 ) : AbstractDecoder(), FieldDecoder {
 
-   private var currentIndex = -1
-
-   @Suppress("UNCHECKED_CAST")
-   override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder {
-      val valueType = AnnotationExtractor(descriptor.annotations).valueType()
-      val value = fieldValue()
-      return when (descriptor.kind) {
-         StructureKind.CLASS ->
-            if (valueType)
-               InlineDecoder(fieldValue(), serializersModule)
-            else
-               RecordDecoder(descriptor, value as GenericRecord, serializersModule, configuration)
-         StructureKind.MAP -> MapDecoder(descriptor, fieldSchema(), value as Map<String, *>, serializersModule, configuration)
-         StructureKind.LIST -> {
-            val decoder: CompositeDecoder = if (descriptor.getElementDescriptor(0).kind == PrimitiveKind.BYTE) {
-               when (value) {
-                  is List<*> -> ByteArrayDecoder((value as List<Byte>).toByteArray(), serializersModule)
-                  is Array<*> -> ByteArrayDecoder((value as Array<Byte>).toByteArray(), serializersModule)
-                  is ByteArray -> ByteArrayDecoder(value, serializersModule)
-                  is ByteBuffer -> ByteArrayDecoder(value.array(), serializersModule)
-                  else -> this
-               }
-            } else {
-               when (value) {
-                  is List<*> -> ListDecoder(fieldSchema(), value, serializersModule, configuration)
-                  is Array<*> -> ListDecoder(fieldSchema(), value.asList(), serializersModule, configuration)
-                  else -> this
-               }
+    private var currentIndex = -1
+
+    @Suppress("UNCHECKED_CAST")
+    override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder {
+        val valueType = AnnotationExtractor(descriptor.annotations).valueType()
+        val value = fieldValue()
+        return when (descriptor.kind) {
+            StructureKind.CLASS ->
+                if (valueType)
+                    InlineDecoder(fieldValue(), serializersModule)
+                else
+                    RecordDecoder(descriptor, value as GenericRecord, serializersModule, configuration)
+            StructureKind.MAP -> MapDecoder(
+                descriptor,
+                fieldSchema(),
+                value as Map<String, *>,
+                serializersModule,
+                configuration
+            )
+            StructureKind.LIST -> {
+                val decoder: CompositeDecoder = if (descriptor.getElementDescriptor(0).kind == PrimitiveKind.BYTE) {
+                    when (value) {
+                        is List<*> -> ByteArrayDecoder((value as List<Byte>).toByteArray(), serializersModule)
+                        is Array<*> -> ByteArrayDecoder((value as Array<Byte>).toByteArray(), serializersModule)
+                        is ByteArray -> ByteArrayDecoder(value, serializersModule)
+                        is ByteBuffer -> ByteArrayDecoder(value.array(), serializersModule)
+                        else -> this
+                    }
+                } else {
+                    when (value) {
+                        is List<*> -> ListDecoder(fieldSchema(), value, serializersModule, configuration)
+                        is Array<*> -> ListDecoder(fieldSchema(), value.asList(), serializersModule, configuration)
+                        else -> this
+                    }
+                }
+                decoder
             }
-            decoder
-         }
-         PolymorphicKind.SEALED, PolymorphicKind.OPEN -> UnionDecoder(descriptor,value as GenericRecord, serializersModule, configuration)
-         else -> throw UnsupportedOperationException("Decoding descriptor of kind ${descriptor.kind} is currently not supported")
-      }
-   }
-
-   private fun fieldValue(): Any? {
-      if (record.hasField(resolvedFieldName())) {
-         return record.get(resolvedFieldName())
-      }
-      return null
-   }
-
-   private fun resolvedFieldName(): String = configuration.namingStrategy.to(FieldNaming(desc, currentIndex).name())
-
-   private fun field(): Schema.Field = record.schema.getField(resolvedFieldName())
-
-   override fun fieldSchema(): Schema {
-      // if the element is nullable, then we should have a union schema which we can extract the non-null schema from
-      val schema = field().schema()
-      return if (schema.isNullable) {
-         schema.extractNonNull()
-      } else {
-         schema
-      }
-   }
-
-   override fun decodeString(): String = StringFromAvroValue.fromValue(fieldValue())
-
-   override fun decodeBoolean(): Boolean {
-      return when (val v = fieldValue()) {
-         is Boolean -> v
-         null -> throw SerializationException("Cannot decode <null> as a Boolean")
-         else -> throw SerializationException("Unsupported type for Boolean ${v.javaClass}")
-      }
-   }
-
-   override fun decodeAny(): Any? = fieldValue()
-
-   override fun decodeByte(): Byte {
-      return when (val v = fieldValue()) {
-         is Byte -> v
-         is Int -> if (v < 255) v.toByte() else throw SerializationException("Out of bound integer cannot be converted to byte [$v]")
-         null -> throw SerializationException("Cannot decode <null> as a Byte")
-         else -> throw SerializationException("Unsupported type for Byte ${v.javaClass}")
-      }
-   }
-
-   override fun decodeNotNullMark(): Boolean {
-      return fieldValue() != null
-   }
-
-   override fun decodeEnum(enumDescriptor: SerialDescriptor): Int {
-      val symbol = EnumFromAvroValue.fromValue(fieldValue()!!)
-      return (0 until enumDescriptor.elementsCount).find { enumDescriptor.getElementName(it) == symbol } ?: -1
-   }
-
-   override fun decodeFloat(): Float {
-      return when (val v = fieldValue()) {
-         is Float -> v
-         null -> throw SerializationException("Cannot decode <null> as a Float")
-         else -> throw SerializationException("Unsupported type for Float ${v.javaClass}")
-      }
-   }
-
-   override fun decodeInt(): Int {
-      return when (val v = fieldValue()) {
-         is Int -> v
-         null -> throw SerializationException("Cannot decode <null> as a Int")
-         else -> throw SerializationException("Unsupported type for Int ${v.javaClass}")
-      }
-   }
-
-   override fun decodeLong(): Long {
-      return when (val v = fieldValue()) {
-         is Long -> v
-         is Int -> v.toLong()
-         null -> throw SerializationException("Cannot decode <null> as a Long")
-         else -> throw SerializationException("Unsupported type for Long [is ${v.javaClass}]")
-      }
-   }
-
-   override fun decodeDouble(): Double {
-      return when (val v = fieldValue()) {
-         is Double -> v
-         is Float -> v.toDouble()
-         null -> throw SerializationException("Cannot decode <null> as a Double")
-         else -> throw SerializationException("Unsupported type for Double ${v.javaClass}")
-      }
-   }
-
-   override fun decodeElementIndex(descriptor: SerialDescriptor): Int {
-      currentIndex++
-      return if (currentIndex < descriptor.elementsCount) currentIndex else CompositeDecoder.DECODE_DONE
-   }
+            PolymorphicKind.SEALED, PolymorphicKind.OPEN -> UnionDecoder(
+                descriptor,
+                value as GenericRecord,
+                serializersModule,
+                configuration
+            )
+            else -> throw UnsupportedOperationException("Decoding descriptor of kind ${descriptor.kind} is currently not supported")
+        }
+    }
+
+    private fun fieldValue(): Any? {
+        if (record.hasField(resolvedFieldName())) {
+            return record.get(resolvedFieldName())
+        }
+        return null
+    }
+
+    private fun resolvedFieldName(): String = configuration.namingStrategy.to(FieldNaming(desc, currentIndex).name())
+
+    private fun field(): Schema.Field = record.schema.getField(resolvedFieldName())
+
+    override fun fieldSchema(): Schema {
+        // if the element is nullable, then we should have a union schema which we can extract the non-null schema from
+        val schema = field().schema()
+        return if (schema.isNullable) {
+            schema.extractNonNull()
+        } else {
+            schema
+        }
+    }
+
+    override fun decodeString(): String = StringFromAvroValue.fromValue(fieldValue())
+
+    override fun decodeBoolean(): Boolean {
+        return when (val v = fieldValue()) {
+            is Boolean -> v
+            null -> throw SerializationException("Cannot decode <null> as a Boolean")
+            else -> throw SerializationException("Unsupported type for Boolean ${v.javaClass}")
+        }
+    }
+
+    override fun decodeAny(): Any? = fieldValue()
+
+    override fun decodeByte(): Byte {
+        return when (val v = fieldValue()) {
+            is Byte -> v
+            is Int -> if (v < 255) v.toByte() else throw SerializationException("Out of bound integer cannot be converted to byte [$v]")
+            null -> throw SerializationException("Cannot decode <null> as a Byte")
+            else -> throw SerializationException("Unsupported type for Byte ${v.javaClass}")
+        }
+    }
+
+    override fun decodeNotNullMark(): Boolean {
+        return fieldValue() != null
+    }
+
+    override fun decodeEnum(enumDescriptor: SerialDescriptor): Int {
+        val symbol = EnumFromAvroValue.fromValue(fieldValue()!!)
+
+        val enumValueByEnumName =
+            (0 until enumDescriptor.elementsCount).associateBy { enumDescriptor.getElementName(it) }
+
+        return enumValueByEnumName[symbol] ?: AnnotationExtractor(enumDescriptor.annotations).enumDefault()?.let {
+            enumValueByEnumName[it]
+        } ?: -1
+    }
+
+    override fun decodeFloat(): Float {
+        return when (val v = fieldValue()) {
+            is Float -> v
+            null -> throw SerializationException("Cannot decode <null> as a Float")
+            else -> throw SerializationException("Unsupported type for Float ${v.javaClass}")
+        }
+    }
+
+    override fun decodeInt(): Int {
+        return when (val v = fieldValue()) {
+            is Int -> v
+            null -> throw SerializationException("Cannot decode <null> as a Int")
+            else -> throw SerializationException("Unsupported type for Int ${v.javaClass}")
+        }
+    }
+
+    override fun decodeLong(): Long {
+        return when (val v = fieldValue()) {
+            is Long -> v
+            is Int -> v.toLong()
+            null -> throw SerializationException("Cannot decode <null> as a Long")
+            else -> throw SerializationException("Unsupported type for Long [is ${v.javaClass}]")
+        }
+    }
+
+    override fun decodeDouble(): Double {
+        return when (val v = fieldValue()) {
+            is Double -> v
+            is Float -> v.toDouble()
+            null -> throw SerializationException("Cannot decode <null> as a Double")
+            else -> throw SerializationException("Unsupported type for Double ${v.javaClass}")
+        }
+    }
+
+    override fun decodeElementIndex(descriptor: SerialDescriptor): Int {
+        currentIndex++
+        return if (currentIndex < descriptor.elementsCount) currentIndex else CompositeDecoder.DECODE_DONE
+    }
 }
 
diff --git a/src/main/kotlin/com/github/avrokotlin/avro4k/schema/schemas.kt b/src/main/kotlin/com/github/avrokotlin/avro4k/schema/schemas.kt
index f275935..bd987b5 100644
--- a/src/main/kotlin/com/github/avrokotlin/avro4k/schema/schemas.kt
+++ b/src/main/kotlin/com/github/avrokotlin/avro4k/schema/schemas.kt
@@ -49,7 +49,7 @@ fun Schema.overrideNamespace(namespace: String): Schema {
          copy
       }
       Schema.Type.UNION -> Schema.createUnion(types.map { it.overrideNamespace(namespace) })
-      Schema.Type.ENUM -> Schema.createEnum(name, doc, namespace, enumSymbols)
+      Schema.Type.ENUM -> Schema.createEnum(name, doc, namespace, enumSymbols, enumDefault)
       Schema.Type.FIXED -> Schema.createFixed(name, doc, namespace, fixedSize)
       Schema.Type.MAP -> Schema.createMap(valueType.overrideNamespace(namespace))
       Schema.Type.ARRAY -> Schema.createArray(elementType.overrideNamespace(namespace))


Test patch:
diff --git a/src/test/kotlin/com/github/avrokotlin/avro4k/decoder/EnumDefaultDecoderTest.kt b/src/test/kotlin/com/github/avrokotlin/avro4k/decoder/EnumDefaultDecoderTest.kt
new file mode 100644
index 0000000..f408dfb
--- /dev/null
+++ b/src/test/kotlin/com/github/avrokotlin/avro4k/decoder/EnumDefaultDecoderTest.kt
@@ -0,0 +1,35 @@
+package com.github.avrokotlin.avro4k.decoder
+
+import com.github.avrokotlin.avro4k.Avro
+import com.github.avrokotlin.avro4k.AvroEnumDefault
+import kotlinx.serialization.Serializable
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.shouldBe
+
+
+class EnumDefaultDecoderTest : FunSpec({
+   test("Decoding enum with an unknown or future value uses default value") {
+      val encoded = Avro.default.encodeToByteArray(FutureWrap.serializer(), FutureWrap(FutureEnumWithDefault.C))
+      val decoded = Avro.default.decodeFromByteArray(Wrap.serializer(), encoded)
+
+      decoded shouldBe Wrap(EnumWithDefault.UNKNOWN)
+   }
+}) {
+   @Serializable
+   @AvroEnumDefault("UNKNOWN")
+   enum class EnumWithDefault {
+      UNKNOWN, A, B
+   }
+
+   @Serializable
+   @AvroEnumDefault("UNKNOWN")
+   enum class FutureEnumWithDefault {
+      UNKNOWN, A, B, C
+   }
+
+   @Serializable
+   data class Wrap(val value: EnumWithDefault)
+
+   @Serializable
+   data class FutureWrap(val value: FutureEnumWithDefault)
+}
diff --git a/src/test/kotlin/com/github/avrokotlin/avro4k/schema/EnumSchemaTest.kt b/src/test/kotlin/com/github/avrokotlin/avro4k/schema/EnumSchemaTest.kt
index e594dbf..016fef2 100644
--- a/src/test/kotlin/com/github/avrokotlin/avro4k/schema/EnumSchemaTest.kt
+++ b/src/test/kotlin/com/github/avrokotlin/avro4k/schema/EnumSchemaTest.kt
@@ -47,6 +47,14 @@ class EnumSchemaTest : WordSpec({
 
          schema.toString(true) shouldBe expected.toString(true)
       }
+      "modifying namespaces retains enum defaults" {
+         val schemaWithNewNameSpace = Avro.default.schema(EnumWithDefaultTest.serializer()).overrideNamespace("new")
+
+         val expected = org.apache.avro.Schema.Parser()
+            .parse(javaClass.getResourceAsStream("/enum_with_default_new_namespace.json"))
+
+         schemaWithNewNameSpace.toString(true) shouldBe expected.toString(true)
+      }
       "fail with unknown values" {
          shouldThrow<IllegalStateException> {
             Avro.default.schema(EnumWithUnknownDefaultTest.serializer())
diff --git a/src/test/resources/enum_with_default_new_namespace.json b/src/test/resources/enum_with_default_new_namespace.json
new file mode 100644
index 0000000..98b9374
--- /dev/null
+++ b/src/test/resources/enum_with_default_new_namespace.json
@@ -0,0 +1,19 @@
+{
+   "type": "record",
+   "name": "EnumWithDefaultTest",
+   "namespace": "new",
+   "fields": [
+      {
+         "name": "type",
+         "type": {
+            "type": "enum",
+            "name": "IngredientType",
+            "symbols": [
+               "VEGGIE",
+               "MEAT"
+            ],
+            "default": "MEAT"
+         }
+      }
+   ]
+}