jackson-modules-java8 icon indicating copy to clipboard operation
jackson-modules-java8 copied to clipboard

Different result of `Instant` deserialization between readValue and readTree/treeToValue

Open valepakh opened this issue 1 year ago • 3 comments

Deserializing Instant from the string of "millis.nanos", which is the default serialization method in Jacskon yields different result when doing so through the ObjectMapper.readValue method or first reading it into a JsonNode through ObjectMapper.readTree and then reading the value from the tree via ObjectMapper.treeToValue

The root cause seems to be that readTree stores the information in the node in the double format, which then gets truncated when deserializing it to the Instant, while when deserializing directly, this step is skipped.

I wrote the test, which doesn't really uses a JSON but if you create a proper class with annotations and the field of type Instant the result will be the same:

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.time.Instant;
import org.junit.jupiter.api.Test;

class InstantDeserializationTest {
    @Test
    void readValue() throws JsonProcessingException {
        ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
        Instant instant = Instant.ofEpochSecond(1730977056L, 232784600);
        String instantString = mapper.writeValueAsString(instant);
        assertThat(instantString, is("1730977056.232784600"));

        Instant deserInstant = mapper.readValue(instantString, Instant.class);

        assertThat(deserInstant.getEpochSecond(), is(instant.getEpochSecond()));
        assertThat(deserInstant.getNano(), is(instant.getNano()));
    }

    @Test
    void readTree() throws JsonProcessingException {
        ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
        Instant instant = Instant.ofEpochSecond(1730977056L, 232784600);
        String instantString = mapper.writeValueAsString(instant);
        assertThat(instantString, is("1730977056.232784600"));

        JsonNode jsonNode = mapper.readTree(instantString);
        Instant deserInstant = mapper.treeToValue(jsonNode, Instant.class);

        assertThat(deserInstant.getEpochSecond(), is(instant.getEpochSecond()));
        // fails here, the actual value is 232784500 due to the double truncation
        assertThat(deserInstant.getNano(), is(instant.getNano()));
    }
}

valepakh avatar Nov 18 '24 13:11 valepakh

Which Jackson version(s) was this tested with?

cowtowncoder avatar Nov 18 '24 17:11 cowtowncoder

Which Jackson version(s) was this tested with?

2.18.1

valepakh avatar Nov 18 '24 20:11 valepakh

Yes, I think this is an unfortunate side-effect of readTree() having to decide what to do with fractional numbers: whether to read them as 64-bit doubles (lossy, range), or as BigDecimals (unlimited precision, range, non-lossy; but slower to handle).

By default, doubles are used: this can be changed with config setting(s) but cannot be solved by Date/Time module itself. Setting to enable is DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS and should retain accuracy.

cowtowncoder avatar Nov 19 '24 00:11 cowtowncoder

Or, new (2.19+), JsonNodeFeature.USE_BIG_DECIMAL_FOR_FLOATS.

cowtowncoder avatar Nov 17 '25 20:11 cowtowncoder

Work-around exists; closing.

cowtowncoder avatar Nov 17 '25 20:11 cowtowncoder