Skip to content

Commit 7a177e8

Browse files
committed
Disallow more conflicts with class discriminator on encoding:
- from JsonNamingStrategy - from ALL_JSON_OBJECTS - from default polymorphic serializer Changed the type of thrown exception to SerializationException because it is our contract for .encodeToString. Stop recommending array polymorphism because it is outdated.
1 parent 886bb10 commit 7a177e8

File tree

7 files changed

+83
-21
lines changed

7 files changed

+83
-21
lines changed

formats/json-tests/commonTest/src/kotlinx/serialization/features/DefaultPolymorphicSerializerTest.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package kotlinx.serialization.features
66
import kotlinx.serialization.*
77
import kotlinx.serialization.json.*
88
import kotlinx.serialization.modules.*
9+
import kotlinx.serialization.test.assertFailsWithMessage
910
import kotlin.test.*
1011

1112
class DefaultPolymorphicSerializerTest : JsonTestBase() {
@@ -33,4 +34,17 @@ class DefaultPolymorphicSerializerTest : JsonTestBase() {
3334
json.decodeFromString<Project>(""" {"type":"unknown","name":"example"}""", it))
3435
}
3536

37+
@Test
38+
fun defaultSerializerConflictWithDiscriminatorNotAllowed() {
39+
@Suppress("UNCHECKED_CAST") val module = SerializersModule {
40+
polymorphicDefaultSerializer(Project::class) {
41+
DefaultProject.serializer() as KSerializer<Project>
42+
}
43+
}
44+
val j = Json { serializersModule = module }
45+
assertFailsWithMessage<SerializationException>("Class 'kotlinx.serialization.features.DefaultPolymorphicSerializerTest.DefaultProject' cannot be serialized as base class 'kotlinx.serialization.Polymorphic<Project>' because it has property name that conflicts with JSON class discriminator 'type'.") {
46+
j.encodeToString<Project>(DefaultProject("example", "custom"))
47+
}
48+
}
49+
3650
}

formats/json-tests/commonTest/src/kotlinx/serialization/features/JsonNamingStrategyTest.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ class JsonNamingStrategyTest : JsonTestBase() {
214214
}
215215

216216
@Serializable
217+
@SerialName("SealedBase")
217218
sealed interface SealedBase {
218219
@Serializable
219220
@JsonClassDiscriminator("typeSub")
@@ -244,4 +245,28 @@ class JsonNamingStrategyTest : JsonTestBase() {
244245
json
245246
)
246247
}
248+
249+
@Test
250+
fun testClashWithDiscriminator() {
251+
val correctJson = Json(jsonWithNaming) {
252+
classDiscriminator = "test_base"
253+
}
254+
val holder = Holder(SealedBase.SealedSub2(), SealedBase.SealedMid.SealedSub1)
255+
256+
// Should pass because same name is only on different levels
257+
assertJsonFormAndRestored(
258+
Holder.serializer(),
259+
holder,
260+
"""{"test_base":{"test_base":"SealedSub2","test_case":0},"test_mid":{"typeSub":"SealedSub1"}}""",
261+
correctJson
262+
)
263+
264+
val incorrectJson = Json(jsonWithNaming) {
265+
classDiscriminator = "test_case"
266+
}
267+
268+
assertFailsWithMessage<SerializationException>("Class 'SealedSub2' cannot be serialized as base class 'SealedBase' because it has property name that conflicts with JSON class discriminator 'test_case'.") {
269+
incorrectJson.encodeToString<Holder>(holder)
270+
}
271+
}
247272
}

