A Frayed Knot
A Frayed Knot

Reputation: 469

Distinguish null from missing properties in Jackson using Kotlin data classes

I want to use Jackson to deserialize and later serialize jsons using Kotlin's data classes. It's important that I maintain the distinction between an explicitly null property, and a property which was omitted in the original json.

I have a large domain model (50+ classes) built almost entirely out of Kotlin data classes. Kotlin's data classes provide a lot of useful functionalities that I need to use elsewhere in my program, and for that reason I'd like to keep them instead of converting my models.

I've currently got this code working, but only for Java classes or using Kotlin properties declared in the body of the Kotlin class, and not working for properties declared in the constructor. For Kotlin's data class utility functions to work, all properties must be declared in the constructor.

Here's my object mapper setup code:

val objectMapper = ObjectMapper()

objectMapper.registerModule(KotlinModule())
objectMapper.registerModule(Jdk8Module())

objectMapper.setSerializationInclusion(JsonInclude.Include.ALWAYS)
objectMapper.configOverride(Optional::class.java).includeAsProperty =
    JsonInclude.Value.construct(JsonInclude.Include.NON_NULL, null)

objectMapper.configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, true)

objectMapper.configure(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN, true)

objectMapper.configure(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, true)
objectMapper.nodeFactory = JsonNodeFactory.withExactBigDecimals(true)

Here are my test classes:

TestClass1.java

public class TestClass1 {
    public TestClass1() {}
    public TestClass1(int intVal, Optional<Double> optDblVal) {
        this.intVal = intVal;
        this.optDblVal = optDblVal;
    }
    public Integer intVal;
    public Optional<Double> optDblVal;
}

TestClasses.kt

data class TestClass2(val intVal: Int?, val optDblVal: Optional<Double>?)

class TestClass3(val intVal: Int?, val optDblVal: Optional<Double>?)

class TestClass4 {
    val intVal: Int? = null
    val optDblVal: Optional<Double>? = null
}

and here are my tests:

JsonReserializationTests.kt

@Test
fun `Test 1 - Explicit null Double reserialized as explicit null`() {
    val inputJson = """
        {
          "intVal" : 7,
          "optDblVal" : null
        }
        """.trimIndent()

    val intermediateObject = handler.objectMapper.readValue(inputJson, TestClassN::class.java)
    val actualJson = handler.objectMapper
        .writerWithDefaultPrettyPrinter()
        .writeValueAsString(intermediateObject)
        .replace("\r", "")

    assertEquals(inputJson, actualJson)
}

@Test
fun `Test 2 - Missing Double not reserialized`() {
    val inputJson = """
        {
          "intVal" : 7
        }
        """.trimIndent()

    val intermediateObject = handler.objectMapper.readValue(inputJson, TestClassN::class.java)
    val actualJson = handler.objectMapper
        .writerWithDefaultPrettyPrinter()
        .writeValueAsString(intermediateObject)
        .replace("\r", "")

    assertEquals(inputJson, actualJson)
}

Test Results for each class

Test Results

Upvotes: 5

Views: 4125

Answers (3)

broc.seib
broc.seib

Reputation: 22741

data class Example(
  val greeting: Optional<String>? = null
)

This allows you to distinguish all three cases in the JSON:

  • non-null value ({"greeting":"hello"}greeting.isPresent() == true)
  • null value ({"greeting":null}greeting.isPresent() == false)
  • not present ({ }greeting == null)

This is just a concise summary of what @Pemassi offered, with the key insight being to make a default null assignment to the nullable Optional<T> member.

Note that the semantics of .isPresent() is potentially confusing to a casual observer, since it does not refer to the presence of a value in the JSON.

A full unit test demonstration is here.

Upvotes: 3

Pemassi
Pemassi

Reputation: 642

I just found the Kotiln Plugin that makes no-argument constructor for data class automatically. This should help you without much editing. However, this is not a good design pattern, so I still recommend giving default value to all members.

Here is a link for the Kotlin NoArg Plugin

