Skip to content

Commit e010dfb

Browse files
committed
Fix #376: add factory methods/constructor for OffsetDateTime ser/deser with custom formatter
1 parent 22b224f commit e010dfb

File tree

4 files changed

+242
-0
lines changed

4 files changed

+242
-0
lines changed

datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/deser/InstantDeserializer.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,22 @@ protected InstantDeserializer(InstantDeserializer<T> base,
299299
_alwaysAllowStringifiedDateTimestamps = features.isEnabled(JavaTimeFeature.ALWAYS_ALLOW_STRINGIFIED_DATE_TIMESTAMPS);
300300
}
301301

302+
/**
303+
* Factory method to create a new deserializer instance with a custom {@link DateTimeFormatter}.
304+
* This is primarily intended for {@link OffsetDateTime} and {@link ZonedDateTime} deserialization,
305+
* allowing customization of parsing behavior (e.g., defaulting offset values or controlling nano-second precision).
306+
*
307+
* @param base Base deserializer to copy settings from (typically one of the static instances like
308+
* {@link #OFFSET_DATE_TIME}, {@link #ZONED_DATE_TIME}, or {@link #INSTANT})
309+
* @param formatter Custom {@link DateTimeFormatter} to use for parsing
310+
* @return New deserializer instance with the custom formatter
311+
* @since 2.19
312+
*/
313+
public static <T extends Temporal> InstantDeserializer<T> withCustomFormatter(
314+
InstantDeserializer<T> base, DateTimeFormatter formatter) {
315+
return new InstantDeserializer<>(base, formatter);
316+
}
317+
302318
@Override
303319
protected InstantDeserializer<T> withDateFormat(DateTimeFormatter dtf) {
304320
if (dtf == _formatter) {

datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/OffsetDateTimeSerializer.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,19 @@ public OffsetDateTimeSerializer(OffsetDateTimeSerializer base, Boolean useTimest
3535
super(base, useTimestamp, base._useNanoseconds, formatter, shape);
3636
}
3737

38+
/**
39+
* Constructor for creating a new serializer instance with a custom {@link DateTimeFormatter}.
40+
* This allows customization of the serialization output format, such as controlling nano-second precision
41+
* (e.g., 3 digits instead of 9).
42+
*
43+
* @param formatter Custom {@link DateTimeFormatter} to use for formatting
44+
* @since 2.19
45+
*/
46+
public OffsetDateTimeSerializer(DateTimeFormatter formatter) {
47+
// Call the protected constructor with useTimestamp=false to ensure string serialization
48+
this(INSTANCE, false, formatter, JsonFormat.Shape.STRING);
49+
}
50+
3851
@Override
3952
protected JSR310FormattedSerializerBase<?> withFormat(Boolean useTimestamp,
4053
DateTimeFormatter formatter, JsonFormat.Shape shape)

datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/OffsetDateTimeDeserTest.java

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import com.fasterxml.jackson.databind.ObjectMapper;
1919
import com.fasterxml.jackson.databind.ObjectReader;
2020
import com.fasterxml.jackson.databind.SerializationFeature;
21+
import com.fasterxml.jackson.databind.json.JsonMapper;
2122
import com.fasterxml.jackson.databind.exc.MismatchedInputException;
2223
import com.fasterxml.jackson.datatype.jsr310.DecimalUtils;
2324
import com.fasterxml.jackson.datatype.jsr310.MockObjectConfiguration;
@@ -873,4 +874,96 @@ private static ZoneOffset getOffset(OffsetDateTime date, ZoneId zone)
873874
private static String offsetWithoutColon(String string){
874875
return new StringBuilder(string).deleteCharAt(string.lastIndexOf(":")).toString();
875876
}
877+
878+
/*
879+
/**********************************************************
880+
/* Tests for custom formatter (#376)
881+
/**********************************************************
882+
*/
883+
884+
@Test
885+
public void testDeserializationWithCustomFormatter() throws Exception
886+
{
887+
// Create a custom formatter that can parse ISO_LOCAL_DATE_TIME and default offset to 0
888+
DateTimeFormatter customFormatter = new java.time.format.DateTimeFormatterBuilder()
889+
.append(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
890+
.optionalStart()
891+
.parseLenient()
892+
.appendOffsetId()
893+
.parseStrict()
894+
.optionalEnd()
895+
.parseDefaulting(java.time.temporal.ChronoField.OFFSET_SECONDS, 0)
896+
.toFormatter();
897+
898+
// Create custom deserializer with the custom formatter
899+
InstantDeserializer<OffsetDateTime> customDeserializer =
900+
InstantDeserializer.withCustomFormatter(InstantDeserializer.OFFSET_DATE_TIME, customFormatter);
901+
902+
// Create a custom module to override the default deserializer
903+
com.fasterxml.jackson.databind.module.SimpleModule customModule =
904+
new com.fasterxml.jackson.databind.module.SimpleModule("CustomOffsetDateTimeModule");
905+
customModule.addDeserializer(OffsetDateTime.class, customDeserializer);
906+
907+
// Add both JavaTimeModule (for other types) and our custom module
908+
// The custom module will override OffsetDateTime deserialization
909+
ObjectMapper mapper = mapperBuilder()
910+
.addModule(customModule)
911+
.build();
912+
913+
// Test deserializing date-time without offset (should default to +00:00)
914+
// This is the main use case from issue #376 - parsing ISO_LOCAL_DATE_TIME with default offset
915+
String jsonWithoutOffset = q("2025-01-01T22:01:05");
916+
OffsetDateTime result = mapper.readValue(jsonWithoutOffset, OffsetDateTime.class);
917+
918+
assertNotNull(result);
919+
assertEquals(2025, result.getYear());
920+
assertEquals(1, result.getMonthValue());
921+
assertEquals(1, result.getDayOfMonth());
922+
assertEquals(22, result.getHour());
923+
assertEquals(1, result.getMinute());
924+
assertEquals(5, result.getSecond());
925+
assertEquals(ZoneOffset.UTC, result.getOffset());
926+
927+
// Test that standard ISO format with offset still works
928+
String jsonWithOffset = q("2025-01-01T22:01:05+02:00");
929+
OffsetDateTime resultWithOffset = mapper.readValue(jsonWithOffset, OffsetDateTime.class);
930+
931+
assertNotNull(resultWithOffset);
932+
assertEquals(2025, resultWithOffset.getYear());
933+
// Verify parsing succeeded - the exact time may be adjusted based on offset conversion
934+
assertTrue(resultWithOffset.toInstant().equals(OffsetDateTime.parse("2025-01-01T22:01:05+02:00").toInstant()));
935+
}
936+
937+
@Test
938+
public void testDeserializationWithCustomFormatterRoundTrip() throws Exception
939+
{
940+
// Create a custom formatter that can parse ISO_LOCAL_DATE_TIME and default offset to 0
941+
DateTimeFormatter customFormatter = new java.time.format.DateTimeFormatterBuilder()
942+
.append(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
943+
.optionalStart()
944+
.parseLenient()
945+
.appendOffsetId()
946+
.parseStrict()
947+
.optionalEnd()
948+
.parseDefaulting(java.time.temporal.ChronoField.OFFSET_SECONDS, 0)
949+
.toFormatter();
950+
951+
InstantDeserializer<OffsetDateTime> customDeserializer =
952+
InstantDeserializer.withCustomFormatter(InstantDeserializer.OFFSET_DATE_TIME, customFormatter);
953+
954+
com.fasterxml.jackson.databind.module.SimpleModule customModule =
955+
new com.fasterxml.jackson.databind.module.SimpleModule();
956+
customModule.addDeserializer(OffsetDateTime.class, customDeserializer);
957+
958+
ObjectMapper mapper = mapperBuilder()
959+
.addModule(customModule)
960+
.build();
961+
962+
// Verify standard ISO format still works
963+
OffsetDateTime original = OffsetDateTime.of(2025, 1, 1, 22, 1, 5, 0, ZoneOffset.UTC);
964+
String json = mapper.writeValueAsString(original);
965+
OffsetDateTime roundTripped = mapper.readValue(json, OffsetDateTime.class);
966+
967+
assertIsEqual(original, roundTripped);
968+
}
876969
}

datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/ser/OffsetDateTimeSerTest.java

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import java.time.Instant;
44
import java.time.OffsetDateTime;
55
import java.time.ZoneId;
6+
import java.time.ZoneOffset;
67
import java.time.ZonedDateTime;
78
import java.time.format.DateTimeFormatter;
89
import java.time.temporal.Temporal;
@@ -13,6 +14,7 @@
1314
import com.fasterxml.jackson.annotation.JsonFormat;
1415
import com.fasterxml.jackson.databind.ObjectMapper;
1516
import com.fasterxml.jackson.databind.SerializationFeature;
17+
import com.fasterxml.jackson.databind.json.JsonMapper;
1618
import com.fasterxml.jackson.datatype.jsr310.DecimalUtils;
1719
import com.fasterxml.jackson.datatype.jsr310.MockObjectConfiguration;
1820
import com.fasterxml.jackson.datatype.jsr310.ModuleTestBase;
@@ -284,4 +286,122 @@ public void testShapeInt() throws Exception {
284286
String json1 = newMapper().writeValueAsString(new Pojo1());
285287
assertEquals("{\"t1\":1651053600000,\"t2\":1651053600.000000000}", json1);
286288
}
289+
290+
/*
291+
/**********************************************************
292+
/* Tests for custom formatter (#376)
293+
/**********************************************************
294+
*/
295+
296+
@Test
297+
public void testSerializationWithCustomFormatter() throws Exception
298+
{
299+
// Create a custom formatter that displays only 3 digits of nano-seconds instead of 9
300+
// Use ISO_LOCAL_DATE and ISO_LOCAL_TIME separately to control nanosecond precision
301+
DateTimeFormatter customFormatter = new java.time.format.DateTimeFormatterBuilder()
302+
.append(DateTimeFormatter.ISO_LOCAL_DATE)
303+
.appendLiteral('T')
304+
.appendValue(java.time.temporal.ChronoField.HOUR_OF_DAY, 2)
305+
.appendLiteral(':')
306+
.appendValue(java.time.temporal.ChronoField.MINUTE_OF_HOUR, 2)
307+
.optionalStart()
308+
.appendLiteral(':')
309+
.appendValue(java.time.temporal.ChronoField.SECOND_OF_MINUTE, 2)
310+
.optionalStart()
311+
.appendFraction(java.time.temporal.ChronoField.NANO_OF_SECOND, 3, 3, true)
312+
.optionalEnd()
313+
.optionalEnd()
314+
.appendOffsetId()
315+
.toFormatter();
316+
317+
OffsetDateTimeSerializer customSerializer = new OffsetDateTimeSerializer(customFormatter);
318+
319+
com.fasterxml.jackson.databind.module.SimpleModule customModule =
320+
new com.fasterxml.jackson.databind.module.SimpleModule("CustomOffsetDateTimeModule");
321+
customModule.addSerializer(OffsetDateTime.class, customSerializer);
322+
323+
// Add both JavaTimeModule and our custom module
324+
ObjectMapper mapper = mapperBuilder()
325+
.addModule(customModule)
326+
.build();
327+
328+
// Create a date with nanoseconds (123456789 nanos = .123456789 seconds)
329+
OffsetDateTime date = OffsetDateTime.of(2025, 1, 1, 22, 1, 5, 123456789, ZoneOffset.UTC);
330+
String json = mapper.writeValueAsString(date);
331+
332+
// Should output with only 3 digits of nano precision (.123 instead of .123456789)
333+
assertEquals(q("2025-01-01T22:01:05.123Z"), json);
334+
}
335+
336+
@Test
337+
public void testSerializationWithCustomFormatterNoNanos() throws Exception
338+
{
339+
// Create a formatter without nanoseconds
340+
DateTimeFormatter customFormatter = new java.time.format.DateTimeFormatterBuilder()
341+
.append(DateTimeFormatter.ISO_LOCAL_DATE)
342+
.appendLiteral('T')
343+
.appendValue(java.time.temporal.ChronoField.HOUR_OF_DAY, 2)
344+
.appendLiteral(':')
345+
.appendValue(java.time.temporal.ChronoField.MINUTE_OF_HOUR, 2)
346+
.optionalStart()
347+
.appendLiteral(':')
348+
.appendValue(java.time.temporal.ChronoField.SECOND_OF_MINUTE, 2)
349+
.optionalEnd()
350+
.appendOffsetId()
351+
.toFormatter();
352+
353+
OffsetDateTimeSerializer customSerializer = new OffsetDateTimeSerializer(customFormatter);
354+
355+
com.fasterxml.jackson.databind.module.SimpleModule customModule =
356+
new com.fasterxml.jackson.databind.module.SimpleModule("CustomOffsetDateTimeModule");
357+
customModule.addSerializer(OffsetDateTime.class, customSerializer);
358+
359+
ObjectMapper mapper = mapperBuilder()
360+
.addModule(customModule)
361+
.build();
362+
363+
OffsetDateTime date = OffsetDateTime.of(2025, 1, 1, 22, 1, 5, 123456789, ZoneOffset.UTC);
364+
String json = mapper.writeValueAsString(date);
365+
366+
// Should output without nanoseconds
367+
assertEquals(q("2025-01-01T22:01:05Z"), json);
368+
}
369+
370+
@Test
371+
public void testSerializationWithCustomFormatterAndOffset() throws Exception
372+
{
373+
// Create a custom formatter that displays only 3 digits of nano-seconds
374+
DateTimeFormatter customFormatter = new java.time.format.DateTimeFormatterBuilder()
375+
.append(DateTimeFormatter.ISO_LOCAL_DATE)
376+
.appendLiteral('T')
377+
.appendValue(java.time.temporal.ChronoField.HOUR_OF_DAY, 2)
378+
.appendLiteral(':')
379+
.appendValue(java.time.temporal.ChronoField.MINUTE_OF_HOUR, 2)
380+
.optionalStart()
381+
.appendLiteral(':')
382+
.appendValue(java.time.temporal.ChronoField.SECOND_OF_MINUTE, 2)
383+
.optionalStart()
384+
.appendFraction(java.time.temporal.ChronoField.NANO_OF_SECOND, 3, 3, true)
385+
.optionalEnd()
386+
.optionalEnd()
387+
.appendOffsetId()
388+
.toFormatter();
389+
390+
OffsetDateTimeSerializer customSerializer = new OffsetDateTimeSerializer(customFormatter);
391+
392+
com.fasterxml.jackson.databind.module.SimpleModule customModule =
393+
new com.fasterxml.jackson.databind.module.SimpleModule("CustomOffsetDateTimeModule");
394+
customModule.addSerializer(OffsetDateTime.class, customSerializer);
395+
396+
ObjectMapper mapper = mapperBuilder()
397+
.addModule(customModule)
398+
.build();
399+
400+
// Create a date with a non-UTC offset
401+
OffsetDateTime date = OffsetDateTime.of(2025, 1, 1, 22, 1, 5, 123456789, ZoneOffset.ofHours(5));
402+
String json = mapper.writeValueAsString(date);
403+
404+
// Should output with offset +05:00 and 3 digits of nano precision
405+
assertEquals(q("2025-01-01T22:01:05.123+05:00"), json);
406+
}
287407
}

0 commit comments

Comments
 (0)