Skip to content

Commit

Permalink
Implement ClassDiscriminatorMode.ALL, .NONE, and .POLYMORPHIC (#2532)
Browse files Browse the repository at this point in the history
Implement ClassDiscriminatorMode.ALL, .NONE, and .POLYMORPHIC

As a part of the solution for #1247
  • Loading branch information
sandwwraith authored Dec 19, 2023
1 parent ad9ddd1 commit cd9f8b0
Show file tree
Hide file tree
Showing 28 changed files with 692 additions and 253 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import kotlinx.serialization.*
import kotlinx.serialization.internal.*
import kotlin.js.*
import kotlin.jvm.*
import kotlin.native.concurrent.*
import kotlin.reflect.*

/**
Expand Down
76 changes: 60 additions & 16 deletions docs/json.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ In this chapter, we'll walk through features of [JSON](https://www.json.org/json
* [Allowing structured map keys](#allowing-structured-map-keys)
* [Allowing special floating-point values](#allowing-special-floating-point-values)
* [Class discriminator for polymorphism](#class-discriminator-for-polymorphism)
* [Class discriminator output mode](#class-discriminator-output-mode)
* [Decoding enums in a case-insensitive manner](#decoding-enums-in-a-case-insensitive-manner)
* [Global naming strategy](#global-naming-strategy)
* [Json elements](#json-elements)
Expand Down Expand Up @@ -470,6 +471,45 @@ As you can see, discriminator from the `Base` class is used:

<!--- TEST -->

### Class discriminator output mode

Class discriminator provides information for serializing and deserializing [polymorphic class hierarchies](polymorphism.md#sealed-classes).
As shown above, it is only added for polymorphic classes by default.
In case you want to encode more or less information for various third party APIs about types in the output, it is possible to control
addition of the class discriminator with the [JsonBuilder.classDiscriminatorMode] property.

For example, [ClassDiscriminatorMode.NONE] does not add class discriminator at all, in case the receiving party is not interested in Kotlin types:

```kotlin
val format = Json { classDiscriminatorMode = ClassDiscriminatorMode.NONE }

@Serializable
sealed class Project {
abstract val name: String
}

@Serializable
class OwnedProject(override val name: String, val owner: String) : Project()

fun main() {
val data: Project = OwnedProject("kotlinx.coroutines", "kotlin")
println(format.encodeToString(data))
}
```

> You can get the full code [here](../guide/example/example-json-12.kt).
Note that it would be impossible to deserialize this output back with kotlinx.serialization.

```text
{"name":"kotlinx.coroutines","owner":"kotlin"}
```

Two other available values are [ClassDiscriminatorMode.POLYMORPHIC] (default behavior) and [ClassDiscriminatorMode.ALL_JSON_OBJECTS] (adds discriminator whenever possible).
Consult their documentation for details.

<!--- TEST -->

### Decoding enums in a case-insensitive manner

[Kotlin's naming policy recommends](https://kotlinlang.org/docs/coding-conventions.html#property-names) naming enum values
Expand All @@ -491,7 +531,7 @@ fun main() {
}
```

> You can get the full code [here](../guide/example/example-json-12.kt).
> You can get the full code [here](../guide/example/example-json-13.kt).
It affects serial names as well as alternative names specified with [JsonNames] annotation, so both values are successfully decoded:

Expand Down Expand Up @@ -523,7 +563,7 @@ fun main() {
}
```

> You can get the full code [here](../guide/example/example-json-13.kt).
> You can get the full code [here](../guide/example/example-json-14.kt).
As you can see, both serialization and deserialization work as if all serial names are transformed from camel case to snake case:

Expand Down Expand Up @@ -575,7 +615,7 @@ fun main() {
}
```

> You can get the full code [here](../guide/example/example-json-14.kt).
> You can get the full code [here](../guide/example/example-json-15.kt).
A `JsonElement` prints itself as a valid JSON:

Expand Down Expand Up @@ -618,7 +658,7 @@ fun main() {
}
```

> You can get the full code [here](../guide/example/example-json-15.kt).
> You can get the full code [here](../guide/example/example-json-16.kt).
The above example sums `votes` in all objects in the `forks` array, ignoring the objects that have no `votes`:

Expand Down Expand Up @@ -658,7 +698,7 @@ fun main() {
}
```

> You can get the full code [here](../guide/example/example-json-16.kt).
> You can get the full code [here](../guide/example/example-json-17.kt).
As a result, you get a proper JSON string:

Expand Down Expand Up @@ -687,7 +727,7 @@ fun main() {
}
```

> You can get the full code [here](../guide/example/example-json-17.kt).
> You can get the full code [here](../guide/example/example-json-18.kt).
The result is exactly what you would expect:

Expand Down Expand Up @@ -733,7 +773,7 @@ fun main() {
}
```

> You can get the full code [here](../guide/example/example-json-18.kt).
> You can get the full code [here](../guide/example/example-json-19.kt).
Even though `pi` was defined as a number with 30 decimal places, the resulting JSON does not reflect this.
The [Double] value is truncated to 15 decimal places, and the String is wrapped in quotes - which is not a JSON number.
Expand Down Expand Up @@ -773,7 +813,7 @@ fun main() {
}
```

> You can get the full code [here](../guide/example/example-json-19.kt).
> You can get the full code [here](../guide/example/example-json-20.kt).
`pi_literal` now accurately matches the value defined.

Expand Down Expand Up @@ -813,7 +853,7 @@ fun main() {
}
```

> You can get the full code [here](../guide/example/example-json-20.kt).
> You can get the full code [here](../guide/example/example-json-21.kt).
The exact value of `pi` is decoded, with all 30 decimal places of precision that were in the source JSON.

Expand All @@ -835,7 +875,7 @@ fun main() {
}
```

> You can get the full code [here](../guide/example/example-json-21.kt).
> You can get the full code [here](../guide/example/example-json-22.kt).
```text
Exception in thread "main" kotlinx.serialization.json.internal.JsonEncodingException: Creating a literal unquoted value of 'null' is forbidden. If you want to create JSON null literal, use JsonNull object, otherwise, use JsonPrimitive
Expand Down Expand Up @@ -911,7 +951,7 @@ fun main() {
}
```

> You can get the full code [here](../guide/example/example-json-22.kt).
> You can get the full code [here](../guide/example/example-json-23.kt).
The output shows that both cases are correctly deserialized into a Kotlin [List].

Expand Down Expand Up @@ -963,7 +1003,7 @@ fun main() {
}
```

> You can get the full code [here](../guide/example/example-json-23.kt).
> You can get the full code [here](../guide/example/example-json-24.kt).
You end up with a single JSON object, not an array with one element:

Expand Down Expand Up @@ -1008,7 +1048,7 @@ fun main() {
}
```

> You can get the full code [here](../guide/example/example-json-24.kt).
> You can get the full code [here](../guide/example/example-json-25.kt).
See the effect of the custom serializer:

Expand Down Expand Up @@ -1081,7 +1121,7 @@ fun main() {
}
```

> You can get the full code [here](../guide/example/example-json-25.kt).
> You can get the full code [here](../guide/example/example-json-26.kt).
No class discriminator is added in the JSON output:

Expand Down Expand Up @@ -1177,7 +1217,7 @@ fun main() {
}
```

> You can get the full code [here](../guide/example/example-json-26.kt).
> You can get the full code [here](../guide/example/example-json-27.kt).
This gives you fine-grained control on the representation of the `Response` class in the JSON output:

Expand Down Expand Up @@ -1242,7 +1282,7 @@ fun main() {
}
```

> You can get the full code [here](../guide/example/example-json-27.kt).
> You can get the full code [here](../guide/example/example-json-28.kt).
```text
UnknownProject(name=example, details={"type":"unknown","maintainer":"Unknown","license":"Apache 2.0"})
Expand Down Expand Up @@ -1296,6 +1336,10 @@ The next chapter covers [Alternative and custom formats (experimental)](formats.
[JsonBuilder.allowSpecialFloatingPointValues]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-builder/allow-special-floating-point-values.html
[JsonBuilder.classDiscriminator]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-builder/class-discriminator.html
[JsonClassDiscriminator]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-class-discriminator/index.html
[JsonBuilder.classDiscriminatorMode]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-builder/class-discriminator-mode.html
[ClassDiscriminatorMode.NONE]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-class-discriminator-mode/-n-o-n-e/index.html
[ClassDiscriminatorMode.POLYMORPHIC]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-class-discriminator-mode/-p-o-l-y-m-o-r-p-h-i-c/index.html
[ClassDiscriminatorMode.ALL_JSON_OBJECTS]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-class-discriminator-mode/-a-l-l_-j-s-o-n_-o-b-j-e-c-t-s/index.html
[JsonBuilder.decodeEnumsCaseInsensitive]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-builder/decode-enums-case-insensitive.html
[JsonBuilder.namingStrategy]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-builder/naming-strategy.html
[JsonNamingStrategy]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-naming-strategy/index.html
Expand Down
1 change: 1 addition & 0 deletions docs/serialization-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ Once the project is set up, we can start serializing some classes.
* <a name='allowing-structured-map-keys'></a>[Allowing structured map keys](json.md#allowing-structured-map-keys)
* <a name='allowing-special-floating-point-values'></a>[Allowing special floating-point values](json.md#allowing-special-floating-point-values)
* <a name='class-discriminator-for-polymorphism'></a>[Class discriminator for polymorphism](json.md#class-discriminator-for-polymorphism)
* <a name='class-discriminator-output-mode'></a>[Class discriminator output mode](json.md#class-discriminator-output-mode)
* <a name='decoding-enums-in-a-case-insensitive-manner'></a>[Decoding enums in a case-insensitive manner](json.md#decoding-enums-in-a-case-insensitive-manner)
* <a name='global-naming-strategy'></a>[Global naming strategy](json.md#global-naming-strategy)
* <a name='json-elements'></a>[Json elements](json.md#json-elements)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/*
* Copyright 2017-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
*/

package kotlinx.serialization.json.polymorphic

import kotlinx.serialization.*
import kotlinx.serialization.builtins.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*
import kotlinx.serialization.json.*
import kotlinx.serialization.modules.*
import kotlin.test.*

abstract class JsonClassDiscriminatorModeBaseTest(
val discriminator: ClassDiscriminatorMode,
val deserializeBack: Boolean = true
) : JsonTestBase() {

@Serializable
sealed class SealedBase

@Serializable
@SerialName("container")
data class SealedContainer(val i: Inner): SealedBase()

@Serializable
@SerialName("inner")
data class Inner(val x: String, val e: SampleEnum = SampleEnum.OptionB)

@Serializable
@SerialName("outer")
data class Outer(val inn: Inner, val lst: List<Inner>, val lss: List<String>)

data class ContextualType(val text: String)

object CtxSerializer : KSerializer<ContextualType> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("CtxSerializer") {
element("a", String.serializer().descriptor)
element("b", String.serializer().descriptor)
}

override fun serialize(encoder: Encoder, value: ContextualType) {
encoder.encodeStructure(descriptor) {
encodeStringElement(descriptor, 0, value.text.substringBefore("#"))
encodeStringElement(descriptor, 1, value.text.substringAfter("#"))
}
}

override fun deserialize(decoder: Decoder): ContextualType {
lateinit var a: String
lateinit var b: String
decoder.decodeStructure(descriptor) {
while (true) {
when (decodeElementIndex(descriptor)) {
0 -> a = decodeStringElement(descriptor, 0)
1 -> b = decodeStringElement(descriptor, 1)
else -> break
}
}
}
return ContextualType("$a#$b")
}
}

@Serializable
@SerialName("withContextual")
data class WithContextual(@Contextual val ctx: ContextualType, val i: Inner)

val ctxModule = serializersModuleOf(CtxSerializer)

val json = Json(default) {
ignoreUnknownKeys = true
serializersModule = polymorphicTestModule + ctxModule
encodeDefaults = true
classDiscriminatorMode = discriminator
}

@Serializable
@SerialName("mixed")
data class MixedPolyAndRegular(val sb: SealedBase, val sc: SealedContainer, val i: Inner)

private inline fun <reified T> doTest(expected: String, obj: T) {
parametrizedTest { mode ->
val serialized = json.encodeToString(serializer<T>(), obj, mode)
assertEquals(expected, serialized, "Failed with mode = $mode")
if (deserializeBack) {
val deserialized: T = json.decodeFromString(serializer(), serialized, mode)
assertEquals(obj, deserialized, "Failed with mode = $mode")
}
}
}

fun testMixed(expected: String) {
val i = Inner("in", SampleEnum.OptionC)
val o = MixedPolyAndRegular(SealedContainer(i), SealedContainer(i), i)
doTest(expected, o)
}

fun testIncludeNonPolymorphic(expected: String) {
val o = Outer(Inner("X"), listOf(Inner("a"), Inner("b")), listOf("foo"))
doTest(expected, o)
}

fun testIncludePolymorphic(expected: String) {
val o = OuterNullableBox(OuterNullableImpl(InnerImpl(42), null), InnerImpl2(239))
doTest(expected, o)
}

fun testIncludeSealed(expected: String) {
val b = Box<SealedBase>(SealedContainer(Inner("x", SampleEnum.OptionC)))
doTest(expected, b)
}

fun testContextual(expected: String) {
val c = WithContextual(ContextualType("c#d"), Inner("x"))
doTest(expected, c)
}

@Serializable
@JsonClassDiscriminator("message_type")
sealed class Base

@Serializable // Class discriminator is inherited from Base
sealed class ErrorClass : Base()

@Serializable
@SerialName("ErrorClassImpl")
data class ErrorClassImpl(val msg: String) : ErrorClass()

@Serializable
@SerialName("Cont")
data class Cont(val ec: ErrorClass, val eci: ErrorClassImpl)

fun testCustomDiscriminator(expected: String) {
val c = Cont(ErrorClassImpl("a"), ErrorClassImpl("b"))
doTest(expected, c)
}

fun testTopLevelPolyImpl(expectedOpen: String, expectedSealed: String) {
assertEquals(expectedOpen, json.encodeToString(InnerImpl(42)))
assertEquals(expectedSealed, json.encodeToString(SealedContainer(Inner("x"))))
}

@Serializable
@SerialName("NullableMixed")
data class NullableMixed(val sb: SealedBase?, val sc: SealedContainer?)

fun testNullable(expected: String) {
val nm = NullableMixed(null, null)
doTest(expected, nm)
}
}
Loading

0 comments on commit cd9f8b0

Please sign in to comment.