Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Distinguish null from missing properties in Jackson using Kotlin data classes

Tags:

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

like image 426
A Frayed Knot Avatar asked Feb 19 '20 05:02

A Frayed Knot


1 Answers

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.

like image 136
Pemassi Avatar answered Oct 14 '22 06:10

Pemassi