From 6a2cfc484745d883a0ce05acd8a3a41deb041179 Mon Sep 17 00:00:00 2001 From: seonwoo_jung <79202163+seonwooj0810@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:15:35 +0900 Subject: [PATCH 1/3] Add JavaTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS (#76) Default ISO serialization omits the sub-second field when it is zero, so timestamp width varies with the value. Add an opt-in JavaTimeFeature that always writes at least 3 (millisecond) sub-second digits for Instant, OffsetDateTime, ZonedDateTime and LocalDateTime, while preserving higher precision (up to 9 digits) so no information is truncated. Signed-off-by: seonwoo_jung <79202163+seonwooj0810@users.noreply.github.com> --- .../datatype/jsr310/JavaTimeFeature.java | 28 +++- .../datatype/jsr310/JavaTimeModule.java | 8 +- .../jsr310/ser/InstantSerializer.java | 23 +++ .../jsr310/ser/InstantSerializerBase.java | 19 +++ .../jsr310/ser/LocalDateTimeSerializer.java | 37 ++++- .../jsr310/ser/OffsetDateTimeSerializer.java | 24 +++ .../jsr310/ser/SubSecondFormatters.java | 74 +++++++++ .../jsr310/ser/ZonedDateTimeSerializer.java | 47 +++++- .../ser/AlwaysWriteSubSecondDigits76Test.java | 149 ++++++++++++++++++ release-notes/CREDITS-2.x | 9 ++ release-notes/VERSION-2.x | 4 +- 11 files changed, 412 insertions(+), 10 deletions(-) create mode 100644 datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/SubSecondFormatters.java create mode 100644 datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/ser/AlwaysWriteSubSecondDigits76Test.java diff --git a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/JavaTimeFeature.java b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/JavaTimeFeature.java index b509af6e..5d738540 100644 --- a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/JavaTimeFeature.java +++ b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/JavaTimeFeature.java @@ -51,7 +51,33 @@ public enum JavaTimeFeature implements JacksonFeature *

* Default setting is false, meaning that Month is serialized/deserialized as a zero-based index. */ - ONE_BASED_MONTHS(false) + ONE_BASED_MONTHS(false), + + /** + * Feature that determines whether sub-second digits are always written when + * serializing {@link java.time.Instant}, {@link java.time.OffsetDateTime}, + * {@link java.time.ZonedDateTime} and {@link java.time.LocalDateTime} as + * ISO-8601 Strings using the default format. + *

+ * When disabled (the default), the JDK-provided ISO formatters are used and + * a zero sub-second value is omitted altogether -- {@code 2017-09-14T04:28:48Z} + * -- which means that output width varies with the value, breaking systems + * that expect fixed-precision timestamps (or that sort timestamps as text). + *

+ * When enabled, at least 3 (millisecond) sub-second digits are always written, + * zero-padded if necessary -- {@code 2017-09-14T04:28:48.000Z}. Higher precision + * is preserved: a value with microsecond or nanosecond precision is written with + * 6 or 9 digits respectively, so no information is lost. + *

+ * Only affects the default format: an explicit {@code DateTimeFormatter} or + * a {@link com.fasterxml.jackson.annotation.JsonFormat} pattern takes precedence, + * as does writing values as numeric timestamps. + *

+ * Default setting is disabled, for backwards compatibility. + * + * @since 2.23 + */ + ALWAYS_WRITE_SUBSECOND_DIGITS(false) ; /** diff --git a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/JavaTimeModule.java b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/JavaTimeModule.java index ae953380..80ede099 100644 --- a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/JavaTimeModule.java +++ b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/JavaTimeModule.java @@ -157,12 +157,12 @@ public void setupModule(SetupContext context) { JavaTimeSerializers sers = new JavaTimeSerializers(); sers.addSerializer(Duration.class, DurationSerializer.INSTANCE); - sers.addSerializer(Instant.class, InstantSerializer.INSTANCE); - sers.addSerializer(LocalDateTime.class, LocalDateTimeSerializer.INSTANCE); + sers.addSerializer(Instant.class, InstantSerializer.INSTANCE.withFeatures(_features)); + sers.addSerializer(LocalDateTime.class, LocalDateTimeSerializer.INSTANCE.withFeatures(_features)); sers.addSerializer(LocalDate.class, LocalDateSerializer.INSTANCE); sers.addSerializer(LocalTime.class, LocalTimeSerializer.INSTANCE); sers.addSerializer(MonthDay.class, MonthDaySerializer.INSTANCE); - sers.addSerializer(OffsetDateTime.class, OffsetDateTimeSerializer.INSTANCE); + sers.addSerializer(OffsetDateTime.class, OffsetDateTimeSerializer.INSTANCE.withFeatures(_features)); sers.addSerializer(OffsetTime.class, OffsetTimeSerializer.INSTANCE); sers.addSerializer(Period.class, new ToStringSerializer(Period.class)); sers.addSerializer(Year.class, YearSerializer.INSTANCE); @@ -173,7 +173,7 @@ public void setupModule(SetupContext context) { * serialization with timezone offset only, not timezone id. * But this is configurable. */ - sers.addSerializer(ZonedDateTime.class, ZonedDateTimeSerializer.INSTANCE); + sers.addSerializer(ZonedDateTime.class, ZonedDateTimeSerializer.INSTANCE.withFeatures(_features)); // since 2.11: need to override Type Id handling // (actual concrete type is `ZoneRegion`, but that's not visible) diff --git a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/InstantSerializer.java b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/InstantSerializer.java index 0ed65bcd..de19a3e9 100644 --- a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/InstantSerializer.java +++ b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/InstantSerializer.java @@ -22,6 +22,9 @@ import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import com.fasterxml.jackson.core.util.JacksonFeatureSet; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeFeature; + /** * Serializer for Java 8 temporal {@link Instant}s, {@link OffsetDateTime}, and {@link ZonedDateTime}s. * @@ -59,6 +62,26 @@ protected InstantSerializer(InstantSerializer base, super(base, useTimestamp, useNanoseconds, formatter); } + /** + * @since 2.23 + */ + protected InstantSerializer(InstantSerializer base, DateTimeFormatter defaultFormat) { + super(base, defaultFormat); + } + + /** + * Method called by {@link com.fasterxml.jackson.datatype.jsr310.JavaTimeModule} + * to apply module-level {@link JavaTimeFeature} settings. + * + * @since 2.23 + */ + public InstantSerializer withFeatures(JacksonFeatureSet features) { + if (features.isEnabled(JavaTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS)) { + return new InstantSerializer(this, SubSecondFormatters.INSTANT); + } + return this; + } + @Override protected JSR310FormattedSerializerBase withFormat(Boolean useTimestamp, DateTimeFormatter formatter, JsonFormat.Shape shape) { diff --git a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/InstantSerializerBase.java b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/InstantSerializerBase.java index 1305a1c2..866bbbb8 100644 --- a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/InstantSerializerBase.java +++ b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/InstantSerializerBase.java @@ -92,6 +92,25 @@ protected InstantSerializerBase(InstantSerializerBase base, Boolean useTimest getNanoseconds = base.getNanoseconds; } + /** + * Copy-constructor used for replacing the "hidden" default formatter -- and only + * that -- of an existing serializer; needed for + * {@link com.fasterxml.jackson.datatype.jsr310.JavaTimeFeature#ALWAYS_WRITE_SUBSECOND_DIGITS}. + * Note that the default formatter must NOT be passed as {@code _formatter}, since + * a non-null {@code _formatter} also forces serialization as a JSON String. + * + * @since 2.23 + */ + protected InstantSerializerBase(InstantSerializerBase base, + DateTimeFormatter defaultFormat) + { + super(base, base._useTimestamp, base._useNanoseconds, base._formatter, base._shape); + this.defaultFormat = defaultFormat; + getEpochMillis = base.getEpochMillis; + getEpochSeconds = base.getEpochSeconds; + getNanoseconds = base.getNanoseconds; + } + @Override protected abstract JSR310FormattedSerializerBase withFormat( Boolean useTimestamp, diff --git a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/LocalDateTimeSerializer.java b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/LocalDateTimeSerializer.java index 4383a36a..ca193993 100644 --- a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/LocalDateTimeSerializer.java +++ b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/LocalDateTimeSerializer.java @@ -25,8 +25,10 @@ import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.JsonToken; import com.fasterxml.jackson.core.type.WritableTypeId; +import com.fasterxml.jackson.core.util.JacksonFeatureSet; import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.jsontype.TypeSerializer; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeFeature; /** * Serializer for Java 8 temporal {@link LocalDateTime}s. @@ -39,18 +41,49 @@ public class LocalDateTimeSerializer extends JSR310FormattedSerializerBase features) { + if (features.isEnabled(JavaTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS)) { + return new LocalDateTimeSerializer(this, SubSecondFormatters.LOCAL_DATE_TIME); + } + return this; } @Override @@ -59,7 +92,7 @@ protected JSR310FormattedSerializerBase withFormat(Boolean useTim } protected DateTimeFormatter _defaultFormatter() { - return DateTimeFormatter.ISO_LOCAL_DATE_TIME; + return (_defaultFormat == null) ? DateTimeFormatter.ISO_LOCAL_DATE_TIME : _defaultFormat; } @Override diff --git a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/OffsetDateTimeSerializer.java b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/OffsetDateTimeSerializer.java index 1a961c8d..7bdd0e9c 100644 --- a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/OffsetDateTimeSerializer.java +++ b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/OffsetDateTimeSerializer.java @@ -4,6 +4,9 @@ import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; +import com.fasterxml.jackson.core.util.JacksonFeatureSet; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeFeature; + public class OffsetDateTimeSerializer extends InstantSerializerBase { private static final long serialVersionUID = 1L; @@ -35,6 +38,27 @@ public OffsetDateTimeSerializer(OffsetDateTimeSerializer base, Boolean useTimest super(base, useTimestamp, base._useNanoseconds, formatter, shape); } + /** + * @since 2.23 + */ + protected OffsetDateTimeSerializer(OffsetDateTimeSerializer base, + DateTimeFormatter defaultFormat) { + super(base, defaultFormat); + } + + /** + * Method called by {@link com.fasterxml.jackson.datatype.jsr310.JavaTimeModule} + * to apply module-level {@link JavaTimeFeature} settings. + * + * @since 2.23 + */ + public OffsetDateTimeSerializer withFeatures(JacksonFeatureSet features) { + if (features.isEnabled(JavaTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS)) { + return new OffsetDateTimeSerializer(this, SubSecondFormatters.OFFSET_DATE_TIME); + } + return this; + } + /** * Method for constructing a new {@code OffsetDateTimeSerializer} with settings * of this serializer but with custom {@link DateTimeFormatter} overrides. diff --git a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/SubSecondFormatters.java b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/SubSecondFormatters.java new file mode 100644 index 00000000..cb472a7b --- /dev/null +++ b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/SubSecondFormatters.java @@ -0,0 +1,74 @@ +package com.fasterxml.jackson.datatype.jsr310.ser; + +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.temporal.ChronoField; + +/** + * Container for the ISO-8601 {@link DateTimeFormatter}s used in place of the + * JDK-provided defaults when + * {@link com.fasterxml.jackson.datatype.jsr310.JavaTimeFeature#ALWAYS_WRITE_SUBSECOND_DIGITS} + * is enabled. + *

+ * These differ from the JDK counterparts only in the sub-second field: instead of + * omitting it when zero, at least 3 (millisecond) digits are always written, and up + * to 9 when the value carries higher precision (so nothing is truncated). + * + * @since 2.23 + */ +class SubSecondFormatters +{ + private SubSecondFormatters() { } + + /** + * Date and time down to the seconds field, followed by 3 to 9 sub-second digits: + * the shared prefix of all formatters here. + */ + private static DateTimeFormatterBuilder _localDateTimeBuilder() { + return new DateTimeFormatterBuilder() + .append(DateTimeFormatter.ISO_LOCAL_DATE) + .appendLiteral('T') + .appendValue(ChronoField.HOUR_OF_DAY, 2) + .appendLiteral(':') + .appendValue(ChronoField.MINUTE_OF_HOUR, 2) + .appendLiteral(':') + .appendValue(ChronoField.SECOND_OF_MINUTE, 2) + .appendFraction(ChronoField.NANO_OF_SECOND, 3, 9, true); + } + + /** + * Counterpart of {@link DateTimeFormatter#ISO_LOCAL_DATE_TIME}. + */ + public final static DateTimeFormatter LOCAL_DATE_TIME = _localDateTimeBuilder() + .toFormatter(); + + /** + * Counterpart of {@link DateTimeFormatter#ISO_OFFSET_DATE_TIME}. + */ + public final static DateTimeFormatter OFFSET_DATE_TIME = _localDateTimeBuilder() + .appendOffsetId() + .toFormatter(); + + /** + * Counterpart of {@link DateTimeFormatter#ISO_ZONED_DATE_TIME}, that is, + * {@link #OFFSET_DATE_TIME} with the optional {@code [Zone/Id]} suffix. + */ + public final static DateTimeFormatter ZONED_DATE_TIME = new DateTimeFormatterBuilder() + .append(OFFSET_DATE_TIME) + .optionalStart() + .appendLiteral('[') + .parseCaseSensitive() + .appendZoneRegionId() + .appendLiteral(']') + .toFormatter(); + + /** + * Counterpart of {@link DateTimeFormatter#ISO_INSTANT} (and of + * {@link java.time.Instant#toString()}, which is what the default + * {@code Instant} serialization actually uses): UTC-based, so the + * offset is always rendered as {@code Z}. + */ + public final static DateTimeFormatter INSTANT = OFFSET_DATE_TIME + .withZone(ZoneOffset.UTC); +} diff --git a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/ZonedDateTimeSerializer.java b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/ZonedDateTimeSerializer.java index f84f950b..9375aafc 100644 --- a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/ZonedDateTimeSerializer.java +++ b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/ZonedDateTimeSerializer.java @@ -7,8 +7,10 @@ import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.core.util.JacksonFeatureSet; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeFeature; public class ZonedDateTimeSerializer extends InstantSerializerBase { private static final long serialVersionUID = 1L; @@ -21,7 +23,15 @@ public class ZonedDateTimeSerializer extends InstantSerializerBase features) { + if (features.isEnabled(JavaTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS)) { + return new ZonedDateTimeSerializer(this, + SubSecondFormatters.OFFSET_DATE_TIME, SubSecondFormatters.ZONED_DATE_TIME); + } + return this; } @Override @@ -89,7 +125,7 @@ public void serialize(ZonedDateTime value, JsonGenerator g, SerializerProvider p ; // use default handling } else if (shouldWriteWithZoneId(provider)) { // write with zone - g.writeString(DateTimeFormatter.ISO_ZONED_DATE_TIME.format(value)); + g.writeString(_zoneIdFormatter().format(value)); return; } } @@ -110,6 +146,13 @@ protected String formatValue(ZonedDateTime value, SerializerProvider provider) { return formatted; } + /** + * @since 2.23 + */ + protected DateTimeFormatter _zoneIdFormatter() { + return (_zoneIdFormat == null) ? DateTimeFormatter.ISO_ZONED_DATE_TIME : _zoneIdFormat; + } + /** * @since 2.8 */ diff --git a/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/ser/AlwaysWriteSubSecondDigits76Test.java b/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/ser/AlwaysWriteSubSecondDigits76Test.java new file mode 100644 index 00000000..965d56db --- /dev/null +++ b/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/ser/AlwaysWriteSubSecondDigits76Test.java @@ -0,0 +1,149 @@ +package com.fasterxml.jackson.datatype.jsr310.ser; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; +import java.util.Locale; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.datatype.jsr310.ModuleTestBase; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Tests for [modules-java8#76]: {@link JavaTimeFeature#ALWAYS_WRITE_SUBSECOND_DIGITS}. + */ +public class AlwaysWriteSubSecondDigits76Test extends ModuleTestBase +{ + static class Wrapper { + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") + public OffsetDateTime value; + + Wrapper(OffsetDateTime v) { value = v; } + } + + // NOTE: cannot use `ModuleTestBase.mapperBuilder()` here, since it already registers a + // plain `JavaTimeModule` and duplicate registrations of the same module are ignored + private static JsonMapper.Builder builderWithFeature() { + return JsonMapper.builder() + .defaultLocale(Locale.ENGLISH) + .addModule(new JavaTimeModule() + .enable(JavaTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS)); + } + + private final ObjectMapper MAPPER = builderWithFeature() + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .build(); + + private final ObjectMapper DEFAULT_MAPPER = mapperBuilder() + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .build(); + + @Test + public void testInstantZeroSubSecond() throws Exception + { + Instant value = Instant.parse("2017-09-14T04:28:48Z"); + // Default: sub-second field omitted entirely + assertEquals(q("2017-09-14T04:28:48Z"), DEFAULT_MAPPER.writeValueAsString(value)); + // Enabled: zero-padded to millisecond precision + assertEquals(q("2017-09-14T04:28:48.000Z"), MAPPER.writeValueAsString(value)); + } + + @Test + public void testInstantHigherPrecisionNotTruncated() throws Exception + { + assertEquals(q("2017-09-14T04:28:48.100Z"), + MAPPER.writeValueAsString(Instant.parse("2017-09-14T04:28:48.100Z"))); + assertEquals(q("2017-09-14T04:28:48.123456Z"), + MAPPER.writeValueAsString(Instant.parse("2017-09-14T04:28:48.123456Z"))); + assertEquals(q("2017-09-14T04:28:48.123456789Z"), + MAPPER.writeValueAsString(Instant.parse("2017-09-14T04:28:48.123456789Z"))); + } + + @Test + public void testOffsetDateTime() throws Exception + { + OffsetDateTime value = OffsetDateTime.parse("2017-09-14T04:28:48+02:00"); + assertEquals(q("2017-09-14T04:28:48+02:00"), DEFAULT_MAPPER.writeValueAsString(value)); + assertEquals(q("2017-09-14T04:28:48.000+02:00"), MAPPER.writeValueAsString(value)); + + // Note: the JDK ISO formatter renders 100 msec as ".1"; with the feature on, + // width is stable at (at least) 3 digits + OffsetDateTime millis = OffsetDateTime.parse("2017-09-14T04:28:48.100+02:00"); + assertEquals(q("2017-09-14T04:28:48.1+02:00"), DEFAULT_MAPPER.writeValueAsString(millis)); + assertEquals(q("2017-09-14T04:28:48.100+02:00"), MAPPER.writeValueAsString(millis)); + } + + @Test + public void testZonedDateTime() throws Exception + { + ZonedDateTime value = ZonedDateTime.parse("2017-09-14T04:28:48+02:00[Europe/Budapest]"); + assertEquals(q("2017-09-14T04:28:48.000+02:00"), MAPPER.writeValueAsString(value)); + } + + @Test + public void testZonedDateTimeWithZoneId() throws Exception + { + ObjectMapper mapper = builderWithFeature() + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .enable(SerializationFeature.WRITE_DATES_WITH_ZONE_ID) + .build(); + ZonedDateTime value = ZonedDateTime.parse("2017-09-14T04:28:48+02:00[Europe/Budapest]"); + assertEquals(q("2017-09-14T04:28:48.000+02:00[Europe/Budapest]"), + mapper.writeValueAsString(value)); + } + + @Test + public void testLocalDateTime() throws Exception + { + LocalDateTime value = LocalDateTime.parse("2017-09-14T04:28:48"); + assertEquals(q("2017-09-14T04:28:48"), DEFAULT_MAPPER.writeValueAsString(value)); + assertEquals(q("2017-09-14T04:28:48.000"), MAPPER.writeValueAsString(value)); + + // Seconds keep being written even when zero (as with the JDK ISO formatter) + LocalDateTime noSeconds = LocalDateTime.parse("2017-09-14T04:28"); + assertEquals(q("2017-09-14T04:28:00"), DEFAULT_MAPPER.writeValueAsString(noSeconds)); + assertEquals(q("2017-09-14T04:28:00.000"), MAPPER.writeValueAsString(noSeconds)); + } + + // Feature must not leak into numeric timestamp serialization + @Test + public void testTimestampsUnaffected() throws Exception + { + ObjectMapper mapper = builderWithFeature() + .enable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .build(); + assertEquals("1505363328.000000000", + mapper.writeValueAsString(Instant.parse("2017-09-14T04:28:48Z"))); + } + + // ... nor override an explicit `@JsonFormat` pattern + @Test + public void testExplicitPatternWins() throws Exception + { + assertEquals(a2q("{'value':'2017-09-14T04:28:48'}"), + MAPPER.writeValueAsString(new Wrapper(OffsetDateTime.parse("2017-09-14T04:28:48Z")))); + } + + // Values written with the feature on must still be readable + @Test + public void testRoundTrip() throws Exception + { + for (String raw : new String[] { + "2017-09-14T04:28:48Z", "2017-09-14T04:28:48.123456789Z", + "1970-01-01T00:00:00Z", "+10000-09-14T04:28:48Z", "-0100-09-14T04:28:48Z" }) { + Instant value = Instant.parse(raw); + String json = MAPPER.writeValueAsString(value); + assertEquals(value, MAPPER.readValue(json, Instant.class), + "Round-trip failed for " + raw + " (serialized as " + json + ")"); + } + } +} diff --git a/release-notes/CREDITS-2.x b/release-notes/CREDITS-2.x index 58147120..b7a331bc 100644 --- a/release-notes/CREDITS-2.x +++ b/release-notes/CREDITS-2.x @@ -234,3 +234,12 @@ Boleslav Bobcik (@bbobcik) Albert Lovers (@AlbertLovers) * Reported, contributed fix for #381: Fix a potential problem in `JavaTimeModule._findFactory()` (2.21.0) + +Ryan (@rycler) + * Reported #76: Missing milliseconds, when parsing Java 8 date-time, if they are zeros + (2.23.0) + +Seonwoo Jung (@seonwooj0810) + * Contributed fix for #76: Missing milliseconds, when parsing Java 8 date-time, + if they are zeros + (2.23.0) diff --git a/release-notes/VERSION-2.x b/release-notes/VERSION-2.x index fa7849ab..ebe5db89 100644 --- a/release-notes/VERSION-2.x +++ b/release-notes/VERSION-2.x @@ -10,7 +10,9 @@ Modules: 2.23.0 (not yet released) -No changes since 2.22 +#76: Missing milliseconds, when parsing Java 8 date-time, if they are zeros + (reported by @rycler) + (fix contributed by Seonwoo J) 2.22.1 (07-Jul-2026) 2.22.0 (31-May-2026) From ca53f712731d3f806e9d50f17330af82b571160a Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Sun, 9 Aug 2026 20:37:22 -0700 Subject: [PATCH 2/3] Fixes to handling --- .../jsr310/ser/InstantSerializer.java | 49 +++++++++++++++-- .../jsr310/ser/SubSecondFormatters.java | 16 +++--- .../ser/AlwaysWriteSubSecondDigits76Test.java | 54 +++++++++++++++++++ release-notes/CREDITS-2.x | 4 +- release-notes/VERSION-2.x | 2 +- 5 files changed, 109 insertions(+), 16 deletions(-) diff --git a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/InstantSerializer.java b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/InstantSerializer.java index de19a3e9..0340d177 100644 --- a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/InstantSerializer.java +++ b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/InstantSerializer.java @@ -23,6 +23,7 @@ import java.time.format.DateTimeFormatter; import com.fasterxml.jackson.core.util.JacksonFeatureSet; +import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.datatype.jsr310.JavaTimeFeature; /** @@ -37,10 +38,20 @@ public class InstantSerializer extends InstantSerializerBase public static final InstantSerializer INSTANCE = new InstantSerializer(); + /** + * Whether {@link com.fasterxml.jackson.datatype.jsr310.JavaTimeFeature#ALWAYS_WRITE_SUBSECOND_DIGITS} + * is enabled: if so, the default representation is padded to at least 3 sub-second + * digits. + * + * @since 2.23 + */ + private final boolean _alwaysWriteSubsecondDigits; + protected InstantSerializer() { super(Instant.class, Instant::toEpochMilli, Instant::getEpochSecond, Instant::getNano, // null -> use 'value.toString()', default format null); + _alwaysWriteSubsecondDigits = false; } @Deprecated // since 2.14 @@ -55,18 +66,21 @@ protected InstantSerializer(InstantSerializer base, protected InstantSerializer(InstantSerializer base, Boolean useTimestamp, DateTimeFormatter formatter, JsonFormat.Shape shape) { super(base, useTimestamp, base._useNanoseconds, formatter, shape); + _alwaysWriteSubsecondDigits = base._alwaysWriteSubsecondDigits; } protected InstantSerializer(InstantSerializer base, Boolean useTimestamp, Boolean useNanoseconds, DateTimeFormatter formatter) { super(base, useTimestamp, useNanoseconds, formatter); + _alwaysWriteSubsecondDigits = base._alwaysWriteSubsecondDigits; } /** * @since 2.23 */ - protected InstantSerializer(InstantSerializer base, DateTimeFormatter defaultFormat) { - super(base, defaultFormat); + protected InstantSerializer(InstantSerializer base, boolean alwaysWriteSubsecondDigits) { + super(base, base._useTimestamp, base._useNanoseconds, base._formatter, base._shape); + _alwaysWriteSubsecondDigits = alwaysWriteSubsecondDigits; } /** @@ -77,11 +91,40 @@ protected InstantSerializer(InstantSerializer base, DateTimeFormatter defaultFor */ public InstantSerializer withFeatures(JacksonFeatureSet features) { if (features.isEnabled(JavaTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS)) { - return new InstantSerializer(this, SubSecondFormatters.INSTANT); + return new InstantSerializer(this, true); } return this; } + /** + * Overridden to implement + * {@link com.fasterxml.jackson.datatype.jsr310.JavaTimeFeature#ALWAYS_WRITE_SUBSECOND_DIGITS} + * by padding the default representation, instead of swapping in a different formatter. + *

+ * Rationale: the default representation is {@link Instant#toString()}, that is, + * {@link DateTimeFormatter#ISO_INSTANT}, which writes exactly 0, 3, 6 or 9 sub-second + * digits -- so the only case needing a fix is the zero one. Formatting through a + * zone-bound {@code DateTimeFormatter} instead would resolve the value via + * {@link java.time.LocalDateTime}, whose year range is narrower than that of + * {@code Instant}, and would thereby fail for {@link Instant#MIN} / {@link Instant#MAX}. + * + * @since 2.23 + */ + @Override + protected String formatValue(Instant value, SerializerProvider provider) + { + String formatted = super.formatValue(value, provider); + // Only applies to the default representation: an explicit formatter wins + if (_alwaysWriteSubsecondDigits && (_formatter == null) && (value.getNano() == 0)) { + final int last = formatted.length() - 1; + // Defensive: `ISO_INSTANT` always ends in 'Z', but do not corrupt output if not + if ((last >= 0) && (formatted.charAt(last) == 'Z')) { + formatted = formatted.substring(0, last) + ".000Z"; + } + } + return formatted; + } + @Override protected JSR310FormattedSerializerBase withFormat(Boolean useTimestamp, DateTimeFormatter formatter, JsonFormat.Shape shape) { diff --git a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/SubSecondFormatters.java b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/SubSecondFormatters.java index cb472a7b..e0e3fcf1 100644 --- a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/SubSecondFormatters.java +++ b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/SubSecondFormatters.java @@ -1,6 +1,5 @@ package com.fasterxml.jackson.datatype.jsr310.ser; -import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatterBuilder; import java.time.temporal.ChronoField; @@ -14,6 +13,12 @@ * These differ from the JDK counterparts only in the sub-second field: instead of * omitting it when zero, at least 3 (millisecond) digits are always written, and up * to 9 when the value carries higher precision (so nothing is truncated). + *

+ * Note that there is deliberately no counterpart of {@link DateTimeFormatter#ISO_INSTANT} + * here: formatting an {@link java.time.Instant} through a zone-bound formatter goes via + * {@link java.time.LocalDateTime}, whose year range is narrower than {@code Instant}'s, + * so {@link java.time.Instant#MIN} / {@link java.time.Instant#MAX} would fail. See + * {@link InstantSerializer#formatValue} for the handling used instead. * * @since 2.23 */ @@ -62,13 +67,4 @@ private static DateTimeFormatterBuilder _localDateTimeBuilder() { .appendZoneRegionId() .appendLiteral(']') .toFormatter(); - - /** - * Counterpart of {@link DateTimeFormatter#ISO_INSTANT} (and of - * {@link java.time.Instant#toString()}, which is what the default - * {@code Instant} serialization actually uses): UTC-based, so the - * offset is always rendered as {@code Z}. - */ - public final static DateTimeFormatter INSTANT = OFFSET_DATE_TIME - .withZone(ZoneOffset.UTC); } diff --git a/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/ser/AlwaysWriteSubSecondDigits76Test.java b/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/ser/AlwaysWriteSubSecondDigits76Test.java index 965d56db..4ecd5698 100644 --- a/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/ser/AlwaysWriteSubSecondDigits76Test.java +++ b/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/ser/AlwaysWriteSubSecondDigits76Test.java @@ -3,6 +3,7 @@ import java.time.Instant; import java.time.LocalDateTime; import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.Locale; @@ -30,6 +31,13 @@ static class Wrapper { Wrapper(OffsetDateTime v) { value = v; } } + static class ShapeOnlyWrapper { + @JsonFormat(shape = JsonFormat.Shape.STRING) + public Instant value; + + ShapeOnlyWrapper(Instant v) { value = v; } + } + // NOTE: cannot use `ModuleTestBase.mapperBuilder()` here, since it already registers a // plain `JavaTimeModule` and duplicate registrations of the same module are ignored private static JsonMapper.Builder builderWithFeature() { @@ -146,4 +154,50 @@ public void testRoundTrip() throws Exception "Round-trip failed for " + raw + " (serialized as " + json + ")"); } } + + // The full `Instant` range must keep working: it is wider than that of + // `LocalDateTime`, so the feature must not route `Instant` through a + // zone-bound formatter + @Test + public void testInstantMinMax() throws Exception + { + // no sub-second part -> padded + assertEquals(q("-1000000000-01-01T00:00:00Z"), + DEFAULT_MAPPER.writeValueAsString(Instant.MIN)); + assertEquals(q("-1000000000-01-01T00:00:00.000Z"), + MAPPER.writeValueAsString(Instant.MIN)); + + // already at nanosecond precision -> unchanged + assertEquals(q("+1000000000-12-31T23:59:59.999999999Z"), + DEFAULT_MAPPER.writeValueAsString(Instant.MAX)); + assertEquals(q("+1000000000-12-31T23:59:59.999999999Z"), + MAPPER.writeValueAsString(Instant.MAX)); + + // and just inside the `LocalDateTime` boundary, either side + assertEquals(q("-999999999-01-01T00:00:00.000Z"), + MAPPER.writeValueAsString(LocalDateTime.MIN.toInstant(ZoneOffset.UTC))); + assertEquals(q("-1000000000-12-31T23:59:59.000Z"), + MAPPER.writeValueAsString(LocalDateTime.MIN.toInstant(ZoneOffset.UTC).minusSeconds(1))); + } + + // `Instant.toString()` writes 0, 3, 6 or 9 sub-second digits; only the + // zero case may be rewritten, the rest must be passed through untouched + @Test + public void testInstantSubSecondWidthsPreserved() throws Exception + { + assertEquals(q("1970-01-01T00:00:00.000000001Z"), + MAPPER.writeValueAsString(Instant.ofEpochSecond(0, 1))); + assertEquals(q("1970-01-01T00:00:00.000001Z"), + MAPPER.writeValueAsString(Instant.ofEpochSecond(0, 1000))); + assertEquals(q("1970-01-01T00:00:00.001Z"), + MAPPER.writeValueAsString(Instant.ofEpochSecond(0, 1000000))); + } + + // `@JsonFormat(shape=STRING)` without a pattern must not lose the padding + @Test + public void testShapeStringWithoutPattern() throws Exception + { + assertEquals(a2q("{'value':'2017-09-14T04:28:48.000Z'}"), + MAPPER.writeValueAsString(new ShapeOnlyWrapper(Instant.parse("2017-09-14T04:28:48Z")))); + } } diff --git a/release-notes/CREDITS-2.x b/release-notes/CREDITS-2.x index b7a331bc..c8693d31 100644 --- a/release-notes/CREDITS-2.x +++ b/release-notes/CREDITS-2.x @@ -236,10 +236,10 @@ Albert Lovers (@AlbertLovers) (2.21.0) Ryan (@rycler) - * Reported #76: Missing milliseconds, when parsing Java 8 date-time, if they are zeros + * Reported #76: Missing milliseconds, when serializing Java 8 date-time, if they are zeros (2.23.0) Seonwoo Jung (@seonwooj0810) - * Contributed fix for #76: Missing milliseconds, when parsing Java 8 date-time, + * Contributed fix for #76: Missing milliseconds, when serializing Java 8 date-time, if they are zeros (2.23.0) diff --git a/release-notes/VERSION-2.x b/release-notes/VERSION-2.x index 5ea03079..411bc5d6 100644 --- a/release-notes/VERSION-2.x +++ b/release-notes/VERSION-2.x @@ -10,7 +10,7 @@ Modules: 2.23.0 (not yet released) -#76: Missing milliseconds, when parsing Java 8 date-time, if they are zeros +#76: Missing milliseconds, when serializing Java 8 date-time, if they are zeros (reported by @rycler) (fix contributed by Seonwoo J) From ddd805bf1945238da35bd1fb0f9c534b32fa4068 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Mon, 10 Aug 2026 18:54:03 -0700 Subject: [PATCH 3/3] Fixing stuff --- .../datatype/jsr310/JavaTimeFeature.java | 11 ++++- .../deser/JSR310DateTimeDeserializerBase.java | 2 +- .../jsr310/ser/InstantSerializerBase.java | 17 ++----- .../ser/JSR310FormattedSerializerBase.java | 44 ++++++++++++++++++- .../jsr310/ser/LocalDateTimeSerializer.java | 17 ++----- .../jsr310/ser/SubSecondFormatters.java | 41 ++++++++++++----- .../jsr310/ser/ZonedDateTimeSerializer.java | 6 +++ release-notes/VERSION-2.x | 3 +- 8 files changed, 98 insertions(+), 43 deletions(-) diff --git a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/JavaTimeFeature.java b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/JavaTimeFeature.java index 5d738540..36bebe27 100644 --- a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/JavaTimeFeature.java +++ b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/JavaTimeFeature.java @@ -56,8 +56,8 @@ public enum JavaTimeFeature implements JacksonFeature /** * Feature that determines whether sub-second digits are always written when * serializing {@link java.time.Instant}, {@link java.time.OffsetDateTime}, - * {@link java.time.ZonedDateTime} and {@link java.time.LocalDateTime} as - * ISO-8601 Strings using the default format. + * {@link java.time.ZonedDateTime} and {@link java.time.LocalDateTime} values + * as ISO-8601 Strings using the default format. *

* When disabled (the default), the JDK-provided ISO formatters are used and * a zero sub-second value is omitted altogether -- {@code 2017-09-14T04:28:48Z} @@ -73,6 +73,13 @@ public enum JavaTimeFeature implements JacksonFeature * a {@link com.fasterxml.jackson.annotation.JsonFormat} pattern takes precedence, * as does writing values as numeric timestamps. *

+ * Also note that this only applies to values, and NOT to {@link java.util.Map} + * keys: date/time keys keep being written using the JDK-provided ISO formatters, + * so a zero sub-second value is still omitted there. Types other than the four + * listed above -- notably {@link java.time.LocalTime} and + * {@link java.time.OffsetTime}, whose ISO formats also omit the seconds field -- + * are likewise unaffected. + *

* Default setting is disabled, for backwards compatibility. * * @since 2.23 diff --git a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/deser/JSR310DateTimeDeserializerBase.java b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/deser/JSR310DateTimeDeserializerBase.java index 8ec7efba..68e42954 100644 --- a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/deser/JSR310DateTimeDeserializerBase.java +++ b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/deser/JSR310DateTimeDeserializerBase.java @@ -27,7 +27,7 @@ public abstract class JSR310DateTimeDeserializerBase protected final DateTimeFormatter _formatter; /** - * Setting that indicates the {@Link JsonFormat.Shape} specified for this deserializer + * Setting that indicates the {@link JsonFormat.Shape} specified for this deserializer * as a {@link com.fasterxml.jackson.annotation.JsonFormat.Shape} annotation on * property or class, or due to per-type "config override", or from global settings: * If Shape is NUMBER_INT, the input value is considered to be epoch days. If not a diff --git a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/InstantSerializerBase.java b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/InstantSerializerBase.java index 866bbbb8..81c3ebd8 100644 --- a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/InstantSerializerBase.java +++ b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/InstantSerializerBase.java @@ -47,8 +47,6 @@ public abstract class InstantSerializerBase extends JSR310FormattedSerializerBase { - private final DateTimeFormatter defaultFormat; - private final ToLongFunction getEpochMillis; private final ToLongFunction getEpochSeconds; @@ -61,8 +59,7 @@ protected InstantSerializerBase(Class supportedType, ToLongFunction getEpo { // Bit complicated, just because we actually want to "hide" default formatter, // so that it won't accidentally force use of textual presentation - super(supportedType, null); - this.defaultFormat = defaultFormat; + super(supportedType, null, defaultFormat); this.getEpochMillis = getEpochMillis; this.getEpochSeconds = getEpochSeconds; this.getNanoseconds = getNanoseconds; @@ -86,26 +83,18 @@ protected InstantSerializerBase(InstantSerializerBase base, protected InstantSerializerBase(InstantSerializerBase base, Boolean useTimestamp, Boolean useNanoseconds, DateTimeFormatter dtf, JsonFormat.Shape shape) { super(base, useTimestamp, useNanoseconds, dtf, shape); - defaultFormat = base.defaultFormat; getEpochMillis = base.getEpochMillis; getEpochSeconds = base.getEpochSeconds; getNanoseconds = base.getNanoseconds; } /** - * Copy-constructor used for replacing the "hidden" default formatter -- and only - * that -- of an existing serializer; needed for - * {@link com.fasterxml.jackson.datatype.jsr310.JavaTimeFeature#ALWAYS_WRITE_SUBSECOND_DIGITS}. - * Note that the default formatter must NOT be passed as {@code _formatter}, since - * a non-null {@code _formatter} also forces serialization as a JSON String. - * * @since 2.23 */ protected InstantSerializerBase(InstantSerializerBase base, DateTimeFormatter defaultFormat) { - super(base, base._useTimestamp, base._useNanoseconds, base._formatter, base._shape); - this.defaultFormat = defaultFormat; + super(base, defaultFormat); getEpochMillis = base.getEpochMillis; getEpochSeconds = base.getEpochSeconds; getNanoseconds = base.getNanoseconds; @@ -166,7 +155,7 @@ protected JsonToken serializationShape(SerializerProvider provider) { // @since 2.12 protected String formatValue(T value, SerializerProvider provider) { - DateTimeFormatter formatter = (_formatter == null) ? defaultFormat :_formatter; + DateTimeFormatter formatter = (_formatter == null) ? _defaultFormat :_formatter; if (formatter != null) { if (formatter.getZone() == null) { // timezone set if annotated on property // If the user specified to use the context TimeZone explicitly, and the formatter provided doesn't contain a TZ diff --git a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/JSR310FormattedSerializerBase.java b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/JSR310FormattedSerializerBase.java index 0b465f95..b17bd3f3 100644 --- a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/JSR310FormattedSerializerBase.java +++ b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/JSR310FormattedSerializerBase.java @@ -65,6 +65,18 @@ abstract class JSR310FormattedSerializerBase */ protected final DateTimeFormatter _formatter; + /** + * Format to use when no explicit {@link #_formatter} is configured. Unlike + * {@code _formatter}, a non-null value here does NOT force serialization as a + * JSON String -- which is exactly why the two cannot be collapsed into one. + *

+ * May be {@code null}, in which case the sub-class decides the fallback + * (typically either a JDK {@code ISO_*} constant or {@code value.toString()}). + * + * @since 2.23 + */ + protected final DateTimeFormatter _defaultFormat; + protected final JsonFormat.Shape _shape; /** @@ -81,13 +93,22 @@ protected JSR310FormattedSerializerBase(Class supportedType) { protected JSR310FormattedSerializerBase(Class supportedType, DateTimeFormatter formatter) { + this(supportedType, formatter, null); + } + + /** + * @since 2.23 + */ + protected JSR310FormattedSerializerBase(Class supportedType, + DateTimeFormatter formatter, DateTimeFormatter defaultFormat) { super(supportedType); _useTimestamp = null; _useNanoseconds = null; _shape = null; _formatter = formatter; + _defaultFormat = defaultFormat; } - + protected JSR310FormattedSerializerBase(JSR310FormattedSerializerBase base, Boolean useTimestamp, DateTimeFormatter dtf, JsonFormat.Shape shape) { @@ -103,6 +124,27 @@ protected JSR310FormattedSerializerBase(JSR310FormattedSerializerBase base, _useNanoseconds = useNanoseconds; _formatter = dtf; _shape = shape; + _defaultFormat = base._defaultFormat; + } + + /** + * Copy-constructor used for replacing the default format -- and only that -- + * of an existing serializer; needed for + * {@link com.fasterxml.jackson.datatype.jsr310.JavaTimeFeature#ALWAYS_WRITE_SUBSECOND_DIGITS}. + * Note that the replacement must NOT be passed as {@code _formatter}, since a + * non-null {@code _formatter} also forces serialization as a JSON String. + * + * @since 2.23 + */ + protected JSR310FormattedSerializerBase(JSR310FormattedSerializerBase base, + DateTimeFormatter defaultFormat) + { + super(base.handledType()); + _useTimestamp = base._useTimestamp; + _useNanoseconds = base._useNanoseconds; + _formatter = base._formatter; + _shape = base._shape; + _defaultFormat = defaultFormat; } protected abstract JSR310FormattedSerializerBase withFormat(Boolean useTimestamp, diff --git a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/LocalDateTimeSerializer.java b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/LocalDateTimeSerializer.java index ca193993..f22bbf9d 100644 --- a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/LocalDateTimeSerializer.java +++ b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/LocalDateTimeSerializer.java @@ -42,35 +42,24 @@ public class LocalDateTimeSerializer extends JSR310FormattedSerializerBase withFormat(Boolean useTim } protected DateTimeFormatter _defaultFormatter() { - return (_defaultFormat == null) ? DateTimeFormatter.ISO_LOCAL_DATE_TIME : _defaultFormat; + return _defaultFormat; } @Override diff --git a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/SubSecondFormatters.java b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/SubSecondFormatters.java index e0e3fcf1..825bf845 100644 --- a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/SubSecondFormatters.java +++ b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/SubSecondFormatters.java @@ -1,7 +1,9 @@ package com.fasterxml.jackson.datatype.jsr310.ser; +import java.time.chrono.IsoChronology; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatterBuilder; +import java.time.format.ResolverStyle; import java.time.temporal.ChronoField; /** @@ -12,7 +14,10 @@ *

* These differ from the JDK counterparts only in the sub-second field: instead of * omitting it when zero, at least 3 (millisecond) digits are always written, and up - * to 9 when the value carries higher precision (so nothing is truncated). + * to 9 when the value carries higher precision (so nothing is truncated). They are + * otherwise built the same way -- including {@link ResolverStyle#STRICT} and the ISO + * chronology -- so that they remain drop-in replacements even though they are only + * ever used for printing here. *

* Note that there is deliberately no counterpart of {@link DateTimeFormatter#ISO_INSTANT} * here: formatting an {@link java.time.Instant} through a zone-bound formatter goes via @@ -42,29 +47,45 @@ private static DateTimeFormatterBuilder _localDateTimeBuilder() { .appendFraction(ChronoField.NANO_OF_SECOND, 3, 9, true); } + /** + * {@link #_localDateTimeBuilder()} followed by the offset: the shared prefix of the + * offset- and zone-based formatters. + */ + private static DateTimeFormatterBuilder _offsetDateTimeBuilder() { + return _localDateTimeBuilder().appendOffsetId(); + } + + /** + * Completes a builder the way the JDK completes its own {@code ISO_*} constants. + *

+ * Note that {@link DateTimeFormatterBuilder#toFormatter()} alone would yield + * {@link ResolverStyle#SMART} and no chronology, which is not what the constants + * these replace do. + */ + private static DateTimeFormatter _isoFormatter(DateTimeFormatterBuilder b) { + return b.toFormatter() + .withResolverStyle(ResolverStyle.STRICT) + .withChronology(IsoChronology.INSTANCE); + } + /** * Counterpart of {@link DateTimeFormatter#ISO_LOCAL_DATE_TIME}. */ - public final static DateTimeFormatter LOCAL_DATE_TIME = _localDateTimeBuilder() - .toFormatter(); + final static DateTimeFormatter LOCAL_DATE_TIME = _isoFormatter(_localDateTimeBuilder()); /** * Counterpart of {@link DateTimeFormatter#ISO_OFFSET_DATE_TIME}. */ - public final static DateTimeFormatter OFFSET_DATE_TIME = _localDateTimeBuilder() - .appendOffsetId() - .toFormatter(); + final static DateTimeFormatter OFFSET_DATE_TIME = _isoFormatter(_offsetDateTimeBuilder()); /** * Counterpart of {@link DateTimeFormatter#ISO_ZONED_DATE_TIME}, that is, * {@link #OFFSET_DATE_TIME} with the optional {@code [Zone/Id]} suffix. */ - public final static DateTimeFormatter ZONED_DATE_TIME = new DateTimeFormatterBuilder() - .append(OFFSET_DATE_TIME) + final static DateTimeFormatter ZONED_DATE_TIME = _isoFormatter(_offsetDateTimeBuilder() .optionalStart() .appendLiteral('[') .parseCaseSensitive() .appendZoneRegionId() - .appendLiteral(']') - .toFormatter(); + .appendLiteral(']')); } diff --git a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/ZonedDateTimeSerializer.java b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/ZonedDateTimeSerializer.java index 9375aafc..58d0817c 100644 --- a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/ZonedDateTimeSerializer.java +++ b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/ZonedDateTimeSerializer.java @@ -27,6 +27,12 @@ public class ZonedDateTimeSerializer extends InstantSerializerBase + * Separate from the inherited {@code _defaultFormat} on purpose: writing with the + * zone id is a second output shape that has always used {@code ISO_ZONED_DATE_TIME} + * regardless of the default format (including a custom one passed to + * {@link #ZonedDateTimeSerializer(DateTimeFormatter)}), so the two cannot be + * collapsed without changing existing behaviour. * * @since 2.23 */ diff --git a/release-notes/VERSION-2.x b/release-notes/VERSION-2.x index 411bc5d6..e19f3731 100644 --- a/release-notes/VERSION-2.x +++ b/release-notes/VERSION-2.x @@ -10,7 +10,8 @@ Modules: 2.23.0 (not yet released) -#76: Missing milliseconds, when serializing Java 8 date-time, if they are zeros +#76: Missing milliseconds, when serializing Java 8 date-time if they are zeros (Add + `JavaTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS`) (reported by @rycler) (fix contributed by Seonwoo J)