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

【WIP】Deserialization support when value class is specified as a Map key #224

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ public class KotlinModule private constructor(
_deserializers = KotlinDeserializers(cache, useJavaDurationConversion)

_keySerializers = KotlinKeySerializers(cache)
_keyDeserializers = KotlinKeyDeserializers
_keyDeserializers = KotlinKeyDeserializers(cache)

_abstractTypes = ClosedRangeResolver

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,18 @@ import com.fasterxml.jackson.databind.BeanDescription
import com.fasterxml.jackson.databind.DeserializationConfig
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JavaType
import com.fasterxml.jackson.databind.JsonMappingException
import com.fasterxml.jackson.databind.KeyDeserializer
import com.fasterxml.jackson.databind.deser.std.StdKeyDeserializer
import com.fasterxml.jackson.databind.exc.InvalidDefinitionException
import com.fasterxml.jackson.databind.module.SimpleKeyDeserializers
import io.github.projectmapk.jackson.module.kogera.ReflectionCache
import io.github.projectmapk.jackson.module.kogera.ValueClassBoxConverter
import io.github.projectmapk.jackson.module.kogera.isUnboxableValueClass
import io.github.projectmapk.jackson.module.kogera.toSignature
import kotlinx.metadata.isSecondary
import kotlinx.metadata.jvm.signature
import java.lang.reflect.Method
import java.math.BigInteger

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

internal object KotlinKeyDeserializers : SimpleKeyDeserializers() {
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)), e)
}
}

companion object {
fun createOrNull(
valueClass: Class<*>,
cache: ReflectionCache
): ValueClassKeyDeserializer<*, *>? {
val jmClass = cache.getJmClass(valueClass) ?: return null
val primaryKmConstructorSignature =
jmClass.constructors.first { !it.isSecondary }.signature

// 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.
return valueClass.declaredMethods.find { it.toSignature() == primaryKmConstructorSignature }?.let {
val unboxedClass = it.returnType

val converter = cache.getValueClassBoxConverter(unboxedClass, valueClass)

ValueClassKeyDeserializer(it, converter)
}
}
}
}

internal class KotlinKeyDeserializers(private val cache: ReflectionCache) : SimpleKeyDeserializers() {
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, cache)
else -> null
}
}
}
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 io.github.projectmapk.jackson.module.kogera.deser.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 io.github.projectmapk.jackson.module.kogera.zIntegration.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 io.github.projectmapk.jackson.module.kogera.defaultMapper
import io.github.projectmapk.jackson.module.kogera.jacksonObjectMapper
import io.github.projectmapk.jackson.module.kogera.readValue
import io.github.projectmapk.jackson.module.kogera.zIntegration.deser.valueClass.NonNullObject
import io.github.projectmapk.jackson.module.kogera.zIntegration.deser.valueClass.NullableObject
import io.github.projectmapk.jackson.module.kogera.zIntegration.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 io.github.projectmapk.jackson.module.kogera.zIntegration.deser.valueClass.mapKey.keyDeserializer

import com.fasterxml.jackson.databind.module.SimpleModule
import io.github.projectmapk.jackson.module.kogera.jacksonObjectMapper
import io.github.projectmapk.jackson.module.kogera.readValue
import io.github.projectmapk.jackson.module.kogera.zIntegration.deser.valueClass.NonNullObject
import io.github.projectmapk.jackson.module.kogera.zIntegration.deser.valueClass.NullableObject
import io.github.projectmapk.jackson.module.kogera.zIntegration.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 io.github.projectmapk.jackson.module.kogera.zIntegration.deser.valueClass.mapKey.keyDeserializer.byAnnotation

import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import io.github.projectmapk.jackson.module.kogera.defaultMapper
import io.github.projectmapk.jackson.module.kogera.jacksonObjectMapper
import io.github.projectmapk.jackson.module.kogera.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
Loading