Skip to content

Commit bb2f0cc

Browse files
authored
Merge pull request #278 from ProjectMapK/porting-910
Deserialization support when value class is specified as a Map key
2 parents 856ac59 + 9552e73 commit bb2f0cc

File tree

7 files changed

+341
-9
lines changed

7 files changed

+341
-9
lines changed

src/main/kotlin/io/github/projectmapk/jackson/module/kogera/KotlinModule.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ public class KotlinModule private constructor(
7979
_deserializers = KotlinDeserializers(cache, useJavaDurationConversion)
8080

8181
_keySerializers = KotlinKeySerializers(cache)
82-
_keyDeserializers = KotlinKeyDeserializers
82+
_keyDeserializers = KotlinKeyDeserializers(cache)
8383

8484
_abstractTypes = ClosedRangeResolver
8585

src/main/kotlin/io/github/projectmapk/jackson/module/kogera/deser/deserializers/KotlinKeyDeserializers.kt

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,18 @@ import com.fasterxml.jackson.databind.BeanDescription
44
import com.fasterxml.jackson.databind.DeserializationConfig
55
import com.fasterxml.jackson.databind.DeserializationContext
66
import com.fasterxml.jackson.databind.JavaType
7+
import com.fasterxml.jackson.databind.JsonMappingException
78
import com.fasterxml.jackson.databind.KeyDeserializer
89
import com.fasterxml.jackson.databind.deser.std.StdKeyDeserializer
10+
import com.fasterxml.jackson.databind.exc.InvalidDefinitionException
911
import com.fasterxml.jackson.databind.module.SimpleKeyDeserializers
12+
import io.github.projectmapk.jackson.module.kogera.ReflectionCache
13+
import io.github.projectmapk.jackson.module.kogera.ValueClassBoxConverter
14+
import io.github.projectmapk.jackson.module.kogera.isUnboxableValueClass
15+
import io.github.projectmapk.jackson.module.kogera.toSignature
16+
import kotlinx.metadata.isSecondary
17+
import kotlinx.metadata.jvm.signature
18+
import java.lang.reflect.Method
1019
import java.math.BigInteger
1120

1221
// The reason why key is treated as nullable is to match the tentative behavior of StdKeyDeserializer.
@@ -40,18 +49,74 @@ internal object ULongKeyDeserializer : StdKeyDeserializer(-1, ULong::class.java)
4049
key?.let { ULongChecker.readWithRangeCheck(null, BigInteger(it)) }
4150
}
4251

