Skip to content

Commit 8d75abc

Browse files
authored
Merge pull request #203 from ProjectMapK/develop
For release 2.16.0-beta9
2 parents ddebf90 + d5d4bd6 commit 8d75abc

File tree

25 files changed

+619
-198
lines changed

25 files changed

+619
-198
lines changed

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ val jacksonVersion = libs.versions.jackson.get()
1616
val generatedSrcPath = "${layout.buildDirectory.get()}/generated/kotlin"
1717

1818
group = groupStr
19-
version = "${jacksonVersion}-beta8"
19+
version = "${jacksonVersion}-beta9"
2020

2121
repositories {
2222
mavenCentral()

docs/AboutValueClassSupport.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,29 @@ The same policy applies to deserialization.
4343
This policy was decided with reference to the behavior as of `jackson-module-kotlin 2.14.1` and [kotlinx-serialization](https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/value-classes.md#serializable-value-classes).
4444
However, these are just basic policies, and the behavior can be overridden with `JsonSerializer` or `JsonDeserializer`.
4545

46+
### Handling of `value class` that wraps nullable
47+
When deserializing a `value class` that wraps a nullable as a parameter, if the input is `null`,
48+
there is a problem in determining whether the value should be `null` or wrapped.
49+
`Kogera` provides special handling of such cases to make the behavior as intuitive as possible.
50+
Note that such handling is applied only when the input is `null`, not when it is `undefined`.
51+
52+
First, it tries to use the `nullValue` set in the deserializer, regardless of the nullability of the parameter.
53+
This is the behavior defined by `Jackson` and is difficult to change.
54+
55+
If the value retrieved here is `null`, the behavior will diverge depending on the nullability of the parameter.
56+
57+
If the parameter is defined as non-null, then `ValueClassDeserializer.boxedNullValue` is used.
58+
By default, this will be a wrapped `null` (cached value after the second time).
59+
60+
The `ValueClassDeserializer` is a `StdDeserializer` defined by `Kogera` to handle such cases.
61+
You can also set your own `boxedNullValue` by inheriting from it.
62+
Note that this class is defined in `Java` for compatibility.
63+
64+
If a parameter is defined as nullable, it will be `null`.
65+
66+
Finally, if the retrieved value is `null`, a `Nulls.SKIP` decision is made.
67+
However, the call with the default argument will fail until #51 is resolved.
68+
4669
### Serialization performance improvement using `JsonUnbox`
4770
In `jackson-module-kogera`, the `jackson` functionality is modified by reflection so that the `Jackson` functionality works for `value class` as well.
4871
These are executed on all calls.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package io.github.projectmapk.jackson.module.kogera.deser;
2+
3+
import com.fasterxml.jackson.databind.JavaType;
4+
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
5+
import io.github.projectmapk.jackson.module.kogera.deser.deserializers.ValueClassBoxDeserializer;
6+
import kotlin.jvm.JvmClassMappingKt;
7+
import kotlin.reflect.KClass;
8+
import org.jetbrains.annotations.NotNull;
9+
import org.jetbrains.annotations.Nullable;
10+
11+
/**
12+
* An interface to be inherited by JsonDeserializer that handles value classes that may wrap nullable.
13+
* @see ValueClassBoxDeserializer for implementation.
14+
*/
15+
// To ensure maximum compatibility with StdDeserializer, this class is defined in Java.
16+
public abstract class ValueClassDeserializer<D> extends StdDeserializer<D> {
17+
protected ValueClassDeserializer(@NotNull KClass<?> vc) {
18+
super(JvmClassMappingKt.getJavaClass(vc));
19+
}
20+
21+
protected ValueClassDeserializer(@NotNull Class<?> vc) {
22+
super(vc);
23+
}
24+
25+
protected ValueClassDeserializer(@NotNull JavaType valueType) {
26+
super(valueType);
27+
}
28+
29+
protected ValueClassDeserializer(@NotNull StdDeserializer<D> src) {
30+
super(src);
31+
}
32+
33+
@Override
34+
@NotNull
35+
public final Class<D> handledType() {
36+
//noinspection unchecked
37+
return (Class<D>) super.handledType();
38+
}
39+
40+
/**
41+
* If the parameter definition is a value class that wraps a nullable and is non-null,
42+
* and the input to JSON is explicitly null, this value is used.
43+
*/
44+
// It is defined so that null can also be returned so that Nulls.SKIP can be applied.
45+
@Nullable
46+
public abstract D getBoxedNullValue();
47+
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ import com.fasterxml.jackson.databind.util.StdConverter
1313
*/
1414
internal class ValueClassBoxConverter<S : Any?, D : Any>(
1515
unboxedClass: Class<S>,
16-
val valueClass: Class<D>
16+
val boxedClass: Class<D>
1717
) : StdConverter<S, D>() {
18-
private val boxMethod = valueClass.getDeclaredMethod("box-impl", unboxedClass).apply {
18+
private val boxMethod = boxedClass.getDeclaredMethod("box-impl", unboxedClass).apply {
1919
if (!this.isAccessible) this.isAccessible = true
2020
}
2121

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,19 @@ public fun jsonMapper(initializer: JsonMapper.Builder.() -> Unit = {}): JsonMapp
3333
return builder.build()
3434
}
3535

36-
public fun jacksonObjectMapper(): ObjectMapper = jsonMapper { addModule(kotlinModule()) }
37-
public fun jacksonMapperBuilder(): JsonMapper.Builder = JsonMapper.builder().addModule(kotlinModule())
38-
39-
public fun ObjectMapper.registerKotlinModule(): ObjectMapper = this.registerModule(kotlinModule())
36+
// region: JvmOverloads is set for bytecode compatibility for versions below 2.17.
37+
@JvmOverloads
38+
public fun jacksonObjectMapper(initializer: KotlinModule.Builder.() -> Unit = {}): ObjectMapper =
39+
jsonMapper { addModule(kotlinModule(initializer)) }
40+
41+
@JvmOverloads
42+
public fun jacksonMapperBuilder(initializer: KotlinModule.Builder.() -> Unit = {}): JsonMapper.Builder =
43+
JsonMapper.builder().addModule(kotlinModule(initializer))
44+
45+
@JvmOverloads
46+
public fun ObjectMapper.registerKotlinModule(initializer: KotlinModule.Builder.() -> Unit = {}): ObjectMapper =
47+
this.registerModule(kotlinModule(initializer))
48+
// endregion
4049

4150
public inline fun <reified T> jacksonTypeRef(): TypeReference<T> = object : TypeReference<T>() {}
4251

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package io.github.projectmapk.jackson.module.kogera
33
import com.fasterxml.jackson.annotation.JsonCreator
44
import kotlinx.metadata.KmClassifier
55
import kotlinx.metadata.KmType
6-
import kotlinx.metadata.KmValueParameter
6+
import kotlinx.metadata.isNullable
77
import kotlinx.metadata.jvm.JvmMethodSignature
88
import java.lang.reflect.AnnotatedElement
99
import java.lang.reflect.Constructor
@@ -14,6 +14,9 @@ internal typealias KotlinDuration = kotlin.time.Duration
1414

1515
internal fun Class<*>.isUnboxableValueClass() = this.getAnnotation(JvmInline::class.java) != null
1616

17+
// JmClass must be value class.
18+
internal fun JmClass.wrapsNullValueClass() = inlineClassUnderlyingType!!.isNullable
19+
1720
private val primitiveClassToDesc = mapOf(
1821
Byte::class.javaPrimitiveType to 'B',
1922
Char::class.javaPrimitiveType to 'C',
@@ -53,9 +56,6 @@ internal fun Constructor<*>.toSignature(): JvmMethodSignature =
5356
internal fun Method.toSignature(): JvmMethodSignature =
5457
JvmMethodSignature(this.name, parameterTypes.toDescBuilder().appendDescriptor(this.returnType).toString())
5558

56-
internal fun List<KmValueParameter>.hasVarargParam(): Boolean =
57-
lastOrNull()?.let { it.varargElementType != null } ?: false
58-
5959
internal val defaultConstructorMarker: Class<*> by lazy {
6060
Class.forName("kotlin.jvm.internal.DefaultConstructorMarker")
6161
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import io.github.projectmapk.jackson.module.kogera.reconstructClassOrNull
2020
import io.github.projectmapk.jackson.module.kogera.ser.KotlinDurationValueToJavaDurationConverter
2121
import io.github.projectmapk.jackson.module.kogera.ser.KotlinToJavaDurationConverter
2222
import io.github.projectmapk.jackson.module.kogera.ser.SequenceToIteratorConverter
23+
import io.github.projectmapk.jackson.module.kogera.wrapsNullValueClass
2324
import kotlinx.metadata.KmTypeProjection
2425
import kotlinx.metadata.KmValueParameter
2526
import kotlinx.metadata.isNullable
@@ -100,8 +101,7 @@ internal class KotlinFallbackAnnotationIntrospector(
100101

101102
// Determine if the unbox result of value class is nullable
102103
// @see findNullSerializer
103-
private fun Class<*>.requireRebox(): Boolean =
104-
cache.getJmClass(this)!!.inlineClassUnderlyingType!!.isNullable
104+
private fun Class<*>.requireRebox(): Boolean = cache.getJmClass(this)!!.wrapsNullValueClass()
105105

106106
// Perform proper serialization even if the value wrapped by the value class is null.
107107
// If value is a non-null object type, it must not be reboxing.

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ internal class KotlinPrimaryAnnotationIntrospector(
103103
paramDef.type.isNullable -> false
104104
// Default argument are defined
105105
paramDef.declaresDefaultValue -> false
106+
// vararg is treated as an empty array because undefined input is allowed
107+
paramDef.varargElementType != null -> false
106108
// The conversion in case of null is defined.
107109
type.hasDefaultEmptyValue() -> false
108110
else -> true

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

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import io.github.projectmapk.jackson.module.kogera.KotlinDuration
1414
import io.github.projectmapk.jackson.module.kogera.ReflectionCache
1515
import io.github.projectmapk.jackson.module.kogera.ValueClassBoxConverter
1616
import io.github.projectmapk.jackson.module.kogera.deser.JavaToKotlinDurationConverter
17+
import io.github.projectmapk.jackson.module.kogera.deser.ValueClassDeserializer
1718
import io.github.projectmapk.jackson.module.kogera.hasCreatorAnnotation
1819
import io.github.projectmapk.jackson.module.kogera.isUnboxableValueClass
1920
import io.github.projectmapk.jackson.module.kogera.toSignature
@@ -91,20 +92,28 @@ internal object ULongDeserializer : StdDeserializer<ULong>(ULong::class.java) {
9192
internal class ValueClassBoxDeserializer<S, D : Any>(
9293
private val creator: Method,
9394
private val converter: ValueClassBoxConverter<S, D>
94-
) : StdDeserializer<D>(converter.valueClass) {
95+
) : ValueClassDeserializer<D>(converter.boxedClass) {
9596
private val inputType: Class<*> = creator.parameterTypes[0]
9697

9798
init {
9899
creator.apply { if (!this.isAccessible) this.isAccessible = true }
99100
}
100101

102+
// Cache the result of wrapping null, since the result is always expected to be the same.
103+
@get:JvmName("boxedNullValue")
104+
private val boxedNullValue: D by lazy { instantiate(null) }
105+
106+
override fun getBoxedNullValue(): D = boxedNullValue
107+
108+
// To instantiate the value class in the same way as other classes,
109+
// it is necessary to call creator(e.g. constructor-impl) -> box-impl in that order.
110+
// Input is null only when called from KotlinValueInstantiator.
111+
@Suppress("UNCHECKED_CAST")
112+
private fun instantiate(input: Any?): D = converter.convert(creator.invoke(null, input) as S)
113+
101114
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): D {
102115
val input = p.readValueAs(inputType)
103-
104-
// To instantiate the value class in the same way as other classes,
105-
// it is necessary to call creator(e.g. constructor-impl) -> box-impl in that order.
106-
@Suppress("UNCHECKED_CAST")
107-
return converter.convert(creator.invoke(null, input) as S)
116+
return instantiate(input)
108117
}
109118
}
110119

src/main/kotlin/io/github/projectmapk/jackson/module/kogera/deser/valueInstantiator/KotlinValueInstantiator.kt

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,20 @@ import com.fasterxml.jackson.databind.BeanDescription
55
import com.fasterxml.jackson.databind.DeserializationConfig
66
import com.fasterxml.jackson.databind.DeserializationContext
77
import com.fasterxml.jackson.databind.JavaType
8+
import com.fasterxml.jackson.databind.JsonDeserializer
89
import com.fasterxml.jackson.databind.JsonMappingException
910
import com.fasterxml.jackson.databind.deser.SettableBeanProperty
1011
import com.fasterxml.jackson.databind.deser.ValueInstantiator
11-
import com.fasterxml.jackson.databind.deser.impl.NullsAsEmptyProvider
1212
import com.fasterxml.jackson.databind.deser.impl.PropertyValueBuffer
1313
import com.fasterxml.jackson.databind.deser.std.StdValueInstantiator
14-
import com.fasterxml.jackson.databind.exc.MismatchedInputException
14+
import com.fasterxml.jackson.databind.exc.InvalidNullException
1515
import com.fasterxml.jackson.databind.module.SimpleValueInstantiators
1616
import io.github.projectmapk.jackson.module.kogera.ReflectionCache
17+
import io.github.projectmapk.jackson.module.kogera.deser.ValueClassDeserializer
1718
import io.github.projectmapk.jackson.module.kogera.deser.valueInstantiator.creator.ConstructorValueCreator
1819
import io.github.projectmapk.jackson.module.kogera.deser.valueInstantiator.creator.MethodValueCreator
1920
import io.github.projectmapk.jackson.module.kogera.deser.valueInstantiator.creator.ValueCreator
21+
import io.github.projectmapk.jackson.module.kogera.wrapsNullValueClass
2022
import java.lang.reflect.Constructor
2123
import java.lang.reflect.Executable
2224
import java.lang.reflect.Method
@@ -39,6 +41,15 @@ internal class KotlinValueInstantiator(
3941
private fun SettableBeanProperty.skipNulls(): Boolean =
4042
nullIsSameAsDefault || (metadata.valueNulls == Nulls.SKIP)
4143

44+
// If the argument is a value class that wraps nullable and non-null,
45+
// and the input is explicit null, the value class is instantiated with null as input.
46+
private fun requireValueClassSpecialNullValue(
47+
isNullableParam: Boolean,
48+
valueDeserializer: JsonDeserializer<*>?
49+
): Boolean = !isNullableParam &&
50+
valueDeserializer is ValueClassDeserializer<*> &&
51+
cache.getJmClass(valueDeserializer.handledType())!!.wrapsNullValueClass()
52+
4253
private val valueCreator: ValueCreator<*>? by ReflectProperties.lazySoft {
4354
val creator = _withArgsCreator.annotated as Executable
4455
val jmClass = cache.getJmClass(creator.declaringClass) ?: return@lazySoft null
@@ -65,38 +76,37 @@ internal class KotlinValueInstantiator(
6576
valueCreator.valueParameters.forEachIndexed { idx, paramDef ->
6677
val jsonProp = props[idx]
6778
val isMissing = !buffer.hasParameter(jsonProp)
68-
69-
if (isMissing && paramDef.isOptional) {
70-
return@forEachIndexed
71-
}
79+
val valueDeserializer: JsonDeserializer<*>? by lazy { jsonProp.valueDeserializer }
7280

7381
var paramVal = if (!isMissing || jsonProp.hasInjectableValueId()) {
74-
buffer.getParameter(jsonProp).apply {
75-
if (this == null && jsonProp.skipNulls() && paramDef.isOptional) return@forEachIndexed
82+
buffer.getParameter(jsonProp) ?: run {
83+
// Deserializer.getNullValue could not be used because there is no way to get and parse parameters
84+
// from the BeanDescription and using AnnotationIntrospector would override user customization.
85+
if (requireValueClassSpecialNullValue(paramDef.isNullable, valueDeserializer)) {
86+
(valueDeserializer as ValueClassDeserializer<*>).boxedNullValue?.let { return@run it }
87+
}
88+
89+
if (jsonProp.skipNulls() && paramDef.isOptional) return@forEachIndexed
90+
91+
null
7692
}
7793
} else {
78-
if (paramDef.isNullable) {
79-
// do not try to create any object if it is nullable and the value is missing
80-
null
81-
} else {
82-
// to get suitable "missing" value provided by deserializer
83-
jsonProp.valueDeserializer?.getAbsentValue(ctxt)
94+
when {
95+
paramDef.isOptional || paramDef.isVararg -> return@forEachIndexed
96+
// to get suitable "missing" value provided by nullValueProvider
97+
else -> jsonProp.nullValueProvider?.getAbsentValue(ctxt)
8498
}
8599
}
86100

87101
if (paramVal == null) {
88102
if (jsonProp.type.requireEmptyValue()) {
89-
paramVal = NullsAsEmptyProvider(jsonProp.valueDeserializer).getNullValue(ctxt)
103+
paramVal = valueDeserializer!!.getEmptyValue(ctxt)
90104
} else {
91105
val isMissingAndRequired = isMissing && jsonProp.isRequired
92106
if (isMissingAndRequired || !(paramDef.isNullable || paramDef.isGenericType)) {
93-
throw MismatchedInputException.from(
94-
ctxt.parser,
95-
jsonProp.type,
96-
"Instantiation of $valueTypeDesc value failed for JSON property ${jsonProp.name} " +
97-
"due to missing (therefore NULL) value for creator parameter ${paramDef.name} " +
98-
"which is a non-nullable type"
99-
).wrapWithPath(this.valueClass, jsonProp.name)
107+
throw InvalidNullException
108+
.from(ctxt, jsonProp.fullName, jsonProp.type)
109+
.wrapWithPath(this.valueClass, jsonProp.name)
100110
}
101111
}
102112
}

0 commit comments

Comments
 (0)