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

Added value class deserialization support. #768

Merged
merged 21 commits into from
Feb 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ These Kotlin classes are supported with the following fields for serialization/d
* CharRange _(start, end)_
* LongRange _(start, end)_

Deserialization for `value class` is also supported since 2.17.
Please refer to [this page](./docs/value-class-support.md) for more information on using `value class`, including serialization.

(others are likely to work, but may not be tuned for Jackson)

# Sealed classes without @JsonSubTypes
Expand Down
134 changes: 134 additions & 0 deletions docs/value-class-handling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
This is a document that summarizes how `value class` is handled in `kotlin-module`.

# Annotation assigned to a property (parameter)
In `Kotlin`, annotations on properties will be assigned to the parameters of the primary constructor.
On the other hand, if the parameter contains a `value class`, this annotation will not work.
See #651 for details.

# Serialize
Serialization is performed as follows

1. If the value is unboxed in the getter of a property, re-box it
2. Serialization is performed by the serializer specified for the class or by the default serializer of `kotlin-module`

## Re-boxing of value
Re-boxing is handled by `KotlinAnnotationIntrospector#findSerializationConverter`.

The properties re-boxed here are handled as if the type of the getter was `value class`.
This allows the `JsonSerializer` specified for the mapper, class and property to work.

### Edge case on `value class` that wraps `null`
If the property is non-null and the `value class` that is the value wraps `null`,
then the value is re-boxed by `KotlinAnnotationIntrospector#findNullSerializer`.
This is the case for serializing `Dto` as follows.

```kotlin
@JvmInline
value class WrapsNullable(val v: String?)

data class Dto(val value: WrapsNullable = WrapsNullable(null))
```

In this case, features like the `JsonSerialize` annotation will not work as expected due to the difference in processing paths.

## Default serializers with `kotlin-module`
Default serializers for boxed values are implemented in `KotlinSerializers`.
There are two types: `ValueClassUnboxSerializer` and `ValueClassSerializer.StaticJsonValue`.

The former gets the value by unboxing and the latter by executing the method with the `JsonValue` annotation.
The serializer for the retrieved value is then obtained and serialization is performed.

# Deserialize
Deserialization is performed as follows

1. Get `KFunction` from a non-synthetic constructor (if the constructor is a creator)
2. If it is unboxed on a parameter, refine it to a boxed type
3. `value class` is deserialized by `Jackson` default handling or by `kotlin-module` deserializer
4. Instantiation is done by calling `KFunction`

