diff --git a/api/avro4k-core.api b/api/avro4k-core.api index 389560bc..d9ea3e09 100644 --- a/api/avro4k-core.api +++ b/api/avro4k-core.api @@ -358,6 +358,7 @@ public abstract class com/github/avrokotlin/avro4k/serializer/AvroSerializer : c public abstract fun deserializeAvro (Lcom/github/avrokotlin/avro4k/AvroDecoder;)Ljava/lang/Object; public fun deserializeGeneric (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public fun getSupportsNull ()Z public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V public abstract fun serializeAvro (Lcom/github/avrokotlin/avro4k/AvroEncoder;Ljava/lang/Object;)V public fun serializeGeneric (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V diff --git a/build.gradle.kts b/build.gradle.kts index 8a231b33..56cd15f9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { testImplementation(libs.kotest.core) testImplementation(libs.kotest.json) testImplementation(libs.kotest.property) + testImplementation(libs.mockk) testImplementation(kotlin("reflect")) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 427e707c..191d1eee 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,7 @@ kotlinxSerialization = "1.7.0" kotestVersion = "5.9.1" okio = "3.9.0" apache-avro = "1.11.4" +mockk = "1.13.6" [libraries] apache-avro = { group = "org.apache.avro", name = "avro", version.ref = "apache-avro" } @@ -15,6 +16,7 @@ kotest-core = { group = "io.kotest", name = "kotest-assertions-core", version.re kotest-json = { group = "io.kotest", name = "kotest-assertions-json", version.ref = "kotestVersion" } kotest-junit5 = { group = "io.kotest", name = "kotest-runner-junit5", version.ref = "kotestVersion" } kotest-property = { group = "io.kotest", name = "kotest-property", version.ref = "kotestVersion" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } [plugins] dokka = { id = "org.jetbrains.dokka", version = "2.0.0" } diff --git a/src/main/kotlin/com/github/avrokotlin/avro4k/serializer/AvroSerializer.kt b/src/main/kotlin/com/github/avrokotlin/avro4k/serializer/AvroSerializer.kt index ad60eb5e..125c2d40 100644 --- a/src/main/kotlin/com/github/avrokotlin/avro4k/serializer/AvroSerializer.kt +++ b/src/main/kotlin/com/github/avrokotlin/avro4k/serializer/AvroSerializer.kt @@ -14,6 +14,7 @@ import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.SerialKind import kotlinx.serialization.descriptors.buildSerialDescriptor +import kotlinx.serialization.descriptors.nullable import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import org.apache.avro.Schema @@ -32,6 +33,17 @@ public abstract class AvroSerializer( @OptIn(InternalSerializationApi::class) final override val descriptor: SerialDescriptor = SerialDescriptorWithAvroSchemaDelegate(buildSerialDescriptor(descriptorName, SerialKind.CONTEXTUAL), this) + .let { if (supportsNull) it.nullable else it } + + /** + * Indicates if this serializer is able to serialize and deserialize a null value. + * It allows materializing an encoded null value by a specific non-null instance (like an object placeholder). + * + * `false` by default, which means a `null` avro encoded value will always be represented by a nullable field. + */ + @ExperimentalSerializationApi + public open val supportsNull: Boolean + get() = false final override fun serialize( encoder: Encoder, diff --git a/src/test/kotlin/com/github/avrokotlin/avro4k/encoding/CustomAvroSerializerTest.kt b/src/test/kotlin/com/github/avrokotlin/avro4k/encoding/CustomAvroSerializerTest.kt new file mode 100644 index 00000000..3ef996f9 --- /dev/null +++ b/src/test/kotlin/com/github/avrokotlin/avro4k/encoding/CustomAvroSerializerTest.kt @@ -0,0 +1,106 @@ +package com.github.avrokotlin.avro4k.encoding + +import com.github.avrokotlin.avro4k.AvroDecoder +import com.github.avrokotlin.avro4k.AvroEncoder +import com.github.avrokotlin.avro4k.serializer.AvroSerializer +import com.github.avrokotlin.avro4k.serializer.SchemaSupplierContext +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.mockk.mockk +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.AbstractDecoder +import kotlinx.serialization.encoding.AbstractEncoder +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.modules.SerializersModule +import org.apache.avro.Schema + +class CustomAvroSerializerTest : StringSpec({ + "Non Avro encoder or decoder should fail" { + class CustomSerializer : BasicSerializer() + shouldThrow { + CustomSerializer().serialize(CustomEncoder(), object {}) + } + shouldThrow { + CustomSerializer().deserialize(CustomDecoder()) + } + } + "Non Avro encoder or decoder should not fail when generic methods are implemented" { + var serializeGenericCalled = false + val expectedDeserializedValue = object {} + + class CustomSerializer : BasicSerializer() { + override fun deserializeGeneric(decoder: Decoder): Any { + return expectedDeserializedValue + } + + override fun serializeGeneric(encoder: Encoder, value: Any) { + serializeGenericCalled = true + } + } + CustomSerializer().serialize(CustomEncoder(), object {}) + serializeGenericCalled shouldBe true + + CustomSerializer().deserialize(CustomDecoder()) shouldBe expectedDeserializedValue + } + "Should not fail when using Avro encoder or decoder" { + var serializeAvroCalled = false + val expectedDeserializedValue = object {} + + class CustomSerializer : BasicSerializer() { + override fun serializeAvro(encoder: AvroEncoder, value: Any) { + value shouldBe expectedDeserializedValue + serializeAvroCalled = true + } + + override fun deserializeAvro(decoder: AvroDecoder): Any { + return expectedDeserializedValue + } + } + CustomSerializer().serialize(mockk(), expectedDeserializedValue) + serializeAvroCalled shouldBe true + + CustomSerializer().deserialize(mockk()) shouldBe expectedDeserializedValue + } + "Supports null to true should make the descriptor nullable" { + class CustomSerializer : BasicSerializer() { + override val supportsNull: Boolean + get() = true + } + CustomSerializer().descriptor.isNullable shouldBe true + } + "descriptor should not be nullable by default" { + class CustomSerializer : BasicSerializer() + CustomSerializer().descriptor.isNullable shouldBe false + } +}) + +private abstract class BasicSerializer : AvroSerializer("basic") { + override fun serializeAvro(encoder: AvroEncoder, value: Any) { + TODO("Not yet implemented") + } + + override fun deserializeAvro(decoder: AvroDecoder): Any { + TODO("Not yet implemented") + } + + override fun getSchema(context: SchemaSupplierContext): Schema { + TODO("Not yet implemented") + } +} + +private class CustomEncoder : AbstractEncoder() { + override val serializersModule: SerializersModule + get() = EmptySerializersModule() +} + +private class CustomDecoder : AbstractDecoder() { + override val serializersModule: SerializersModule + get() = EmptySerializersModule() + + override fun decodeElementIndex(descriptor: SerialDescriptor): Int { + TODO("Not yet implemented") + } +} \ No newline at end of file