Skip to content

Commit

Permalink
Introduce @InheritableSerialInfo and @JsonClassDiscriminator to confi…
Browse files Browse the repository at this point in the history
…gure discriminator per polymorphic base class

Fixes #546
  • Loading branch information
sandwwraith committed Aug 12, 2021
1 parent e75d43e commit c478b94
Show file tree
Hide file tree
Showing 11 changed files with 317 additions and 72 deletions.
38 changes: 38 additions & 0 deletions core/commonMain/src/kotlinx/serialization/Annotations.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<A>().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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<InheritableDiscriminator>()
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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -70,7 +70,7 @@ class SerialDescriptorAnnotationsTest {
val value =
WithNames.serializer().descriptor
.getElementAnnotations(1).filterIsInstance<CustomAnnotationWithDefault>().single()
assertEquals("foo", value.value)
assertEquals("default_annotation_value", value.value)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Project>("""{"name":"kotlinx.serialization"}""")
* println(project)
* val oldProject = Json.decodeFromString<Project>("""{"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": {"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)

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,22 @@ import kotlinx.serialization.internal.*
import kotlinx.serialization.json.*

@Suppress("UNCHECKED_CAST")
internal inline fun <T> JsonEncoder.encodePolymorphically(serializer: SerializationStrategy<T>, value: T, ifPolymorphic: () -> Unit) {
internal inline fun <T> JsonEncoder.encodePolymorphically(
serializer: SerializationStrategy<T>,
value: T,
ifPolymorphic: (String) -> Unit
) {
if (serializer !is AbstractPolymorphicSerializer<*> || json.configuration.useArrayPolymorphism) {
serializer.serialize(this, value)
return
}
val actualSerializer = findActualSerializer(serializer as SerializationStrategy<Any>, value as Any)
ifPolymorphic()
actualSerializer.serialize(this, value)
}

private fun JsonEncoder.findActualSerializer(
serializer: SerializationStrategy<Any>,
value: Any
): SerializationStrategy<Any> {
val casted = serializer as AbstractPolymorphicSerializer<Any>
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(
Expand Down Expand Up @@ -64,7 +60,7 @@ internal fun <T> JsonDecoder.decodeSerializableValuePolymorphic(deserializer: De
}

val jsonTree = cast<JsonObject>(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)
Expand All @@ -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<JsonClassDiscriminator>().singleOrNull()?.discriminator
?: json.configuration.classDiscriminator

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -64,13 +64,13 @@ internal class StreamingJsonEncoder(

override fun <T> encodeSerializableValue(serializer: SerializationStrategy<T>, 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)
Expand All @@ -83,9 +83,9 @@ internal class StreamingJsonEncoder(
composer.indent()
}

if (writePolymorphic) {
writePolymorphic = false
if (polymorphicDiscriminator != null) {
encodeTypeInfo(descriptor)
polymorphicDiscriminator = null
}

if (mode == newMode) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -70,7 +70,7 @@ private sealed class AbstractJsonTreeEncoder(
override fun <T> encodeSerializableValue(serializer: SerializationStrategy<T>, 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)
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit c478b94

Please sign in to comment.