FasterXML / jackson-databind

General data-binding package for Jackson (2.x): works on streaming API (core) implementation(s)
Apache License 2.0
3.52k stars 1.38k forks source link

Deserializing Java record with @JsonUnwrapped annotation throws exception "can not set final" #3726

Open jonjanisch opened 1 year ago

jonjanisch commented 1 year ago

I'm attempting to deserialize JSON into a simple Java record where one of the members, a simple POJO, is annotated as @JsonUnwrapped.

public record TestRecord(String name, @JsonUnwrapped Address address) {}

If I attempt to deserialize the JSON string {"name":"Bob","city":"New York","state":"NY"}, I get the exception:

    com.fasterxml.jackson.databind.JsonMappingException: Can not set final org.example.AppTest$Address field org.example.AppTest$TestRecord.address to org.example.AppTest$Address
 at [Source: UNKNOWN; byte offset: #UNKNOWN]

    at com.fasterxml.jackson.databind.JsonMappingException.from(JsonMappingException.java:276)
Caused by: java.lang.IllegalAccessException: Can not set final org.example.AppTest$Address field org.example.AppTest$TestRecord.address to org.example.AppTest$Address
    at java.base/jdk.internal.reflect.UnsafeFieldAccessorImpl.throwFinalFieldIllegalAccessException(UnsafeFieldAccessorImpl.java:76)
    at java.base/jdk.internal.reflect.UnsafeFieldAccessorImpl.throwFinalFieldIllegalAccessException(UnsafeFieldAccessorImpl.java:80)
    at java.base/jdk.internal.reflect.UnsafeQualifiedObjectFieldAccessorImpl.set(UnsafeQualifiedObjectFieldAccessorImpl.java:79)
    at java.base/java.lang.reflect.Field.set(Field.java:799)

Version information Tested both 2.13.2 and 2.14.1 - same problem with both versions.

To Reproduce This uses JDK17 multiline string but trivial to replace with escaped string.

@Test
void should_DeserializeUnwrappedRecord() throws JsonProcessingException {

    Address address = new Address();
    address.setCity("New York");
    address.setState("NY");
    TestRecord expectedRecord = new TestRecord("Bob", address);

    String json = """
            {"name":"Bob","city":"New York","state":"NY"}""";

    JsonMapper objectMapper = new JsonMapper();
    TestRecord actualValue = objectMapper.readValue(json, TestRecord.class);
    assertEquals(expectedRecord, actualValue);
}

Additional context If I change TestRecord to a plain Java class, it works fine.

I've tried using the annotations @JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY) and annotating all fields with @JsonProperty to no avail.

Taz03 commented 1 year ago

@cowtowncoder is there any updates on this?

pjfanning commented 1 year ago

@Taz03 come on - your comments go to lots of people. Do you really need to spam everyone every 3 hours?

cowtowncoder commented 1 year ago

@JsonUnwrapped does not work with Constructor-based deserialization. There is an earlier issue #1467 that covers it.

@Taz03 If there was an update, it would be noted here. There isn't, hasn't been, and no immediate progress in sight.

zjwmiao commented 1 year ago

you can customize a deserilaizer

@JsonDeserialize(using = TestDeserializer.class)
public record TestRecord(String name, @JsonUnwrapped Address address) {}

static class TestDeserializer extends JsonDeserializer<TestRecord> {
  @Override
  public TestRecord deserialize(JsonParser jsonParser, DeserializationContext context) throws IOException, JacksonException {
    JsonNode node = jsonParser.getCodec().readTree(jsonParser);
    String name = node.get("name").asText();
    String city = node.get("city").asText();
    String state = node.get("state").asText();
    return new TestRecord(name, new Address(city, state));
  }
}
ObjectMapper objectMapper = new ObjectMapper();
String json = "{\"name\":\"Bob\",\"city\":\"New York\",\"state\":\"NY\"}";
System.out.println(objectMapper.readValue(json, TestRecord.class));
// TestRecord[name=Bob, address=Address[city=New York, state=NY]]

Or, use @JsonCreator and @JsonProperty

public record TestRecord(String name, @JsonUnwrapped @JsonProperty(access = Access.READ_ONLY) Address address) {
  @JsonCreator
  public TestRecord(@JsonProperty("name") String name, @JsonProperty("city") String city,
      @JsonProperty("state") String state) {
    this(name, new Address(city, state));
  }
}
ObjectMapper objectMapper = new ObjectMapper();
String json = "{\"name\":\"Bob\",\"city\":\"New York\",\"state\":\"NY\"}";
System.out.println(objectMapper.readValue(json, TestRecord.class));
// TestRecord[name=Bob, address=Address[city=New York, state=NY]]
Eitraz commented 1 week ago

As I spent quite some time to find a solution that fits my case, it's not more than right to share it here. In my case the "address" is a more complex object and handing the fields manually was not an option for me.

This is similar to what I ended up with, based on the example record above:

public record TestRecord(
        String name,
        @JsonUnwrapped Address address
) {
    @JsonCreator(mode = JsonCreator.Mode.DELEGATING)
    public TestRecord(JsonNode node) {
        this(
                ObjectMapperHolder.getObjectMapper().convertValue(node.get("name"), String.class),
                ObjectMapperHolder.getObjectMapper().convertValue(node, Address.class)
        );
    }
}
cowtowncoder commented 1 week ago

Actually there might be way forward for this, see #4271.