Skip to content

Commit

Permalink
Merge pull request #198 from ProjectMapK/develop
Browse files Browse the repository at this point in the history
Release 2023-12-20 12:53:18 +0000
  • Loading branch information
k163377 authored Dec 21, 2023
2 parents ddebf90 + a8bb30d commit f7768d3
Show file tree
Hide file tree
Showing 22 changed files with 525 additions and 176 deletions.
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ val jacksonVersion = libs.versions.jackson.get()
val generatedSrcPath = "${layout.buildDirectory.get()}/generated/kotlin"

group = groupStr
version = "${jacksonVersion}-beta8"
version = "${jacksonVersion}-beta9"

repositories {
mavenCentral()
Expand Down
23 changes: 23 additions & 0 deletions docs/AboutValueClassSupport.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,29 @@ The same policy applies to deserialization.
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).
However, these are just basic policies, and the behavior can be overridden with `JsonSerializer` or `JsonDeserializer`.

### Handling of `value class` that wraps nullable
When deserializing a `value class` that wraps a nullable as a parameter, if the input is `null`,
there is a problem in determining whether the value should be `null` or wrapped.
`Kogera` provides special handling of such cases to make the behavior as intuitive as possible.
Note that such handling is applied only when the input is `null`, not when it is `undefined`.

First, it tries to use the `nullValue` set in the deserializer, regardless of the nullability of the parameter.
This is the behavior defined by `Jackson` and is difficult to change.

If the value retrieved here is `null`, the behavior will diverge depending on the nullability of the parameter.

If the parameter is defined as non-null, then `ValueClassDeserializer.boxedNullValue` is used.
By default, this will be a wrapped `null` (cached value after the second time).

The `ValueClassDeserializer` is a `StdDeserializer` defined by `Kogera` to handle such cases.
You can also set your own `boxedNullValue` by inheriting from it.
Note that this class is defined in `Java` for compatibility.

If a parameter is defined as nullable, it will be `null`.

Finally, if the retrieved value is `null`, a `Nulls.SKIP` decision is made.
However, the call with the default argument will fail until #51 is resolved.

### Serialization performance improvement using `JsonUnbox`
In `jackson-module-kogera`, the `jackson` functionality is modified by reflection so that the `Jackson` functionality works for `value class` as well.
These are executed on all calls.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package io.github.projectmapk.jackson.module.kogera.deser;

