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

Add default KeyDeserializer for value class #910

Merged
merged 4 commits into from
Feb 2, 2025
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
1 change: 1 addition & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@
<exclude>
com.fasterxml.jackson.module.kotlin.KotlinNamesAnnotationIntrospector#KotlinNamesAnnotationIntrospector(com.fasterxml.jackson.module.kotlin.ReflectionCache,boolean)
</exclude>
<exclude>com.fasterxml.jackson.module.kotlin.KotlinKeyDeserializers#INSTANCE</exclude>
</excludes>
</parameter>
</configuration>
Expand Down
1 change: 1 addition & 0 deletions release-notes/CREDITS-2.x
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions release-notes/VERSION-2.x
Original file line number Diff line number Diff line change
Expand Up @@ -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%).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<S, D : Any>(
private val creator: Method,
private val converter: ValueClassBoxConverter<S, D>
) : 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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>(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
Expand All @@ -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
Expand All @@ -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")
}
}
Original file line number Diff line number Diff line change
@@ -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<Map<Primitive, String?>>("""{"1":null}""")
assertEquals(mapOf(Primitive(1) to null), result)
}

@Test
fun nonNullObject() {
val result = defaultMapper.readValue<Map<NonNullObject, String?>>("""{"foo":null}""")
assertEquals(mapOf(NonNullObject("foo") to null), result)
}

@Test
fun nullableObject() {
val result = defaultMapper.readValue<Map<NullableObject, String?>>("""{"bar":null}""")
assertEquals(mapOf(NullableObject("bar") to null), result)
}
}

data class Dst(
val p: Map<Primitive, String?>,
val nn: Map<NonNullObject, String?>,
val n: Map<NullableObject, String?>
)

@Test
fun wrapped() {
val src = """
{
"p":{"1":null},
"nn":{"foo":null},
"n":{"bar":null}
}
""".trimIndent()
val result = defaultMapper.readValue<Dst>(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<InvocationTargetException> {
defaultMapper.readValue<Map<HasCheckConstructor, String?>>("""{"-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<JsonMappingException> {
defaultMapper.readValue<Map<Wrapper, String?>>("""{"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<Map<Wrapper, String?>>("""{"foo-bar":null}""")
val expected = mapOf(Wrapper(Wrapped("foo", "bar")) to null)

assertEquals(expected, result)
}
}
Original file line number Diff line number Diff line change
@@ -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<Map<Primitive, String?>>("""{"1":null}""")
assertEquals(mapOf(Primitive(101) to null), result)
}

@Test
fun nonNullObject() {
val result = mapper.readValue<Map<NonNullObject, String?>>("""{"foo":null}""")
assertEquals(mapOf(NonNullObject("foo-deser") to null), result)
}

@Test
fun nullableObject() {
val result = mapper.readValue<Map<NullableObject, String?>>("""{"bar":null}""")
assertEquals(mapOf(NullableObject("bar-deser") to null), result)
}
}

data class Dst(
val p: Map<Primitive, String?>,
val nn: Map<NonNullObject, String?>,
val n: Map<NullableObject, String?>
)

@Test
fun wrapped() {
val src = """
{
"p":{"1":null},
"nn":{"foo":null},
"n":{"bar":null}
}
""".trimIndent()
val result = mapper.readValue<Dst>(src)
val expected = Dst(
mapOf(Primitive(101) to null),
mapOf(NonNullObject("foo-deser") to null),
mapOf(NullableObject("bar-deser") to null)
)

assertEquals(expected, result)
}
}
Original file line number Diff line number Diff line change
@@ -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<Map<Value, String?>>("""{"1":null}""")

assertEquals(mapOf(Value(101) to null), result)
}

data class Wrapper(val v: Map<Value, String?>)

@Test
fun paramDeserTest() {
val mapper = jacksonObjectMapper()
val result = mapper.readValue<Wrapper>("""{"v":{"1":null}}""")

assertEquals(Wrapper(mapOf(Value(101) to null)), result)
}
}
Loading