FasterXML / jackson-modules-java8

Set of support modules for Java 8 datatypes (Optionals, date/time) and features (parameter names)
Apache License 2.0
398 stars 116 forks source link

Date and time serialization are very inconsistent #309

Open fbacchella opened 4 months ago

fbacchella commented 4 months ago

I’m comparing results of different time object serialization with different settings and the result are very different.

I wrote the following code

package com.axibase.date;

import java.time.Instant;
import java.time.ZoneId;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.TimeZone;
import java.util.function.Consumer;

import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectWriter;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.databind.jsontype.impl.StdTypeResolverBuilder;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.module.afterburner.AfterburnerModule;

public class JacksonConcistency {
    private static TimeZone defaultTz;
    @BeforeClass
    public static void saveTimeZone() {
        defaultTz = TimeZone.getDefault();
        TimeZone.setDefault(TimeZone.getTimeZone("Japan/Tokyo"));
    }
    @AfterClass
    public static void restoreTimeZone() {
        TimeZone.setDefault(defaultTz);
    }

    public JsonMapper getMapper(Consumer<JsonMapper.Builder> configurator) {
        JsonMapper.Builder builder = JsonMapper.builder();
        builder.setDefaultTyping(StdTypeResolverBuilder.noTypeInfoBuilder());
        builder.addModule(new JavaTimeModule());
        builder.addModule(new Jdk8Module());
        builder.addModule(new AfterburnerModule());
        configurator.accept(builder);
        return builder.build();
    }

    public void runTest(Object value, Map.Entry<String, Consumer<JsonMapper>> mapperConfigurator) {
        try {
            JsonMapper simpleMapper = getMapper(m -> {});
            JsonMapper axibaseMapper = getMapper(m -> m.addModule(new JacksonModule()));
            mapperConfigurator.getValue().accept(simpleMapper);
            mapperConfigurator.getValue().accept(axibaseMapper);
            ObjectWriter simpleWritter =  simpleMapper.writerFor(Object.class);
            ObjectWriter simpleWritter =  simpleMapper.writerFor(Object.class);
            System.err.format("  %s %s%n", value.getClass().getName(), simpleWritter.writeValueAsString(value));
axibaseWritter.writeValueAsString(value));
        } catch (JsonProcessingException e) {
            throw new IllegalStateException(e);
        }
    }

    private Calendar fromInstant(Instant i, ZoneId tz) {
        return new Calendar.Builder().setCalendarType("iso8601").setInstant(i.toEpochMilli()).setTimeZone(TimeZone.getTimeZone(tz)).build();
    }

    @Test
    public void testDefaultSettigs() {
        List<Map.Entry<String, Consumer<JsonMapper>>> configurators = List.of(
                Map.entry("TIMESTAMPS_AS_MILLISECONDS",
                      m -> m.disable(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS)),
                Map.entry("TIMESTAMPS_AS_NANOSECONDS",
                    m -> m.enable(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS)),
                Map.entry("AS_STRING",
                    m -> {
                        m.setTimeZone(TimeZone.getTimeZone("America/New_York"));
                        m.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
                        m.disable(SerializationFeature.WRITE_DATES_WITH_ZONE_ID);
                        m.disable(SerializationFeature.WRITE_DATES_WITH_CONTEXT_TIME_ZONE);
                    }
                ),
                Map.entry("WITH_ZONE_ID",
                    m -> {
                        m.setTimeZone(TimeZone.getTimeZone("America/New_York"));
                        m.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
                        m.enable(SerializationFeature.WRITE_DATES_WITH_ZONE_ID);
                        m.disable(SerializationFeature.WRITE_DATES_WITH_CONTEXT_TIME_ZONE);
                    }
                ),
                Map.entry("WITH_CONTEXT_TIME_ZONE",
                    m -> {
                        m.setTimeZone(TimeZone.getTimeZone("America/New_York"));
                        m.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
                        m.disable(SerializationFeature.WRITE_DATES_WITH_ZONE_ID);
                        m.enable(SerializationFeature.WRITE_DATES_WITH_CONTEXT_TIME_ZONE);
                    }
                ),
                Map.entry("WITH_CONTEXT_TIME_ZONE_AND_ID",
                    m -> {
                        m.setTimeZone(TimeZone.getTimeZone("America/New_York"));
                        m.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
                        m.enable(SerializationFeature.WRITE_DATES_WITH_ZONE_ID);
                        m.enable(SerializationFeature.WRITE_DATES_WITH_CONTEXT_TIME_ZONE);
                    }
                )
        );
        ZoneId moscow = ZoneId.of("Europe/Moscow");
        long seconds = 1714421498L;
        long withNanos = 936_155_001L;
        long withMilli = 936_000_000L;
        long withoutSubSecond = 0L;
        List<Map.Entry<String, List<Object>>> values = List.of(
            Map.entry("Instant with nano",
                List.of(Instant.ofEpochSecond(seconds, withNanos))
            ),
            Map.entry("Instant with milli",
                List.of(Instant.ofEpochSecond(seconds, withMilli),
                        Date.from(Instant.ofEpochSecond(seconds, withMilli))
                )
            ),
            Map.entry("Instant without subseconds",
                List.of(Instant.ofEpochSecond(seconds, withoutSubSecond),
                        Date.from(Instant.ofEpochSecond(seconds, withoutSubSecond))
                )
            ),
            Map.entry("Date with nano",
                List.of(Instant.ofEpochSecond(seconds, withNanos).atZone(moscow))
            ),
            Map.entry("Date with milli",
                List.of(Instant.ofEpochSecond(seconds, withMilli).atZone(moscow),
                        fromInstant(Instant.ofEpochSecond(seconds, withMilli), moscow)
                )
            ),
            Map.entry("Date without subseconds",
                List.of(Instant.ofEpochSecond(seconds, withoutSubSecond).atZone(moscow),
                        fromInstant(Instant.ofEpochSecond(seconds, withoutSubSecond), moscow)
                )
            )
        );
        for (Map.Entry<String, Consumer<JsonMapper>> c: configurators) {
            System.err.format("**** %s ****%n", c.getKey());
            for (Map.Entry<String, List<Object>> v: values) {
                System.err.format("%s%n", v.getKey());
                for (Object o: v.getValue()) {
                    runTest(o, c);
                }
            }
            System.err.println();
        }
    }
}

