jackson-module-kotlin icon indicating copy to clipboard operation
jackson-module-kotlin copied to clipboard

Support for inline classes

Open jnizet opened this issue 6 years ago • 22 comments

Kotlin 1.3 has experimental inline classes.

When wrapping a primitive type, an inline class instance is just compiled to the primitive type, making the code very efficient, whie still keeping it typesafe and preventing for example to sum Watts with Volts.

When the inline class instance is nullable, a wrapper class is used (just like java.lang.Integer is used to represent a nullable Int).

Unfortunately, inline classes don't follow the same rules as standard wrapper classes. Example:

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule

inline class Watt(val value: Long)

data class Foo(val nonNullable: Watt, val nullable: Watt?, val otherNullable: Watt?)
data class Bar(val nonNullable: Long, val nullable: Long?, val otherNullable: Long?)

fun main() {
    val foo = Foo(Watt(1000), Watt(2000), null)
    val bar = Bar(1000, 2000, null)

    val objectMapper = ObjectMapper().registerModule(KotlinModule())

    println("With Watt: ${objectMapper.writeValueAsString(foo)}")
    println("With Long: ${objectMapper.writeValueAsString(bar)}")
}

The output of this program is:

With Watt: {"nonNullable":1000,"nullable":{"value":2000},"otherNullable":null}
With Long: {"nonNullable":1000,"nullable":2000,"otherNullable":null}

It would be really nice if the first output was identical to the second one, and of course if unmarshalling worked too.

jnizet avatar Dec 14 '18 09:12 jnizet

I use this workaround to read and write inline classes. The box-impl method boxes the value, so it can be used as JsonCreator. https://github.com/Kotlin/KEEP/blob/master/proposals/inline-classes.md#inline-classes-abi-jvm

inline class Watt(@JsonValue val value: Long)

@Test
fun `Read and write inline classes with Jackson`() {
  val mapper = ObjectMapper().registerModule(KotlinModule()).registerModule(InlineModule)
  val watt = Watt(1234)
  val json = mapper.writeValueAsString(watt)
  assertEquals("1234", json)
  val result = mapper.readValue<Watt>(json)
  assertEquals(watt, result)
}

object InlineModule : SimpleModule("Inline") {
  override fun setupModule(context: SetupContext) {
    super.setupModule(context)
    context.appendAnnotationIntrospector(KotlinNamesAnnotationIntrospector)
  }

  object InlineAnnotationIntrospector : NopAnnotationIntrospector() {
    override fun findCreatorAnnotation(config: MapperConfig<*>, a: Annotated): JsonCreator.Mode? {
      if (a is AnnotatedMethod && a.name == "box-impl") {
        return JsonCreator.Mode.DEFAULT
      }
      return null
    }
  }
}

TjeuKayim avatar Dec 31 '18 14:12 TjeuKayim

@TjeuKayim, is serialization working with inline classes now?

kostya05983 avatar Oct 24 '19 06:10 kostya05983

I tried building the latest master branch, however the build fails.

image

Instead I used 7bfb771be392073d6580f6a5102c035a4bcfaae9. I ran these unit-tests, and they all failed.

image

So inline classes are still not working in the latest release.

TjeuKayim avatar Oct 24 '19 12:10 TjeuKayim

Please do not use master as that is for 3.0.0 (and still has couple of issues) -- code for 2.10 is in 2.10 branch (for patches to 2.10), and 2.11 is for developing next 2.x minor version.

cowtowncoder avatar Oct 24 '19 17:10 cowtowncoder

This would have to be studied to see how an inline class would be detected safely and without fail, I'm not sure if just the naming convention of the static constructor is clear enough. Maybe if detecting is Kotlin class, plus that naming convention, and a pattern of other methods maybe.

apatrida avatar Oct 26 '19 06:10 apatrida

I think this would be a pretty messy fix, but is worth looking into. @TjeuKayim did you abandon that PR completely?

apatrida avatar Oct 26 '19 06:10 apatrida

Unfortunately, I can't spend more time on this, and currently I'm not using Kotlin for any project. There has to be changed a lot: https://github.com/TjeuKayim/jackson-module-kotlin/compare/master...TjeuKayim:class-descriptor-reflection. I just found out that reflection functionality has improved since Kotlin 1.3.20 (https://github.com/Kotlin/KEEP/blob/master/proposals/inline-classes.md#reflection).

TjeuKayim avatar Oct 26 '19 07:10 TjeuKayim

This would have to be studied to see how an inline class would be detected safely @apatrida

I used kotlin.reflect.jvm.internal.impl.descriptorsClassDescriptor.isInline, but this is an internal API.

TjeuKayim avatar Oct 26 '19 12:10 TjeuKayim

Has there been any updates to this? Or workaround code samples? @TjeuKayim, do you have any compartmentalized samples?

StephenOTT avatar May 11 '20 22:05 StephenOTT

