From c46f171c57bb54ff98a5800f81e5ac49b302020a Mon Sep 17 00:00:00 2001 From: Leonard Ehrenfried Date: Wed, 18 Mar 2026 13:48:32 +0100 Subject: [PATCH 1/4] Deduplicate LocalTime instances --- .../util/LocalTimeISO8601XmlAdapter.java | 8 +- .../util/LocalTimeISO8601XmlAdapterTest.java | 100 ++++++++++++++++++ 2 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 src/test/java/org/rutebanken/util/LocalTimeISO8601XmlAdapterTest.java diff --git a/src/main/java/org/rutebanken/util/LocalTimeISO8601XmlAdapter.java b/src/main/java/org/rutebanken/util/LocalTimeISO8601XmlAdapter.java index bc69f251..a183d89c 100644 --- a/src/main/java/org/rutebanken/util/LocalTimeISO8601XmlAdapter.java +++ b/src/main/java/org/rutebanken/util/LocalTimeISO8601XmlAdapter.java @@ -21,6 +21,7 @@ import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatterBuilder; import java.time.temporal.ChronoField; +import java.util.HashMap; public class LocalTimeISO8601XmlAdapter extends XmlAdapter { @@ -28,14 +29,15 @@ public class LocalTimeISO8601XmlAdapter extends XmlAdapter { .optionalStart().appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true).optionalEnd() .optionalStart().appendPattern("XXXXX") .optionalEnd() - + // .parseDefaulting(ChronoField.OFFSET_SECONDS,OffsetDateTime.now().getLong(ChronoField.OFFSET_SECONDS) ).toFormatter(); + private final HashMap cache = new HashMap<>(); + @Override public LocalTime unmarshal(String inputDate) { - return LocalTime.parse(inputDate, formatter); - + return cache.computeIfAbsent(inputDate, key -> LocalTime.parse(key, formatter)); } @Override diff --git a/src/test/java/org/rutebanken/util/LocalTimeISO8601XmlAdapterTest.java b/src/test/java/org/rutebanken/util/LocalTimeISO8601XmlAdapterTest.java new file mode 100644 index 00000000..969a5c0d --- /dev/null +++ b/src/test/java/org/rutebanken/util/LocalTimeISO8601XmlAdapterTest.java @@ -0,0 +1,100 @@ +package org.rutebanken.util; + +import org.junit.jupiter.api.Test; + +import java.time.LocalTime; + +import static org.junit.jupiter.api.Assertions.*; + +public class LocalTimeISO8601XmlAdapterTest { + + private final LocalTimeISO8601XmlAdapter adapter = new LocalTimeISO8601XmlAdapter(); + + @Test + public void testUnmarshalBasicTime() { + LocalTime result = adapter.unmarshal("14:30:00"); + assertEquals(LocalTime.of(14, 30, 0), result); + } + + @Test + public void testUnmarshalTimeWithMilliseconds() { + LocalTime result = adapter.unmarshal("14:30:00.123"); + assertEquals(LocalTime.of(14, 30, 0, 123_000_000), result); + } + + @Test + public void testUnmarshalTimeWithOffset() { + LocalTime result = adapter.unmarshal("14:30:00+02:00"); + assertEquals(LocalTime.of(14, 30, 0), result); + } + + @Test + public void testUnmarshalTimeWithMillisecondsAndOffset() { + LocalTime result = adapter.unmarshal("14:30:00.456+01:00"); + assertEquals(LocalTime.of(14, 30, 0, 456_000_000), result); + } + + @Test + public void testUnmarshalMidnight() { + LocalTime result = adapter.unmarshal("00:00:00"); + assertEquals(LocalTime.MIDNIGHT, result); + } + + @Test + public void testUnmarshalNoon() { + LocalTime result = adapter.unmarshal("12:00:00"); + assertEquals(LocalTime.NOON, result); + } + + @Test + public void testUnmarshalEndOfDay() { + LocalTime result = adapter.unmarshal("23:59:59"); + assertEquals(LocalTime.of(23, 59, 59), result); + } + + @Test + public void testMarshalBasicTime() { + String result = adapter.marshal(LocalTime.of(14, 30, 0)); + assertEquals("14:30:00", result); + } + + @Test + public void testMarshalTimeWithNanoseconds() { + String result = adapter.marshal(LocalTime.of(14, 30, 0, 123_000_000)); + assertEquals("14:30:00.123", result); + } + + @Test + public void testMarshalMidnight() { + String result = adapter.marshal(LocalTime.MIDNIGHT); + assertEquals("00:00:00", result); + } + + @Test + public void testMarshalNoon() { + String result = adapter.marshal(LocalTime.NOON); + assertEquals("12:00:00", result); + } + + @Test + public void testMarshalNull() { + String result = adapter.marshal(null); + assertNull(result); + } + + @Test + public void testRoundTrip() { + LocalTime original = LocalTime.of(15, 45, 30, 500_000_000); + String marshalled = adapter.marshal(original); + LocalTime unmarshalled = adapter.unmarshal(marshalled); + assertEquals(original, unmarshalled); + } + + @Test + public void testCaching() { + String timeString = "10:20:30"; + LocalTime first = adapter.unmarshal(timeString); + LocalTime second = adapter.unmarshal(timeString); + assertSame(first, second, "Same string should return cached instance"); + } +} From ecefd1dc403263374d1addc9b32dc57528b77a26 Mon Sep 17 00:00:00 2001 From: Leonard Ehrenfried Date: Wed, 18 Mar 2026 14:50:29 +0100 Subject: [PATCH 2/4] Improve de-duplicating by comparing parsed objects, not input strings --- .../org/rutebanken/util/LocalTimeISO8601XmlAdapter.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/rutebanken/util/LocalTimeISO8601XmlAdapter.java b/src/main/java/org/rutebanken/util/LocalTimeISO8601XmlAdapter.java index a183d89c..3b1c0c32 100644 --- a/src/main/java/org/rutebanken/util/LocalTimeISO8601XmlAdapter.java +++ b/src/main/java/org/rutebanken/util/LocalTimeISO8601XmlAdapter.java @@ -33,11 +33,12 @@ public class LocalTimeISO8601XmlAdapter extends XmlAdapter { // .parseDefaulting(ChronoField.OFFSET_SECONDS,OffsetDateTime.now().getLong(ChronoField.OFFSET_SECONDS) ).toFormatter(); - private final HashMap cache = new HashMap<>(); + private final HashMap cache = new HashMap<>(); @Override - public LocalTime unmarshal(String inputDate) { - return cache.computeIfAbsent(inputDate, key -> LocalTime.parse(key, formatter)); + public LocalTime unmarshal(String input) { + var key = LocalTime.parse(input, formatter); + return cache.computeIfAbsent(key, time -> time); } @Override From 0435f50e39cb2a31c43b48189ce3cfabad52b486 Mon Sep 17 00:00:00 2001 From: Leonard Ehrenfried Date: Wed, 18 Mar 2026 16:25:55 +0100 Subject: [PATCH 3/4] Add test for identical time values --- .../util/LocalTimeISO8601XmlAdapterTest.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/test/java/org/rutebanken/util/LocalTimeISO8601XmlAdapterTest.java b/src/test/java/org/rutebanken/util/LocalTimeISO8601XmlAdapterTest.java index 969a5c0d..3003ccb1 100644 --- a/src/test/java/org/rutebanken/util/LocalTimeISO8601XmlAdapterTest.java +++ b/src/test/java/org/rutebanken/util/LocalTimeISO8601XmlAdapterTest.java @@ -1,5 +1,7 @@ package org.rutebanken.util; +import java.util.List; +import java.util.stream.Collectors; import org.junit.jupiter.api.Test; import java.time.LocalTime; @@ -97,4 +99,21 @@ public void testCaching() { LocalTime second = adapter.unmarshal(timeString); assertSame(first, second, "Same string should return cached instance"); } + + @Test + public void testCachingIdenticalValue() { + var equalInputs = List.of( + "12:20:00", + "12:20:00+02:00", + "12:20:00.000", + "12:20:00.000+02:00" + ); + + var parsed = equalInputs.stream() + .map(adapter::unmarshal) + .collect(Collectors.toList()); + var first = parsed.get(0); + + parsed.forEach(time -> assertSame(first, time, "Same time value should return same instance")); + } } From 065f158c6aab560f4a4feaa00e8798302526489f Mon Sep 17 00:00:00 2001 From: Leonard Ehrenfried Date: Wed, 18 Mar 2026 16:36:38 +0100 Subject: [PATCH 4/4] Add documentation for caching local times --- .../org/rutebanken/util/LocalTimeISO8601XmlAdapter.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/org/rutebanken/util/LocalTimeISO8601XmlAdapter.java b/src/main/java/org/rutebanken/util/LocalTimeISO8601XmlAdapter.java index 3b1c0c32..4031bd0f 100644 --- a/src/main/java/org/rutebanken/util/LocalTimeISO8601XmlAdapter.java +++ b/src/main/java/org/rutebanken/util/LocalTimeISO8601XmlAdapter.java @@ -33,6 +33,13 @@ public class LocalTimeISO8601XmlAdapter extends XmlAdapter { // .parseDefaulting(ChronoField.OFFSET_SECONDS,OffsetDateTime.now().getLong(ChronoField.OFFSET_SECONDS) ).toFormatter(); + /** + * We store a cache of parsed LocalTime instances to avoid wasting memory in immutable value + * objects that strictly identical and interchangeable. + * Since there is a limited number of seconds in a single day, this cannot grow unbounded. + * If input data differs by milliseconds or even nanoseconds, this might cause problems. It + * is, however, very unlikely to happen in the context of NeTEx. + */ private final HashMap cache = new HashMap<>(); @Override