The output is

**** TIMESTAMPS_AS_MILLISECONDS ****
Instant with nano
  java.time.Instant 1714421498936
Instant with milli
  java.time.Instant 1714421498936
  java.util.Date 1714421498936
Instant without subseconds
  java.time.Instant 1714421498000
  java.util.Date 1714421498000
Date with nano
  java.time.ZonedDateTime 1714421498936
Date with milli
  java.time.ZonedDateTime 1714421498936
  java.util.GregorianCalendar 1714421498936
Date without subseconds
  java.time.ZonedDateTime 1714421498000
  java.util.GregorianCalendar 1714421498000

**** TIMESTAMPS_AS_NANOSECONDS ****
Instant with nano
  java.time.Instant 1714421498.936155001
Instant with milli
  java.time.Instant 1714421498.936000000
  java.util.Date 1714421498936
Instant without subseconds
  java.time.Instant 1714421498.000000000
  java.util.Date 1714421498000
Date with nano
  java.time.ZonedDateTime 1714421498.936155001
Date with milli
  java.time.ZonedDateTime 1714421498.936000000
  java.util.GregorianCalendar 1714421498936
Date without subseconds
  java.time.ZonedDateTime 1714421498.000000000
  java.util.GregorianCalendar 1714421498000

**** AS_STRING ****
Instant with nano
  java.time.Instant "2024-04-29T20:11:38.936155001Z"
Instant with milli
  java.time.Instant "2024-04-29T20:11:38.936Z"
  java.util.Date "2024-04-29T16:11:38.936-04:00"
Instant without subseconds
  java.time.Instant "2024-04-29T20:11:38Z"
  java.util.Date "2024-04-29T16:11:38.000-04:00"
Date with nano
  java.time.ZonedDateTime "2024-04-29T23:11:38.936155001+03:00"
Date with milli
  java.time.ZonedDateTime "2024-04-29T23:11:38.936+03:00"
  java.util.GregorianCalendar "2024-04-29T16:11:38.936-04:00"