formats/json-tests/commonTest/src/kotlinx/serialization/json/polymorphic/JsonClassDiscriminatorModeBaseTest.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ abstract class JsonClassDiscriminatorModeBaseTest(
8080
@SerialName("mixed")
8181
data class MixedPolyAndRegular(val sb: SealedBase, val sc: SealedContainer, val i: Inner)
8282

83-
private inline fun <reified T> doTest(expected: String, obj: T) {
83+
internal inline fun <reified T> doTest(expected: String, obj: T) {
8484
parametrizedTest { mode ->
8585
val serialized = json.encodeToString(serializer<T>(), obj, mode)
8686
assertEquals(expected, serialized, "Failed with mode = $mode")
@@ -150,4 +150,8 @@ abstract class JsonClassDiscriminatorModeBaseTest(
150150
val nm = NullableMixed(null, null)
151151
doTest(expected, nm)
152152
}
153+
154+
@Serializable
155+
@SerialName("Conflict")
156+
class Conflict(val type: String)
153157
}

formats/json-tests/commonTest/src/kotlinx/serialization/json/polymorphic/JsonClassDiscriminatorModeTest.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package kotlinx.serialization.json.polymorphic
77
import kotlinx.serialization.*
88
import kotlinx.serialization.json.*
99
import kotlinx.serialization.modules.*
10+
import kotlinx.serialization.test.assertFailsWithMessage
1011
import kotlin.test.*
1112

1213
class ClassDiscriminatorModeAllObjectsTest :
@@ -46,6 +47,13 @@ class ClassDiscriminatorModeAllObjectsTest :
4647
@Test
4748
fun testNullable() = testNullable("""{"type":"NullableMixed","sb":null,"sc":null}""")
4849

50+
@Test
51+
fun testConflictWithDiscriminator() {
52+
assertFailsWithMessage<SerializationException>("Class 'Conflict' cannot be serialized in ALL_JSON_OBJECTS class discriminator mode because it has property name that conflicts with JSON class discriminator 'type'") {
53+
json.encodeToString(Conflict("foo"))
54+
}
55+
}
56+
4957
}
5058

5159
class ClassDiscriminatorModeNoneTest :
@@ -83,6 +91,11 @@ class ClassDiscriminatorModeNoneTest :
8391
@Test
8492
fun testNullable() = testNullable("""{"sb":null,"sc":null}""")
8593

94+
@Test
95+
fun testConflictWithDiscriminator() {
96+
doTest("""{"type":"foo"}""", Conflict("foo"))
97+
}
98+
8699
interface CommandType
87100

88101
@Serializable // For Kotlin/JS

formats/json-tests/commonTest/src/kotlinx/serialization/modules/SerialNameCollisionInSealedClassesTest.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ class SerialNameCollisionInSealedClassesTest {
2424

2525
@Test
2626
fun testCollisionWithDiscriminator() {
27-
assertFailsWith<IllegalStateException> { Json("type").encodeToString(Base.serializer(), Base.Child("a")) }
28-
assertFailsWith<IllegalStateException> { Json("type2").encodeToString(Base.serializer(), Base.Child("a")) }
27+
assertFailsWith<SerializationException> { Json("type").encodeToString(Base.serializer(), Base.Child("a")) }
28+
assertFailsWith<SerializationException> { Json("type2").encodeToString(Base.serializer(), Base.Child("a")) }
2929
Json("f").encodeToString(Base.serializer(), Base.Child("a"))
3030
}
3131

formats/json/commonMain/src/kotlinx/serialization/json/internal/JsonNamesMap.kt

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ package kotlinx.serialization.json.internal
88
import kotlinx.serialization.*
99
import kotlinx.serialization.descriptors.*
1010
import kotlinx.serialization.encoding.*
11+
import kotlinx.serialization.internal.jsonCachedSerialNames
1112
import kotlinx.serialization.json.*
12-
import kotlin.native.concurrent.*
1313

1414
internal val JsonDeserializationNamesKey = DescriptorSchemaCache.Key<Map<String, Int>>()
1515

@@ -19,7 +19,7 @@ private fun SerialDescriptor.buildDeserializationNamesMap(json: Json): Map<Strin
1919
fun MutableMap<String, Int>.putOrThrow(name: String, index: Int) {
2020
val entity = if (kind == SerialKind.ENUM) "enum value" else "property"
2121
if (name in this) {
22-
throw JsonException(
22+
throw JsonDecodingException(
2323
"The suggested name '$name' for $entity ${getElementName(index)} is already one of the names for $entity " +
2424
"${getElementName(getValue(name))} in ${this@buildDeserializationNamesMap}"
2525
)
@@ -72,6 +72,12 @@ internal fun SerialDescriptor.getJsonElementName(json: Json, index: Int): String
7272
return if (strategy == null) getElementName(index) else serializationNamesIndices(json, strategy)[index]
7373
}
7474

75+
// Emits only names used for encoding, i.e. from naming strategy, but not from @JsonNames
76+
internal fun SerialDescriptor.getJsonEncodedNames(json: Json): Set<String> {
77+
val strategy = namingStrategy(json)
78+
return if (strategy == null) jsonCachedSerialNames() else serializationNamesIndices(json, strategy).toSet()
79+
}
80+
7581
internal fun SerialDescriptor.namingStrategy(json: Json) =
7682
if (kind == StructureKind.CLASS) json.configuration.namingStrategy else null
7783

@@ -85,7 +91,6 @@ private fun Json.decodeCaseInsensitive(descriptor: SerialDescriptor) =
8591
* Serves same purpose as [SerialDescriptor.getElementIndex] but respects [JsonNames] annotation
8692
* and [JsonConfiguration] settings.
8793
*/
88-
@OptIn(ExperimentalSerializationApi::class)
8994
internal fun SerialDescriptor.getJsonNameIndex(json: Json, name: String): Int {
9095
if (json.decodeCaseInsensitive(this)) {
9196
return getJsonNameIndexSlowPath(json, name.lowercase())

formats/json/commonMain/src/kotlinx/serialization/json/internal/Polymorphic.kt

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ import kotlinx.serialization.*
99
import kotlinx.serialization.descriptors.*
1010
import kotlinx.serialization.internal.*
1111
import kotlinx.serialization.json.*
12-
import kotlinx.serialization.modules.*
13-
import kotlin.jvm.*
1412

1513
@Suppress("UNCHECKED_CAST")
1614
internal inline fun <T> JsonEncoder.encodePolymorphically(
@@ -37,32 +35,35 @@ internal inline fun <T> JsonEncoder.encodePolymorphically(
3735
val casted = serializer as AbstractPolymorphicSerializer<Any>
3836
requireNotNull(value) { "Value for serializer ${serializer.descriptor} should always be non-null. Please report issue to the kotlinx.serialization tracker." }
3937
val actual = casted.findPolymorphicSerializer(this, value)
40-
if (baseClassDiscriminator != null) {
41-
validateIfSealed(serializer, actual, baseClassDiscriminator)
42-
checkKind(actual.descriptor.kind)
43-
}
4438
actual as SerializationStrategy<T>
4539
} else serializer
4640

47-
if (baseClassDiscriminator != null) ifPolymorphic(baseClassDiscriminator, actualSerializer.descriptor.serialName)
41+
if (baseClassDiscriminator != null) {
42+
json.checkEncodingConflicts(serializer, actualSerializer, baseClassDiscriminator)
43+
checkKind(actualSerializer.descriptor.kind)
44+
ifPolymorphic(baseClassDiscriminator, actualSerializer.descriptor.serialName)
45+
}
4846
actualSerializer.serialize(this, value)
4947
}
5048

51-
private fun validateIfSealed(
49+
private fun Json.checkEncodingConflicts(
5250
serializer: SerializationStrategy<*>,
5351
actualSerializer: SerializationStrategy<*>,
5452
classDiscriminator: String
5553
) {
56-
if (serializer !is SealedClassSerializer<*>) return
57-
@Suppress("DEPRECATION_ERROR")
58-
if (classDiscriminator in actualSerializer.descriptor.jsonCachedSerialNames()) {
54+
if (classDiscriminator in actualSerializer.descriptor.getJsonEncodedNames(this)) {
5955
val baseName = serializer.descriptor.serialName
6056
val actualName = actualSerializer.descriptor.serialName
61-
error(
62-
"Sealed class '$actualName' cannot be serialized as base class '$baseName' because" +
57+
58+
val text = when {
59+
configuration.classDiscriminatorMode == ClassDiscriminatorMode.ALL_JSON_OBJECTS && baseName == actualName -> "in ALL_JSON_OBJECTS class discriminator mode"
60+
else -> "as base class '$baseName'"
61+
}
62+
throw JsonEncodingException(
63+
"Class '$actualName' cannot be serialized $text because" +
6364
" it has property name that conflicts with JSON class discriminator '$classDiscriminator'. " +
64-
"You can either change class discriminator in JsonConfiguration, " +
65-
"rename property with @SerialName annotation or fall back to array polymorphism"
65+
"You can either change class discriminator in JsonConfiguration, or " +
66+
"rename property with @SerialName annotation."
6667
)
6768
}
6869
}

0 commit comments

Comments
 (0)