43-
internal object KotlinKeyDeserializers : SimpleKeyDeserializers() {
44-
private fun readResolve(): Any = KotlinKeyDeserializers
52+
// The implementation is designed to be compatible with various creators, just in case.
53+
internal class ValueClassKeyDeserializer<S, D : Any>(
54+
private val creator: Method,
55+
private val converter: ValueClassBoxConverter<S, D>
56+
) : KeyDeserializer() {
57+
private val unboxedClass: Class<*> = creator.parameterTypes[0]
4558

59+
init {
60+
creator.apply { if (!this.isAccessible) this.isAccessible = true }
61+
}
62+
63+
// Based on databind error
64+
// https://github.com/FasterXML/jackson-databind/blob/341f8d360a5f10b5e609d6ee0ea023bf597ce98a/src/main/java/com/fasterxml/jackson/databind/deser/DeserializerCache.java#L624
65+
private fun errorMessage(boxedType: JavaType): String =
66+
"Could not find (Map) Key deserializer for types wrapped in $boxedType"
67+
68+
override fun deserializeKey(key: String?, ctxt: DeserializationContext): Any {
69+
val unboxedJavaType = ctxt.constructType(unboxedClass)
70+
71+
return try {
72+
// findKeyDeserializer does not return null, and an exception will be thrown if not found.
73+
val value = ctxt.findKeyDeserializer(unboxedJavaType, null).deserializeKey(key, ctxt)
74+
@Suppress("UNCHECKED_CAST")
75+
converter.convert(creator.invoke(null, value) as S)
76+
} catch (e: InvalidDefinitionException) {
77+
throw JsonMappingException.from(ctxt, errorMessage(ctxt.constructType(converter.boxedClass)), e)
78+
}
79+
}
80+
81+
companion object {
82+
fun createOrNull(
83+
valueClass: Class<*>,
84+
cache: ReflectionCache
85+
): ValueClassKeyDeserializer<*, *>? {
86+
val jmClass = cache.getJmClass(valueClass) ?: return null
87+
val primaryKmConstructorSignature =
88+
jmClass.constructors.first { !it.isSecondary }.signature
89+
90+
// Only primary constructor is allowed as creator, regardless of visibility.
91+
// This is because it is based on the WrapsNullableValueClassBoxDeserializer.
92+
// Also, as far as I could research, there was no such functionality as JsonKeyCreator,
93+
// so it was not taken into account.
94+
return valueClass.declaredMethods.find { it.toSignature() == primaryKmConstructorSignature }?.let {
95+
val unboxedClass = it.returnType
96+
97+
val converter = cache.getValueClassBoxConverter(unboxedClass, valueClass)
98+
99+
ValueClassKeyDeserializer(it, converter)
100+
}
101+
}
102+
}
103+
}
104+
105+
internal class KotlinKeyDeserializers(private val cache: ReflectionCache) : SimpleKeyDeserializers() {
46106
override fun findKeyDeserializer(
47107
type: JavaType,
48108
config: DeserializationConfig?,
49109
beanDesc: BeanDescription?
50-
): KeyDeserializer? = when (type.rawClass) {
51-
UByte::class.java -> UByteKeyDeserializer
52-
UShort::class.java -> UShortKeyDeserializer
53-
UInt::class.java -> UIntKeyDeserializer
54-
ULong::class.java -> ULongKeyDeserializer
55-
else -> null
110+
): KeyDeserializer? {
111+
val rawClass = type.rawClass
112+
113+
return when {
114+
rawClass == UByte::class.java -> UByteKeyDeserializer
115+
rawClass == UShort::class.java -> UShortKeyDeserializer
116+
rawClass == UInt::class.java -> UIntKeyDeserializer
117+
rawClass == ULong::class.java -> ULongKeyDeserializer
118+
rawClass.isUnboxableValueClass() -> ValueClassKeyDeserializer.createOrNull(rawClass, cache)
119+
else -> null
120+
}
56121
}
57122
}

src/test/kotlin/io/github/projectmapk/jackson/module/kogera/zIntegration/deser/valueClass/ValueClasses.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,17 @@ import com.fasterxml.jackson.core.JsonParser
44
import com.fasterxml.jackson.databind.DeserializationContext
55
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
66
import io.github.projectmapk.jackson.module.kogera.deser.WrapsNullableValueClassDeserializer
7+
import com.fasterxml.jackson.databind.KeyDeserializer as JacksonKeyDeserializer
78

89
@JvmInline
910
value class Primitive(val v: Int) {
1011
class Deserializer : StdDeserializer<Primitive>(Primitive::class.java) {
1112
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Primitive = Primitive(p.intValue + 100)
1213
}
14+
15+
class KeyDeserializer : JacksonKeyDeserializer() {
16+
override fun deserializeKey(key: String, ctxt: DeserializationContext) = Primitive(key.toInt() + 100)
17+
}
1318
}
1419

1520
@JvmInline
@@ -18,6 +23,10 @@ value class NonNullObject(val v: String) {
1823
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): NonNullObject =
1924
NonNullObject(p.valueAsString + "-deser")
2025
}
26+
27+
class KeyDeserializer : JacksonKeyDeserializer() {
28+
override fun deserializeKey(key: String, ctxt: DeserializationContext) = NonNullObject("$key-deser")
29+
}
2130
}
2231