Date without subseconds
  java.time.ZonedDateTime "2024-04-29T23:11:38+03:00"
  java.util.GregorianCalendar "2024-04-29T16:11:38.000-04:00"

**** WITH_ZONE_ID ****
Instant with nano
  java.time.Instant "2024-04-29T20:11:38.936155001Z"
Instant with milli
  java.time.Instant "2024-04-29T20:11:38.936Z"
  java.util.Date "2024-04-29T16:11:38.936-04:00"
Instant without subseconds
  java.time.Instant "2024-04-29T20:11:38Z"
  java.util.Date "2024-04-29T16:11:38.000-04:00"
Date with nano
  java.time.ZonedDateTime "2024-04-29T23:11:38.936155001+03:00[Europe/Moscow]"
Date with milli
  java.time.ZonedDateTime "2024-04-29T23:11:38.936+03:00[Europe/Moscow]"
  java.util.GregorianCalendar "2024-04-29T16:11:38.936-04:00"
Date without subseconds
  java.time.ZonedDateTime "2024-04-29T23:11:38+03:00[Europe/Moscow]"
  java.util.GregorianCalendar "2024-04-29T16:11:38.000-04:00"

**** WITH_CONTEXT_TIME_ZONE ****
Instant with nano
  java.time.Instant "2024-04-29T20:11:38.936155001Z"
Instant with milli
  java.time.Instant "2024-04-29T20:11:38.936Z"
  java.util.Date "2024-04-29T16:11:38.936-04:00"
Instant without subseconds
  java.time.Instant "2024-04-29T20:11:38Z"
  java.util.Date "2024-04-29T16:11:38.000-04:00"
Date with nano
  java.time.ZonedDateTime "2024-04-29T16:11:38.936155001-04:00"
Date with milli
  java.time.ZonedDateTime "2024-04-29T16:11:38.936-04:00"
  java.util.GregorianCalendar "2024-04-29T16:11:38.936-04:00"
Date without subseconds
  java.time.ZonedDateTime "2024-04-29T16:11:38-04:00"
  java.util.GregorianCalendar "2024-04-29T16:11:38.000-04:00"

**** WITH_CONTEXT_TIME_ZONE_AND_ID ****
Instant with nano
  java.time.Instant "2024-04-29T20:11:38.936155001Z"
Instant with milli
  java.time.Instant "2024-04-29T20:11:38.936Z"
  java.util.Date "2024-04-29T16:11:38.936-04:00"
Instant without subseconds
  java.time.Instant "2024-04-29T20:11:38Z"
  java.util.Date "2024-04-29T16:11:38.000-04:00"
Date with nano
  java.time.ZonedDateTime "2024-04-29T23:11:38.936155001+03:00[Europe/Moscow]"
Date with milli
  java.time.ZonedDateTime "2024-04-29T23:11:38.936+03:00[Europe/Moscow]"
  java.util.GregorianCalendar "2024-04-29T16:11:38.936-04:00"
Date without subseconds
  java.time.ZonedDateTime "2024-04-29T23:11:38+03:00[Europe/Moscow]"
  java.util.GregorianCalendar "2024-04-29T16:11:38.000-04:00"

I don’t think the output should depend of the class used, except for the precision. And many are plain wrong, where many features are not really applied. For example with WRITE_DATES_WITH_CONTEXT_TIME_ZONE, even Instant should not be serialized az UTC, but indeed with the context time zone.

cowtowncoder commented 4 months ago

Due to this module being changed over time by bunch of different contributors, without clear owner (after initial version), there are indeed many inconsistencies. But because of (backwards-)compatibility limitations changes need to be done carefully and incrementally.

Given above, this issue is not quite something that can be worked as-is, since its scope is wider than what individual PRs could tackle. It'd be great to create a set of "smaller" issues instead, ones that focused on one specific date/time type or configuration feature.

fbacchella commented 4 months ago

To solve that problem, I have written a dedicated module(https://github.com/fbacchella/LogHub/blob/no_axibase/loghub-core/src/main/java/com/axibase/date/JacksonModule.java). By loading it after the JavaTimeModule, it hides the problematic serializers.

cowtowncoder commented 4 months ago

@fbacchella Thank you for sharing this.