From c478b9427060c54b733f5dff3713527e9f37b542 Mon Sep 17 00:00:00 2001 From: Leonid Startsev Date: Tue, 25 May 2021 16:59:33 +0300 Subject: [PATCH] Introduce @InheritableSerialInfo and @JsonClassDiscriminator to configure discriminator per polymorphic base class Fixes #546 --- .../src/kotlinx/serialization/Annotations.kt | 38 ++++++ .../InheritableSerialInfoTest.kt | 46 ++++++++ .../SerialDescriptorAnnotationsTest.kt | 4 +- .../serialization/json/JsonAnnotations.kt | 72 ++++++++++++ .../kotlinx/serialization/json/JsonNames.kt | 39 ------- .../json/internal/Polymorphic.kt | 33 +++--- .../json/internal/StreamingJsonEncoder.kt | 10 +- .../json/internal/TreeJsonEncoder.kt | 10 +- .../features/JsonClassDiscriminatorTest.kt | 109 ++++++++++++++++++ .../json/internal/DynamicEncoders.kt | 10 +- .../json/DynamicPolymorphismTest.kt | 18 +++ 11 files changed, 317 insertions(+), 72 deletions(-) create mode 100644 core/commonTest/src/kotlinx/serialization/InheritableSerialInfoTest.kt create mode 100644 formats/json/commonMain/src/kotlinx/serialization/json/JsonAnnotations.kt delete mode 100644 formats/json/commonMain/src/kotlinx/serialization/json/JsonNames.kt create mode 100644 formats/json/commonTest/src/kotlinx/serialization/features/JsonClassDiscriminatorTest.kt diff --git a/core/commonMain/src/kotlinx/serialization/Annotations.kt b/core/commonMain/src/kotlinx/serialization/Annotations.kt index 88da9df6e..d1615f36b 100644 --- a/core/commonMain/src/kotlinx/serialization/Annotations.kt +++ b/core/commonMain/src/kotlinx/serialization/Annotations.kt @@ -142,6 +142,44 @@ public annotation class Transient @ExperimentalSerializationApi public annotation class SerialInfo +/** + * Meta-annotation that commands the compiler plugin to handle the annotation as serialization-specific. + * Serialization-specific annotations are preserved in the [SerialDescriptor] and can be retrieved + * during serialization process with [SerialDescriptor.getElementAnnotations]. + * + * In contrary to regular [SerialInfo], this one makes annotations inheritable: + * If class X marked as [Serializable] has any of its supertypes annotated with annotation A that has `@InheritableSerialInfo` on it, + * A appears in X's [SerialDescriptor] even if X itself is not annotated. + * It is possible to use A multiple times on different supertypes. Resulting X's [SerialDescriptor.annotations] would still contain + * only one instance of A. + * Note that if A has any arguments, their values should be the same across all hierarchy. Otherwise, a compilation error + * would be reported by the plugin. + * + * Example: + * ``` + * @InheritableSerialInfo + * annotation class A(val value: Int) + * + * @A(1) // Annotation can also be inherited from interfaces + * interface I + * + * @Serializable + * @A(1) // Argument value is the same as in I, no compiler error + * abstract class Base: I + * + * @Serializable + * class Derived: Base() + * + * // This function returns 1. + * fun foo(): Int = Derived.serializer().descriptor.annotations.filterIsInstance().single().value + * ``` + */ +@Target(AnnotationTarget.ANNOTATION_CLASS) +@Retention(AnnotationRetention.BINARY) +@ExperimentalSerializationApi +public annotation class InheritableSerialInfo + + /** * Instructs the plugin to use [ContextSerializer] on a given property or type. * Context serializer is usually used when serializer for type can only be found in runtime. diff --git a/core/commonTest/src/kotlinx/serialization/InheritableSerialInfoTest.kt b/core/commonTest/src/kotlinx/serialization/InheritableSerialInfoTest.kt new file mode 100644 index 000000000..cd5b7a0dc --- /dev/null +++ b/core/commonTest/src/kotlinx/serialization/InheritableSerialInfoTest.kt @@ -0,0 +1,46 @@ +package kotlinx.serialization + +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlin.test.* + + +class InheritableSerialInfoTest { + + @InheritableSerialInfo + annotation class InheritableDiscriminator(val discriminator: String) + + @InheritableDiscriminator("a") + interface A + + @InheritableDiscriminator("a") + interface B + + @InheritableDiscriminator("a") + @Serializable + abstract class C: A + + @Serializable + sealed class D: C(), B + + @Serializable + class E: D() + + @Serializable + class E2: C() + + @Serializable + class E3: A, B + + private fun doTest(descriptor: SerialDescriptor) { + val list = descriptor.annotations.filterIsInstance() + assertEquals(1, list.size) + assertEquals("a", list.first().discriminator) + } + + @Test + fun testInheritanceFromSealed() = doTest(E.serializer().descriptor) + @Test + fun testInheritanceFromAbstract() = doTest(E2.serializer().descriptor) + @Test + fun testInheritanceFromInterface() = doTest(E3.serializer().descriptor) +} diff --git a/core/commonTest/src/kotlinx/serialization/SerialDescriptorAnnotationsTest.kt b/core/commonTest/src/kotlinx/serialization/SerialDescriptorAnnotationsTest.kt index de25da464..770ac50b0 100644 --- a/core/commonTest/src/kotlinx/serialization/SerialDescriptorAnnotationsTest.kt +++ b/core/commonTest/src/kotlinx/serialization/SerialDescriptorAnnotationsTest.kt @@ -23,7 +23,7 @@ class SerialDescriptorAnnotationsTest { @SerialInfo @Target(AnnotationTarget.PROPERTY, AnnotationTarget.CLASS) - annotation class CustomAnnotationWithDefault(val value: String = "foo") + annotation class CustomAnnotationWithDefault(val value: String = "default_annotation_value") @SerialInfo @Target(AnnotationTarget.PROPERTY) @@ -70,7 +70,7 @@ class SerialDescriptorAnnotationsTest { val value = WithNames.serializer().descriptor .getElementAnnotations(1).filterIsInstance().single() - assertEquals("foo", value.value) + assertEquals("default_annotation_value", value.value) } @Test diff --git a/formats/json/commonMain/src/kotlinx/serialization/json/JsonAnnotations.kt b/formats/json/commonMain/src/kotlinx/serialization/json/JsonAnnotations.kt new file mode 100644 index 000000000..aae69889a --- /dev/null +++ b/formats/json/commonMain/src/kotlinx/serialization/json/JsonAnnotations.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2017-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.serialization.json + +import kotlinx.serialization.* +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.* +import kotlinx.serialization.json.internal.* +import kotlin.native.concurrent.* + +/** + * Indicates that the field can be represented in JSON + * with multiple possible alternative names. + * [Json] format recognizes this annotation and is able to decode + * the data using any of the alternative names. + * + * Unlike [SerialName] annotation, does not affect JSON encoding in any way. + * + * Example of usage: + * ``` + * @Serializable + * data class Project(@JsonNames("title") val name: String) + * + * val project = Json.decodeFromString("""{"name":"kotlinx.serialization"}""") + * println(project) + * val oldProject = Json.decodeFromString("""{"title":"kotlinx.coroutines"}""") + * println(oldProject) + * ``` + * + * This annotation has lesser priority than [SerialName]. + * + * @see JsonBuilder.useAlternativeNames + */ +@SerialInfo +@Target(AnnotationTarget.PROPERTY) +@ExperimentalSerializationApi +public annotation class JsonNames(vararg val names: String) + +/** + * Specifies key for class discriminator value used during polymorphic serialization in [Json]. + * Provided key is used only for an annotated class and its subclasses; + * to configure global class discriminator, use [JsonBuilder.classDiscriminator] + * property. + * + * This annotation is [inheritable][InheritableSerialInfo], so it should be sufficient to place it on a base class of hierarchy. + * It is not possible to define different class discriminators for different parts of class hierarchy. + * Pay attention to the fact that class discriminator, same as polymorphic serializer's base class, is + * determined statically. + * + * Example: + * ``` + * @Serializable + * @JsonClassDiscriminator("message_type") + * abstract class Base + * + * @Serializable // Class discriminator is inherited from Base + * abstract class ErrorClass: Base() + * + * @Serializable + * class Message(val message: Base, val error: ErrorClass?) + * + * val message = Json.decodeFromString("""{"message": {"message_type":"my.app.BaseMessage", "message": "not found"}, "error": {"message_type":"my.app.GenericError", "error_code": 404}}""") + * ``` + * + * @see JsonBuilder.classDiscriminator + */ +@InheritableSerialInfo +@Target(AnnotationTarget.CLASS) +@ExperimentalSerializationApi +public annotation class JsonClassDiscriminator(val discriminator: String) diff --git a/formats/json/commonMain/src/kotlinx/serialization/json/JsonNames.kt b/formats/json/commonMain/src/kotlinx/serialization/json/JsonNames.kt deleted file mode 100644 index fcb122780..000000000 --- a/formats/json/commonMain/src/kotlinx/serialization/json/JsonNames.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2017-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. - */ - -package kotlinx.serialization.json - -import kotlinx.serialization.* -import kotlinx.serialization.descriptors.* -import kotlinx.serialization.encoding.* -import kotlinx.serialization.json.internal.* -import kotlin.native.concurrent.* - -/** - * An annotation that indicates the field can be represented in JSON - * with multiple possible alternative names. - * [Json] format recognizes this annotation and is able to decode - * the data using any of the alternative names. - * - * Unlike [SerialName] annotation, does not affect JSON encoding in any way. - * - * Example of usage: - * ``` - * @Serializable - * data class Project(@JsonNames("title") val name: String) - * - * val project = Json.decodeFromString("""{"name":"kotlinx.serialization"}""") - * println(project) - * val oldProject = Json.decodeFromString("""{"title":"kotlinx.coroutines"}""") - * println(oldProject) - * ``` - * - * This annotation has lesser priority than [SerialName]. - * - * @see JsonBuilder.useAlternativeNames - */ -@SerialInfo -@Target(AnnotationTarget.PROPERTY) -@ExperimentalSerializationApi -public annotation class JsonNames(vararg val names: String) diff --git a/formats/json/commonMain/src/kotlinx/serialization/json/internal/Polymorphic.kt b/formats/json/commonMain/src/kotlinx/serialization/json/internal/Polymorphic.kt index 73f00c476..459f4af47 100644 --- a/formats/json/commonMain/src/kotlinx/serialization/json/internal/Polymorphic.kt +++ b/formats/json/commonMain/src/kotlinx/serialization/json/internal/Polymorphic.kt @@ -11,26 +11,22 @@ import kotlinx.serialization.internal.* import kotlinx.serialization.json.* @Suppress("UNCHECKED_CAST") -internal inline fun JsonEncoder.encodePolymorphically(serializer: SerializationStrategy, value: T, ifPolymorphic: () -> Unit) { +internal inline fun JsonEncoder.encodePolymorphically( + serializer: SerializationStrategy, + value: T, + ifPolymorphic: (String) -> Unit +) { if (serializer !is AbstractPolymorphicSerializer<*> || json.configuration.useArrayPolymorphism) { serializer.serialize(this, value) return } - val actualSerializer = findActualSerializer(serializer as SerializationStrategy, value as Any) - ifPolymorphic() - actualSerializer.serialize(this, value) -} - -private fun JsonEncoder.findActualSerializer( - serializer: SerializationStrategy, - value: Any -): SerializationStrategy { val casted = serializer as AbstractPolymorphicSerializer - val actualSerializer = casted.findPolymorphicSerializer(this, value) - validateIfSealed(casted, actualSerializer, json.configuration.classDiscriminator) - val kind = actualSerializer.descriptor.kind - checkKind(kind) - return actualSerializer + val baseClassDiscriminator = serializer.descriptor.classDiscriminator(json) + val actualSerializer = casted.findPolymorphicSerializer(this, value as Any) + validateIfSealed(casted, actualSerializer, baseClassDiscriminator) + checkKind(actualSerializer.descriptor.kind) + ifPolymorphic(baseClassDiscriminator) + actualSerializer.serialize(this, value) } private fun validateIfSealed( @@ -64,7 +60,7 @@ internal fun JsonDecoder.decodeSerializableValuePolymorphic(deserializer: De } val jsonTree = cast(decodeJsonElement(), deserializer.descriptor) - val discriminator = json.configuration.classDiscriminator + val discriminator = deserializer.descriptor.classDiscriminator(json) val type = jsonTree[discriminator]?.jsonPrimitive?.content val actualSerializer = deserializer.findPolymorphicSerializerOrNull(this, type) ?: throwSerializerNotFound(type, jsonTree) @@ -79,3 +75,8 @@ private fun throwSerializerNotFound(type: String?, jsonTree: JsonObject): Nothin else "class discriminator '$type'" throw JsonDecodingException(-1, "Polymorphic serializer was not found for $suffix", jsonTree.toString()) } + +internal fun SerialDescriptor.classDiscriminator(json: Json): String = + annotations.filterIsInstance().singleOrNull()?.discriminator + ?: json.configuration.classDiscriminator + diff --git a/formats/json/commonMain/src/kotlinx/serialization/json/internal/StreamingJsonEncoder.kt b/formats/json/commonMain/src/kotlinx/serialization/json/internal/StreamingJsonEncoder.kt index 2693e8e29..59a776503 100644 --- a/formats/json/commonMain/src/kotlinx/serialization/json/internal/StreamingJsonEncoder.kt +++ b/formats/json/commonMain/src/kotlinx/serialization/json/internal/StreamingJsonEncoder.kt @@ -44,7 +44,7 @@ internal class StreamingJsonEncoder( // Forces serializer to wrap all values into quotes private var forceQuoting: Boolean = false - private var writePolymorphic = false + private var polymorphicDiscriminator: String? = null init { val i = mode.ordinal @@ -64,13 +64,13 @@ internal class StreamingJsonEncoder( override fun encodeSerializableValue(serializer: SerializationStrategy, value: T) { encodePolymorphically(serializer, value) { - writePolymorphic = true + polymorphicDiscriminator = it } } private fun encodeTypeInfo(descriptor: SerialDescriptor) { composer.nextItem() - encodeString(configuration.classDiscriminator) + encodeString(polymorphicDiscriminator!!) composer.print(COLON) composer.space() encodeString(descriptor.serialName) @@ -83,9 +83,9 @@ internal class StreamingJsonEncoder( composer.indent() } - if (writePolymorphic) { - writePolymorphic = false + if (polymorphicDiscriminator != null) { encodeTypeInfo(descriptor) + polymorphicDiscriminator = null } if (mode == newMode) { diff --git a/formats/json/commonMain/src/kotlinx/serialization/json/internal/TreeJsonEncoder.kt b/formats/json/commonMain/src/kotlinx/serialization/json/internal/TreeJsonEncoder.kt index a8df371fb..f89f4da49 100644 --- a/formats/json/commonMain/src/kotlinx/serialization/json/internal/TreeJsonEncoder.kt +++ b/formats/json/commonMain/src/kotlinx/serialization/json/internal/TreeJsonEncoder.kt @@ -33,7 +33,7 @@ private sealed class AbstractJsonTreeEncoder( @JvmField protected val configuration = json.configuration - private var writePolymorphic = false + private var polymorphicDiscriminator: String? = null override fun encodeJsonElement(element: JsonElement) { encodeSerializableValue(JsonElementSerializer, element) @@ -70,7 +70,7 @@ private sealed class AbstractJsonTreeEncoder( override fun encodeSerializableValue(serializer: SerializationStrategy, value: T) { // Writing non-structured data (i.e. primitives) on top-level (e.g. without any tag) requires special output if (currentTagOrNull != null || serializer.descriptor.kind !is PrimitiveKind && serializer.descriptor.kind !== SerialKind.ENUM) { - encodePolymorphically(serializer, value) { writePolymorphic = true } + encodePolymorphically(serializer, value) { polymorphicDiscriminator = it } } else JsonPrimitiveEncoder(json, nodeConsumer).apply { encodeSerializableValue(serializer, value) endEncode(serializer.descriptor) @@ -126,9 +126,9 @@ private sealed class AbstractJsonTreeEncoder( else -> JsonTreeEncoder(json, consumer) } - if (writePolymorphic) { - writePolymorphic = false - encoder.putElement(configuration.classDiscriminator, JsonPrimitive(descriptor.serialName)) + if (polymorphicDiscriminator != null) { + encoder.putElement(polymorphicDiscriminator!!, JsonPrimitive(descriptor.serialName)) + polymorphicDiscriminator = null } return encoder diff --git a/formats/json/commonTest/src/kotlinx/serialization/features/JsonClassDiscriminatorTest.kt b/formats/json/commonTest/src/kotlinx/serialization/features/JsonClassDiscriminatorTest.kt new file mode 100644 index 000000000..5ad3a42bf --- /dev/null +++ b/formats/json/commonTest/src/kotlinx/serialization/features/JsonClassDiscriminatorTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2017-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.serialization.features + +import kotlinx.serialization.* +import kotlinx.serialization.builtins.* +import kotlinx.serialization.json.* +import kotlinx.serialization.modules.* +import kotlin.test.* + +class JsonClassDiscriminatorTest : JsonTestBase() { + @Serializable + @JsonClassDiscriminator("sealedType") + sealed class SealedMessage { + @Serializable + @SerialName("SealedMessage.StringMessage") + data class StringMessage(val description: String, val message: String) : SealedMessage() + + @SerialName("EOF") + @Serializable + object EOF : SealedMessage() + } + + @Serializable + @JsonClassDiscriminator("abstractType") + abstract class AbstractMessage { + @Serializable + @SerialName("Message.StringMessage") + data class StringMessage(val description: String, val message: String) : AbstractMessage() + + @Serializable + @SerialName("Message.IntMessage") + data class IntMessage(val description: String, val message: Int) : AbstractMessage() + } + + + @Test + fun testSealedClassesHaveCustomDiscriminator() { + val messages = listOf( + SealedMessage.StringMessage("string message", "foo"), + SealedMessage.EOF + ) + val expected = + """[{"sealedType":"SealedMessage.StringMessage","description":"string message","message":"foo"},{"sealedType":"EOF"}]""" + assertJsonFormAndRestored( + ListSerializer(SealedMessage.serializer()), + messages, + expected, + ) + } + + @Test + fun testAbstractClassesHaveCustomDiscriminator() { + val messages = listOf( + AbstractMessage.StringMessage("string message", "foo"), + AbstractMessage.IntMessage("int message", 42), + ) + val module = SerializersModule { + polymorphic(AbstractMessage::class) { + subclass(AbstractMessage.StringMessage.serializer()) + subclass(AbstractMessage.IntMessage.serializer()) + } + } + val json = Json { serializersModule = module } + val expected = + """[{"abstractType":"Message.StringMessage","description":"string message","message":"foo"},{"abstractType":"Message.IntMessage","description":"int message","message":42}]""" + assertJsonFormAndRestored(ListSerializer(AbstractMessage.serializer()), messages, expected, json) + } + + @Serializable + @JsonClassDiscriminator("message_type") + abstract class Base + + @Serializable + abstract class ErrorClass : Base() + + @Serializable + data class Message(val message: Base, val error: ErrorClass?) + + @Serializable + @SerialName("my.app.BaseMessage") + data class BaseMessage(val message: String) : Base() + + @Serializable + @SerialName("my.app.GenericError") + data class GenericError(@SerialName("error_code") val errorCode: Int) : ErrorClass() + + + @Test + fun testDocumentationInheritanceSample() { + val module = SerializersModule { + polymorphic(Base::class) { + subclass(BaseMessage.serializer()) + } + polymorphic(ErrorClass::class) { + subclass(GenericError.serializer()) + } + } + val json = Json { serializersModule = module } + assertJsonFormAndRestored( + Message.serializer(), + Message(BaseMessage("not found"), GenericError(404)), + """{"message":{"message_type":"my.app.BaseMessage","message":"not found"},"error":{"message_type":"my.app.GenericError","error_code":404}}""", + json + ) + } +} diff --git a/formats/json/jsMain/src/kotlinx/serialization/json/internal/DynamicEncoders.kt b/formats/json/jsMain/src/kotlinx/serialization/json/internal/DynamicEncoders.kt index b97858e33..4bd46d59c 100644 --- a/formats/json/jsMain/src/kotlinx/serialization/json/internal/DynamicEncoders.kt +++ b/formats/json/jsMain/src/kotlinx/serialization/json/internal/DynamicEncoders.kt @@ -61,7 +61,7 @@ private class DynamicObjectEncoder( /** * Flag of usage polymorphism with discriminator attribute */ - private var writePolymorphic = false + private var polymorphicDiscriminator: String? = null private object NoOutputMark @@ -184,7 +184,7 @@ private class DynamicObjectEncoder( override fun encodeSerializableValue(serializer: SerializationStrategy, value: T) { encodePolymorphically(serializer, value) { - writePolymorphic = true + polymorphicDiscriminator = it } } @@ -208,9 +208,9 @@ private class DynamicObjectEncoder( enterNode(child, newMode) } - if (writePolymorphic) { - writePolymorphic = false - current.jsObject[json.configuration.classDiscriminator] = descriptor.serialName + if (polymorphicDiscriminator != null) { + current.jsObject[polymorphicDiscriminator!!] = descriptor.serialName + polymorphicDiscriminator = null } current.index = 0 diff --git a/formats/json/jsTest/src/kotlinx/serialization/json/DynamicPolymorphismTest.kt b/formats/json/jsTest/src/kotlinx/serialization/json/DynamicPolymorphismTest.kt index c95799a9b..3ff05ba05 100644 --- a/formats/json/jsTest/src/kotlinx/serialization/json/DynamicPolymorphismTest.kt +++ b/formats/json/jsTest/src/kotlinx/serialization/json/DynamicPolymorphismTest.kt @@ -39,6 +39,14 @@ class DynamicPolymorphismTest { data class DefaultChild(val default: String? = "default"): Sealed(5) } + @Serializable + @JsonClassDiscriminator("sealed_custom") + sealed class SealedCustom { + @Serializable + @SerialName("data_class") + data class DataClassChild(val name: String) : SealedCustom() + } + @Serializable data class CompositeClass(val mark: String, val nested: Sealed) @@ -75,6 +83,16 @@ class DynamicPolymorphismTest { } } + @Test + fun testCustomClassDiscriminator() { + val value = SealedCustom.DataClassChild("custom-discriminator-test") + encodeAndDecode(SealedCustom.serializer(), value, objectJson) { + assertEquals("data_class", this["sealed_custom"]) + assertEquals(undefined, this.type) + assertEquals(2, fieldsCount(this)) + } + } + @Test fun testComposite() { val nestedValue = Sealed.DataClassChild("child")