https://kotlinlang.org/docs/reference/compiler-plugins.html#no-arg-compiler-plugin

Upvotes: 0

Pemassi
Pemassi

Reputation: 642

Let's talk about TestClass2.

If you convert Kotlin code to Java Code, you can find the reason.

Intellij offers a converting tool for Kotlin. You can find it from the menu Tools -> Kotlin -> Show Kotlin Bytecode.

Here is a Java code from the TestClass2 Kotlin code.

public final class TestClass2 {
   @Nullable
   private final Integer intVal;
   @Nullable
   private final Optional optDblVal;

   @Nullable
   public final Integer getIntVal() {
      return this.intVal;
   }

   @Nullable
   public final Optional getOptDblVal() {
      return this.optDblVal;
   }

   public TestClass2(@Nullable Integer intVal, @Nullable Optional optDblVal) {
      this.intVal = intVal;
      this.optDblVal = optDblVal;
   }

   @Nullable
   public final Integer component1() {
      return this.intVal;
   }

   @Nullable
   public final Optional component2() {
      return this.optDblVal;
   }

   @NotNull
   public final TestClass2 copy(@Nullable Integer intVal, @Nullable Optional optDblVal) {
      return new TestClass2(intVal, optDblVal);
   }

   // $FF: synthetic method
   public static TestClass2 copy$default(TestClass2 var0, Integer var1, Optional var2, int var3, Object var4) {
      if ((var3 & 1) != 0) {
         var1 = var0.intVal;
      }

      if ((var3 & 2) != 0) {
         var2 = var0.optDblVal;
      }

      return var0.copy(var1, var2);
   }

   @NotNull
   public String toString() {
      return "TestClass2(intVal=" + this.intVal + ", optDblVal=" + this.optDblVal + ")";
   }

   public int hashCode() {
      Integer var10000 = this.intVal;
      int var1 = (var10000 != null ? var10000.hashCode() : 0) * 31;
      Optional var10001 = this.optDblVal;
      return var1 + (var10001 != null ? var10001.hashCode() : 0);
   }

   public boolean equals(@Nullable Object var1) {
      if (this != var1) {
         if (var1 instanceof TestClass2) {
            TestClass2 var2 = (TestClass2)var1;
            if (Intrinsics.areEqual(this.intVal, var2.intVal) && Intrinsics.areEqual(this.optDblVal, var2.optDblVal)) {
               return true;
            }
         }

         return false;
      } else {
         return true;
      }
   }

The original code is too long, so here is the constructor only.

public TestClass2(@Nullable Integer intVal, @Nullable Optional optDblVal) {
      this.intVal = intVal;
      this.optDblVal = optDblVal;
}

Since Jackson library cannot create an instance without parameters because there is no non-parameter constructor, it will try to create a new instance with some parameters. For test case 2, JSON has only one parameter so that it will look for a one-parameter constructor, but there is no so that it will throw an exception. This is also why test case 1 is passed.

Therefore, what you have to do is that you have to give all default values to all the parameters of data class to make a non-parameter constructor like the below code.

data class TestClass2(val intVal: Int? = null, val optDblVal: Optional<Double>? = null)

Then, if you see in Java code, the class will have a non-parameter constructor.

   public TestClass2(@Nullable Integer intVal, @Nullable Optional optDblVal) {
      this.intVal = intVal;
      this.optDblVal = optDblVal;
   }

   // $FF: synthetic method
   public TestClass2(Integer var1, Optional var2, int var3, DefaultConstructorMarker var4) 
   {
      if ((var3 & 1) != 0) {
         var1 = (Integer)null;
      }

      if ((var3 & 2) != 0) {
         var2 = (Optional)null;
      }

      this(var1, var2);
   }

   public TestClass2() {
      this((Integer)null, (Optional)null, 3, (DefaultConstructorMarker)null);
   }

So, if you still want to use Kotlin data class, you have to give default values to all the variables.

Upvotes: 3

Related Questions