Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce 'treatNullAsMissing' flag #853

Merged
merged 4 commits into from
Jun 22, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions runtime/commonMain/src/kotlinx/serialization/json/Json.kt
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ public class JsonBuilder {
public var prettyPrint: Boolean = false
public var unquotedPrint: Boolean = false
public var indent: String = " "
public var coerceInputValues: Boolean = false
public var useArrayPolymorphism: Boolean = false
public var classDiscriminator: String = "type"
public var serialModule: SerialModule = EmptyModule
Expand All @@ -293,6 +294,7 @@ public class JsonBuilder {
prettyPrint,
unquotedPrint,
indent,
coerceInputValues,
useArrayPolymorphism,
classDiscriminator
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ import kotlin.jvm.*
*
* * [indent] specifies indent string to use with [prettyPrint] mode.
*
* * [coerceInputValues] allows coercing incorrect json value to the default property value in the following cases:
* 1. Json value is `null` but property type is non-nullable.
* 2. Property type is an enum type, but json value contains unknown enum member.
*
* * [useArrayPolymorphism] switches polymorphic serialization to the default array format.
* This is an option for legacy JSON format and should not be generally used.
*
Expand All @@ -54,6 +58,7 @@ public data class JsonConfiguration @UnstableDefault constructor(
internal val prettyPrint: Boolean = false,
internal val unquotedPrint: Boolean = false,
internal val indent: String = defaultIndent,
internal val coerceInputValues: Boolean = false,
internal val useArrayPolymorphism: Boolean = false,
internal val classDiscriminator: String = defaultDiscriminator
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,16 @@ internal class JsonReader(private val source: String) {

fun takeString(): String {
if (tokenClass != TC_OTHER && tokenClass != TC_STRING) fail(
"Expected string or non-null literal", tokenPosition)
"Expected string or non-null literal", tokenPosition
)
return takeStringInternal()
}

fun peekString(isLenient: Boolean): String? {
return if (tokenClass != TC_STRING && (!isLenient || tokenClass != TC_OTHER)) null
else takeStringInternal(advance = false)
}

fun takeStringQuoted(): String {
if (tokenClass != TC_STRING) fail(
"Expected string literal with quotes. $lenientHint",
Expand All @@ -157,11 +163,11 @@ internal class JsonReader(private val source: String) {
return takeStringInternal()
}

private fun takeStringInternal(): String {
private fun takeStringInternal(advance: Boolean = true): String {
val prevStr = if (offset < 0)
String(buf, 0, length) else
source.substring(offset, offset + length)
nextToken()
if (advance) nextToken()
return prevStr
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package kotlinx.serialization.json.internal

import kotlinx.serialization.*
import kotlinx.serialization.CompositeDecoder.Companion.UNKNOWN_NAME
import kotlinx.serialization.builtins.*
import kotlinx.serialization.json.*
import kotlinx.serialization.modules.*
Expand Down Expand Up @@ -108,6 +109,21 @@ internal class StreamingJsonInput internal constructor(
}
}

/*
* Checks whether JSON has `null` value for non-null property or unknown enum value for enum property
*/
private fun coerceInputValue(descriptor: SerialDescriptor, index: Int): Boolean {
val elementDescriptor = descriptor.getElementDescriptor(index)
if (reader.tokenClass == TC_NULL && !elementDescriptor.isNullable) return true // null for non-nullable
if (elementDescriptor.kind == UnionKind.ENUM_KIND) {
val enumValue = reader.peekString(configuration.isLenient)
?: return false // if value is not a string, decodeEnum() will throw correct exception
val enumIndex = elementDescriptor.getElementIndex(enumValue)
if (enumIndex == UNKNOWN_NAME) return true
qwwdfsad marked this conversation as resolved.
Show resolved Hide resolved
}
return false
}

private fun decodeObjectIndex(tokenClass: Byte, descriptor: SerialDescriptor): Int {
if (tokenClass == TC_COMMA && !reader.canBeginValue) {
reader.fail("Unexpected trailing comma")
Expand All @@ -119,11 +135,17 @@ internal class StreamingJsonInput internal constructor(
reader.requireTokenClass(TC_COLON) { "Expected ':'" }
reader.nextToken()
val index = descriptor.getElementIndex(key)
if (index != CompositeDecoder.UNKNOWN_NAME) {
return index
val isUnknown = if (index != UNKNOWN_NAME) {
if (configuration.coerceInputValues && coerceInputValue(descriptor, index)) {
false // skip known element
} else {
return index // read known element
}
} else {
true // unknown element
}

if (!configuration.ignoreUnknownKeys) {
if (isUnknown && !configuration.ignoreUnknownKeys) {
reader.fail(
"Encountered an unknown key '$key'. You can enable 'JsonConfiguration.ignoreUnknownKeys' property" +
" to ignore unknown keys"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,25 @@ private open class JsonTreeInput(
) : AbstractJsonTreeInput(json, value) {
private var position = 0

/*
* Checks whether JSON has `null` value for non-null property or unknown enum value for enum property
*/
private fun coerceInputValue(descriptor: SerialDescriptor, index: Int, tag: String): Boolean {
val elementDescriptor = descriptor.getElementDescriptor(index)
if (currentElement(tag).isNull && !elementDescriptor.isNullable) return true // null for non-nullable
if (elementDescriptor.kind == UnionKind.ENUM_KIND) {
val enumValue = (currentElement(tag) as? JsonElement)?.contentOrNull
?: return false // if value is not a string, decodeEnum() will throw correct exception
val enumIndex = elementDescriptor.getElementIndex(enumValue)
if (enumIndex == CompositeDecoder.UNKNOWN_NAME) return true
}
return false
}

override fun decodeElementIndex(descriptor: SerialDescriptor): Int {
while (position < descriptor.elementsCount) {
val name = descriptor.getTag(position++)
if (name in value) {
if (name in value && (!configuration.coerceInputValues || !coerceInputValue(descriptor, position - 1, name))) {
return position - 1
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
package kotlinx.serialization.json

import kotlinx.serialization.*
import kotlinx.serialization.test.assertStringFormAndRestored
import kotlinx.serialization.test.*
import kotlin.test.*

class JsonMapKeysTest : JsonTestBase() {
Expand All @@ -19,12 +19,12 @@ class JsonMapKeysTest : JsonTestBase() {
private data class WithComplexKey(val map: Map<IntData, String>)

@Test
fun testMapKeysShouldBeStrings() = parametrizedTest(default) { fmt ->
fun testMapKeysShouldBeStrings() = parametrizedTest(default) {
assertStringFormAndRestored(
"""{"map":{"10":10,"20":20}}""",
WithMap(mapOf(10L to 10L, 20L to 20L)),
WithMap.serializer(),
fmt
this
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ abstract class JsonTestBase {
processResults(streamingResult, treeResult)
}

private inner class DualFormat(
private inner class SwitchableJson(
val json: Json,
val useStreaming: Boolean,
override val context: SerialModule = EmptyModule
Expand All @@ -114,9 +114,9 @@ abstract class JsonTestBase {
}
}

protected fun parametrizedTest(json: Json, test: StringFormat.(StringFormat) -> Unit) {
val streamingResult = runCatching { json.test(DualFormat(json, true)) }
val treeResult = runCatching { json.test(DualFormat(json, false)) }
protected fun parametrizedTest(json: Json, test: StringFormat.() -> Unit) {
val streamingResult = runCatching { SwitchableJson(json, true).test() }
val treeResult = runCatching { SwitchableJson(json, false).test() }
processResults(streamingResult, treeResult)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* Copyright 2017-2020 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 kotlin.test.*

class JsonUseDefaultOnNullAndUnknownTest : JsonTestBase() {
@Serializable
data class WithBoolean(val b: Boolean = false)

@Serializable
data class WithEnum(val e: SampleEnum = SampleEnum.OptionC)

@Serializable
data class MultipleValues(
val data: StringData,
val data2: IntData = IntData(0),
val i: Int = 42,
val e: SampleEnum = SampleEnum.OptionA,
val foo: String
)

val json = Json(JsonConfiguration.Default.copy(coerceInputValues = true, isLenient = true))

private inline fun <T> doTest(inputs: List<String>, expected: T, serializer: KSerializer<T>) {
for (input in inputs) {
parametrizedTest(json) {
assertEquals(expected, parse(serializer, input), "Failed on input: $input")
}
}
}

@Test
fun testUseDefaultOnNonNullableBoolean() = doTest(
listOf(
"""{"b":false}""",
"""{"b":null}""",
"""{}""",
),
WithBoolean(),
WithBoolean.serializer()
)

@Test
fun testUseDefaultOnUnknownEnum() {
doTest(
listOf(
"""{"e":unknown_value}""",
"""{"e":"unknown_value"}""",
"""{"e":null}""",
"""{}""",
),
WithEnum(),
WithEnum.serializer()
)
assertFailsWith<JsonDecodingException> {
json.parse(WithEnum.serializer(), """{"e":{"x":"definitely not a valid enum value"}}""")
}
assertFailsWith<JsonDecodingException> { // test user still sees exception on missing quotes
Json(json.configuration.copy(isLenient = false)).parse(WithEnum.serializer(), """{"e":unknown_value}""")
}
}

@Test
fun testUseDefaultInMultipleCases() {
val testData = mapOf(
"""{"data":{"data":"foo"},"data2":null,"i":null,"e":null,"foo":"bar"}""" to MultipleValues(
StringData("foo"),
foo = "bar"
),
"""{"data":{"data":"foo"},"data2":{"intV":42},"i":null,"e":null,"foo":"bar"}""" to MultipleValues(
StringData(
"foo"
), IntData(42), foo = "bar"
),
"""{"data":{"data":"foo"},"data2":{"intV":42},"i":0,"e":"NoOption","foo":"bar"}""" to MultipleValues(
StringData("foo"),
IntData(42),
i = 0,
foo = "bar"
),
"""{"data":{"data":"foo"},"data2":{"intV":42},"i":0,"e":"OptionC","foo":"bar"}""" to MultipleValues(
StringData("foo"),
IntData(42),
i = 0,
e = SampleEnum.OptionC,
foo = "bar"
),
)
for ((input, expected) in testData) {
assertEquals(expected, json.parse(MultipleValues.serializer(), input), "Failed on input: $input")
}
}

}