Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Jackson doesn't invoke default constructor of a case class on deserialization #330

Closed
batytskyy opened this issue Jun 22, 2017 · 4 comments
Labels

Comments

@batytskyy
Copy link

batytskyy commented Jun 22, 2017

Jackson version 2.8.9, 2.8.6:
Say, the following case class is defined:
case class Result(data: Seq[Int] = Seq.empty) { def this() = this(Seq(1)) }

Then, if we write the following test:

import com.fasterxml.jackson.annotation.JsonInclude.Include
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import com.fasterxml.jackson.module.scala.experimental.ScalaObjectMapper
import org.scalatest.WordSpec

class DeserilizationTest extends WordSpec {

  "Object mapper" when {
    val Mapper = new ObjectMapper() with ScalaObjectMapper
    val module = new SimpleModule
    Mapper
      .registerModule(module)
      .registerModule(DefaultScalaModule)
      .setSerializationInclusion(Include.NON_EMPTY)

    "deserializing case class with a default constructor" in {
      val result = Result()
      val serialized = Mapper.writeValueAsString(result)
      val deserialized = Mapper.readValue(serialized.getBytes, classOf[Result])
      assert(deserialized.data === Seq(1))
    }
  }
}

case class Result(data: Seq[Int] = Seq.empty) {
  def this() = this(Seq(1))
}

It will fail in a few runs because deserialized.data would equal to null.

The root cause is the:
com.fasterxml.jackson.module.scala.introspect.BeanIntrospector#def findConstructorParam(c: Class[_], name: String): Option[ConstructorParameter]

This method resolves
val primaryConstructor = c.getConstructors.headOption
If primaryConstructor is resolved to a default constructor with no arguments, then findConstructorParam will eventually return None. If None is returned, BeanDeserializerBase._vanillaProcessing will eventually be set to true, this will make Jackson call the default constructor of Result.

But, if the primaryConstructor is resolved to the constructor with the argument, then findConstructorParam will return Some(), which eventually leads Jackson to ignore the default constructor.

The primaryConstructor resolution should be changed to something like:
c.getConstructors.find(findADefaultConstructor).orElse(c.getConstructors.headOption)

Note:
The issue of not calling the default constructor can be eliminated by annotating it with @JsonCreator as follows:
case class Result(data: Seq[Int] = Seq.empty) { @JsonCreator def this() = this(Seq(1)) }

@batytskyy batytskyy changed the title Jackson doesn't invoke default default constructor of a case class on deserialization Jackson doesn't invoke default constructor of a case class on deserialization Jun 22, 2017
@prismec
Copy link

prismec commented Jun 23, 2017

Here is another example that should be related to this issue (version 2.8.9):

case class Metric(path: Path,
                  value: Double = 0,
                  time: DateTime = now(),
                  tags: Tags = NoTags) {

  def this(path: Path) = this(path, 0, now(), Set())
  def this(path: Path, value: Double) = this(path, value, now(), Set())
  def this(path: Path, value: Double, time: DateTime) = this(path, value, time, Set())
  def this(path: String) = this(Path(path), 0, now(), Set())
  def this(path: String, value: Double) = this(Path(path), value, now(), Set())
  def this(path: String, value: Double, time: DateTime) = this(Path(path), value, time, Set())
}

objectMapper.readValue(objectMapper.writeValueAsString(metricInstance), classOf[Metric])

The serialized JSON is
{"path":{"path":"/path","level":1,"isRoot":false},"value":0.5,"time":"2017-05-10T00:00:00.000+02:00","tags":[]}

Deserialization sometimes succeeds and sometimes fails with

com.fasterxml.jackson.databind.JsonMappingException: Can not deserialize instance of java.lang.String out of START_OBJECT token
 at [Source: {"path":{"path":"/path","level":1,"isRoot":false},"value":0.5,"time":"2017-05-10T00:00:00.000+02:00","tags":[]}; line: 1, column: 9] (through reference chain: smarter.ecommerce.commons.metrics.Metric["path"])

	at com.fasterxml.jackson.databind.JsonMappingException.from(JsonMappingException.java:270)
	at com.fasterxml.jackson.databind.DeserializationContext.reportMappingException(DeserializationContext.java:1234)
	at com.fasterxml.jackson.databind.DeserializationContext.handleUnexpectedToken(DeserializationContext.java:1122)
	at com.fasterxml.jackson.databind.DeserializationContext.handleUnexpectedToken(DeserializationContext.java:1075)
	at com.fasterxml.jackson.databind.deser.std.StringDeserializer.deserialize(StringDeserializer.java:60)
	at com.fasterxml.jackson.databind.deser.std.StringDeserializer.deserialize(StringDeserializer.java:11)
	at com.fasterxml.jackson.databind.deser.SettableBeanProperty.deserialize(SettableBeanProperty.java:504)
	at com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeWithErrorWrapping(BeanDeserializer.java:511)
	at com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeUsingPropertyBased(BeanDeserializer.java:400)
	at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.deserializeFromObjectUsingNonDefault(BeanDeserializerBase.java:1191)
	at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:314)
	at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:148)
	at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:3798)
	at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:2842)

@dougdonohoe
Copy link

dougdonohoe commented Mar 9, 2018

In 2.9.4, this issue is causing me headaches. I also have code that spuriously fails converting JSON to a case class. I believe it is because the order of constructors returned in getConstructors is non-deterministic. The assumption in BeanIntrospector.findConstructorParam (line 57) that the 1st constructor returned is the correct one is flawed. My example class has 10 constructors.

Running this code produces a different ordering on each invocation:

val clazz = classOf[SearchQuery]
clazz.getConstructors foreach(c => info(c.toGenericString))

When the JVM gods decide to order my constructors in a way that the "right" one is at the top, things work correctly.

The @JsonCreator fix worked. Here's how you annotate the primary constructor of a case class:

case class SearchQuery @JsonCreator() (... fields ...)

@pjfanning
Copy link
Member

seems similar to #159

@pjfanning pjfanning added the 2.12 label Jan 30, 2021
@pjfanning
Copy link
Member

I've added a possible fix in v2.12.2

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

4 participants