Skip to content

Commit c0976fd

Browse files
authored
Support @JsonNames for enum values (#1473)
Fixes #1458 Move JsonNames implementation to internal package
1 parent 2aa3a30 commit c0976fd

File tree

6 files changed

+128
-61
lines changed

6 files changed

+128
-61
lines changed

formats/json/commonMain/src/kotlinx/serialization/json/JsonNames.kt

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package kotlinx.serialization.json
66

77
import kotlinx.serialization.*
88
import kotlinx.serialization.descriptors.*
9+
import kotlinx.serialization.encoding.*
910
import kotlinx.serialization.json.internal.*
1011
import kotlin.native.concurrent.*
1112

@@ -36,28 +37,3 @@ import kotlin.native.concurrent.*
3637
@Target(AnnotationTarget.PROPERTY)
3738
@ExperimentalSerializationApi
3839
public annotation class JsonNames(vararg val names: String)
39-
40-
@SharedImmutable
41-
internal val JsonAlternativeNamesKey = DescriptorSchemaCache.Key<Map<String, Int>>()
42-
43-
@OptIn(ExperimentalSerializationApi::class)
44-
internal fun SerialDescriptor.buildAlternativeNamesMap(): Map<String, Int> {
45-
fun MutableMap<String, Int>.putOrThrow(name: String, index: Int) {
46-
if (name in this) {
47-
throw JsonException(
48-
"The suggested name '$name' for property ${getElementName(index)} is already one of the names for property " +
49-
"${getElementName(getValue(name))} in ${this@buildAlternativeNamesMap}"
50-
)
51-
}
52-
this[name] = index
53-
}
54-
55-
var builder: MutableMap<String, Int>? = null
56-
for (i in 0 until elementsCount) {
57-
getElementAnnotations(i).filterIsInstance<JsonNames>().singleOrNull()?.names?.forEach { name ->
58-
if (builder == null) builder = createMapForCache(elementsCount)
59-
builder!!.putOrThrow(name, i)
60-
}
61-
}
62-
return builder ?: emptyMap()
63-
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Copyright 2017-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package kotlinx.serialization.json.internal
6+
7+
import kotlinx.serialization.*
8+
import kotlinx.serialization.descriptors.*
9+
import kotlinx.serialization.encoding.*
10+
import kotlinx.serialization.json.*
11+
import kotlin.native.concurrent.*
12+
13+
@SharedImmutable
14+
internal val JsonAlternativeNamesKey = DescriptorSchemaCache.Key<Map<String, Int>>()
15+
16+
@OptIn(ExperimentalSerializationApi::class)
17+
internal fun SerialDescriptor.buildAlternativeNamesMap(): Map<String, Int> {
18+
fun MutableMap<String, Int>.putOrThrow(name: String, index: Int) {
19+
if (name in this) {
20+
throw JsonException(
21+
"The suggested name '$name' for property ${getElementName(index)} is already one of the names for property " +
22+
"${getElementName(getValue(name))} in ${this@buildAlternativeNamesMap}"
23+
)
24+
}
25+
this[name] = index
26+
}
27+
28+
var builder: MutableMap<String, Int>? = null
29+
for (i in 0 until elementsCount) {
30+
getElementAnnotations(i).filterIsInstance<JsonNames>().singleOrNull()?.names?.forEach { name ->
31+
if (builder == null) builder = createMapForCache(elementsCount)
32+
builder!!.putOrThrow(name, i)
33+
}
34+
}
35+
return builder ?: emptyMap()
36+
}
37+
38+
/**
39+
* Serves same purpose as [SerialDescriptor.getElementIndex] but respects
40+
* [JsonNames] annotation and [JsonConfiguration.useAlternativeNames] state.
41+
*/
42+
@OptIn(ExperimentalSerializationApi::class)
43+
internal fun SerialDescriptor.getJsonNameIndex(json: Json, name: String): Int {
44+
val index = getElementIndex(name)
45+
// Fast path, do not go through ConcurrentHashMap.get
46+
// Note, it blocks ability to detect collisions between the primary name and alternate,
47+
// but it eliminates a significant performance penalty (about -15% without this optimization)
48+
if (index != CompositeDecoder.UNKNOWN_NAME) return index
49+
if (!json.configuration.useAlternativeNames) return index
50+
// Slow path
51+
val alternativeNamesMap =
52+
json.schemaCache.getOrPut(this, JsonAlternativeNamesKey, this::buildAlternativeNamesMap)
53+
return alternativeNamesMap[name] ?: CompositeDecoder.UNKNOWN_NAME
54+
}
55+
56+
/**
57+
* Throws on [CompositeDecoder.UNKNOWN_NAME]
58+
*/
59+
@OptIn(ExperimentalSerializationApi::class)
60+
internal fun SerialDescriptor.getJsonNameIndexOrThrow(json: Json, name: String): Int {
61+
val index = getJsonNameIndex(json, name)
62+
if (index == CompositeDecoder.UNKNOWN_NAME)
63+
throw SerializationException("$serialName does not contain element with name '$name'")
64+
return index
65+
}

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

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2017-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
2+
* Copyright 2017-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
33
*/
44

55
package kotlinx.serialization.json.internal
@@ -101,19 +101,6 @@ internal open class StreamingJsonDecoder(
101101
}
102102
}
103103

104-
private fun SerialDescriptor.getJsonElementIndex(key: String): Int {
105-
val index = this.getElementIndex(key)
106-
// Fast path, do not go through ConcurrentHashMap.get
107-
// Note, it blocks ability to detect collisions between the primary name and alternate,
108-
// but it eliminates a significant performance penalty (about -15% without this optimization)
109-
if (index != UNKNOWN_NAME) return index
110-
if (!json.configuration.useAlternativeNames) return index
111-
// Slow path
112-
val alternativeNamesMap =
113-
json.schemaCache.getOrPut(this, JsonAlternativeNamesKey, this::buildAlternativeNamesMap)
114-
return alternativeNamesMap[key] ?: UNKNOWN_NAME
115-
}
116-
117104
/*
118105
* Checks whether JSON has `null` value for non-null property or unknown enum value for enum property
119106
*/
@@ -122,8 +109,8 @@ internal open class StreamingJsonDecoder(
122109
if (!elementDescriptor.isNullable && !lexer.tryConsumeNotNull()) return true
123110
if (elementDescriptor.kind == SerialKind.ENUM) {
124111
val enumValue = lexer.peekString(configuration.isLenient)
125-
?: return false // if value is not a string, decodeEnum() will throw correct exception
126-
val enumIndex = elementDescriptor.getElementIndex(enumValue)
112+
?: return false // if value is not a string, decodeEnum() will throw correct exception
113+
val enumIndex = elementDescriptor.getJsonNameIndex(json, enumValue)
127114
if (enumIndex == UNKNOWN_NAME) {
128115
// Encountered unknown enum value, have to skip it
129116
lexer.consumeString()
@@ -140,7 +127,7 @@ internal open class StreamingJsonDecoder(
140127
hasComma = false
141128
val key = decodeStringKey()
142129
lexer.consumeNextToken(COLON)
143-
val index = descriptor.getJsonElementIndex(key)
130+
val index = descriptor.getJsonNameIndex(json, key)
144131
val isUnknown = if (index != UNKNOWN_NAME) {
145132
if (configuration.coerceInputValues && coerceInputValue(descriptor, index)) {
146133
hasComma = lexer.tryConsumeComma()
@@ -264,7 +251,7 @@ internal open class StreamingJsonDecoder(
264251
else super.decodeInline(inlineDescriptor)
265252

266253
override fun decodeEnum(enumDescriptor: SerialDescriptor): Int {
267-
return enumDescriptor.getElementIndexOrThrow(decodeString())
254+
return enumDescriptor.getJsonNameIndexOrThrow(json, decodeString())
268255
}
269256
}
270257

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

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ private sealed class AbstractJsonTreeDecoder(
8383
protected abstract fun currentElement(tag: String): JsonElement
8484

8585
override fun decodeTaggedEnum(tag: String, enumDescriptor: SerialDescriptor): Int =
86-
enumDescriptor.getElementIndexOrThrow(getValue(tag).content)
86+
enumDescriptor.getJsonNameIndexOrThrow(json, getValue(tag).content)
8787

8888
override fun decodeTaggedNull(tag: String): Nothing? = null
8989

@@ -193,7 +193,7 @@ private open class JsonTreeDecoder(
193193
if (elementDescriptor.kind == SerialKind.ENUM) {
194194
val enumValue = (currentElement(tag) as? JsonPrimitive)?.contentOrNull
195195
?: return false // if value is not a string, decodeEnum() will throw correct exception
196-
val enumIndex = elementDescriptor.getElementIndex(enumValue)
196+
val enumIndex = elementDescriptor.getJsonNameIndex(json, enumValue)
197197
if (enumIndex == CompositeDecoder.UNKNOWN_NAME) return true
198198
}
199199
return false
@@ -297,14 +297,3 @@ private class JsonTreeListDecoder(json: Json, override val value: JsonArray) : A
297297
return CompositeDecoder.DECODE_DONE
298298
}
299299
}
300-
301-
/**
302-
* Same as [SerialDescriptor.getElementIndex], but throws [SerializationException] if
303-
* given [name] is not associated with any element in the descriptor.
304-
*/
305-
internal fun SerialDescriptor.getElementIndexOrThrow(name: String): Int {
306-
val index = getElementIndex(name)
307-
if (index == CompositeDecoder.UNKNOWN_NAME)
308-
throw SerializationException("$serialName does not contain element with name '$name'")
309-
return index
310-
}

formats/json/commonTest/src/kotlinx/serialization/features/JsonAlternativeNamesTest.kt

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,19 @@ class JsonAlternativeNamesTest : JsonTestBase() {
1616
@Serializable
1717
data class WithNames(@JsonNames("foo", "_foo") val data: String)
1818

19+
@Serializable
20+
enum class AlternateEnumNames {
21+
@JsonNames("someValue", "some_value")
22+
VALUE_A,
23+
VALUE_B
24+
}
25+
26+
@Serializable
27+
data class WithEnumNames(
28+
val enumList: List<AlternateEnumNames>,
29+
val checkCoercion: AlternateEnumNames = AlternateEnumNames.VALUE_B
30+
)
31+
1932
@Serializable
2033
data class CollisionWithAlternate(
2134
@JsonNames("_foo") val data: String,
@@ -24,12 +37,49 @@ class JsonAlternativeNamesTest : JsonTestBase() {
2437

2538
private val inputString1 = """{"foo":"foo"}"""
2639
private val inputString2 = """{"_foo":"foo"}"""
27-
private val json = Json { useAlternativeNames = true }
40+
41+
private fun parameterizedCoercingTest(test: (json: Json, streaming: Boolean, msg: String) -> Unit) {
42+
for (coercing in listOf(true, false)) {
43+
val json = Json {
44+
coerceInputValues = coercing
45+
useAlternativeNames = true
46+
}
47+
parametrizedTest { streaming ->
48+
test(
49+
json, streaming,
50+
"Failed test with coercing=$coercing and streaming=$streaming"
51+
)
52+
}
53+
}
54+
}
55+
56+
@Test
57+
fun testEnumSupportsAlternativeNames() = noLegacyJs {
58+
val input = """{"enumList":["VALUE_A", "someValue", "some_value", "VALUE_B"], "checkCoercion":"someValue"}"""
59+
val expected = WithEnumNames(
60+
listOf(
61+
AlternateEnumNames.VALUE_A,
62+
AlternateEnumNames.VALUE_A,
63+
AlternateEnumNames.VALUE_A,
64+
AlternateEnumNames.VALUE_B
65+
), AlternateEnumNames.VALUE_A
66+
)
67+
parameterizedCoercingTest { json, streaming, msg ->
68+
assertEquals(expected, json.decodeFromString(input, streaming), msg)
69+
}
70+
}
71+
72+
@Test
73+
fun topLevelEnumSupportAlternativeNames() = noLegacyJs {
74+
parameterizedCoercingTest { json, streaming, msg ->
75+
assertEquals(AlternateEnumNames.VALUE_A, json.decodeFromString("\"someValue\"", streaming), msg)
76+
}
77+
}
2878

2979
@Test
3080
fun testParsesAllAlternativeNames() = noLegacyJs {
3181
for (input in listOf(inputString1, inputString2)) {
32-
for (streaming in listOf(true, false)) {
82+
parameterizedCoercingTest { json, streaming, _ ->
3383
val data = json.decodeFromString(WithNames.serializer(), input, useStreaming = streaming)
3484
assertEquals("foo", data.data, "Failed to parse input '$input' with streaming=$streaming")
3585
}
@@ -39,7 +89,7 @@ class JsonAlternativeNamesTest : JsonTestBase() {
3989
@Test
4090
fun testThrowsAnErrorOnDuplicateNames2() = noLegacyJs {
4191
val serializer = CollisionWithAlternate.serializer()
42-
parametrizedTest { streaming ->
92+
parameterizedCoercingTest { json, streaming, _ ->
4393
assertFailsWithMessage<SerializationException>(
4494
"""The suggested name '_foo' for property foo is already one of the names for property data""",
4595
"Class ${serializer.descriptor.serialName} did not fail with streaming=$streaming"

formats/json/jsMain/src/kotlinx/serialization/json/internal/DynamicDecoders.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2017-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
2+
* Copyright 2017-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
33
*/
44

55
package kotlinx.serialization.json.internal
@@ -79,7 +79,7 @@ private open class DynamicInput(
7979
}
8080

8181
override fun decodeTaggedEnum(tag: String, enumDescriptor: SerialDescriptor): Int =
82-
enumDescriptor.getElementIndexOrThrow(getByTag(tag) as String)
82+
enumDescriptor.getJsonNameIndexOrThrow(json, getByTag(tag) as String)
8383

8484
protected open fun getByTag(tag: String): dynamic = value[tag]
8585

0 commit comments

Comments
 (0)