From 42ee22da7a7fe870667deb485ccfb4e761db8eae Mon Sep 17 00:00:00 2001 From: Vincent Paturet Date: Mon, 7 Sep 2026 14:34:34 +0200 Subject: [PATCH 1/2] Accept sub-millisecond fractional seconds in xs:dateTime xs:dateTime places no limit on the number of fractional-second digits, but the parse formatter accepted at most three. A schema-valid timestamp carrying microseconds threw a DateTimeParseException, which JAXB reports to the default ValidationEventHandler and leaves the property unset - so consumers saw a silent null. Widen the fraction to NANO_OF_SECOND with a maximum of nine digits. Both the field and the width have to change: widening the width alone leaves MILLI_OF_SECOND unable to carry more than three digits, which parses without error but silently truncates. The fraction stays optional and minimal-width, so marshalled output is unchanged for every value that can round-trip today. --- .../util/LocalDateTimeISO8601XmlAdapter.java | 2 +- .../LocalDateTimeISO8601XmlAdapterTest.java | 77 +++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 src/test/java/org/rutebanken/util/LocalDateTimeISO8601XmlAdapterTest.java diff --git a/src/main/java/org/rutebanken/util/LocalDateTimeISO8601XmlAdapter.java b/src/main/java/org/rutebanken/util/LocalDateTimeISO8601XmlAdapter.java index 433e75f..54894b7 100644 --- a/src/main/java/org/rutebanken/util/LocalDateTimeISO8601XmlAdapter.java +++ b/src/main/java/org/rutebanken/util/LocalDateTimeISO8601XmlAdapter.java @@ -25,7 +25,7 @@ public class LocalDateTimeISO8601XmlAdapter extends XmlAdapter { private static final DateTimeFormatter formatter = new DateTimeFormatterBuilder().appendPattern("yyyy-MM-dd'T'HH:mm:ss") - .optionalStart().appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true).optionalEnd() + .optionalStart().appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true).optionalEnd() .optionalStart().appendPattern("XXXXX") .optionalEnd() diff --git a/src/test/java/org/rutebanken/util/LocalDateTimeISO8601XmlAdapterTest.java b/src/test/java/org/rutebanken/util/LocalDateTimeISO8601XmlAdapterTest.java new file mode 100644 index 0000000..3abd18a --- /dev/null +++ b/src/test/java/org/rutebanken/util/LocalDateTimeISO8601XmlAdapterTest.java @@ -0,0 +1,77 @@ +package org.rutebanken.util; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.*; + +class LocalDateTimeISO8601XmlAdapterTest { + + private final LocalDateTimeISO8601XmlAdapter adapter = new LocalDateTimeISO8601XmlAdapter(); + + @Test + void testUnmarshalBasicDateTime() { + LocalDateTime result = adapter.unmarshal("2026-08-31T00:00:00"); + assertEquals(LocalDateTime.of(2026, 8, 31, 0, 0, 0), result); + } + + @Test + void testUnmarshalDateTimeWithMilliseconds() { + LocalDateTime result = adapter.unmarshal("2026-05-03T23:30:09.398"); + assertEquals(LocalDateTime.of(2026, 5, 3, 23, 30, 9, 398_000_000), result); + } + + @Test + void testUnmarshalDateTimeWithOffset() { + LocalDateTime result = adapter.unmarshal("2026-05-03T23:30:09+02:00"); + assertEquals(LocalDateTime.of(2026, 5, 3, 23, 30, 9), result); + } + + @Test + void testUnmarshalDateTimeWithMicroseconds() { + LocalDateTime result = adapter.unmarshal("2026-05-03T23:30:09.398629"); + assertEquals(LocalDateTime.of(2026, 5, 3, 23, 30, 9, 398_629_000), result); + } + + @Test + void testUnmarshalDateTimeWithMicrosecondsAndOffset() { + LocalDateTime result = adapter.unmarshal("2026-05-03T23:30:09.398629+02:00"); + assertEquals(LocalDateTime.of(2026, 5, 3, 23, 30, 9, 398_629_000), result); + } + + @Test + void testUnmarshalDateTimeWithNanoseconds() { + LocalDateTime result = adapter.unmarshal("2026-05-03T23:30:09.123456789+02:00"); + assertEquals(LocalDateTime.of(2026, 5, 3, 23, 30, 9, 123_456_789), result); + } + + @Test + void testMarshalBasicDateTime() { + String result = adapter.marshal(LocalDateTime.of(2026, 8, 31, 0, 0, 0)); + assertEquals("2026-08-31T00:00:00", result); + } + + @Test + void testMarshalDateTimeWithMilliseconds() { + String result = adapter.marshal(LocalDateTime.of(2026, 5, 3, 23, 30, 9, 398_000_000)); + assertEquals("2026-05-03T23:30:09.398", result); + } + + @Test + void testMarshalDateTimeWithMicroseconds() { + String result = adapter.marshal(LocalDateTime.of(2026, 5, 3, 23, 30, 9, 398_629_000)); + assertEquals("2026-05-03T23:30:09.398629", result); + } + + @Test + void testMarshalNull() { + assertNull(adapter.marshal(null)); + } + + @Test + void testRoundTripWithMicroseconds() { + LocalDateTime original = LocalDateTime.of(2026, 5, 3, 23, 30, 9, 398_629_000); + assertEquals(original, adapter.unmarshal(adapter.marshal(original))); + } +} From bdb7e1ecbcddbd8d2346a56ae16b4c2c21b6a537 Mon Sep 17 00:00:00 2001 From: Vincent Paturet Date: Mon, 7 Sep 2026 15:28:53 +0200 Subject: [PATCH 2/2] Accept sub-millisecond fractional seconds in xs:time The xs:dateTime adapter was widened to nanosecond precision, but LocalTimeISO8601XmlAdapter carried the identical defect: its parse formatter accepted at most three fractional digits. Since bindings.xjb routes every xs:time element through this adapter, a schema-valid DepartureTime, ArrivalTime or WaitTime carrying microseconds threw a DateTimeParseException and was left unset by the default ValidationEventHandler - fields far more commonly populated than the xs:dateTime ones that motivated the first fix. A producer emitting microseconds in a date-time emits them in passing times too, so fixing only xs:dateTime left the same data silently disappearing. Widen the fraction to NANO_OF_SECOND with a maximum of nine digits, as in LocalDateTimeISO8601XmlAdapter. Marshalled output is unchanged for every value that can round-trip today: the fraction stays optional and minimal-width. Caching is unaffected, since the cache admits only times whose nano is zero. --- .../util/LocalTimeISO8601XmlAdapter.java | 2 +- .../util/LocalTimeISO8601XmlAdapterTest.java | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/rutebanken/util/LocalTimeISO8601XmlAdapter.java b/src/main/java/org/rutebanken/util/LocalTimeISO8601XmlAdapter.java index f066b9e..bdde2eb 100644 --- a/src/main/java/org/rutebanken/util/LocalTimeISO8601XmlAdapter.java +++ b/src/main/java/org/rutebanken/util/LocalTimeISO8601XmlAdapter.java @@ -27,7 +27,7 @@ public class LocalTimeISO8601XmlAdapter extends XmlAdapter { private static final DateTimeFormatter formatter = new DateTimeFormatterBuilder().appendPattern("HH:mm:ss") - .optionalStart().appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true).optionalEnd() + .optionalStart().appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true).optionalEnd() .optionalStart().appendPattern("XXXXX") .optionalEnd() diff --git a/src/test/java/org/rutebanken/util/LocalTimeISO8601XmlAdapterTest.java b/src/test/java/org/rutebanken/util/LocalTimeISO8601XmlAdapterTest.java index 3003ccb..f32e1af 100644 --- a/src/test/java/org/rutebanken/util/LocalTimeISO8601XmlAdapterTest.java +++ b/src/test/java/org/rutebanken/util/LocalTimeISO8601XmlAdapterTest.java @@ -36,6 +36,24 @@ public void testUnmarshalTimeWithMillisecondsAndOffset() { assertEquals(LocalTime.of(14, 30, 0, 456_000_000), result); } + @Test + public void testUnmarshalTimeWithMicroseconds() { + LocalTime result = adapter.unmarshal("07:55:00.398629"); + assertEquals(LocalTime.of(7, 55, 0, 398_629_000), result); + } + + @Test + public void testUnmarshalTimeWithMicrosecondsAndOffset() { + LocalTime result = adapter.unmarshal("07:55:00.398629+02:00"); + assertEquals(LocalTime.of(7, 55, 0, 398_629_000), result); + } + + @Test + public void testUnmarshalTimeWithNanoseconds() { + LocalTime result = adapter.unmarshal("07:55:00.123456789+02:00"); + assertEquals(LocalTime.of(7, 55, 0, 123_456_789), result); + } + @Test public void testUnmarshalMidnight() { LocalTime result = adapter.unmarshal("00:00:00"); @@ -66,6 +84,12 @@ public void testMarshalTimeWithNanoseconds() { assertEquals("14:30:00.123", result); } + @Test + public void testMarshalTimeWithMicroseconds() { + String result = adapter.marshal(LocalTime.of(7, 55, 0, 398_629_000)); + assertEquals("07:55:00.398629", result); + } + @Test public void testMarshalMidnight() { String result = adapter.marshal(LocalTime.MIDNIGHT); @@ -92,6 +116,12 @@ public void testRoundTrip() { assertEquals(original, unmarshalled); } + @Test + public void testRoundTripWithMicroseconds() { + LocalTime original = LocalTime.of(7, 55, 0, 398_629_000); + assertEquals(original, adapter.unmarshal(adapter.marshal(original))); + } + @Test public void testCaching() { String timeString = "10:20:30";