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