From 6bd6d6fd41c7eeee3505643049578789c86f0629 Mon Sep 17 00:00:00 2001 From: seonwooj0810 Date: Sat, 8 Aug 2026 20:56:57 +0900 Subject: [PATCH 1/7] Add DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS (modules-java8#76) Ports the 2.x JavaTimeFeature of the same name (modules-java8#386) to 3.x's ctxt.isEnabled(...) pattern already used by TRUNCATE_TO_MSECS_ON_WRITE, rather than the 2.x withFeatures(JacksonFeatureSet) module-setup wiring. When enabled, Instant/OffsetDateTime/ZonedDateTime/LocalDateTime always serialize with at least millisecond-precision sub-second digits instead of omitting the field when it's zero. Explicit formatters/patterns and numeric-timestamp serialization are unaffected. --- release-notes/CREDITS | 5 + release-notes/VERSION | 5 + .../jackson/databind/cfg/DateTimeFeature.java | 26 ++++ .../ext/javatime/ser/InstantSerializer.java | 5 + .../javatime/ser/InstantSerializerBase.java | 18 +++ .../javatime/ser/LocalDateTimeSerializer.java | 30 ++-- .../ser/OffsetDateTimeSerializer.java | 5 + .../ext/javatime/ser/SubSecondFormatters.java | 74 +++++++++ .../javatime/ser/ZonedDateTimeSerializer.java | 10 +- .../AlwaysWriteSubSecondDigitsTest.java | 147 ++++++++++++++++++ .../databind/ext/javatime/TestFeatures.java | 7 + 11 files changed, 321 insertions(+), 11 deletions(-) create mode 100644 src/main/java/tools/jackson/databind/ext/javatime/ser/SubSecondFormatters.java create mode 100644 src/test/java/tools/jackson/databind/ext/javatime/AlwaysWriteSubSecondDigitsTest.java diff --git a/release-notes/CREDITS b/release-notes/CREDITS index 11c5b3a518..06c6d1dd61 100644 --- a/release-notes/CREDITS +++ b/release-notes/CREDITS @@ -605,6 +605,11 @@ seonwoo_jung (@seonwooj0810) * Fixed #6065: `SerializationFeature.APPLY_JSON_INCLUDE_FOR_CONTAINERS` does not fully remove empty collection during serialization [3.2.1] + * Contributed [modules-java8#76]: Add `DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS` + to always write millisecond+ sub-second digits when serializing `Instant`, + `OffsetDateTime`, `ZonedDateTime` and `LocalDateTime` (ported from the 2.x + `JavaTimeFeature` of the same name, modules-java8#386) + [3.3.0] * Fixed #6101: `@JsonInclude(NON_EMPTY, content=CUSTOM)` does not omit a Map property after all entries are filtered [3.2.2] diff --git a/release-notes/VERSION b/release-notes/VERSION index ce6c0be76d..3de3871577 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -7,6 +7,11 @@ Versions: 3.x (for earlier see VERSION-2.x) 3.3.0 (not yet released) +[modules-java8#76]: Add `DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS` to always write + millisecond+ sub-second digits when serializing `Instant`, `OffsetDateTime`, + `ZonedDateTime` and `LocalDateTime` (ported from the 2.x `JavaTimeFeature` of the + same name, modules-java8#386) + (contributed by @seonwooj0810) #1127: `@JsonTypeInfo` with `EXTERNAL_PROPERTY` does not handle arrays of polymorphic types: now fails eagerly with clear `InvalidDefinitionException` (on both serialization and deserialization) instead of confusing low-level error diff --git a/src/main/java/tools/jackson/databind/cfg/DateTimeFeature.java b/src/main/java/tools/jackson/databind/cfg/DateTimeFeature.java index 083396f3f2..e13765182b 100644 --- a/src/main/java/tools/jackson/databind/cfg/DateTimeFeature.java +++ b/src/main/java/tools/jackson/databind/cfg/DateTimeFeature.java @@ -49,6 +49,32 @@ public enum DateTimeFeature implements DatatypeFeature */ ALWAYS_ALLOW_STRINGIFIED_DATE_TIMESTAMPS(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 3.3 + */ + ALWAYS_WRITE_SUBSECOND_DIGITS(false), + /** * Feature that determines whether {@link java.time.ZoneId} is normalized * (via call to {@code java.time.ZoneId#normalized()}) when deserializing diff --git a/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializer.java b/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializer.java index 5b8b401b08..29a4d6bc4b 100644 --- a/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializer.java +++ b/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializer.java @@ -62,4 +62,9 @@ protected JSR310FormattedSerializerBase withFeatures(Boolean writeZoneId, Boo return new InstantSerializer(this, _formatter, _useTimestamp, writeNanoseconds, this._shape); } + + @Override + protected DateTimeFormatter _alwaysWriteSubSecondDigitsFormatter() { + return SubSecondFormatters.INSTANT; + } } diff --git a/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializerBase.java b/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializerBase.java index 2d3bc5e7fe..5de7bb3ac5 100644 --- a/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializerBase.java +++ b/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializerBase.java @@ -134,9 +134,27 @@ protected JsonToken serializationShape(SerializationContext ctxt) { return JsonToken.VALUE_STRING; } + /** + * Overridden by subclasses to supply a formatter equivalent to {@link #defaultFormat} + * that always writes at least millisecond-precision sub-second digits, for use with + * {@link DateTimeFeature#ALWAYS_WRITE_SUBSECOND_DIGITS}. Returning {@code null} (the + * default) means the subclass has no such counterpart, and the feature has no effect. + * + * @since 3.3 + */ + protected DateTimeFormatter _alwaysWriteSubSecondDigitsFormatter() { + return null; + } + protected String formatValue(T value, SerializationContext ctxt) { DateTimeFormatter formatter = (_formatter == null) ? defaultFormat :_formatter; + if ((_formatter == null) && ctxt.isEnabled(DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS)) { + DateTimeFormatter subSecondFormatter = _alwaysWriteSubSecondDigitsFormatter(); + if (subSecondFormatter != null) { + formatter = subSecondFormatter; + } + } 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/src/main/java/tools/jackson/databind/ext/javatime/ser/LocalDateTimeSerializer.java b/src/main/java/tools/jackson/databind/ext/javatime/ser/LocalDateTimeSerializer.java index 9c70236e3e..8bde36fd18 100644 --- a/src/main/java/tools/jackson/databind/ext/javatime/ser/LocalDateTimeSerializer.java +++ b/src/main/java/tools/jackson/databind/ext/javatime/ser/LocalDateTimeSerializer.java @@ -76,11 +76,7 @@ public void serialize(LocalDateTime value, JsonGenerator g, SerializationContext _serializeAsArrayContents(value, g, ctxt); g.writeEndArray(); } else { - DateTimeFormatter dtf = _formatter; - if (dtf == null) { - dtf = _defaultFormatter(); - } - g.writeString(value.format(dtf)); + g.writeString(value.format(_effectiveFormatter(ctxt))); } } @@ -101,15 +97,29 @@ public void serializeWithType(LocalDateTime value, JsonGenerator g, Serializatio && typeIdDef.valueShape == JsonToken.START_ARRAY) { _serializeAsArrayContents(value, g, ctxt); } else { - DateTimeFormatter dtf = _formatter; - if (dtf == null) { - dtf = _defaultFormatter(); - } - g.writeString(value.format(dtf)); + g.writeString(value.format(_effectiveFormatter(ctxt))); } typeSer.writeTypeSuffix(g, ctxt, typeIdDef); } + /** + * Resolves the formatter to use when no numeric-timestamp shape applies: the + * explicit per-property {@code _formatter} if set, else the plain default, or -- + * if {@link DateTimeFeature#ALWAYS_WRITE_SUBSECOND_DIGITS} is enabled -- a + * counterpart that always writes sub-second digits. + * + * @since 3.3 + */ + private DateTimeFormatter _effectiveFormatter(SerializationContext ctxt) { + if (_formatter != null) { + return _formatter; + } + if (ctxt.isEnabled(DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS)) { + return SubSecondFormatters.LOCAL_DATE_TIME; + } + return _defaultFormatter(); + } + private final void _serializeAsArrayContents(LocalDateTime value, JsonGenerator g, SerializationContext ctxt) throws JacksonException diff --git a/src/main/java/tools/jackson/databind/ext/javatime/ser/OffsetDateTimeSerializer.java b/src/main/java/tools/jackson/databind/ext/javatime/ser/OffsetDateTimeSerializer.java index bd47ae26cb..ee82fa005c 100644 --- a/src/main/java/tools/jackson/databind/ext/javatime/ser/OffsetDateTimeSerializer.java +++ b/src/main/java/tools/jackson/databind/ext/javatime/ser/OffsetDateTimeSerializer.java @@ -61,4 +61,9 @@ protected JSR310FormattedSerializerBase withFeatures(Boolean writeZoneId, Boo return new OffsetDateTimeSerializer(this, _formatter, _useTimestamp, writeNanoseconds, _shape); } + + @Override + protected DateTimeFormatter _alwaysWriteSubSecondDigitsFormatter() { + return SubSecondFormatters.OFFSET_DATE_TIME; + } } diff --git a/src/main/java/tools/jackson/databind/ext/javatime/ser/SubSecondFormatters.java b/src/main/java/tools/jackson/databind/ext/javatime/ser/SubSecondFormatters.java new file mode 100644 index 0000000000..4b88ab2e92 --- /dev/null +++ b/src/main/java/tools/jackson/databind/ext/javatime/ser/SubSecondFormatters.java @@ -0,0 +1,74 @@ +package tools.jackson.databind.ext.javatime.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 tools.jackson.databind.cfg.DateTimeFeature#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 3.3 + */ +final 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}. + */ + static final DateTimeFormatter LOCAL_DATE_TIME = _localDateTimeBuilder() + .toFormatter(); + + /** + * Counterpart of {@link DateTimeFormatter#ISO_OFFSET_DATE_TIME}. + */ + static final 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. + */ + static final 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}. + */ + static final DateTimeFormatter INSTANT = OFFSET_DATE_TIME + .withZone(ZoneOffset.UTC); +} diff --git a/src/main/java/tools/jackson/databind/ext/javatime/ser/ZonedDateTimeSerializer.java b/src/main/java/tools/jackson/databind/ext/javatime/ser/ZonedDateTimeSerializer.java index 4641724a63..9a8aea5d83 100644 --- a/src/main/java/tools/jackson/databind/ext/javatime/ser/ZonedDateTimeSerializer.java +++ b/src/main/java/tools/jackson/databind/ext/javatime/ser/ZonedDateTimeSerializer.java @@ -78,7 +78,10 @@ public void serialize(ZonedDateTime value, JsonGenerator g, SerializationContext value = value.truncatedTo(ChronoUnit.MILLIS); } // write with zone - g.writeString(DateTimeFormatter.ISO_ZONED_DATE_TIME.format(value)); + DateTimeFormatter formatter = ctxt.isEnabled(DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS) + ? SubSecondFormatters.ZONED_DATE_TIME + : DateTimeFormatter.ISO_ZONED_DATE_TIME; + g.writeString(formatter.format(value)); return; } } @@ -111,4 +114,9 @@ protected JsonToken serializationShape(SerializationContext ctxt) { } return super.serializationShape(ctxt); } + + @Override + protected DateTimeFormatter _alwaysWriteSubSecondDigitsFormatter() { + return SubSecondFormatters.OFFSET_DATE_TIME; + } } diff --git a/src/test/java/tools/jackson/databind/ext/javatime/AlwaysWriteSubSecondDigitsTest.java b/src/test/java/tools/jackson/databind/ext/javatime/AlwaysWriteSubSecondDigitsTest.java new file mode 100644 index 0000000000..623154e3f8 --- /dev/null +++ b/src/test/java/tools/jackson/databind/ext/javatime/AlwaysWriteSubSecondDigitsTest.java @@ -0,0 +1,147 @@ +package tools.jackson.databind.ext.javatime; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.cfg.DateTimeFeature; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Tests for {@link DateTimeFeature#ALWAYS_WRITE_SUBSECOND_DIGITS}. + *

+ * Ported from the equivalent 2.x feature in + * {@code jackson-modules-java8} (see + * modules-java8#386, + * fixing modules-java8#76). + */ +public class AlwaysWriteSubSecondDigitsTest extends DateTimeTestBase +{ + static class Wrapper { + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") + public OffsetDateTime value; + + Wrapper(OffsetDateTime v) { value = v; } + } + + private final ObjectMapper MAPPER = newMapperBuilder() + .enable(DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS) + .build(); + + private final ObjectMapper DEFAULT_MAPPER = newMapper(); + + @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 = newMapperBuilder() + .enable(DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS) + .enable(DateTimeFeature.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)); + + // Same path, feature disabled: zero sub-second is omitted, matching the JDK formatter + ObjectMapper defaultZoneIdMapper = newMapperBuilder() + .enable(DateTimeFeature.WRITE_DATES_WITH_ZONE_ID) + .build(); + assertEquals(q("2017-09-14T04:28:48+02:00[Europe/Budapest]"), + defaultZoneIdMapper.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 = newMapperBuilder() + .enable(DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS) + .enable(DateTimeFeature.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/src/test/java/tools/jackson/databind/ext/javatime/TestFeatures.java b/src/test/java/tools/jackson/databind/ext/javatime/TestFeatures.java index 84472fe299..a5000f5db0 100644 --- a/src/test/java/tools/jackson/databind/ext/javatime/TestFeatures.java +++ b/src/test/java/tools/jackson/databind/ext/javatime/TestFeatures.java @@ -44,4 +44,11 @@ public void testAdjustDatesToContextTimeZoneSettingEnabledByDefault() assertTrue(DateTimeFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE.enabledByDefault(), "Adjust dates to context time zone setting should be enabled by default."); } + + @Test + public void testAlwaysWriteSubSecondDigitsSettingDisabledByDefault() + { + assertFalse(DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS.enabledByDefault(), + "Always write sub-second digits setting should be disabled by default."); + } } From 8eca33a41de501b2e5b15a22879abca9922f6107 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Mon, 10 Aug 2026 19:00:27 -0700 Subject: [PATCH 2/7] Fix release notes --- release-notes/CREDITS | 7 ++----- release-notes/VERSION | 7 ++----- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/release-notes/CREDITS b/release-notes/CREDITS index 06c6d1dd61..e4caea5ace 100644 --- a/release-notes/CREDITS +++ b/release-notes/CREDITS @@ -605,14 +605,11 @@ seonwoo_jung (@seonwooj0810) * Fixed #6065: `SerializationFeature.APPLY_JSON_INCLUDE_FOR_CONTAINERS` does not fully remove empty collection during serialization [3.2.1] - * Contributed [modules-java8#76]: Add `DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS` - to always write millisecond+ sub-second digits when serializing `Instant`, - `OffsetDateTime`, `ZonedDateTime` and `LocalDateTime` (ported from the 2.x - `JavaTimeFeature` of the same name, modules-java8#386) - [3.3.0] * Fixed #6101: `@JsonInclude(NON_EMPTY, content=CUSTOM)` does not omit a Map property after all entries are filtered [3.2.2] + * Contributed #6151: Add `DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS` + [3.3.0] @doeiqts * Reported #6065: `SerializationFeature.APPLY_JSON_INCLUDE_FOR_CONTAINERS` does not fully diff --git a/release-notes/VERSION b/release-notes/VERSION index 3de3871577..d1015f51d5 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -7,11 +7,6 @@ Versions: 3.x (for earlier see VERSION-2.x) 3.3.0 (not yet released) -[modules-java8#76]: Add `DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS` to always write - millisecond+ sub-second digits when serializing `Instant`, `OffsetDateTime`, - `ZonedDateTime` and `LocalDateTime` (ported from the 2.x `JavaTimeFeature` of the - same name, modules-java8#386) - (contributed by @seonwooj0810) #1127: `@JsonTypeInfo` with `EXTERNAL_PROPERTY` does not handle arrays of polymorphic types: now fails eagerly with clear `InvalidDefinitionException` (on both serialization and deserialization) instead of confusing low-level error @@ -43,6 +38,8 @@ Versions: 3.x (for earlier see VERSION-2.x) #6142: Invalidate read-only lookup snapshot in `SerializerCache` when a typed serializer entry is replaced (and not just when added) (fix by @Dongnyoung) +#6151: Add `DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS` + (contributed by @seonwooj0810) 3.2.2 (not yet released) From 27207211f885e9087f0ae849a54ef8298169c051 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Mon, 10 Aug 2026 19:13:16 -0700 Subject: [PATCH 3/7] Fix an issue wrt overrides --- .../ext/javatime/ser/InstantSerializer.java | 6 ++++-- .../javatime/ser/InstantSerializerBase.java | 20 +++++++++++++------ .../ser/OffsetDateTimeSerializer.java | 5 +++-- .../javatime/ser/ZonedDateTimeSerializer.java | 7 +++++-- .../AlwaysWriteSubSecondDigitsTest.java | 17 ++++++++++++++++ 5 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializer.java b/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializer.java index 29a4d6bc4b..89b4dcf416 100644 --- a/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializer.java +++ b/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializer.java @@ -64,7 +64,9 @@ protected JSR310FormattedSerializerBase withFeatures(Boolean writeZoneId, Boo } @Override - protected DateTimeFormatter _alwaysWriteSubSecondDigitsFormatter() { - return SubSecondFormatters.INSTANT; + protected DateTimeFormatter _alwaysWriteSubSecondDigitsFormatter(DateTimeFormatter defaultFormat) { + // Standard default for `Instant` is `null` (meaning `Instant.toString()`); + // anything else is caller-provided and must be left alone + return (defaultFormat == null) ? SubSecondFormatters.INSTANT : null; } } diff --git a/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializerBase.java b/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializerBase.java index 5de7bb3ac5..7be12abd17 100644 --- a/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializerBase.java +++ b/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializerBase.java @@ -135,14 +135,22 @@ protected JsonToken serializationShape(SerializationContext ctxt) { } /** - * Overridden by subclasses to supply a formatter equivalent to {@link #defaultFormat} - * that always writes at least millisecond-precision sub-second digits, for use with - * {@link DateTimeFeature#ALWAYS_WRITE_SUBSECOND_DIGITS}. Returning {@code null} (the - * default) means the subclass has no such counterpart, and the feature has no effect. + * Overridden by subclasses to supply a formatter equivalent to the standard + * built-in default that always writes at least millisecond-precision sub-second + * digits, for use with {@link DateTimeFeature#ALWAYS_WRITE_SUBSECOND_DIGITS}. + *

+ * Implementations MUST return {@code null} unless given {@code defaultFormat} is + * the standard built-in default of the subclass: some subclasses (notably + * {@link ZonedDateTimeSerializer}) allow caller-provided default formatters, and + * those must not be overridden by the feature. Returning {@code null} (which the + * base implementation always does) means the feature has no effect. + * + * @param defaultFormat Default formatter that would be used if the feature was + * not enabled (possibly {@code null}) * * @since 3.3 */ - protected DateTimeFormatter _alwaysWriteSubSecondDigitsFormatter() { + protected DateTimeFormatter _alwaysWriteSubSecondDigitsFormatter(DateTimeFormatter defaultFormat) { return null; } @@ -150,7 +158,7 @@ protected String formatValue(T value, SerializationContext ctxt) { DateTimeFormatter formatter = (_formatter == null) ? defaultFormat :_formatter; if ((_formatter == null) && ctxt.isEnabled(DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS)) { - DateTimeFormatter subSecondFormatter = _alwaysWriteSubSecondDigitsFormatter(); + DateTimeFormatter subSecondFormatter = _alwaysWriteSubSecondDigitsFormatter(defaultFormat); if (subSecondFormatter != null) { formatter = subSecondFormatter; } diff --git a/src/main/java/tools/jackson/databind/ext/javatime/ser/OffsetDateTimeSerializer.java b/src/main/java/tools/jackson/databind/ext/javatime/ser/OffsetDateTimeSerializer.java index ee82fa005c..9a3dfca3a5 100644 --- a/src/main/java/tools/jackson/databind/ext/javatime/ser/OffsetDateTimeSerializer.java +++ b/src/main/java/tools/jackson/databind/ext/javatime/ser/OffsetDateTimeSerializer.java @@ -63,7 +63,8 @@ protected JSR310FormattedSerializerBase withFeatures(Boolean writeZoneId, Boo } @Override - protected DateTimeFormatter _alwaysWriteSubSecondDigitsFormatter() { - return SubSecondFormatters.OFFSET_DATE_TIME; + protected DateTimeFormatter _alwaysWriteSubSecondDigitsFormatter(DateTimeFormatter defaultFormat) { + return (defaultFormat == DateTimeFormatter.ISO_OFFSET_DATE_TIME) + ? SubSecondFormatters.OFFSET_DATE_TIME : null; } } diff --git a/src/main/java/tools/jackson/databind/ext/javatime/ser/ZonedDateTimeSerializer.java b/src/main/java/tools/jackson/databind/ext/javatime/ser/ZonedDateTimeSerializer.java index 9a8aea5d83..ad3949eedf 100644 --- a/src/main/java/tools/jackson/databind/ext/javatime/ser/ZonedDateTimeSerializer.java +++ b/src/main/java/tools/jackson/databind/ext/javatime/ser/ZonedDateTimeSerializer.java @@ -116,7 +116,10 @@ protected JsonToken serializationShape(SerializationContext ctxt) { } @Override - protected DateTimeFormatter _alwaysWriteSubSecondDigitsFormatter() { - return SubSecondFormatters.OFFSET_DATE_TIME; + protected DateTimeFormatter _alwaysWriteSubSecondDigitsFormatter(DateTimeFormatter defaultFormat) { + // 10-Aug-2026, tatu: Caller may pass its own default formatter (see + // `ZonedDateTimeSerializer(DateTimeFormatter)`); if so, must not override it + return (defaultFormat == DateTimeFormatter.ISO_OFFSET_DATE_TIME) + ? SubSecondFormatters.OFFSET_DATE_TIME : null; } } diff --git a/src/test/java/tools/jackson/databind/ext/javatime/AlwaysWriteSubSecondDigitsTest.java b/src/test/java/tools/jackson/databind/ext/javatime/AlwaysWriteSubSecondDigitsTest.java index 623154e3f8..0e65033cae 100644 --- a/src/test/java/tools/jackson/databind/ext/javatime/AlwaysWriteSubSecondDigitsTest.java +++ b/src/test/java/tools/jackson/databind/ext/javatime/AlwaysWriteSubSecondDigitsTest.java @@ -4,6 +4,7 @@ import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; import org.junit.jupiter.api.Test; @@ -11,6 +12,8 @@ import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.cfg.DateTimeFeature; +import tools.jackson.databind.ext.javatime.ser.ZonedDateTimeSerializer; +import tools.jackson.databind.module.SimpleModule; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -131,6 +134,20 @@ public void testExplicitPatternWins() throws Exception MAPPER.writeValueAsString(new Wrapper(OffsetDateTime.parse("2017-09-14T04:28:48Z")))); } + // ... nor a default formatter passed to the serializer by the caller + @Test + public void testCallerProvidedDefaultFormatterWins() throws Exception + { + DateTimeFormatter df = DateTimeFormatter.ofPattern("yyyy_MM_dd'X'HH:mm:ss"); + ObjectMapper mapper = newMapperBuilder() + .addModule(new SimpleModule() + .addSerializer(new ZonedDateTimeSerializer(df))) + .enable(DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS) + .build(); + assertEquals(q("2017_09_14X04:28:48"), mapper.writeValueAsString( + ZonedDateTime.parse("2017-09-14T04:28:48+02:00[Europe/Budapest]"))); + } + // Values written with the feature on must still be readable @Test public void testRoundTrip() throws Exception From ed7e32a545fe8780b80ca9eacea6520d16538ab1 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Mon, 10 Aug 2026 19:17:14 -0700 Subject: [PATCH 4/7] More fixing --- .../jackson/databind/cfg/DateTimeFeature.java | 5 ++++ .../ext/javatime/ser/InstantSerializer.java | 23 +++++++++++++++-- .../javatime/ser/InstantSerializerBase.java | 12 ++++++--- .../ser/OffsetDateTimeSerializer.java | 3 ++- .../javatime/ser/ZonedDateTimeSerializer.java | 3 ++- .../AlwaysWriteSubSecondDigitsTest.java | 25 +++++++++++++++++++ 6 files changed, 63 insertions(+), 8 deletions(-) diff --git a/src/main/java/tools/jackson/databind/cfg/DateTimeFeature.java b/src/main/java/tools/jackson/databind/cfg/DateTimeFeature.java index e13765182b..3937c605bf 100644 --- a/src/main/java/tools/jackson/databind/cfg/DateTimeFeature.java +++ b/src/main/java/tools/jackson/databind/cfg/DateTimeFeature.java @@ -69,6 +69,11 @@ public enum DateTimeFeature implements DatatypeFeature * a {@link com.fasterxml.jackson.annotation.JsonFormat} pattern takes precedence, * as does writing values as numeric timestamps. *

+ * Note, too, that the very extremes of the {@link java.time.Instant} range + * (notably {@link java.time.Instant#MIN} and {@link java.time.Instant#MAX}, which + * fall outside the range of {@link java.time.LocalDate}) cannot be written with + * Date/Time fields at all, and retain default handling regardless of this setting. + *

* Default setting is disabled, for backwards compatibility. * * @since 3.3 diff --git a/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializer.java b/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializer.java index 89b4dcf416..5cae773b40 100644 --- a/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializer.java +++ b/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializer.java @@ -18,6 +18,7 @@ import com.fasterxml.jackson.annotation.JsonFormat; import java.time.Instant; +import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; @@ -31,6 +32,12 @@ public class InstantSerializer extends InstantSerializerBase { public static final InstantSerializer INSTANCE = new InstantSerializer(); + private final static long SECONDS_PER_DAY = 86400L; + + private final static long MIN_EPOCH_DAY = LocalDate.MIN.toEpochDay(); + + private final static long MAX_EPOCH_DAY = LocalDate.MAX.toEpochDay(); + protected InstantSerializer() { super(Instant.class, Instant::toEpochMilli, Instant::getEpochSecond, Instant::getNano, // null -> use 'value.toString()', default format @@ -64,9 +71,21 @@ protected JSR310FormattedSerializerBase withFeatures(Boolean writeZoneId, Boo } @Override - protected DateTimeFormatter _alwaysWriteSubSecondDigitsFormatter(DateTimeFormatter defaultFormat) { + protected DateTimeFormatter _alwaysWriteSubSecondDigitsFormatter(Instant value, + DateTimeFormatter defaultFormat) { // Standard default for `Instant` is `null` (meaning `Instant.toString()`); // anything else is caller-provided and must be left alone - return (defaultFormat == null) ? SubSecondFormatters.INSTANT : null; + if (defaultFormat != null) { + return null; + } + // Replacement formatter is Date/Time-field-based, so value must be convertible + // into `LocalDate`; `Instant` range is wider than that (by less than a year on + // both ends, but that includes `Instant.MIN` and `Instant.MAX`). For such + // extreme values retain default `Instant.toString()` handling instead of failing + final long epochDay = Math.floorDiv(value.getEpochSecond(), SECONDS_PER_DAY); + if ((epochDay < MIN_EPOCH_DAY) || (epochDay > MAX_EPOCH_DAY)) { + return null; + } + return SubSecondFormatters.INSTANT; } } diff --git a/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializerBase.java b/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializerBase.java index 7be12abd17..1261b7a6a0 100644 --- a/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializerBase.java +++ b/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializerBase.java @@ -142,15 +142,19 @@ protected JsonToken serializationShape(SerializationContext ctxt) { * Implementations MUST return {@code null} unless given {@code defaultFormat} is * the standard built-in default of the subclass: some subclasses (notably * {@link ZonedDateTimeSerializer}) allow caller-provided default formatters, and - * those must not be overridden by the feature. Returning {@code null} (which the - * base implementation always does) means the feature has no effect. + * those must not be overridden by the feature. Implementations should also return + * {@code null} for values the replacement cannot express, so that default handling + * is retained instead of failing. Returning {@code null} (which the base + * implementation always does) means the feature has no effect. * + * @param value Value being serialized * @param defaultFormat Default formatter that would be used if the feature was * not enabled (possibly {@code null}) * * @since 3.3 */ - protected DateTimeFormatter _alwaysWriteSubSecondDigitsFormatter(DateTimeFormatter defaultFormat) { + protected DateTimeFormatter _alwaysWriteSubSecondDigitsFormatter(T value, + DateTimeFormatter defaultFormat) { return null; } @@ -158,7 +162,7 @@ protected String formatValue(T value, SerializationContext ctxt) { DateTimeFormatter formatter = (_formatter == null) ? defaultFormat :_formatter; if ((_formatter == null) && ctxt.isEnabled(DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS)) { - DateTimeFormatter subSecondFormatter = _alwaysWriteSubSecondDigitsFormatter(defaultFormat); + DateTimeFormatter subSecondFormatter = _alwaysWriteSubSecondDigitsFormatter(value, defaultFormat); if (subSecondFormatter != null) { formatter = subSecondFormatter; } diff --git a/src/main/java/tools/jackson/databind/ext/javatime/ser/OffsetDateTimeSerializer.java b/src/main/java/tools/jackson/databind/ext/javatime/ser/OffsetDateTimeSerializer.java index 9a3dfca3a5..b7825b5bc3 100644 --- a/src/main/java/tools/jackson/databind/ext/javatime/ser/OffsetDateTimeSerializer.java +++ b/src/main/java/tools/jackson/databind/ext/javatime/ser/OffsetDateTimeSerializer.java @@ -63,7 +63,8 @@ protected JSR310FormattedSerializerBase withFeatures(Boolean writeZoneId, Boo } @Override - protected DateTimeFormatter _alwaysWriteSubSecondDigitsFormatter(DateTimeFormatter defaultFormat) { + protected DateTimeFormatter _alwaysWriteSubSecondDigitsFormatter(OffsetDateTime value, + DateTimeFormatter defaultFormat) { return (defaultFormat == DateTimeFormatter.ISO_OFFSET_DATE_TIME) ? SubSecondFormatters.OFFSET_DATE_TIME : null; } diff --git a/src/main/java/tools/jackson/databind/ext/javatime/ser/ZonedDateTimeSerializer.java b/src/main/java/tools/jackson/databind/ext/javatime/ser/ZonedDateTimeSerializer.java index ad3949eedf..a38cb7a555 100644 --- a/src/main/java/tools/jackson/databind/ext/javatime/ser/ZonedDateTimeSerializer.java +++ b/src/main/java/tools/jackson/databind/ext/javatime/ser/ZonedDateTimeSerializer.java @@ -116,7 +116,8 @@ protected JsonToken serializationShape(SerializationContext ctxt) { } @Override - protected DateTimeFormatter _alwaysWriteSubSecondDigitsFormatter(DateTimeFormatter defaultFormat) { + protected DateTimeFormatter _alwaysWriteSubSecondDigitsFormatter(ZonedDateTime value, + DateTimeFormatter defaultFormat) { // 10-Aug-2026, tatu: Caller may pass its own default formatter (see // `ZonedDateTimeSerializer(DateTimeFormatter)`); if so, must not override it return (defaultFormat == DateTimeFormatter.ISO_OFFSET_DATE_TIME) diff --git a/src/test/java/tools/jackson/databind/ext/javatime/AlwaysWriteSubSecondDigitsTest.java b/src/test/java/tools/jackson/databind/ext/javatime/AlwaysWriteSubSecondDigitsTest.java index 0e65033cae..dc66f49876 100644 --- a/src/test/java/tools/jackson/databind/ext/javatime/AlwaysWriteSubSecondDigitsTest.java +++ b/src/test/java/tools/jackson/databind/ext/javatime/AlwaysWriteSubSecondDigitsTest.java @@ -1,8 +1,10 @@ package tools.jackson.databind.ext.javatime; import java.time.Instant; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; @@ -148,6 +150,29 @@ public void testCallerProvidedDefaultFormatterWins() throws Exception ZonedDateTime.parse("2017-09-14T04:28:48+02:00[Europe/Budapest]"))); } + // Extremes of `Instant` range fall outside `LocalDate` range and cannot be + // written using Date/Time fields: must retain default handling, not fail + @Test + public void testInstantExtremes() throws Exception + { + assertEquals(q("-1000000000-01-01T00:00:00Z"), + MAPPER.writeValueAsString(Instant.MIN)); + assertEquals(DEFAULT_MAPPER.writeValueAsString(Instant.MIN), + MAPPER.writeValueAsString(Instant.MIN)); + assertEquals(q("+1000000000-12-31T23:59:59.999999999Z"), + MAPPER.writeValueAsString(Instant.MAX)); + assertEquals(DEFAULT_MAPPER.writeValueAsString(Instant.MAX), + MAPPER.writeValueAsString(Instant.MAX)); + + // But values just inside `LocalDate` range are still padded as usual + Instant maxLocal = LocalDate.MAX.atStartOfDay().toInstant(ZoneOffset.UTC); + assertEquals(q("+999999999-12-31T00:00:00.000Z"), + MAPPER.writeValueAsString(maxLocal)); + Instant minLocal = LocalDate.MIN.atStartOfDay().toInstant(ZoneOffset.UTC); + assertEquals(q("-999999999-01-01T00:00:00.000Z"), + MAPPER.writeValueAsString(minLocal)); + } + // Values written with the feature on must still be readable @Test public void testRoundTrip() throws Exception From b2b57177a34276fa8f2e29ed6f69ac0ae3d9879d Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Mon, 10 Aug 2026 19:30:49 -0700 Subject: [PATCH 5/7] Moar fixes --- .../jackson/databind/cfg/DateTimeFeature.java | 9 +++-- .../javatime/ser/LocalDateTimeSerializer.java | 15 +++++--- .../AlwaysWriteSubSecondDigitsTest.java | 13 +++++++ .../javatime/ser/LocalDateTimeSerTest.java | 34 +++++++++++++++++++ 4 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/main/java/tools/jackson/databind/cfg/DateTimeFeature.java b/src/main/java/tools/jackson/databind/cfg/DateTimeFeature.java index 3937c605bf..c80f94b198 100644 --- a/src/main/java/tools/jackson/databind/cfg/DateTimeFeature.java +++ b/src/main/java/tools/jackson/databind/cfg/DateTimeFeature.java @@ -62,13 +62,18 @@ public enum DateTimeFeature implements DatatypeFeature *

* 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. + * is preserved: up to 9 digits are written, as many as needed to avoid losing + * information (but with no trailing zeroes beyond the 3 digit minimum, so + * {@code 123400000} nanoseconds is written as {@code .1234}). *

* 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. *

+ * NOTE: only applies to Date/Time values, and NOT to Date/Time values used as + * {@link java.util.Map} keys: keys are written by separate key serializers that + * are not affected by this setting. + *

* Note, too, that the very extremes of the {@link java.time.Instant} range * (notably {@link java.time.Instant#MIN} and {@link java.time.Instant#MAX}, which * fall outside the range of {@link java.time.LocalDate}) cannot be written with diff --git a/src/main/java/tools/jackson/databind/ext/javatime/ser/LocalDateTimeSerializer.java b/src/main/java/tools/jackson/databind/ext/javatime/ser/LocalDateTimeSerializer.java index 8bde36fd18..cbda52597e 100644 --- a/src/main/java/tools/jackson/databind/ext/javatime/ser/LocalDateTimeSerializer.java +++ b/src/main/java/tools/jackson/databind/ext/javatime/ser/LocalDateTimeSerializer.java @@ -104,9 +104,10 @@ public void serializeWithType(LocalDateTime value, JsonGenerator g, Serializatio /** * Resolves the formatter to use when no numeric-timestamp shape applies: the - * explicit per-property {@code _formatter} if set, else the plain default, or -- - * if {@link DateTimeFeature#ALWAYS_WRITE_SUBSECOND_DIGITS} is enabled -- a - * counterpart that always writes sub-second digits. + * explicit per-property {@code _formatter} if set, else the default from + * {@link #_defaultFormatter()}, or -- if + * {@link DateTimeFeature#ALWAYS_WRITE_SUBSECOND_DIGITS} is enabled -- a + * counterpart of the standard default that always writes sub-second digits. * * @since 3.3 */ @@ -114,10 +115,14 @@ private DateTimeFormatter _effectiveFormatter(SerializationContext ctxt) { if (_formatter != null) { return _formatter; } - if (ctxt.isEnabled(DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS)) { + DateTimeFormatter dtf = _defaultFormatter(); + // Sub-second replacement is a counterpart of the standard built-in default + // only: a subclass-provided default must not be overridden by the feature + if ((dtf == DateTimeFormatter.ISO_LOCAL_DATE_TIME) + && ctxt.isEnabled(DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS)) { return SubSecondFormatters.LOCAL_DATE_TIME; } - return _defaultFormatter(); + return dtf; } private final void _serializeAsArrayContents(LocalDateTime value, JsonGenerator g, diff --git a/src/test/java/tools/jackson/databind/ext/javatime/AlwaysWriteSubSecondDigitsTest.java b/src/test/java/tools/jackson/databind/ext/javatime/AlwaysWriteSubSecondDigitsTest.java index dc66f49876..7ee3952af8 100644 --- a/src/test/java/tools/jackson/databind/ext/javatime/AlwaysWriteSubSecondDigitsTest.java +++ b/src/test/java/tools/jackson/databind/ext/javatime/AlwaysWriteSubSecondDigitsTest.java @@ -7,6 +7,7 @@ import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.util.Collections; import org.junit.jupiter.api.Test; @@ -150,6 +151,18 @@ public void testCallerProvidedDefaultFormatterWins() throws Exception ZonedDateTime.parse("2017-09-14T04:28:48+02:00[Europe/Budapest]"))); } + // Map keys are written by separate key serializers, not affected by the feature + @Test + public void testMapKeysUnaffected() throws Exception + { + assertEquals(a2q("{'2017-09-14T04:28:48Z':1}"), + MAPPER.writeValueAsString( + Collections.singletonMap(Instant.parse("2017-09-14T04:28:48Z"), 1))); + assertEquals(a2q("{'2017-09-14T04:28:48':1}"), + MAPPER.writeValueAsString( + Collections.singletonMap(LocalDateTime.parse("2017-09-14T04:28:48"), 1))); + } + // Extremes of `Instant` range fall outside `LocalDate` range and cannot be // written using Date/Time fields: must retain default handling, not fail @Test diff --git a/src/test/java/tools/jackson/databind/ext/javatime/ser/LocalDateTimeSerTest.java b/src/test/java/tools/jackson/databind/ext/javatime/ser/LocalDateTimeSerTest.java index 06ff113603..1f2747680e 100644 --- a/src/test/java/tools/jackson/databind/ext/javatime/ser/LocalDateTimeSerTest.java +++ b/src/test/java/tools/jackson/databind/ext/javatime/ser/LocalDateTimeSerTest.java @@ -18,6 +18,7 @@ import java.time.LocalDateTime; import java.time.Month; +import java.time.format.DateTimeFormatter; import java.time.temporal.Temporal; import org.junit.jupiter.api.Test; @@ -28,6 +29,7 @@ import tools.jackson.databind.cfg.DateTimeFeature; import tools.jackson.databind.ext.javatime.DateTimeTestBase; import tools.jackson.databind.ext.javatime.MockObjectConfiguration; +import tools.jackson.databind.module.SimpleModule; import static org.junit.jupiter.api.Assertions.*; @@ -41,6 +43,24 @@ static class LDTWrapper { public LDTWrapper(LocalDateTime v) { value = v; } } + /** + * Sub-class that changes the default textual format via {@code _defaultFormatter()}; + * also has to retain itself through contextualization (see {@code withFormat()}). + */ + static class CustomDefaultLocalDateTimeSerializer extends LocalDateTimeSerializer { + private final static DateTimeFormatter DF + = DateTimeFormatter.ofPattern("yyyy_MM_dd'X'HH:mm:ss"); + + @Override + protected DateTimeFormatter _defaultFormatter() { return DF; } + + @Override + protected JSR310FormattedSerializerBase withFormat(DateTimeFormatter f, + Boolean useTimestamp, JsonFormat.Shape shape) { + return this; + } + } + // 05-Feb-2025, tatu: Use Jackson 2.x defaults wrt as-timestamps // serialization private final static ObjectMapper MAPPER = mapperBuilder() @@ -191,4 +211,18 @@ public void testSerializationWithTypeInfo03() throws Exception String value = m.writeValueAsString(time); assertEquals("[\"" + LocalDateTime.class.getName() + "\",\"" + time.toString() + "\"]", value); } + + // [databind#6151]: default format provided by a sub-class must not be replaced + // by `DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS` + @Test + public void serializationWithSubClassDefaultFormat() throws Exception + { + LocalDateTime time = LocalDateTime.of(2017, Month.SEPTEMBER, 14, 4, 28, 48); + ObjectMapper mapper = newMapperBuilder() + .addModule(new SimpleModule() + .addSerializer(new CustomDefaultLocalDateTimeSerializer())) + .enable(DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS) + .build(); + assertEquals(q("2017_09_14X04:28:48"), mapper.writeValueAsString(time)); + } } From 60b07ef44f590e3249212549b37e84f37eb8679e Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Mon, 10 Aug 2026 19:57:33 -0700 Subject: [PATCH 6/7] One minor fix --- .../javatime/ser/InstantSerializerBase.java | 14 +++++++++++ .../javatime/ser/ZonedDateTimeSerializer.java | 11 +++++---- .../AlwaysWriteSubSecondDigitsTest.java | 24 +++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializerBase.java b/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializerBase.java index 1261b7a6a0..45541674f4 100644 --- a/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializerBase.java +++ b/src/main/java/tools/jackson/databind/ext/javatime/ser/InstantSerializerBase.java @@ -134,6 +134,20 @@ protected JsonToken serializationShape(SerializationContext ctxt) { return JsonToken.VALUE_STRING; } + /** + * Accessor for the default formatter this serializer was constructed with: used + * when no explicit {@link #_formatter} is defined. May be {@code null} (meaning + * default {@code toString()} handling), and may be caller-provided (see + * {@link ZonedDateTimeSerializer#ZonedDateTimeSerializer(DateTimeFormatter)}), + * in which case it must not be overridden by settings like + * {@link DateTimeFeature#ALWAYS_WRITE_SUBSECOND_DIGITS}. + * + * @since 3.3 + */ + protected DateTimeFormatter _defaultFormat() { + return defaultFormat; + } + /** * Overridden by subclasses to supply a formatter equivalent to the standard * built-in default that always writes at least millisecond-precision sub-second diff --git a/src/main/java/tools/jackson/databind/ext/javatime/ser/ZonedDateTimeSerializer.java b/src/main/java/tools/jackson/databind/ext/javatime/ser/ZonedDateTimeSerializer.java index a38cb7a555..182bee77e6 100644 --- a/src/main/java/tools/jackson/databind/ext/javatime/ser/ZonedDateTimeSerializer.java +++ b/src/main/java/tools/jackson/databind/ext/javatime/ser/ZonedDateTimeSerializer.java @@ -77,10 +77,13 @@ public void serialize(ZonedDateTime value, JsonGenerator g, SerializationContext if (ctxt.isEnabled(DateTimeFeature.TRUNCATE_TO_MSECS_ON_WRITE)) { value = value.truncatedTo(ChronoUnit.MILLIS); } - // write with zone - DateTimeFormatter formatter = ctxt.isEnabled(DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS) - ? SubSecondFormatters.ZONED_DATE_TIME - : DateTimeFormatter.ISO_ZONED_DATE_TIME; + // write with zone: sub-second variant only if caller has not provided + // its own default format, in which case feature must not change output + DateTimeFormatter formatter = DateTimeFormatter.ISO_ZONED_DATE_TIME; + if (ctxt.isEnabled(DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS) + && (_defaultFormat() == DateTimeFormatter.ISO_OFFSET_DATE_TIME)) { + formatter = SubSecondFormatters.ZONED_DATE_TIME; + } g.writeString(formatter.format(value)); return; } diff --git a/src/test/java/tools/jackson/databind/ext/javatime/AlwaysWriteSubSecondDigitsTest.java b/src/test/java/tools/jackson/databind/ext/javatime/AlwaysWriteSubSecondDigitsTest.java index 7ee3952af8..1cd005459b 100644 --- a/src/test/java/tools/jackson/databind/ext/javatime/AlwaysWriteSubSecondDigitsTest.java +++ b/src/test/java/tools/jackson/databind/ext/javatime/AlwaysWriteSubSecondDigitsTest.java @@ -151,6 +151,30 @@ public void testCallerProvidedDefaultFormatterWins() throws Exception ZonedDateTime.parse("2017-09-14T04:28:48+02:00[Europe/Budapest]"))); } + // ... including on the separate "write with Zone Id" path, where feature must + // stay inert (even though that path does not use the caller's format either) + @Test + public void testCallerProvidedDefaultFormatterWithZoneId() throws Exception + { + DateTimeFormatter df = DateTimeFormatter.ofPattern("yyyy_MM_dd'X'HH:mm:ss"); + ZonedDateTime value = ZonedDateTime.parse("2017-09-14T04:28:48+02:00[Europe/Budapest]"); + ObjectMapper mapper = newMapperBuilder() + .addModule(new SimpleModule() + .addSerializer(new ZonedDateTimeSerializer(df))) + .enable(DateTimeFeature.WRITE_DATES_WITH_ZONE_ID) + .enable(DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS) + .build(); + ObjectMapper defaultMapper = newMapperBuilder() + .addModule(new SimpleModule() + .addSerializer(new ZonedDateTimeSerializer(df))) + .enable(DateTimeFeature.WRITE_DATES_WITH_ZONE_ID) + .build(); + assertEquals(defaultMapper.writeValueAsString(value), + mapper.writeValueAsString(value)); + assertEquals(q("2017-09-14T04:28:48+02:00[Europe/Budapest]"), + mapper.writeValueAsString(value)); + } + // Map keys are written by separate key serializers, not affected by the feature @Test public void testMapKeysUnaffected() throws Exception From 81d4d1456991d2ca72e4ffe9871b3f19bf312a65 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Mon, 10 Aug 2026 20:06:22 -0700 Subject: [PATCH 7/7] One last fix --- .../javatime/ser/ZonedDateTimeSerializer.java | 45 +++++++++++++------ .../AlwaysWriteSubSecondDigitsTest.java | 6 +-- .../javatime/ser/ZonedDateTimeSerTest.java | 34 ++++++++++++++ 3 files changed, 67 insertions(+), 18 deletions(-) diff --git a/src/main/java/tools/jackson/databind/ext/javatime/ser/ZonedDateTimeSerializer.java b/src/main/java/tools/jackson/databind/ext/javatime/ser/ZonedDateTimeSerializer.java index 182bee77e6..760699975b 100644 --- a/src/main/java/tools/jackson/databind/ext/javatime/ser/ZonedDateTimeSerializer.java +++ b/src/main/java/tools/jackson/databind/ext/javatime/ser/ZonedDateTimeSerializer.java @@ -68,22 +68,20 @@ public void serialize(ZonedDateTime value, JsonGenerator g, SerializationContext throws JacksonException { if (!useTimestamp(ctxt)) { - // [modules-java8#333]: `@JsonFormat` with pattern should override - // `SerializationFeature.WRITE_DATES_WITH_ZONE_ID` - if ((_formatter != null) && (_shape == JsonFormat.Shape.STRING)) { + // [modules-java8#333], [databind#6151]: explicitly configured format should + // override `DateTimeFeature.WRITE_DATES_WITH_ZONE_ID` + if (_hasExplicitFormat()) { ; // use default handling } else if (shouldWriteWithZoneId(ctxt)) { // Apply millisecond truncation if enabled if (ctxt.isEnabled(DateTimeFeature.TRUNCATE_TO_MSECS_ON_WRITE)) { value = value.truncatedTo(ChronoUnit.MILLIS); } - // write with zone: sub-second variant only if caller has not provided - // its own default format, in which case feature must not change output - DateTimeFormatter formatter = DateTimeFormatter.ISO_ZONED_DATE_TIME; - if (ctxt.isEnabled(DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS) - && (_defaultFormat() == DateTimeFormatter.ISO_OFFSET_DATE_TIME)) { - formatter = SubSecondFormatters.ZONED_DATE_TIME; - } + // write with zone (note: only standard default format gets here, so + // sub-second variant may be used as-is) + DateTimeFormatter formatter = ctxt.isEnabled(DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS) + ? SubSecondFormatters.ZONED_DATE_TIME + : DateTimeFormatter.ISO_ZONED_DATE_TIME; g.writeString(formatter.format(value)); return; } @@ -94,16 +92,35 @@ public void serialize(ZonedDateTime value, JsonGenerator g, SerializationContext @Override protected String formatValue(ZonedDateTime value, SerializationContext ctxt) { String formatted = super.formatValue(value, ctxt); - // [modules-java8#333]: `@JsonFormat` with pattern should override - // `SerializationFeature.WRITE_DATES_WITH_ZONE_ID` - if (_formatter != null && _shape == JsonFormat.Shape.STRING) { + // [modules-java8#333], [databind#6151]: when an explicitly configured format is + // used, Zone Id is only added if specifically requested (via `@JsonFormat`), + // and NOT due to `DateTimeFeature.WRITE_DATES_WITH_ZONE_ID` + if (_hasExplicitFormat()) { // Why not `if (shouldWriteWithZoneId(provider))` ? if (Boolean.TRUE.equals(_writeZoneId)) { formatted += "[" + value.getZone().getId() + "]"; } } return formatted; - } + } + + /** + * Accessor for checking whether this serializer has an explicitly configured + * format that should be used as-is, taking precedence over + * {@link DateTimeFeature#WRITE_DATES_WITH_ZONE_ID}: either a {@code @JsonFormat} + * pattern (with String shape), or a caller-provided default formatter (see + * {@link #ZonedDateTimeSerializer(DateTimeFormatter)}). + * + * @since 3.3 + */ + protected boolean _hasExplicitFormat() { + if ((_formatter != null) && (_shape == JsonFormat.Shape.STRING)) { + return true; + } + DateTimeFormatter df = _defaultFormat(); + return (df != null) && (df != DateTimeFormatter.ISO_OFFSET_DATE_TIME); + } + public boolean shouldWriteWithZoneId(SerializationContext ctxt) { return (_writeZoneId != null) ? _writeZoneId diff --git a/src/test/java/tools/jackson/databind/ext/javatime/AlwaysWriteSubSecondDigitsTest.java b/src/test/java/tools/jackson/databind/ext/javatime/AlwaysWriteSubSecondDigitsTest.java index 1cd005459b..ae5d243afb 100644 --- a/src/test/java/tools/jackson/databind/ext/javatime/AlwaysWriteSubSecondDigitsTest.java +++ b/src/test/java/tools/jackson/databind/ext/javatime/AlwaysWriteSubSecondDigitsTest.java @@ -151,8 +151,7 @@ public void testCallerProvidedDefaultFormatterWins() throws Exception ZonedDateTime.parse("2017-09-14T04:28:48+02:00[Europe/Budapest]"))); } - // ... including on the separate "write with Zone Id" path, where feature must - // stay inert (even though that path does not use the caller's format either) + // ... including on the separate "write with Zone Id" path @Test public void testCallerProvidedDefaultFormatterWithZoneId() throws Exception { @@ -171,8 +170,7 @@ public void testCallerProvidedDefaultFormatterWithZoneId() throws Exception .build(); assertEquals(defaultMapper.writeValueAsString(value), mapper.writeValueAsString(value)); - assertEquals(q("2017-09-14T04:28:48+02:00[Europe/Budapest]"), - mapper.writeValueAsString(value)); + assertEquals(q("2017_09_14X04:28:48"), mapper.writeValueAsString(value)); } // Map keys are written by separate key serializers, not affected by the feature diff --git a/src/test/java/tools/jackson/databind/ext/javatime/ser/ZonedDateTimeSerTest.java b/src/test/java/tools/jackson/databind/ext/javatime/ser/ZonedDateTimeSerTest.java index dffd0fd880..070ac544cc 100644 --- a/src/test/java/tools/jackson/databind/ext/javatime/ser/ZonedDateTimeSerTest.java +++ b/src/test/java/tools/jackson/databind/ext/javatime/ser/ZonedDateTimeSerTest.java @@ -283,6 +283,40 @@ public void testSerializationAsStringWithZoneIdOn() throws Exception { assertEquals("\"" + DateTimeFormatter.ISO_ZONED_DATE_TIME.format(date) + "\"", value); } + // [databind#6151]: caller-provided default formatter should override + // `WRITE_DATES_WITH_ZONE_ID`, same as `@JsonFormat` pattern does + @Test + public void testSerializationAsStringWithZoneIdOnAndACustomFormatter() throws Exception { + ZonedDateTime date = ZonedDateTime.now(Z3); + ObjectMapper mapper = newMapperBuilder().addModule( + new SimpleModule().addSerializer(new ZonedDateTimeSerializer(FORMATTER_WITHOUT_ZONEID))) + .configure(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS, false) + .configure(DateTimeFeature.WRITE_DATES_WITH_ZONE_ID, true) + .build(); + assertEquals(q(FORMATTER_WITHOUT_ZONEID.format(date)), + mapper.writeValueAsString(date)); + } + + // ... but explicitly requested Zone Id (via `@JsonFormat`) is still appended + @Test + public void testSerializationAsStringWithExplicitZoneIdAndACustomFormatter() throws Exception { + ZonedDateTime date = ZonedDateTime.now(Z3); + ObjectMapper mapper = newMapperBuilder().addModule( + new SimpleModule().addSerializer(new ZonedDateTimeSerializer(FORMATTER_WITHOUT_ZONEID))) + .configure(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS, false) + .build(); + assertEquals(a2q("{'value':'" + + FORMATTER_WITHOUT_ZONEID.format(date) + "[" + date.getZone().getId() + "]'}"), + mapper.writeValueAsString(new ZoneIdRequestedWrapper(date))); + } + + static class ZoneIdRequestedWrapper { + @JsonFormat(with = JsonFormat.Feature.WRITE_DATES_WITH_ZONE_ID) + public ZonedDateTime value; + + public ZoneIdRequestedWrapper(ZonedDateTime v) { value = v; } + } + @Test public void testSerializationAsStringWithDefaultTimeZoneAndContextTimeZoneOnAndACustomFormatter() throws Exception { ZonedDateTime date = ZonedDateTime.now(Z3);