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)