Search code examples
jsonkotlinjackson-databind

Implicitly convert a Number to a more complex type


In my project, I used to have a data class similar to this:

data class SampleClassThatContainsALength(val length: Double)

Using jackson-databind, SampleClassThatContainsALength(0.025) would serialize to {"length":0.025}.

Throughout the project, lengths were implicitly assumed to be in meters, but to reduce ambiguities, I implemented a Length class, which takes care of units:

data class Length(val m: Double) : Comparable<Length> {

    @get:JsonIgnore
    val km: Double get() = m / 1e3

    @get:JsonIgnore
    val cm: Double get() = m / 1e-2

    @get:JsonIgnore
    val mm: Double get() = m / 1e-3

    // More conversions to other units...

    operator fun plus(other: Length) = (this.m + other.m).m
    operator fun minus(other: Length) = (this.m - other.m).m
    operator fun unaryMinus() = (-this.m).m

    // More operator implementations...
}

val Number.km get() = Length(this.toDouble() * 1e3)
val Number.m get() = Length(this.toDouble())
val Number.cm get() = Length(this.toDouble() * 1e-2)
val Number.mm get() = Length(this.toDouble() * 1e-3)
// More extension functions for other units...

The data class thus looks like this now:

data class SampleClassThatContainsALength(val length: Length)

And the example object SampleClassThatContainsALength(0.025.m) serializes to {"length":{"m":0.025}}.

I now have the issue that I want to use the new serialization, as it clearly states the unit, but I still have some old JSON-documents (similar to the serialized JSON at the beginning) which I would like to convert implicitly to the new implementation.

So, in summary, I want both, {"length":0.025} and {"length":{"m":0.025}} to deserialize to SampleClassThatContainsALength(0.025.m).

Right now, the deserialization of {"length":{"m":0.025}} works as expected, but the deserialization of {"length":0.025} fails with

com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot construct instance of `de.uni_freiburg.inatech.streem.image_converter.common.math.units.Length` (although at least one Creator exists): no double/Double-argument constructor/factory method to deserialize from Number value (0.025)
 at [Source: (String)"{"length":0.025}"; line: 1, column: 11] (through reference chain: de.uni_freiburg.inatech.streem.image_converter.data.json.SampleClassThatContainsALength["length"])

Here's what I have attempted so far:

  1. I created a companion object in the Length class and added the following factory method:
companion object {
    @JsonCreator
    @JvmStatic
    fun createFromNumber(number: Number): Length = Length(number.toDouble())
}

This causes the deserialization of {"length":0.025} to work, but now the deserialization of the new format no-longer works:

com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot deserialize value of type `java.lang.Number` from Object value (token `JsonToken.FIELD_NAME`)
 at [Source: (byte[])[19 bytes]; byte offset: #7]
  1. In addition to the factory method in the companion object, I attempted to also mark the primary constructor of Length with @JsonCreator like so:
data class Length @JsonCreator constructor(val m: Double) : Comparable<Length> {

but the results were exactly the same as with attempt 1.

  1. Instead of the factory method, I added a secondary constructor that accepted a Number (instead of a Double):
data class Length(val m: Double) : Comparable<Length> {
    constructor(m: Number) : this(m.toDouble())

While the new format was now working, the old format was still broken.


Solution

  • Wow, that went faster than I thought, and I'm a little ashamed I haven't found this before posting. Nevertheless, here's the solution for anyone with the same problem:

    1. Leave the constructor as it is:
    // No annotation and no secondary constructor necessary
    data class Length(val m: Double) : Comparable<Length> {
    // ...
    
    1. Create two factory methods for both cases:
    companion object {
        // Handles the case {"length":{"m":0.025}}
        @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
        @JvmStatic
        fun createFromObject(m: Double): Length = Length(m)
    
        // Handles the case {"length":0.025}
        @JsonCreator(mode = JsonCreator.Mode.DELEGATING)
        @JvmStatic
        fun createFromNumber(m: String): Length = Length(m.toDouble())
    }
    

    Note that createFromNumber now takes a String and not a Number or Double, which seems to be important.

    Here's what inspired me to do that:

    https://github.com/FasterXML/jackson-databind/issues/2353