-
Notifications
You must be signed in to change notification settings - Fork 175
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #768 from k163377/deser-value-class
Added `value class` deserialization support.
- Loading branch information
Showing
43 changed files
with
13,166 additions
and
18 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
56 changes: 56 additions & 0 deletions
56
src/main/java/com/fasterxml/jackson/module/kotlin/WrapsNullableValueClassDeserializer.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
Oops, something went wrong.