2332
@JvmInline
@@ -28,4 +37,8 @@ value class NullableObject(val v: String?) {
2837

2938
override fun getBoxedNullValue(): NullableObject = NullableObject("null-value-deser")
3039
}
40+
41+
class KeyDeserializer : JacksonKeyDeserializer() {
42+
override fun deserializeKey(key: String, ctxt: DeserializationContext) = NullableObject("$key-deser")
43+
}
3144
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package io.github.projectmapk.jackson.module.kogera.zIntegration.deser.valueClass.mapKey
2+
3+
import com.fasterxml.jackson.databind.DeserializationContext
4+
import com.fasterxml.jackson.databind.JsonMappingException
5+
import com.fasterxml.jackson.databind.exc.InvalidDefinitionException
6+
import com.fasterxml.jackson.databind.module.SimpleModule
7+
import io.github.projectmapk.jackson.module.kogera.defaultMapper
8+
import io.github.projectmapk.jackson.module.kogera.jacksonObjectMapper
9+
import io.github.projectmapk.jackson.module.kogera.readValue
10+
import io.github.projectmapk.jackson.module.kogera.zIntegration.deser.valueClass.NonNullObject
11+
import io.github.projectmapk.jackson.module.kogera.zIntegration.deser.valueClass.NullableObject
12+
import io.github.projectmapk.jackson.module.kogera.zIntegration.deser.valueClass.Primitive
13+
import org.junit.jupiter.api.Assertions.assertEquals
14+
import org.junit.jupiter.api.Assertions.assertTrue
15+
import org.junit.jupiter.api.Nested
16+
import org.junit.jupiter.api.Test
17+
import org.junit.jupiter.api.assertThrows
18+
import java.lang.reflect.InvocationTargetException
19+
import com.fasterxml.jackson.databind.KeyDeserializer as JacksonKeyDeserializer
20+
21+
class WithoutCustomDeserializeMethodTest {
22+
companion object {
23+
val throwable = IllegalArgumentException("test")
24+
}
25+
26+
@Nested
27+
inner class DirectDeserialize {
28+
@Test
29+
fun primitive() {
30+
val result = defaultMapper.readValue<Map<Primitive, String?>>("""{"1":null}""")
31+
assertEquals(mapOf(Primitive(1) to null), result)
32+
}
33+
34+
@Test
35+
fun nonNullObject() {
36+
val result = defaultMapper.readValue<Map<NonNullObject, String?>>("""{"foo":null}""")
37+
assertEquals(mapOf(NonNullObject("foo") to null), result)
38+
}
39+
40+
@Test
41+
fun nullableObject() {
42+
val result = defaultMapper.readValue<Map<NullableObject, String?>>("""{"bar":null}""")
43+
assertEquals(mapOf(NullableObject("bar") to null), result)
44+
}
45+
}
46+
47+
data class Dst(
48+
val p: Map<Primitive, String?>,
49+
val nn: Map<NonNullObject, String?>,
50+
val n: Map<NullableObject, String?>
51+
)
52+
53+
@Test
54+
fun wrapped() {
55+
val src = """
56+
{
57+
"p":{"1":null},
58+
"nn":{"foo":null},
59+
"n":{"bar":null}
60+
}
61+
""".trimIndent()
62+
val result = defaultMapper.readValue<Dst>(src)
63+
val expected = Dst(
64+
mapOf(Primitive(1) to null),
65+
mapOf(NonNullObject("foo") to null),
66+
mapOf(NullableObject("bar") to null)
67+
)
68+
69+
assertEquals(expected, result)
70+
}
71+
72+
@JvmInline
73+
value class HasCheckConstructor(val value: Int) {
74+
init {
75+
if (value < 0) throw throwable
76+
}
77+
}
78+
79+
@Test
80+
fun callConstructorCheckTest() {
81+
val e = assertThrows<InvocationTargetException> {
82+
defaultMapper.readValue<Map<HasCheckConstructor, String?>>("""{"-1":null}""")
83+
}
84+
assertTrue(e.cause === throwable)
85+
}
86+
87+
data class Wrapped(val first: String, val second: String) {
88+
class KeyDeserializer : JacksonKeyDeserializer() {
89+
override fun deserializeKey(key: String, ctxt: DeserializationContext) =
90+
key.split("-").let { Wrapped(it[0], it[1]) }
91+
}
92+
}
93+
94+
@JvmInline
95+
value class Wrapper(val w: Wrapped)
96+
97+
@Test
98+
fun wrappedCustomObject() {
99+
// If a type that cannot be deserialized is specified, the default is an error.
100+
val thrown = assertThrows<JsonMappingException> {
101+
defaultMapper.readValue<Map<Wrapper, String?>>("""{"foo-bar":null}""")
102+
}
103+
assertTrue(thrown.cause is InvalidDefinitionException)
104+
105+
val mapper = jacksonObjectMapper()
106+
.registerModule(
107+
object : SimpleModule() {
108+
init {
109+
addKeyDeserializer(Wrapped::class.java, Wrapped.KeyDeserializer())
110+
}
111+
}
112+
)
113+
114+
val result = mapper.readValue<Map<Wrapper, String?>>("""{"foo-bar":null}""")
115+
val expected = mapOf(Wrapper(Wrapped("foo", "bar")) to null)
116+
117+
assertEquals(expected, result)
118+
}
119+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package io.github.projectmapk.jackson.module.kogera.zIntegration.deser.valueClass.mapKey.keyDeserializer
2+
3+
import com.fasterxml.jackson.databind.module.SimpleModule
4+
import io.github.projectmapk.jackson.module.kogera.jacksonObjectMapper
5+
import io.github.projectmapk.jackson.module.kogera.readValue
6+
import io.github.projectmapk.jackson.module.kogera.zIntegration.deser.valueClass.NonNullObject
7+
import io.github.projectmapk.jackson.module.kogera.zIntegration.deser.valueClass.NullableObject
8+
import io.github.projectmapk.jackson.module.kogera.zIntegration.deser.valueClass.Primitive
9+
import org.junit.jupiter.api.Assertions.assertEquals
10+
import org.junit.jupiter.api.Nested
11+
import org.junit.jupiter.api.Test
12+
13+
class SpecifiedForObjectMapperTest {
14+
companion object {
15+
val mapper = jacksonObjectMapper().apply {
16+
val module = SimpleModule().apply {
17+
this.addKeyDeserializer(Primitive::class.java, Primitive.KeyDeserializer())
18+
this.addKeyDeserializer(NonNullObject::class.java, NonNullObject.KeyDeserializer())
19+
this.addKeyDeserializer(NullableObject::class.java, NullableObject.KeyDeserializer())
20+
}
21+
this.registerModule(module)
22+
}
23+
}
24+
25+
@Nested
26+
inner class DirectDeserialize {
27+
@Test
28+
fun primitive() {
29+
val result = mapper.readValue<Map<Primitive, String?>>("""{"1":null}""")
30+
assertEquals(mapOf(Primitive(101) to null), result)
31+
}
32+
33+
@Test
34+
fun nonNullObject() {
35+
val result = mapper.readValue<Map<NonNullObject, String?>>("""{"foo":null}""")
36+
assertEquals(mapOf(NonNullObject("foo-deser") to null), result)
37+
}
38+
39+
@Test
40+
fun nullableObject() {
41+
val result = mapper.readValue<Map<NullableObject, String?>>("""{"bar":null}""")
42+
assertEquals(mapOf(NullableObject("bar-deser") to null), result)
43+
}
44+
}
45+
46+
data class Dst(
47+
val p: Map<Primitive, String?>,
48+
val nn: Map<NonNullObject, String?>,
49+
val n: Map<NullableObject, String?>
50+
)
51+
52+
@Test
53+
fun wrapped() {
54+
val src = """
55+
{
56+
"p":{"1":null},
57+
"nn":{"foo":null},
58+
"n":{"bar":null}
59+
}
60+
""".trimIndent()
61+
val result = mapper.readValue<Dst>(src)
62+
val expected = Dst(
63+
mapOf(Primitive(101) to null),
64+
mapOf(NonNullObject("foo-deser") to null),
65+
mapOf(NullableObject("bar-deser") to null)
66+
)
67+
68+
assertEquals(expected, result)
69+
}
70+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package io.github.projectmapk.jackson.module.kogera.zIntegration.deser.valueClass.mapKey.keyDeserializer.byAnnotation
2+
3+
import com.fasterxml.jackson.databind.DeserializationContext
4+
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
5+
import io.github.projectmapk.jackson.module.kogera.defaultMapper
6+
import io.github.projectmapk.jackson.module.kogera.jacksonObjectMapper
7+
import io.github.projectmapk.jackson.module.kogera.readValue
8+
import org.junit.jupiter.api.Assertions.assertEquals
9+
import org.junit.jupiter.api.Test
10+
import com.fasterxml.jackson.databind.KeyDeserializer as JacksonKeyDeserializer
11+
12+
class SpecifiedForClassTest {
13+
@JsonDeserialize(keyUsing = Value.KeyDeserializer::class)
14+
@JvmInline
15+
value class Value(val v: Int) {
16+
class KeyDeserializer : JacksonKeyDeserializer() {
17+
override fun deserializeKey(key: String, ctxt: DeserializationContext) = Value(key.toInt() + 100)
18+
}
19+
}
20+
21+
@Test
22+
fun directDeserTest() {
23+
val result = defaultMapper.readValue<Map<Value, String?>>("""{"1":null}""")
24+
25+
assertEquals(mapOf(Value(101) to null), result)
26+
}
27+
28+
data class Wrapper(val v: Map<Value, String?>)
29+
30+
@Test
31+
fun paramDeserTest() {
32+
val mapper = jacksonObjectMapper()
33+
val result = mapper.readValue<Wrapper>("""{"v":{"1":null}}""")
34+
35+
assertEquals(Wrapper(mapOf(Value(101) to null)), result)
36+
}
37+
}

0 commit comments

Comments
 (0)