diff --git a/pom.xml b/pom.xml index 2c241d74..780193fd 100644 --- a/pom.xml +++ b/pom.xml @@ -251,6 +251,7 @@ com.fasterxml.jackson.module.kotlin.KotlinNamesAnnotationIntrospector#KotlinNamesAnnotationIntrospector(com.fasterxml.jackson.module.kotlin.ReflectionCache,boolean) + com.fasterxml.jackson.module.kotlin.KotlinKeyDeserializers#INSTANCE diff --git a/release-notes/CREDITS-2.x b/release-notes/CREDITS-2.x index 4e01c9ce..a408c4e2 100644 --- a/release-notes/CREDITS-2.x +++ b/release-notes/CREDITS-2.x @@ -18,6 +18,7 @@ Contributors: # 2.19.0 (not yet released) WrongWrong (@k163377) +* #910: Add default KeyDeserializer for value class * #885: Performance improvement of strictNullChecks * #884: Changed the base class of MissingKotlinParameterException to InvalidNullException * #878: Fix for #876 diff --git a/release-notes/VERSION-2.x b/release-notes/VERSION-2.x index 465f3867..00f2581f 100644 --- a/release-notes/VERSION-2.x +++ b/release-notes/VERSION-2.x @@ -18,6 +18,8 @@ Co-maintainers: 2.19.0 (not yet released) +#910: A default `KeySerializer` for `value class` has been added. + This eliminates the need to have a custom `KeySerializer` for each `value class` when using it as a key in a `Map`, if only simple boxing is needed. #889: Kotlin has been upgraded to 1.9.25. #885: A new `StrictNullChecks` option(KotlinFeature.NewStrictNullChecks) has been added which greatly improves throughput. Benchmarks show a consistent throughput drop of less than 2% when enabled (prior to the improvement, the worst throughput drop was more than 30%). diff --git a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinKeyDeserializers.kt b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinKeyDeserializers.kt index 70e2e35a..e9f4e222 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinKeyDeserializers.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinKeyDeserializers.kt @@ -5,6 +5,11 @@ import com.fasterxml.jackson.core.exc.InputCoercionException import com.fasterxml.jackson.databind.* import com.fasterxml.jackson.databind.deser.std.StdKeyDeserializer import com.fasterxml.jackson.databind.deser.std.StdKeyDeserializers +import com.fasterxml.jackson.databind.exc.InvalidDefinitionException +import java.lang.reflect.Method +import kotlin.reflect.KClass +import kotlin.reflect.full.primaryConstructor +import kotlin.reflect.jvm.javaMethod // The reason why key is treated as nullable is to match the tentative behavior of StdKeyDeserializer. // If StdKeyDeserializer is modified, need to modify this too. @@ -65,18 +70,68 @@ internal object ULongKeyDeserializer : StdKeyDeserializer(TYPE_LONG, ULong::clas } } -internal object KotlinKeyDeserializers : StdKeyDeserializers() { - private fun readResolve(): Any = KotlinKeyDeserializers +// The implementation is designed to be compatible with various creators, just in case. +internal class ValueClassKeyDeserializer( + private val creator: Method, + private val converter: ValueClassBoxConverter +) : KeyDeserializer() { + private val unboxedClass: Class<*> = creator.parameterTypes[0] + init { + creator.apply { if (!this.isAccessible) this.isAccessible = true } + } + + // Based on databind error + // https://github.com/FasterXML/jackson-databind/blob/341f8d360a5f10b5e609d6ee0ea023bf597ce98a/src/main/java/com/fasterxml/jackson/databind/deser/DeserializerCache.java#L624 + private fun errorMessage(boxedType: JavaType): String = + "Could not find (Map) Key deserializer for types wrapped in $boxedType" + + override fun deserializeKey(key: String?, ctxt: DeserializationContext): Any { + val unboxedJavaType = ctxt.constructType(unboxedClass) + + return try { + // findKeyDeserializer does not return null, and an exception will be thrown if not found. + val value = ctxt.findKeyDeserializer(unboxedJavaType, null).deserializeKey(key, ctxt) + @Suppress("UNCHECKED_CAST") + converter.convert(creator.invoke(null, value) as S) + } catch (e: InvalidDefinitionException) { + throw JsonMappingException.from(ctxt, errorMessage(ctxt.constructType(converter.boxedClass.java)), e) + } + } + + companion object { + fun createOrNull( + boxedClass: KClass<*>, + cache: ReflectionCache + ): ValueClassKeyDeserializer<*, *>? { + // primaryConstructor.javaMethod for the value class returns constructor-impl + // Only primary constructor is allowed as creator, regardless of visibility. + // This is because it is based on the WrapsNullableValueClassBoxDeserializer. + // Also, as far as I could research, there was no such functionality as JsonKeyCreator, + // so it was not taken into account. + val creator = boxedClass.primaryConstructor?.javaMethod ?: return null + val converter = cache.getValueClassBoxConverter(creator.returnType, boxedClass) + + return ValueClassKeyDeserializer(creator, converter) + } + } +} + +internal class KotlinKeyDeserializers(private val cache: ReflectionCache) : StdKeyDeserializers() { override fun findKeyDeserializer( type: JavaType, config: DeserializationConfig?, beanDesc: BeanDescription? - ): KeyDeserializer? = when (type.rawClass) { - UByte::class.java -> UByteKeyDeserializer - UShort::class.java -> UShortKeyDeserializer - UInt::class.java -> UIntKeyDeserializer - ULong::class.java -> ULongKeyDeserializer - else -> null + ): KeyDeserializer? { + val rawClass = type.rawClass + + return when { + rawClass == UByte::class.java -> UByteKeyDeserializer + rawClass == UShort::class.java -> UShortKeyDeserializer + rawClass == UInt::class.java -> UIntKeyDeserializer + rawClass == ULong::class.java -> ULongKeyDeserializer + rawClass.isUnboxableValueClass() -> ValueClassKeyDeserializer.createOrNull(rawClass.kotlin, cache) + else -> null + } } } diff --git a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinModule.kt b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinModule.kt index 8b128958..f09b1707 100644 --- a/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinModule.kt +++ b/src/main/kotlin/com/fasterxml/jackson/module/kotlin/KotlinModule.kt @@ -130,7 +130,7 @@ class KotlinModule private constructor( ) context.addDeserializers(KotlinDeserializers(cache, useJavaDurationConversion)) - context.addKeyDeserializers(KotlinKeyDeserializers) + context.addKeyDeserializers(KotlinKeyDeserializers(cache)) context.addSerializers(KotlinSerializers()) context.addKeySerializers(KotlinKeySerializers()) diff --git a/src/test/kotlin/com/fasterxml/jackson/module/kotlin/kogeraIntegration/deser/valueClass/ValueClasses.kt b/src/test/kotlin/com/fasterxml/jackson/module/kotlin/kogeraIntegration/deser/valueClass/ValueClasses.kt index 5855a6f1..1bfb3ccd 100644 --- a/src/test/kotlin/com/fasterxml/jackson/module/kotlin/kogeraIntegration/deser/valueClass/ValueClasses.kt +++ b/src/test/kotlin/com/fasterxml/jackson/module/kotlin/kogeraIntegration/deser/valueClass/ValueClasses.kt @@ -4,12 +4,17 @@ import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.fasterxml.jackson.module.kotlin.WrapsNullableValueClassDeserializer +import com.fasterxml.jackson.databind.KeyDeserializer as JacksonKeyDeserializer @JvmInline value class Primitive(val v: Int) { class Deserializer : StdDeserializer(Primitive::class.java) { override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Primitive = Primitive(p.intValue + 100) } + + class KeyDeserializer : JacksonKeyDeserializer() { + override fun deserializeKey(key: String, ctxt: DeserializationContext) = Primitive(key.toInt() + 100) + } } @JvmInline @@ -18,6 +23,10 @@ value class NonNullObject(val v: String) { override fun deserialize(p: JsonParser, ctxt: DeserializationContext): NonNullObject = NonNullObject(p.valueAsString + "-deser") } + + class KeyDeserializer : JacksonKeyDeserializer() { + override fun deserializeKey(key: String, ctxt: DeserializationContext) = NonNullObject("$key-deser") + } } @JvmInline @@ -28,4 +37,8 @@ value class NullableObject(val v: String?) { override fun getBoxedNullValue(): NullableObject = NullableObject("null-value-deser") } + + class KeyDeserializer : JacksonKeyDeserializer() { + override fun deserializeKey(key: String, ctxt: DeserializationContext) = NullableObject("$key-deser") + } } diff --git a/src/test/kotlin/com/fasterxml/jackson/module/kotlin/kogeraIntegration/deser/valueClass/mapKey/WithoutCustomDeserializeMethodTest.kt b/src/test/kotlin/com/fasterxml/jackson/module/kotlin/kogeraIntegration/deser/valueClass/mapKey/WithoutCustomDeserializeMethodTest.kt new file mode 100644 index 00000000..b80d7375 --- /dev/null +++ b/src/test/kotlin/com/fasterxml/jackson/module/kotlin/kogeraIntegration/deser/valueClass/mapKey/WithoutCustomDeserializeMethodTest.kt @@ -0,0 +1,117 @@ +package com.fasterxml.jackson.module.kotlin.kogeraIntegration.deser.valueClass.mapKey + +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonMappingException +import com.fasterxml.jackson.databind.exc.InvalidDefinitionException +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.module.kotlin.defaultMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.fasterxml.jackson.module.kotlin.kogeraIntegration.deser.valueClass.NonNullObject +import com.fasterxml.jackson.module.kotlin.kogeraIntegration.deser.valueClass.NullableObject +import com.fasterxml.jackson.module.kotlin.kogeraIntegration.deser.valueClass.Primitive +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.lang.reflect.InvocationTargetException +import com.fasterxml.jackson.databind.KeyDeserializer as JacksonKeyDeserializer + +class WithoutCustomDeserializeMethodTest { + companion object { + val throwable = IllegalArgumentException("test") + } + + @Nested + inner class DirectDeserialize { + @Test + fun primitive() { + val result = defaultMapper.readValue>("""{"1":null}""") + assertEquals(mapOf(Primitive(1) to null), result) + } + + @Test + fun nonNullObject() { + val result = defaultMapper.readValue>("""{"foo":null}""") + assertEquals(mapOf(NonNullObject("foo") to null), result) + } + + @Test + fun nullableObject() { + val result = defaultMapper.readValue>("""{"bar":null}""") + assertEquals(mapOf(NullableObject("bar") to null), result) + } + } + + data class Dst( + val p: Map, + val nn: Map, + val n: Map + ) + + @Test + fun wrapped() { + val src = """ + { + "p":{"1":null}, + "nn":{"foo":null}, + "n":{"bar":null} + } + """.trimIndent() + val result = defaultMapper.readValue(src) + val expected = Dst( + mapOf(Primitive(1) to null), + mapOf(NonNullObject("foo") to null), + mapOf(NullableObject("bar") to null) + ) + + assertEquals(expected, result) + } + + @JvmInline + value class HasCheckConstructor(val value: Int) { + init { + if (value < 0) throw throwable + } + } + + @Test + fun callConstructorCheckTest() { + val e = assertThrows { + defaultMapper.readValue>("""{"-1":null}""") + } + assertTrue(e.cause === throwable) + } + + data class Wrapped(val first: String, val second: String) { + class KeyDeserializer : JacksonKeyDeserializer() { + override fun deserializeKey(key: String, ctxt: DeserializationContext) = + key.split("-").let { Wrapped(it[0], it[1]) } + } + } + + @JvmInline + value class Wrapper(val w: Wrapped) + + @Test + fun wrappedCustomObject() { + // If a type that cannot be deserialized is specified, the default is an error. + val thrown = assertThrows { + defaultMapper.readValue>("""{"foo-bar":null}""") + } + assertTrue(thrown.cause is InvalidDefinitionException) + + val mapper = jacksonObjectMapper() + .registerModule( + object : SimpleModule() { + init { addKeyDeserializer(Wrapped::class.java, Wrapped.KeyDeserializer()) } + } + ) + + val result = mapper.readValue>("""{"foo-bar":null}""") + val expected = mapOf(Wrapper(Wrapped("foo", "bar")) to null) + + assertEquals(expected, result) + } +} diff --git a/src/test/kotlin/com/fasterxml/jackson/module/kotlin/kogeraIntegration/deser/valueClass/mapKey/keyDeserializer/SpecifiedForObjectMapperTest.kt b/src/test/kotlin/com/fasterxml/jackson/module/kotlin/kogeraIntegration/deser/valueClass/mapKey/keyDeserializer/SpecifiedForObjectMapperTest.kt new file mode 100644 index 00000000..e348211f --- /dev/null +++ b/src/test/kotlin/com/fasterxml/jackson/module/kotlin/kogeraIntegration/deser/valueClass/mapKey/keyDeserializer/SpecifiedForObjectMapperTest.kt @@ -0,0 +1,70 @@ +package com.fasterxml.jackson.module.kotlin.kogeraIntegration.deser.valueClass.mapKey.keyDeserializer + +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.fasterxml.jackson.module.kotlin.kogeraIntegration.deser.valueClass.NonNullObject +import com.fasterxml.jackson.module.kotlin.kogeraIntegration.deser.valueClass.NullableObject +import com.fasterxml.jackson.module.kotlin.kogeraIntegration.deser.valueClass.Primitive +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class SpecifiedForObjectMapperTest { + companion object { + val mapper = jacksonObjectMapper().apply { + val module = SimpleModule().apply { + this.addKeyDeserializer(Primitive::class.java, Primitive.KeyDeserializer()) + this.addKeyDeserializer(NonNullObject::class.java, NonNullObject.KeyDeserializer()) + this.addKeyDeserializer(NullableObject::class.java, NullableObject.KeyDeserializer()) + } + this.registerModule(module) + } + } + + @Nested + inner class DirectDeserialize { + @Test + fun primitive() { + val result = mapper.readValue>("""{"1":null}""") + assertEquals(mapOf(Primitive(101) to null), result) + } + + @Test + fun nonNullObject() { + val result = mapper.readValue>("""{"foo":null}""") + assertEquals(mapOf(NonNullObject("foo-deser") to null), result) + } + + @Test + fun nullableObject() { + val result = mapper.readValue>("""{"bar":null}""") + assertEquals(mapOf(NullableObject("bar-deser") to null), result) + } + } + + data class Dst( + val p: Map, + val nn: Map, + val n: Map + ) + + @Test + fun wrapped() { + val src = """ + { + "p":{"1":null}, + "nn":{"foo":null}, + "n":{"bar":null} + } + """.trimIndent() + val result = mapper.readValue(src) + val expected = Dst( + mapOf(Primitive(101) to null), + mapOf(NonNullObject("foo-deser") to null), + mapOf(NullableObject("bar-deser") to null) + ) + + assertEquals(expected, result) + } +} diff --git a/src/test/kotlin/com/fasterxml/jackson/module/kotlin/kogeraIntegration/deser/valueClass/mapKey/keyDeserializer/byAnnotation/SpecifiedForClassTest.kt b/src/test/kotlin/com/fasterxml/jackson/module/kotlin/kogeraIntegration/deser/valueClass/mapKey/keyDeserializer/byAnnotation/SpecifiedForClassTest.kt new file mode 100644 index 00000000..38642f05 --- /dev/null +++ b/src/test/kotlin/com/fasterxml/jackson/module/kotlin/kogeraIntegration/deser/valueClass/mapKey/keyDeserializer/byAnnotation/SpecifiedForClassTest.kt @@ -0,0 +1,37 @@ +package com.fasterxml.jackson.module.kotlin.kogeraIntegration.deser.valueClass.mapKey.keyDeserializer.byAnnotation + +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import com.fasterxml.jackson.module.kotlin.defaultMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import com.fasterxml.jackson.databind.KeyDeserializer as JacksonKeyDeserializer + +class SpecifiedForClassTest { + @JsonDeserialize(keyUsing = Value.KeyDeserializer::class) + @JvmInline + value class Value(val v: Int) { + class KeyDeserializer : JacksonKeyDeserializer() { + override fun deserializeKey(key: String, ctxt: DeserializationContext) = Value(key.toInt() + 100) + } + } + + @Test + fun directDeserTest() { + val result = defaultMapper.readValue>("""{"1":null}""") + + assertEquals(mapOf(Value(101) to null), result) + } + + data class Wrapper(val v: Map) + + @Test + fun paramDeserTest() { + val mapper = jacksonObjectMapper() + val result = mapper.readValue("""{"v":{"1":null}}""") + + assertEquals(Wrapper(mapOf(Value(101) to null)), result) + } +} diff --git a/src/test/kotlin/com/fasterxml/jackson/module/kotlin/kogeraIntegration/deser/valueClass/mapKey/keyDeserializer/byAnnotation/SpecifiedForPropertyTest.kt b/src/test/kotlin/com/fasterxml/jackson/module/kotlin/kogeraIntegration/deser/valueClass/mapKey/keyDeserializer/byAnnotation/SpecifiedForPropertyTest.kt new file mode 100644 index 00000000..5dd383c2 --- /dev/null +++ b/src/test/kotlin/com/fasterxml/jackson/module/kotlin/kogeraIntegration/deser/valueClass/mapKey/keyDeserializer/byAnnotation/SpecifiedForPropertyTest.kt @@ -0,0 +1,28 @@ +package com.fasterxml.jackson.module.kotlin.kogeraIntegration.deser.valueClass.mapKey.keyDeserializer.byAnnotation + +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import com.fasterxml.jackson.databind.KeyDeserializer as JacksonKeyDeserializer + +class SpecifiedForPropertyTest { + @JvmInline + value class Value(val v: Int) { + class KeyDeserializer : JacksonKeyDeserializer() { + override fun deserializeKey(key: String, ctxt: DeserializationContext) = Value(key.toInt() + 100) + } + } + + data class Wrapper(@JsonDeserialize(keyUsing = Value.KeyDeserializer::class) val v: Map) + + @Test + fun paramDeserTest() { + val mapper = jacksonObjectMapper() + val result = mapper.readValue("""{"v":{"1":null}}""") + + assertEquals(Wrapper(mapOf(Value(101) to null)), result) + } +}