Skip to content

Commit

Permalink
Merge pull request #768 from k163377/deser-value-class
Browse files Browse the repository at this point in the history
Added `value class` deserialization support.
  • Loading branch information
k163377 authored Feb 18, 2024
2 parents e306b8d + 71560e6 commit aaf04ab
Show file tree
Hide file tree
Showing 43 changed files with 13,166 additions and 18 deletions.
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

0 comments on commit aaf04ab

Please sign in to comment.