Skip to content

Commit cd9f8b0

Browse files
authored
Implement ClassDiscriminatorMode.ALL, .NONE, and .POLYMORPHIC (#2532)
Implement ClassDiscriminatorMode.ALL, .NONE, and .POLYMORPHIC As a part of the solution for #1247
1 parent ad9ddd1 commit cd9f8b0

28 files changed

+692
-253
lines changed

core/commonMain/src/kotlinx/serialization/modules/SerializersModule.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import kotlinx.serialization.*
88
import kotlinx.serialization.internal.*
99
import kotlin.js.*
1010
import kotlin.jvm.*
11-
import kotlin.native.concurrent.*
1211
import kotlin.reflect.*
1312

1413
/**

docs/json.md

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

471472
<!--- TEST -->
472473

474+
### Class discriminator output mode
475+
476+
Class discriminator provides information for serializing and deserializing [polymorphic class hierarchies](polymorphism.md#sealed-classes).
477+
As shown above, it is only added for polymorphic classes by default.
478+
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
479+
addition of the class discriminator with the [JsonBuilder.classDiscriminatorMode] property.
480+
481+
For example, [ClassDiscriminatorMode.NONE] does not add class discriminator at all, in case the receiving party is not interested in Kotlin types:
482+
483+
```kotlin
484+
val format = Json { classDiscriminatorMode = ClassDiscriminatorMode.NONE }
485+
486+
@Serializable
487+
sealed class Project {
488+
abstract val name: String
489+
}
490+
491+
@Serializable
492+
class OwnedProject(override val name: String, val owner: String) : Project()
493+
494+
fun main() {
495+
val data: Project = OwnedProject("kotlinx.coroutines", "kotlin")
496+
println(format.encodeToString(data))
497+
}
498+
```
499+
500+
> You can get the full code [here](../guide/example/example-json-12.kt).
501+
502+
Note that it would be impossible to deserialize this output back with kotlinx.serialization.
503+
504+
```text
505+
{"name":"kotlinx.coroutines","owner":"kotlin"}
506+
```
507+
508+
Two other available values are [ClassDiscriminatorMode.POLYMORPHIC] (default behavior) and [ClassDiscriminatorMode.ALL_JSON_OBJECTS] (adds discriminator whenever possible).
509+
Consult their documentation for details.
510+
511+
<!--- TEST -->
512+
473513
### Decoding enums in a case-insensitive manner
474514

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

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

@@ -523,7 +563,7 @@ fun main() {
523563
}
524564
```
525565

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

@@ -575,7 +615,7 @@ fun main() {
575615
}
576616
```
577617

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

@@ -618,7 +658,7 @@ fun main() {
618658
}
619659
```
620660

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

@@ -658,7 +698,7 @@ fun main() {
658698
}
659699
```
660700

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

@@ -687,7 +727,7 @@ fun main() {
687727
}
688728
```
689729

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

@@ -733,7 +773,7 @@ fun main() {
733773
}
734774
```
735775

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

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

@@ -813,7 +853,7 @@ fun main() {
813853
}
814854
```
815855

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

@@ -835,7 +875,7 @@ fun main() {
835875
}
836876
```
837877

838-
> You can get the full code [here](../guide/example/example-json-21.kt).
878+
> You can get the full code [here](../guide/example/example-json-22.kt).
839879
840880
```text
841881
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
@@ -911,7 +951,7 @@ fun main() {
911951
}
912952
```
913953

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

@@ -963,7 +1003,7 @@ fun main() {
9631003
}
9641004
```
9651005

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

@@ -1008,7 +1048,7 @@ fun main() {
10081048
}
10091049
```
10101050

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

@@ -1081,7 +1121,7 @@ fun main() {
10811121
}
10821122
```
10831123

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

@@ -1177,7 +1217,7 @@ fun main() {
11771217
}
11781218
```
11791219

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

@@ -1242,7 +1282,7 @@ fun main() {
12421282
}
12431283
```
12441284

1245-
> You can get the full code [here](../guide/example/example-json-27.kt).
1285+
> You can get the full code [here](../guide/example/example-json-28.kt).
12461286
12471287
```text
12481288
UnknownProject(name=example, details={"type":"unknown","maintainer":"Unknown","license":"Apache 2.0"})
@@ -1296,6 +1336,10 @@ The next chapter covers [Alternative and custom formats (experimental)](formats.
12961336
[JsonBuilder.allowSpecialFloatingPointValues]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-builder/allow-special-floating-point-values.html
12971337
[JsonBuilder.classDiscriminator]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-builder/class-discriminator.html
12981338
[JsonClassDiscriminator]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-class-discriminator/index.html
1339+
[JsonBuilder.classDiscriminatorMode]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-builder/class-discriminator-mode.html
1340+
[ClassDiscriminatorMode.NONE]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-class-discriminator-mode/-n-o-n-e/index.html
1341+
[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
1342+
[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
12991343
[JsonBuilder.decodeEnumsCaseInsensitive]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-builder/decode-enums-case-insensitive.html
13001344
[JsonBuilder.namingStrategy]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-builder/naming-strategy.html
13011345
[JsonNamingStrategy]: https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-json/kotlinx.serialization.json/-json-naming-strategy/index.html

docs/serialization-guide.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ Once the project is set up, we can start serializing some classes.
120120
* <a name='allowing-structured-map-keys'></a>[Allowing structured map keys](json.md#allowing-structured-map-keys)
121121
* <a name='allowing-special-floating-point-values'></a>[Allowing special floating-point values](json.md#allowing-special-floating-point-values)
122122
* <a name='class-discriminator-for-polymorphism'></a>[Class discriminator for polymorphism](json.md#class-discriminator-for-polymorphism)
123+
* <a name='class-discriminator-output-mode'></a>[Class discriminator output mode](json.md#class-discriminator-output-mode)
123124
* <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)
124125
* <a name='global-naming-strategy'></a>[Global naming strategy](json.md#global-naming-strategy)
125126
* <a name='json-elements'></a>[Json elements](json.md#json-elements)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/*
2+
* Copyright 2017-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package kotlinx.serialization.json.polymorphic
6+
7+
import kotlinx.serialization.*
8+
import kotlinx.serialization.builtins.*
9+
import kotlinx.serialization.descriptors.*
10+
import kotlinx.serialization.encoding.*
11+
import kotlinx.serialization.json.*
12+
import kotlinx.serialization.modules.*
13+
import kotlin.test.*
14+
15+
abstract class JsonClassDiscriminatorModeBaseTest(
16+
val discriminator: ClassDiscriminatorMode,
17+
val deserializeBack: Boolean = true
18+
) : JsonTestBase() {
19+
20+
@Serializable
21+
sealed class SealedBase
22+
23+
@Serializable
24+
@SerialName("container")
25+
data class SealedContainer(val i: Inner): SealedBase()
26+
27+
@Serializable
28+
@SerialName("inner")
29+
data class Inner(val x: String, val e: SampleEnum = SampleEnum.OptionB)
30+
31+
@Serializable
32+
@SerialName("outer")
33+
data class Outer(val inn: Inner, val lst: List<Inner>, val lss: List<String>)
34+
35+
data class ContextualType(val text: String)
36+
37+
object CtxSerializer : KSerializer<ContextualType> {
38+
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("CtxSerializer") {
39+
element("a", String.serializer().descriptor)
40+
element("b", String.serializer().descriptor)
41+
}
42+
43+
override fun serialize(encoder: Encoder, value: ContextualType) {
44+
encoder.encodeStructure(descriptor) {
45+
encodeStringElement(descriptor, 0, value.text.substringBefore("#"))
46+
encodeStringElement(descriptor, 1, value.text.substringAfter("#"))
47+
}
48+
}
49+
50+
override fun deserialize(decoder: Decoder): ContextualType {
51+
lateinit var a: String
52+
lateinit var b: String
53+
decoder.decodeStructure(descriptor) {
54+
while (true) {
55+
when (decodeElementIndex(descriptor)) {
56+
0 -> a = decodeStringElement(descriptor, 0)
57+
1 -> b = decodeStringElement(descriptor, 1)
58+
else -> break
59+
}
60+
}
61+
}
62+
return ContextualType("$a#$b")
63+
}
64+
}
65+
66+
@Serializable
67+
@SerialName("withContextual")
68+
data class WithContextual(@Contextual val ctx: ContextualType, val i: Inner)
69+
70+
val ctxModule = serializersModuleOf(CtxSerializer)
71+
72+
val json = Json(default) {
73+
ignoreUnknownKeys = true
74+
serializersModule = polymorphicTestModule + ctxModule
75+
encodeDefaults = true
76+
classDiscriminatorMode = discriminator
77+
}
78+
79+
@Serializable
80+
@SerialName("mixed")
81+
data class MixedPolyAndRegular(val sb: SealedBase, val sc: SealedContainer, val i: Inner)
82+
83+
private inline fun <reified T> doTest(expected: String, obj: T) {
84+
parametrizedTest { mode ->
85+
val serialized = json.encodeToString(serializer<T>(), obj, mode)
86+
assertEquals(expected, serialized, "Failed with mode = $mode")
87+
if (deserializeBack) {
88+
val deserialized: T = json.decodeFromString(serializer(), serialized, mode)
89+
assertEquals(obj, deserialized, "Failed with mode = $mode")
90+
}
91+
}
92+
}
93+
94+
fun testMixed(expected: String) {
95+
val i = Inner("in", SampleEnum.OptionC)
96+
val o = MixedPolyAndRegular(SealedContainer(i), SealedContainer(i), i)
97+
doTest(expected, o)
98+
}
99+
100+
fun testIncludeNonPolymorphic(expected: String) {
101+
val o = Outer(Inner("X"), listOf(Inner("a"), Inner("b")), listOf("foo"))
102+
doTest(expected, o)
103+
}
104+
105+
fun testIncludePolymorphic(expected: String) {
106+
val o = OuterNullableBox(OuterNullableImpl(InnerImpl(42), null), InnerImpl2(239))
107+
doTest(expected, o)
108+
}
109+
110+
fun testIncludeSealed(expected: String) {
111+
val b = Box<SealedBase>(SealedContainer(Inner("x", SampleEnum.OptionC)))
112+
doTest(expected, b)
113+
}
114+
115+
fun testContextual(expected: String) {
116+
val c = WithContextual(ContextualType("c#d"), Inner("x"))
117+
doTest(expected, c)
118+
}
119+
120+
@Serializable
121+
@JsonClassDiscriminator("message_type")
122+
sealed class Base
123+
124+
@Serializable // Class discriminator is inherited from Base
125+
sealed class ErrorClass : Base()
126+
127+
@Serializable
128+
@SerialName("ErrorClassImpl")
129+
data class ErrorClassImpl(val msg: String) : ErrorClass()
130+
131+
@Serializable
132+
@SerialName("Cont")
133+
data class Cont(val ec: ErrorClass, val eci: ErrorClassImpl)
134+
135+
fun testCustomDiscriminator(expected: String) {
136+
val c = Cont(ErrorClassImpl("a"), ErrorClassImpl("b"))
137+
doTest(expected, c)
138+
}
139+
140+
fun testTopLevelPolyImpl(expectedOpen: String, expectedSealed: String) {
141+
assertEquals(expectedOpen, json.encodeToString(InnerImpl(42)))
142+
assertEquals(expectedSealed, json.encodeToString(SealedContainer(Inner("x"))))
143+
}
144+
145+
@Serializable
146+
@SerialName("NullableMixed")
147+
data class NullableMixed(val sb: SealedBase?, val sc: SealedContainer?)
148+
149+
fun testNullable(expected: String) {
150+
val nm = NullableMixed(null, null)
151+
doTest(expected, nm)
152+
}
153+
}

0 commit comments

Comments
 (0)