No, I don't have any updates. The last few months I have not used Kotlin. For a workaround I refer to my comment above https://github.com/FasterXML/jackson-module-kotlin/issues/199#issuecomment-450650440.

TjeuKayim avatar May 12 '20 07:05 TjeuKayim

@TjeuKayim thanks. The greater issue i have found is: https://github.com/FasterXML/jackson-module-kotlin/issues/187. Once using the inline within a class, then it seems to fall apart on deserialization.

StephenOTT avatar May 12 '20 14:05 StephenOTT

Found a decent workaround today. Use @JsonDeserialize(builder = BuilderForYourRealClass::class)

Where BuilderForYourRealClass has "with" functions, one for each argument. The key is that you need a "with" method for the wrapped class. For example if I have an inline class MyInlineClass that simply wraps a String then the builder needs a function that accepts a String and creates an instance of MyInlineClass in the build function. Like this:


@JsonDeserialize(builder = MyClassThatUsesAnInlineClassBuilder::class)
data class MyClassThatUsesAnInlineClass(val foo: String, val bar: MyInlineClass)

class MyClassThatUsesAnInlineClassBuilder {
    lateinit var foo: String
    lateinit var bar: String
    fun withFoo(foo: String) = apply { this.foo = foo }
    fun withBar(bar: String) = apply { this.bar = bar }
    fun build() = MyClassThatUsesAnInlineClass(foo, MyInlineClass(bar))
}

efenderbosch avatar May 22 '20 17:05 efenderbosch

Another possible way would be to use JsonCreator:

inline class InlineType(val value: String)

data class ContainsInlineType(val inlineType: InlineType) {
    companion object {
        @JsonCreator
        @JvmStatic
        fun create(inlineType: String) = ContainsInlineType(InlineType(inlineType))
    }
}

wem avatar Feb 19 '21 16:02 wem

@TjeuKayim Info: Your InlineAnnotationIntrospector Workaround does not seem to work with Kotlin 1.5. "box-impl" is now synthetic and jackson does not call the Introspector for it...

JsonCreators or Builder would not be a workaround for me either because they have to be added to every class that uses the inline class... This clutters all DTOs...

mvonrenteln avatar May 25 '21 09:05 mvonrenteln

@k163377 Do you have a plan for value class deserialization support? With the stabilization of more Kotlin standard library components that utilize value classes such as kotlin.time.Duration, this is becoming a pain point for us. Wanted to check with you since you worked on the serialization support. If you don't have plans to work on this soon I may prototype the feature myself for a potential PR.

Quantum64 avatar Dec 30 '21 04:12 Quantum64

@Quantum64 As for the partial support, I was going to start with the answer to #514.

As far as I have researched, I think the following two points are relatively easy to achieve.

  1. deserialize value class
  2. deserialize with a factory function (with JsonCreator) that includes value class in arguments.

However, there are still some unresolved issues regarding the deserialization of constructor containing value class. Also, support for JsonDeserialize annotations has not yet been considered, but I feel that there may be some issues that are difficult to resolve.

p.s. As a personal request, I would like to merge #512 and subsequent refactors first to avoid conflicts and to simplify changes for value class support.

k163377 avatar Dec 30 '21 07:12 k163377

@k163377 Looking at #512 now…✅

dinomite avatar Jan 02 '22 21:01 dinomite

In the process of checking for support, we found that there was still a problem with KFunction that takes a nullable value class as a parameter. It looks like it will be difficult to support value class until this issue is resolved. https://youtrack.jetbrains.com/issue/KT-50685

I was also concerned about the fact that this problem has been persisting since Kotlin 1.4.x. I fear that if nothing is done, this problem will not be solved during Kotlin 1.6.x (jackson-module-kotlin 2.14.x).

I know it is difficult, but I will also check if I can solve the problem on the Kotlin side.

k163377 avatar Jan 13 '22 02:01 k163377

As for the problem I mentioned before that constructor that takes value class as an argument cannot be called, it seems to be fixed by doing the following branch. https://github.com/k163377/jackson-module-kotlin/tree/github-413/get_constructor_backup

However, I am afraid that incorporating this fix will cause confusion because it will result in a situation where the deserialization of the value class will work halfway (there are many parts that do not work). Personally, I think it is better to incorporate this change after doing all of the following (I also hope that the aforementioned kotlin-reflect problem will be solved while making the changes).

  • Merge #538
  • Merge PRs that follow #538
  • Add a deserializer for value class
  • Modify argument reading of KVI to match the case where the argument is a value class

@dinomite What do you think about the above revision policy? As for myself, I am willing to incorporate this change in my policy that "the more some of it works, the better". I would appreciate your advice.

k163377 avatar Jan 16 '22 04:01 k163377

Sharing the status of the kotlin-reflect fix.

The following problems with calls to Functions that containing value class in arguments have been fixed.

However, these fixes are scheduled for release after Kotlin 1.7, and the jackson-module-kotlin fixes will come even later.

