diff --git a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/deser/InstantDeserializer.java b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/deser/InstantDeserializer.java index c882d541..1a5e805f 100644 --- a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/deser/InstantDeserializer.java +++ b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/deser/InstantDeserializer.java @@ -200,12 +200,12 @@ protected InstantDeserializer(Class supportedType, */ @Deprecated() protected InstantDeserializer(Class supportedType, - DateTimeFormatter formatter, - Function parsedToValue, - Function fromMilliseconds, - Function fromNanoseconds, - BiFunction adjust, - boolean replaceZeroOffsetAsZ + DateTimeFormatter formatter, + Function parsedToValue, + Function fromMilliseconds, + Function fromNanoseconds, + BiFunction adjust, + boolean replaceZeroOffsetAsZ ) { this(supportedType, formatter, parsedToValue, fromMilliseconds, fromNanoseconds, adjust, replaceZeroOffsetAsZ, @@ -299,8 +299,11 @@ protected InstantDeserializer(InstantDeserializer base, _alwaysAllowStringifiedDateTimestamps = features.isEnabled(JavaTimeFeature.ALWAYS_ALLOW_STRINGIFIED_DATE_TIMESTAMPS); } + /** + * NOTE: {@code public} since 2.21 + */ @Override - protected InstantDeserializer withDateFormat(DateTimeFormatter dtf) { + public InstantDeserializer withDateFormat(DateTimeFormatter dtf) { if (dtf == _formatter) { return this; } 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 ff7bd4c7..1a961c8d 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 @@ -35,6 +35,28 @@ public OffsetDateTimeSerializer(OffsetDateTimeSerializer base, Boolean useTimest super(base, useTimestamp, base._useNanoseconds, formatter, shape); } + /** + * Method for constructing a new {@code OffsetDateTimeSerializer} with settings + * of this serializer but with custom {@link DateTimeFormatter} overrides. + * Commonly used on {@code INSTANCE} like so: + *
+     *  DateTimeFormatter dtf = new DateTimeFormatterBuilder()
+     *          .append(DateTimeFormatter.ISO_LOCAL_DATE)
+     *          .appendLiteral('T')
+     *          // and so on
+     *          .toFormatter();
+     *  OffsetDateTimeSerializer ser = OffsetDateTimeSerializer.INSTANCE
+     *          .withFormatter(dtf);
+     *  // register via Module
+     *
+ * + * @since 2.21 + */ + public OffsetDateTimeSerializer withFormatter(DateTimeFormatter formatter) + { + return new OffsetDateTimeSerializer(this, _useTimestamp, formatter, _shape); + } + @Override protected JSR310FormattedSerializerBase withFormat(Boolean useTimestamp, DateTimeFormatter formatter, JsonFormat.Shape shape) diff --git a/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/OffsetDateTimeDeserTest.java b/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/OffsetDateTimeDeserTest.java index 26b7423b..9c21a4e5 100644 --- a/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/OffsetDateTimeDeserTest.java +++ b/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/OffsetDateTimeDeserTest.java @@ -13,11 +13,11 @@ import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat.Feature; + import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.ObjectReader; -import com.fasterxml.jackson.databind.SerializationFeature; + +import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.databind.exc.MismatchedInputException; import com.fasterxml.jackson.datatype.jsr310.DecimalUtils; import com.fasterxml.jackson.datatype.jsr310.MockObjectConfiguration; @@ -873,4 +873,93 @@ private static ZoneOffset getOffset(OffsetDateTime date, ZoneId zone) private static String offsetWithoutColon(String string){ return new StringBuilder(string).deleteCharAt(string.lastIndexOf(":")).toString(); } + + /* + /********************************************************************** + /* Tests for custom formatter (modules-java8#376) + /********************************************************************** + */ + + @Test + public void testDeserializationWithCustomFormatter() throws Exception + { + // Create a custom formatter that can parse ISO_LOCAL_DATE_TIME and default offset to 0 + DateTimeFormatter customFormatter = new java.time.format.DateTimeFormatterBuilder() + .append(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + .optionalStart() + .parseLenient() + .appendOffsetId() + .parseStrict() + .optionalEnd() + .parseDefaulting(java.time.temporal.ChronoField.OFFSET_SECONDS, 0) + .toFormatter(); + + // Create custom deserializer with the custom formatter + InstantDeserializer customDeserializer = + InstantDeserializer.OFFSET_DATE_TIME.withDateFormat(customFormatter); + + // Create a custom module to override the default deserializer + SimpleModule customModule = new SimpleModule("CustomOffsetDateTimeModule") + .addDeserializer(OffsetDateTime.class, customDeserializer); + + // Add both JavaTimeModule (for other types) and our custom module + // The custom module will override OffsetDateTime deserialization + ObjectMapper mapper = mapperBuilder() + .addModule(customModule) + .build(); + + // Test deserializing date-time without offset (should default to +00:00) + // This is the main use case from issue #376 - parsing ISO_LOCAL_DATE_TIME with default offset + String jsonWithoutOffset = q("2025-01-01T22:01:05"); + OffsetDateTime result = mapper.readValue(jsonWithoutOffset, OffsetDateTime.class); + + assertNotNull(result); + assertEquals(2025, result.getYear()); + assertEquals(1, result.getMonthValue()); + assertEquals(1, result.getDayOfMonth()); + assertEquals(22, result.getHour()); + assertEquals(1, result.getMinute()); + assertEquals(5, result.getSecond()); + assertEquals(ZoneOffset.UTC, result.getOffset()); + + // Test that standard ISO format with offset still works + String jsonWithOffset = q("2025-01-01T22:01:05+02:00"); + OffsetDateTime resultWithOffset = mapper.readValue(jsonWithOffset, OffsetDateTime.class); + + assertNotNull(resultWithOffset); + assertEquals(2025, resultWithOffset.getYear()); + // Verify parsing succeeded - the exact time may be adjusted based on offset conversion + assertTrue(resultWithOffset.toInstant().equals(OffsetDateTime.parse("2025-01-01T22:01:05+02:00").toInstant())); + } + + @Test + public void testDeserializationWithCustomFormatterRoundTrip() throws Exception + { + // Create a custom formatter that can parse ISO_LOCAL_DATE_TIME and default offset to 0 + DateTimeFormatter customFormatter = new java.time.format.DateTimeFormatterBuilder() + .append(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + .optionalStart() + .parseLenient() + .appendOffsetId() + .parseStrict() + .optionalEnd() + .parseDefaulting(java.time.temporal.ChronoField.OFFSET_SECONDS, 0) + .toFormatter(); + + InstantDeserializer customDeserializer = + InstantDeserializer.OFFSET_DATE_TIME.withDateFormat(customFormatter); + SimpleModule customModule = new SimpleModule("CustomOffsetDateTimeModule") + .addDeserializer(OffsetDateTime.class, customDeserializer); + + ObjectMapper mapper = mapperBuilder() + .addModule(customModule) + .build(); + + // Verify standard ISO format still works + OffsetDateTime original = OffsetDateTime.of(2025, 1, 1, 22, 1, 5, 0, ZoneOffset.UTC); + String json = mapper.writeValueAsString(original); + OffsetDateTime roundTripped = mapper.readValue(json, OffsetDateTime.class); + + assertIsEqual(original, roundTripped); + } } diff --git a/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/ser/OffsetDateTimeSerTest.java b/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/ser/OffsetDateTimeSerTest.java index e41dcd12..689d8644 100644 --- a/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/ser/OffsetDateTimeSerTest.java +++ b/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/ser/OffsetDateTimeSerTest.java @@ -3,8 +3,10 @@ import java.time.Instant; import java.time.OffsetDateTime; import java.time.ZoneId; +import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; import java.time.temporal.Temporal; import java.util.TimeZone; @@ -13,6 +15,7 @@ import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.datatype.jsr310.DecimalUtils; import com.fasterxml.jackson.datatype.jsr310.MockObjectConfiguration; import com.fasterxml.jackson.datatype.jsr310.ModuleTestBase; @@ -284,4 +287,102 @@ public void testShapeInt() throws Exception { String json1 = newMapper().writeValueAsString(new Pojo1()); assertEquals("{\"t1\":1651053600000,\"t2\":1651053600.000000000}", json1); } + + /* + /********************************************************************** + /* Tests for custom formatter (modules-java8#376) + /********************************************************************** + */ + + @Test + public void testSerializationWithCustomFormatter() throws Exception + { + // Create a custom formatter that displays only 3 digits of nano-seconds instead of 9 + // Use ISO_LOCAL_DATE and ISO_LOCAL_TIME separately to control nanosecond precision + DateTimeFormatter customFormatter = new DateTimeFormatterBuilder() + .append(DateTimeFormatter.ISO_LOCAL_DATE) + .appendLiteral('T') + .appendValue(java.time.temporal.ChronoField.HOUR_OF_DAY, 2) + .appendLiteral(':') + .appendValue(java.time.temporal.ChronoField.MINUTE_OF_HOUR, 2) + .optionalStart() + .appendLiteral(':') + .appendValue(java.time.temporal.ChronoField.SECOND_OF_MINUTE, 2) + .optionalStart() + .appendFraction(java.time.temporal.ChronoField.NANO_OF_SECOND, 3, 3, true) + .optionalEnd() + .optionalEnd() + .appendOffsetId() + .toFormatter(); + + // Create a date with nanoseconds (123456789 nanos = .123456789 seconds) + OffsetDateTime date = OffsetDateTime.of(2025, 1, 1, 22, 1, 5, 123456789, ZoneOffset.UTC); + String json = _mapper(customFormatter).writeValueAsString(date); + + // Should output with only 3 digits of nano precision (.123 instead of .123456789) + assertEquals(q("2025-01-01T22:01:05.123Z"), json); + } + + @Test + public void testSerializationWithCustomFormatterNoNanos() throws Exception + { + // Create a formatter without nanoseconds + DateTimeFormatter customFormatter = new DateTimeFormatterBuilder() + .append(DateTimeFormatter.ISO_LOCAL_DATE) + .appendLiteral('T') + .appendValue(java.time.temporal.ChronoField.HOUR_OF_DAY, 2) + .appendLiteral(':') + .appendValue(java.time.temporal.ChronoField.MINUTE_OF_HOUR, 2) + .optionalStart() + .appendLiteral(':') + .appendValue(java.time.temporal.ChronoField.SECOND_OF_MINUTE, 2) + .optionalEnd() + .appendOffsetId() + .toFormatter(); + + OffsetDateTime date = OffsetDateTime.of(2025, 1, 1, 22, 1, 5, 123456789, ZoneOffset.UTC); + String json = _mapper(customFormatter).writeValueAsString(date); + + // Should output without nanoseconds + assertEquals(q("2025-01-01T22:01:05Z"), json); + } + + @Test + public void testSerializationWithCustomFormatterAndOffset() throws Exception + { + // Create a custom formatter that displays only 3 digits of nano-seconds + DateTimeFormatter customFormatter = new DateTimeFormatterBuilder() + .append(DateTimeFormatter.ISO_LOCAL_DATE) + .appendLiteral('T') + .appendValue(java.time.temporal.ChronoField.HOUR_OF_DAY, 2) + .appendLiteral(':') + .appendValue(java.time.temporal.ChronoField.MINUTE_OF_HOUR, 2) + .optionalStart() + .appendLiteral(':') + .appendValue(java.time.temporal.ChronoField.SECOND_OF_MINUTE, 2) + .optionalStart() + .appendFraction(java.time.temporal.ChronoField.NANO_OF_SECOND, 3, 3, true) + .optionalEnd() + .optionalEnd() + .appendOffsetId() + .toFormatter(); + + + // Create a date with a non-UTC offset + OffsetDateTime date = OffsetDateTime.of(2025, 1, 1, 22, 1, 5, 123456789, ZoneOffset.ofHours(5)); + String json = _mapper(customFormatter).writeValueAsString(date); + + // Should output with offset +05:00 and 3 digits of nano precision + assertEquals(q("2025-01-01T22:01:05.123+05:00"), json); + } + + private ObjectMapper _mapper(DateTimeFormatter dtf) { + OffsetDateTimeSerializer customSerializer = OffsetDateTimeSerializer.INSTANCE + .withFormatter(dtf); + SimpleModule customModule = new SimpleModule("CustomOffsetDateTimeModule") + .addSerializer(OffsetDateTime.class, customSerializer); + return mapperBuilder() + .addModule(customModule) + .build(); + } } diff --git a/release-notes/VERSION-2.x b/release-notes/VERSION-2.x index bdd6942c..63ec26b5 100644 --- a/release-notes/VERSION-2.x +++ b/release-notes/VERSION-2.x @@ -14,6 +14,9 @@ Modules: negative timestamps incorrectly (reported by Kevin M) (fix by @cowtowncoder, w/ Claude code) +#376: Allow specifying custom `DateTimeFormatter` for `OffsetDateTime` ser/deser + (new constructors?) + (requested by @ZIRAKrezovic) No changes since 2.20