The special `JsonDeserializer`, `WrapsNullableValueClassDeserializer`, is described in the [section on instantiation](#Instantiation).

## Get `KFunction` from non-synthetic constructor
Constructor with `value class` parameters compiles into a `private` non-synthesized constructor and a synthesized constructor.

A `KFunction` is inherently interconvertible with any constructor or method in a `Java` reflection.
In the case of a constructor with a `value class` parameter, it is the synthetic constructor that is interconvertible.

On the other hand, `Jackson` does not handle synthetic constructors.
Therefore, `kotlin-module` needs to get `KFunction` from a `private` non-synthetic constructor.

This acquisition process is implemented as a `valueClassAwareKotlinFunction` in `ReflectionCache.kt`.

## Refinement to boxed type
Refinement to a boxed type is handled by `KotlineNamesAnnotationIntrospector#refineDeserializationType`.
Like serialization, the parameters refined here are handled as if the type of the parameter was `value class`.

This will cause the result of reading from the `PropertyValueBuffer` with `ValueInstantiator#createFromObjectWith` to be the boxed value.

## Deserialization of `value class`
Deserialization of `value class` may be handled by default by `Jackson` or by `kotlin-module`.

### by `Jackson`
If a custom `JsonDeserializer` is set or a special `JsonCreator` is defined,
deserialization of the `value class` is handled by `Jackson` just like a normal class.
The special `JsonCreator` is a factory function that is configured to return the `value class` in bytecode.

The special `JsonCreator` is handled in exactly the same way as a regular class.
That is, it does not have the restrictions that the mode is fixed to `DELEGATING`
or that it cannot have multiple arguments.
This can be defined by setting the return value to `nullable`, for example

```kotlin
@JvmInline
value class PrimitiveMultiParamCreator(val value: Int) {
companion object {
@JvmStatic
@JsonCreator
fun creator(first: Int, second: Int): PrimitiveMultiParamCreator? =
PrimitiveMultiParamCreator(first + second)
}
}
```

### by `kotlin-module`
Deserialization using constructors or factory functions that return unboxed value in bytecode
is handled by the `WrapsNullableValueClassBoxDeserializer` that defined in `KotlinDeserializer.kt`.

They must always have a parameter size of 1, like `JsonCreator` with `DELEGATING` mode specified.
Note that the `kotlin-module` proprietary implementation raises an `InvalidDefinitionException`
if the parameter size is greater than 2.

## Instantiation
Instantiation by calling `KFunction` obtained from a constructor or factory function is done with `KotlinValueInstantiator#createFromObjectWith`.

Boxed values are required as `KFunction` arguments, but since the `value class` is read as a boxed value as described above,
basic processing is performed as in a normal class.
However, there is special processing for the edge case described below.

### Edge case on `value class` that wraps nullable
If the parameter type is `value class` and non-null, which wraps nullable, and the value on the JSON is null,
the wrapped null is expected to be read as the value.

```kotlin
@JvmInline
value class WrapsNullable(val value: String?)

data class Dto(val wrapsNullable: WrapsNullable)

val mapper = jacksonObjectMapper()

// serialized: {"wrapsNullable":null}
val json = mapper.writeValueAsString(Dto(WrapsNullable(null)))
// expected: Dto(wrapsNullable=WrapsNullable(value=null))
val deserialized = mapper.readValue<Dto>(json)
```

In `kotlin-module`, a special `JsonDeserializer` named `WrapsNullableValueClassDeserializer` was introduced to support this.
This deserializer has a `boxedNullValue` property,
which is referenced in `KotlinValueInstantiator#createFromObjectWith` as appropriate.

I considered implementing it with the traditional `JsonDeserializer#getNullValue`,
but I chose to implement it as a special property because of inconsistencies that could not be resolved
if all cases were covered in detail in the prototype.
Note that this property is referenced by `KotlinValueInstantiator#createFromObjectWith`,
so it will not work when deserializing directly.
160 changes: 160 additions & 0 deletions docs/value-class-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
`jackson-module-kotlin` supports many use cases of `value class` (`inline class`).
This page summarizes the basic policy and points to note regarding the use of the `value class`.

For technical details on `value class` handling, please see [here](./value-class-handling.md).

# Note on the use of `value class`
`jackson-module-kotlin` supports the `value class` for many common use cases, both serialization and deserialization.
However, full compatibility with normal classes (e.g. `data class`) is not achieved.
In particular, there are many edge cases for the `value class` that wraps nullable.

The cause of this difference is that the `value class` itself and the functions that use the `value class` are
compiled into bytecodes that differ significantly from the normal classes.
Due to this difference, some cases cannot be handled by basic `Jackson` parsing, which assumes `Java`.
Known issues related to `value class` can be found [here](https://github.com/FasterXML/jackson-module-kotlin/issues?q=is%3Aissue+is%3Aopen+label%3A%22value+class%22).

In addition, one of the features of the `value class` is improved performance,
but when using `Jackson` (not only `Jackson`, but also other libraries that use reflection),
the performance is rather reduced.
This can be confirmed from [kogera-benchmark](https://github.com/ProjectMapK/kogera-benchmark?tab=readme-ov-file#comparison-of-normal-class-and-value-class).

For these reasons, we recommend careful consideration when using `value class`.

# Basic handling of `value class`
A `value class` is basically treated like a value.

For example, the serialization of `value class` is as follows

```kotlin
@JvmInline
value class Value(val value: Int)

val mapper = jacksonObjectMapper()
mapper.writeValueAsString(Value(1)) // -> 1
```

This is different from the `data class` serialization result.

```kotlin
data class Data(val value: Int)

mapper.writeValueAsString(Data(1)) // -> {"value":1}
```

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`.

# Notes on customization
As noted above, the content associated with the `value class` is not fully compatible with the normal class.
Here is a summary of the customization considerations for such contents.

## Annotation
Annotations assigned to parameters in a primary constructor that contains `value class` as a parameter will not work.
It must be assigned to a field or getter.

```kotlin
data class Dto(
@JsonProperty("vc") // does not work
val p1: ValueClass,
@field:JsonProperty("vc") // does work
val p2: ValueClass
)
```

See #651 for details.

## On serialize
### JsonValue
The `JsonValue` annotation is supported.

```kotlin
@JvmInline
value class ValueClass(val value: UUID) {
@get:JsonValue
val jsonValue get() = value.toString().filter { it != '-' }
}

// -> "e5541a61ac934eff93516eec0f42221e"
mapper.writeValueAsString(ValueClass(UUID.randomUUID()))
```

### JsonSerializer
The `JsonSerializer` basically supports the following methods:
registering to `ObjectMapper`, giving the `JsonSerialize` annotation.
Also, although `value class` is basically serialized as a value,
but it is possible to serialize `value class` like an object by using `JsonSerializer`.

```kotlin
@JvmInline
value class ValueClass(val value: UUID)

class Serializer : StdSerializer<ValueClass>(ValueClass::class.java) {
override fun serialize(value: ValueClass, gen: JsonGenerator, provider: SerializerProvider) {
val uuid = value.value
val obj = mapOf(
"mostSignificantBits" to uuid.mostSignificantBits,
"leastSignificantBits" to uuid.leastSignificantBits
)

gen.writeObject(obj)
}
}

data class Dto(
@field:JsonSerialize(using = Serializer::class)
val value: ValueClass
)

// -> {"value":{"mostSignificantBits":-6594847211741032479,"leastSignificantBits":-5053830536872902344}}
mapper.writeValueAsString(Dto(ValueClass(UUID.randomUUID())))
```

Note that specification with the `JsonSerialize` annotation will not work
if the `value class` wraps null and the property definition is non-null.

## On deserialize
### JsonDeserializer
Like `JsonSerializer`, `JsonDeserializer` is basically supported.
However, it is recommended that `WrapsNullableValueClassDeserializer` be inherited and implemented as a
deserializer for `value class` that wraps nullable.

This deserializer is intended to make the deserialization result be a wrapped null if the parameter definition
is a `value class` that wraps nullable and non-null, and the value on the `JSON` is null.
An example implementation is shown below.

```kotlin
@JvmInline
value class ValueClass(val value: String?)

class Deserializer : WrapsNullableValueClassDeserializer<ValueClass>(ValueClass::class) {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): ValueClass {
TODO("Not yet implemented")
}

override fun getBoxedNullValue(): ValueClass = WRAPPED_NULL

companion object {
private val WRAPPED_NULL = ValueClass(null)
}
}
```

### JsonCreator
`JsonCreator` basically behaves like a `DELEGATING` mode.
Note that defining a creator with multiple arguments will result in a runtime error.

As a workaround, a factory function defined in bytecode with a return value of `value class` can be deserialized in the same way as a normal creator.

```kotlin
@JvmInline
value class PrimitiveMultiParamCreator(val value: Int) {
companion object {
@JvmStatic
@JsonCreator
fun creator(first: Int, second: Int): PrimitiveMultiParamCreator? =
PrimitiveMultiParamCreator(first + second)
}
}
```
1 change: 1 addition & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@
<exclude>com.fasterxml.jackson.module.kotlin.ConstructorValueCreator</exclude>
<exclude>com.fasterxml.jackson.module.kotlin.MethodValueCreator</exclude>
<exclude>com.fasterxml.jackson.module.kotlin.TypesKt</exclude>
<exclude>com.fasterxml.jackson.module.kotlin.KotlinDeserializers</exclude>
<exclude>com.fasterxml.jackson.module.kotlin.ExtensionsKt#isUnboxableValueClass(java.lang.Class)</exclude>
<exclude>com.fasterxml.jackson.module.kotlin.ExtensionsKt#toBitSet(int)</exclude>
<exclude>com.fasterxml.jackson.module.kotlin.ExtensionsKt#wrapWithPath(com.fasterxml.jackson.databind.JsonMappingException,java.lang.Object,java.lang.String)</exclude>
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.17.0 (not yet released)

WrongWrong (@k163377)
* #768: Added value class deserialization support.
* #763: Minor refactoring to support value class in deserialization.
* #760: Improved processing related to parameter parsing on Kotlin.
* #759: Organize internal commons.
Expand Down
1 change: 1 addition & 0 deletions release-notes/VERSION-2.x
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Co-maintainers:

2.17.0 (not yet released)

#768: Added value class deserialization support.
#760: Caching is now applied to the entire parameter parsing process on Kotlin.
#758: Deprecated SingletonSupport and related properties to be consistent with KotlinFeature.SingletonSupport.
#755: Changes in constructor invocation and argument management.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.fasterxml.jackson.module.kotlin;

import com.fasterxml.jackson.core.JacksonException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import kotlin.jvm.JvmClassMappingKt;
import kotlin.reflect.KClass;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.io.IOException;

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

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

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

protected WrapsNullableValueClassDeserializer(@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.
* Note that this will only be called from the KotlinValueInstantiator,
* so it will not work for top-level deserialization of value classes.
*/
// It is defined so that null can also be returned so that Nulls.SKIP can be applied.
@Nullable
public abstract D getBoxedNullValue();

@Override
public abstract D deserialize(@NotNull JsonParser p, @NotNull DeserializationContext ctxt)
throws IOException, JacksonException;
}
Loading