Finally, a huge thanks to @udalov for his great support of these fixes.

k163377 avatar Mar 17 '22 11:03 k163377

Any news on this topic? Inline classes are a very appealing feature to me, but not being able to use them in Jackson is a big deal...

MartinHaeusler avatar Jul 05 '22 15:07 MartinHaeusler

Unfortunately there the maintainers of this module appear to be currently busy with other projects, help wanted and so on. :-/

You may want to reach out on jackson-dev mailing list to see if someone from the dev community might step up to look into issues... but this is a challenge with OSS. So many users and -- sometimes -- so little availability by developers. I would try to help but unfortunately am not very knowledgeable about Kotlin runtime or the module itself.

cowtowncoder avatar Jul 06 '22 21:07 cowtowncoder

Inline (value class) classes have become a major language feature also supporting generics thus it'd be great if Jackson would support them instead of throwing an exception:

Caused by: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot find a (Map) Key deserializer for type [simple type, class at.wrwks.bef.faelligkeit.domain.aufgabe.AufgabeId]

andrashatvani avatar Oct 13 '22 13:10 andrashatvani

@andrashatvani PRs welcome.

cowtowncoder avatar Oct 14 '22 04:10 cowtowncoder

Is it the same as https://github.com/FasterXML/jackson-module-kotlin/issues/413, but the latest version of Kotlin doesn't support inline class anymore

dmitryvim avatar Oct 19 '22 08:10 dmitryvim

I would like to try and take a stab at this, but I think I need a bit of guidance. @k163377 you mentioned

there are many parts [in the deserialization of the value class] that do not work

Do we have tests or examples that could give some coverage of those cases? Also those PRs to be merged before make it harder for a first time contributor to understand the scope of changes, would those be a requirement or nice to haves?

bugflux avatar Oct 19 '22 12:10 bugflux

kotlin-reflect 1.7 is almost mandatory to support value class deserialization. On the other hand, the current jackson-module-kotlin policy does not allow upgrading to kotlin 1.7 until kotlin 1.6 is deprecated. This means that you will have to wait a few more years.

Below is a summary of as much as I can remember of the work that needs to be done and the patterns that need to be covered, but I'm not sure if I've covered it all. Please note that each task requires knowledge of how the value class is represented in Java.

Required work

  1. https://github.com/k163377/jackson-module-kotlin/commit/ca2120b3576c47aecec1d38347d6823abd2d4268
  2. implement deserialization process for value class (probably by adding it to KotlinDeserializers)
  3. modify KotlinValueInstantiator to properly deserialize the value class argument

Note that ideally, Jackson annotations should also be handled properly.

Patterns that need to be covered

All combinations of the following patterns must be covered.

If the value of value class is...

  • primitive type
  • non-null object type
  • nullable object type

If the parameter on the Creator is...

  • non-null
  • nullable

Also note that ideally, options such as nullToEmptyCollection, nullIsSameAsDefault, and strictNullChecks should be supported.


I am very busy these days and have no time to work on this problem. If I had the time and money, I would like to work on a project to rewrite jackson-module-kotlin to my liking...

k163377 avatar Oct 19 '22 16:10 k163377

I have achieved partial support for deserialization by functions containing value class in an experimental project I am creating. It is assumed that deserialization of the value class will work, at least if it was registered a Deserializer with ObjectMapper. https://github.com/ProjectMapK/jackson-module-kogera/pull/40

Snapshots are available from JitPack. https://jitpack.io/#ProjectMapK/jackson-module-kogera/8dc5c4abda Please refer to the following for installation instructions. https://github.com/ProjectMapK/jackson-module-kogera#installation

However, this project does not use kotlin-reflect, so it is not possible to apply it directly to jackson-module-kotlin. Also, there are some known issues as follows

  • The JsonDeserialize annotation does not work
  • JsonCreator annotation given in value class does not work

I would appreciate a star to keep me motivated. https://github.com/ProjectMapK/jackson-module-kogera

k163377 avatar Jan 15 '23 16:01 k163377

I noticed that there is no explicit specification (at least not that I am aware of) for handling value class.

In jackson-module-kogera, the basic specification is to treat value class like a value, as follows.

@JvmInline
value class Value(val value: Int)

val mapper = jacksonObjectMapper()
val json = mapper.writeValueAsString(Value(1)) // -> 1
val value = mapper.readValue<Value>(json) // -> Value(value=1)

The reason for this specification is as follows.

Also, this specification seemed the easiest to implement due to the constraints of handling value class in Jackson.

I plan to use the same specification when support value class in jackson-module-kotlin in the future. I would appreciate any comments or suggestions you may have. I would especially like to hear from @dinomite .

k163377 avatar Feb 13 '23 14:02 k163377

Issues related to deserialization support for value class related content will be summarized in #650. This issue will not be closed for a while as it includes discussion of serialization.

k163377 avatar Mar 17 '23 06:03 k163377