import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import io.github.projectmapk.jackson.module.kogera.deser.deserializers.ValueClassBoxDeserializer;
import kotlin.jvm.JvmClassMappingKt;
import kotlin.reflect.KClass;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
* An interface to be inherited by JsonDeserializer that handles value classes that may wrap nullable.
* @see ValueClassBoxDeserializer for implementation.
*/
// To ensure maximum compatibility with StdDeserializer, this class is defined in Java.
public abstract class ValueClassDeserializer<D> extends StdDeserializer<D> {
protected ValueClassDeserializer(@NotNull KClass<?> vc) {
super(JvmClassMappingKt.getJavaClass(vc));
}

protected ValueClassDeserializer(@NotNull Class<?> vc) {
super(vc);
}

protected ValueClassDeserializer(@NotNull JavaType valueType) {
super(valueType);
}

protected ValueClassDeserializer(@NotNull StdDeserializer<D> src) {
super(src);
}

@Override
@NotNull
public final Class<D> handledType() {
//noinspection unchecked
return (Class<D>) super.handledType();
}

/**
* If the parameter definition is a value class that wraps a nullable and is non-null,
* and the input to JSON is explicitly null, this value is used.
*/
// It is defined so that null can also be returned so that Nulls.SKIP can be applied.
@Nullable
public abstract D getBoxedNullValue();
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import com.fasterxml.jackson.databind.util.StdConverter
*/
internal class ValueClassBoxConverter<S : Any?, D : Any>(
unboxedClass: Class<S>,
val valueClass: Class<D>
val boxedClass: Class<D>
) : StdConverter<S, D>() {
private val boxMethod = valueClass.getDeclaredMethod("box-impl", unboxedClass).apply {
private val boxMethod = boxedClass.getDeclaredMethod("box-impl", unboxedClass).apply {
if (!this.isAccessible) this.isAccessible = true
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,19 @@ public fun jsonMapper(initializer: JsonMapper.Builder.() -> Unit = {}): JsonMapp
return builder.build()
}

public fun jacksonObjectMapper(): ObjectMapper = jsonMapper { addModule(kotlinModule()) }
public fun jacksonMapperBuilder(): JsonMapper.Builder = JsonMapper.builder().addModule(kotlinModule())

public fun ObjectMapper.registerKotlinModule(): ObjectMapper = this.registerModule(kotlinModule())
// region: JvmOverloads is set for bytecode compatibility for versions below 2.17.
@JvmOverloads
public fun jacksonObjectMapper(initializer: KotlinModule.Builder.() -> Unit = {}): ObjectMapper =
jsonMapper { addModule(kotlinModule(initializer)) }

@JvmOverloads
public fun jacksonMapperBuilder(initializer: KotlinModule.Builder.() -> Unit = {}): JsonMapper.Builder =
JsonMapper.builder().addModule(kotlinModule(initializer))

@JvmOverloads
public fun ObjectMapper.registerKotlinModule(initializer: KotlinModule.Builder.() -> Unit = {}): ObjectMapper =
this.registerModule(kotlinModule(initializer))
// endregion

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package io.github.projectmapk.jackson.module.kogera
import com.fasterxml.jackson.annotation.JsonCreator
import kotlinx.metadata.KmClassifier
import kotlinx.metadata.KmType
import kotlinx.metadata.KmValueParameter
import kotlinx.metadata.isNullable
import kotlinx.metadata.jvm.JvmMethodSignature
import java.lang.reflect.AnnotatedElement
import java.lang.reflect.Constructor
Expand All @@ -14,6 +14,9 @@ internal typealias KotlinDuration = kotlin.time.Duration

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

// JmClass must be value class.
internal fun JmClass.wrapsNullValueClass() = inlineClassUnderlyingType!!.isNullable

private val primitiveClassToDesc = mapOf(
Byte::class.javaPrimitiveType to 'B',
Char::class.javaPrimitiveType to 'C',
Expand Down Expand Up @@ -53,9 +56,6 @@ internal fun Constructor<*>.toSignature(): JvmMethodSignature =
internal fun Method.toSignature(): JvmMethodSignature =
JvmMethodSignature(this.name, parameterTypes.toDescBuilder().appendDescriptor(this.returnType).toString())

internal fun List<KmValueParameter>.hasVarargParam(): Boolean =
lastOrNull()?.let { it.varargElementType != null } ?: false

internal val defaultConstructorMarker: Class<*> by lazy {
Class.forName("kotlin.jvm.internal.DefaultConstructorMarker")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import io.github.projectmapk.jackson.module.kogera.reconstructClassOrNull
import io.github.projectmapk.jackson.module.kogera.ser.KotlinDurationValueToJavaDurationConverter
import io.github.projectmapk.jackson.module.kogera.ser.KotlinToJavaDurationConverter
import io.github.projectmapk.jackson.module.kogera.ser.SequenceToIteratorConverter
import io.github.projectmapk.jackson.module.kogera.wrapsNullValueClass
import kotlinx.metadata.KmTypeProjection
import kotlinx.metadata.KmValueParameter
import kotlinx.metadata.isNullable
Expand Down Expand Up @@ -100,8 +101,7 @@ internal class KotlinFallbackAnnotationIntrospector(

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

// Perform proper serialization even if the value wrapped by the value class is null.
// If value is a non-null object type, it must not be reboxing.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ internal class KotlinPrimaryAnnotationIntrospector(
paramDef.type.isNullable -> false
// Default argument are defined
paramDef.declaresDefaultValue -> false
// vararg is treated as an empty array because undefined input is allowed
paramDef.varargElementType != null -> false
// The conversion in case of null is defined.
type.hasDefaultEmptyValue() -> false
else -> true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import io.github.projectmapk.jackson.module.kogera.KotlinDuration
import io.github.projectmapk.jackson.module.kogera.ReflectionCache
import io.github.projectmapk.jackson.module.kogera.ValueClassBoxConverter
import io.github.projectmapk.jackson.module.kogera.deser.JavaToKotlinDurationConverter
import io.github.projectmapk.jackson.module.kogera.deser.ValueClassDeserializer
import io.github.projectmapk.jackson.module.kogera.hasCreatorAnnotation
import io.github.projectmapk.jackson.module.kogera.isUnboxableValueClass
import io.github.projectmapk.jackson.module.kogera.toSignature
Expand Down Expand Up @@ -91,20 +92,28 @@ internal object ULongDeserializer : StdDeserializer<ULong>(ULong::class.java) {
internal class ValueClassBoxDeserializer<S, D : Any>(
private val creator: Method,
private val converter: ValueClassBoxConverter<S, D>
) : StdDeserializer<D>(converter.valueClass) {
) : ValueClassDeserializer<D>(converter.boxedClass) {
private val inputType: Class<*> = creator.parameterTypes[0]

init {
creator.apply { if (!this.isAccessible) this.isAccessible = true }
}

// Cache the result of wrapping null, since the result is always expected to be the same.
@get:JvmName("boxedNullValue")
private val boxedNullValue: D by lazy { instantiate(null) }

override fun getBoxedNullValue(): D = boxedNullValue

// To instantiate the value class in the same way as other classes,
// it is necessary to call creator(e.g. constructor-impl) -> box-impl in that order.
// Input is null only when called from KotlinValueInstantiator.
@Suppress("UNCHECKED_CAST")
private fun instantiate(input: Any?): D = converter.convert(creator.invoke(null, input) as S)

override fun deserialize(p: JsonParser, ctxt: DeserializationContext): D {
val input = p.readValueAs(inputType)

// To instantiate the value class in the same way as other classes,
// it is necessary to call creator(e.g. constructor-impl) -> box-impl in that order.
@Suppress("UNCHECKED_CAST")
return converter.convert(creator.invoke(null, input) as S)
return instantiate(input)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,20 @@ 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.JsonDeserializer
import com.fasterxml.jackson.databind.JsonMappingException
import com.fasterxml.jackson.databind.deser.SettableBeanProperty
import com.fasterxml.jackson.databind.deser.ValueInstantiator
import com.fasterxml.jackson.databind.deser.impl.NullsAsEmptyProvider
import com.fasterxml.jackson.databind.deser.impl.PropertyValueBuffer
import com.fasterxml.jackson.databind.deser.std.StdValueInstantiator
import com.fasterxml.jackson.databind.exc.MismatchedInputException
import com.fasterxml.jackson.databind.exc.InvalidNullException
import com.fasterxml.jackson.databind.module.SimpleValueInstantiators
import io.github.projectmapk.jackson.module.kogera.ReflectionCache
import io.github.projectmapk.jackson.module.kogera.deser.ValueClassDeserializer
import io.github.projectmapk.jackson.module.kogera.deser.valueInstantiator.creator.ConstructorValueCreator
import io.github.projectmapk.jackson.module.kogera.deser.valueInstantiator.creator.MethodValueCreator
import io.github.projectmapk.jackson.module.kogera.deser.valueInstantiator.creator.ValueCreator
import io.github.projectmapk.jackson.module.kogera.wrapsNullValueClass
import java.lang.reflect.Constructor
import java.lang.reflect.Executable
import java.lang.reflect.Method
Expand All @@ -39,6 +41,15 @@ internal class KotlinValueInstantiator(
private fun SettableBeanProperty.skipNulls(): Boolean =
nullIsSameAsDefault || (metadata.valueNulls == Nulls.SKIP)

// If the argument is a value class that wraps nullable and non-null,
// and the input is explicit null, the value class is instantiated with null as input.
private fun requireValueClassSpecialNullValue(
isNullableParam: Boolean,
valueDeserializer: JsonDeserializer<*>?
): Boolean = !isNullableParam &&
valueDeserializer is ValueClassDeserializer<*> &&
cache.getJmClass(valueDeserializer.handledType())!!.wrapsNullValueClass()

private val valueCreator: ValueCreator<*>? by ReflectProperties.lazySoft {
val creator = _withArgsCreator.annotated as Executable
val jmClass = cache.getJmClass(creator.declaringClass) ?: return@lazySoft null
Expand All @@ -65,38 +76,39 @@ internal class KotlinValueInstantiator(
valueCreator.valueParameters.forEachIndexed { idx, paramDef ->
val jsonProp = props[idx]
val isMissing = !buffer.hasParameter(jsonProp)

if (isMissing && paramDef.isOptional) {
return@forEachIndexed
}
val valueDeserializer: JsonDeserializer<*>? by lazy { jsonProp.valueDeserializer }

var paramVal = if (!isMissing || jsonProp.hasInjectableValueId()) {
buffer.getParameter(jsonProp).apply {
if (this == null && jsonProp.skipNulls() && paramDef.isOptional) return@forEachIndexed
buffer.getParameter(jsonProp) ?: run {
// Deserializer.getNullValue could not be used because there is no way to get and parse parameters
// from the BeanDescription and using AnnotationIntrospector would override user customization.
if (requireValueClassSpecialNullValue(paramDef.isNullable, valueDeserializer)) {
(valueDeserializer as ValueClassDeserializer<*>).boxedNullValue?.let { return@run it }
}

if (jsonProp.skipNulls() && paramDef.isOptional) return@forEachIndexed

null
}
} else {
if (paramDef.isNullable) {
when {
paramDef.isOptional || paramDef.isVararg -> return@forEachIndexed
// do not try to create any object if it is nullable and the value is missing
null
} else {
paramDef.isNullable -> null
// to get suitable "missing" value provided by deserializer
jsonProp.valueDeserializer?.getAbsentValue(ctxt)
else -> valueDeserializer?.getAbsentValue(ctxt)
}
}

if (paramVal == null) {
if (jsonProp.type.requireEmptyValue()) {
paramVal = NullsAsEmptyProvider(jsonProp.valueDeserializer).getNullValue(ctxt)
paramVal = valueDeserializer!!.getEmptyValue(ctxt)
} else {
val isMissingAndRequired = isMissing && jsonProp.isRequired
if (isMissingAndRequired || !(paramDef.isNullable || paramDef.isGenericType)) {
throw MismatchedInputException.from(
ctxt.parser,
jsonProp.type,
"Instantiation of $valueTypeDesc value failed for JSON property ${jsonProp.name} " +
"due to missing (therefore NULL) value for creator parameter ${paramDef.name} " +
"which is a non-nullable type"
).wrapWithPath(this.valueClass, jsonProp.name)
throw InvalidNullException
.from(ctxt, jsonProp.fullName, jsonProp.type)
.wrapWithPath(this.valueClass, jsonProp.name)
}
}
}
Expand Down
Loading

0 comments on commit f7768d3

Please sign in to comment.