diff --git a/src/main/java/org/rutebanken/util/FastLocalDateTimeParser.java b/src/main/java/org/rutebanken/util/FastLocalDateTimeParser.java new file mode 100644 index 0000000..5256758 --- /dev/null +++ b/src/main/java/org/rutebanken/util/FastLocalDateTimeParser.java @@ -0,0 +1,255 @@ +/* + * Licensed under the EUPL, Version 1.2 or - as soon they will be approved by + * the European Commission - subsequent versions of the EUPL (the "Licence"); + * You may not use this work except in compliance with the Licence. + * You may obtain a copy of the Licence at: + * + * https://joinup.ec.europa.eu/software/page/eupl + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the Licence is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Licence for the specific language governing permissions and + * limitations under the Licence. + */ + +package org.rutebanken.util; + +import java.time.DateTimeException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +/** + * Position-based parser for the common forms of xs:date, xs:time and xs:dateTime found in NeTEx + * documents: + * + *

+ * Why this exists: NeTEx documents contain millions of time values, and parsing them with a + * {@code DateTimeFormatter} costs around 500 ns and 1 KB of garbage per value, spent in the + * formatter machinery (sub-parser walks, {@code Parsed} copies, optional-group allocations) to + * parse strings that in practice have one fixed shape. This parser reads the fields at fixed + * positions, uses no regular expressions and allocates nothing but the result. + *

+ * It is deliberately strict. The zone designator is validated and then ignored, as the + * formatter-based adapters ignore it when building a local value. Any other form and any + * out-of-range field makes it return {@code null}, so that the caller falls back to its + * {@code DateTimeFormatter}. This keeps the formatter's lenient resolution (day-of-month clamping + * such as {@code 2023-02-30} to {@code 2023-02-28}, {@code 24:00:00} as midnight, a fraction + * separator without digits, ...) and its error messages unchanged. + */ +final class FastLocalDateTimeParser { + + /* + * Layout of the supported text. The date and time parts have fixed positions; the fraction and + * the zone designator are optional and follow directly after the seconds. + * + * yyyy-MM-dd HH:mm:ss.fffffffff+HH:MM + * 0 5 8 0 3 6 | | + * 8 fraction end, zone designator starts here + * + * In a dateTime the time part starts at index 11, after the 'T' separator. + */ + private static final int DATE_LENGTH = 10; + private static final int TIME_LENGTH = 8; + private static final int DATE_TIME_SEPARATOR = DATE_LENGTH; + private static final int DATE_TIME_TIME_START = DATE_LENGTH + 1; + + private static final int MAX_FRACTION_DIGITS = 9; + /** Length of {@code ±HH:MM}. */ + private static final int OFFSET_LENGTH = 6; + private static final int MAX_OFFSET_HOURS = 18; + + /** Multiplier turning a fraction of {@code n} digits into nanoseconds, indexed by {@code n}. */ + private static final int[] NANOS_PER_FRACTION_DIGIT = { + 0, 100_000_000, 10_000_000, 1_000_000, 100_000, 10_000, 1_000, 100, 10, 1, + }; + + /** Returned by the {@code read*} helpers when the text does not have the expected shape. */ + private static final int INVALID = -1; + + private FastLocalDateTimeParser() {} + + /** + * Parses an xs:date in the form {@code yyyy-MM-dd[Z|±HH:MM]}. + * + * @return the parsed date, or {@code null} if {@code text} is null or not in the supported form + */ + static LocalDate parseDate(String text) { + if (text == null || text.length() < DATE_LENGTH || !hasValidZoneSuffix(text, DATE_LENGTH)) { + return null; + } + return readDate(text, 0); + } + + /** + * Parses an xs:time in the form {@code HH:mm:ss[.fffffffff][Z|±HH:MM]}. + * + * @return the parsed time, or {@code null} if {@code text} is null or not in the supported form + */ + static LocalTime parseTime(String text) { + if (text == null) { + return null; + } + return parseTimeAt(text, 0); + } + + /** + * Parses an xs:dateTime in the form {@code yyyy-MM-ddTHH:mm:ss[.fffffffff][Z|±HH:MM]}. + * + * @return the parsed date-time, or {@code null} if {@code text} is null or not in the supported + * form + */ + static LocalDateTime parseDateTime(String text) { + if (text == null || text.length() <= DATE_TIME_SEPARATOR || text.charAt(DATE_TIME_SEPARATOR) != 'T') { + return null; + } + LocalDate date = readDate(text, 0); + if (date == null) { + return null; + } + LocalTime time = parseTimeAt(text, DATE_TIME_TIME_START); + if (time == null) { + return null; + } + return LocalDateTime.of(date, time); + } + + /** + * Parses {@code HH:mm:ss[.fffffffff][Z|±HH:MM]} starting at {@code start} and running to the end + * of the text; {@code null} if malformed or out of range. + */ + private static LocalTime parseTimeAt(String text, int start) { + int timeEnd = start + TIME_LENGTH; + if (text.length() < timeEnd) { + return null; + } + int fractionEnd = readFractionEnd(text, timeEnd); + if (fractionEnd == INVALID || !hasValidZoneSuffix(text, fractionEnd)) { + return null; + } + return readTime(text, start, readNanos(text, timeEnd, fractionEnd)); + } + + /** Reads {@code yyyy-MM-dd} at {@code start}; {@code null} if malformed or out of range. */ + private static LocalDate readDate(String text, int start) { + if (text.charAt(start + 4) != '-' || text.charAt(start + 7) != '-') { + return null; + } + int year = readDigits(text, start, 4); + int month = readDigits(text, start + 5, 2); + int day = readDigits(text, start + 8, 2); + // year 0000 is not a valid year-of-era for the 'yyyy' pattern: leave it to the formatter + if (year <= 0 || month == INVALID || day == INVALID) { + return null; + } + try { + return LocalDate.of(year, month, day); + } catch (DateTimeException e) { + return null; + } + } + + /** Reads {@code HH:mm:ss} at {@code start}; {@code null} if malformed or out of range. */ + private static LocalTime readTime(String text, int start, int nanos) { + if (text.charAt(start + 2) != ':' || text.charAt(start + 5) != ':') { + return null; + } + int hour = readDigits(text, start, 2); + int minute = readDigits(text, start + 3, 2); + int second = readDigits(text, start + 6, 2); + if (hour == INVALID || minute == INVALID || second == INVALID) { + return null; + } + try { + return LocalTime.of(hour, minute, second, nanos); + } catch (DateTimeException e) { + return null; + } + } + + /** + * Locates the end of the optional fraction starting at {@code start}: {@code start} when there + * is none, the index after its last digit otherwise, or {@link #INVALID} for a separator without + * digits or more than nine digits. + */ + private static int readFractionEnd(String text, int start) { + int length = text.length(); + if (length == start || text.charAt(start) != '.') { + return start; + } + int pos = start + 1; + while (pos < length && isDigit(text.charAt(pos))) { + pos++; + } + int fractionDigits = pos - (start + 1); + if (fractionDigits < 1 || fractionDigits > MAX_FRACTION_DIGITS) { + return INVALID; + } + return pos; + } + + /** + * Converts the fraction digits, if any, between the separator at {@code start} and + * {@code fractionEnd} to nanoseconds. {@link #readFractionEnd} has already checked that these + * characters are digits. + */ + private static int readNanos(String text, int start, int fractionEnd) { + if (fractionEnd == start) { + return 0; + } + int value = 0; + for (int i = start + 1; i < fractionEnd; i++) { + value = value * 10 + (text.charAt(i) - '0'); + } + return value * NANOS_PER_FRACTION_DIGIT[fractionEnd - start - 1]; + } + + /** + * Checks that the text either ends at {@code start} or continues with exactly a zone designator: + * {@code Z} or {@code ±HH:MM} within the ±18:00 range. + */ + private static boolean hasValidZoneSuffix(String text, int start) { + int length = text.length(); + if (start == length) { + return true; + } + char designator = text.charAt(start); + if (designator == 'Z') { + return start + 1 == length; + } + if (designator != '+' && designator != '-') { + return false; + } + if (start + OFFSET_LENGTH != length || text.charAt(start + 3) != ':') { + return false; + } + int hours = readDigits(text, start + 1, 2); + int minutes = readDigits(text, start + 4, 2); + if (hours == INVALID || minutes == INVALID || minutes >= 60) { + return false; + } + return hours < MAX_OFFSET_HOURS || (hours == MAX_OFFSET_HOURS && minutes == 0); + } + + /** Reads exactly {@code count} ASCII digits starting at {@code start}, or {@link #INVALID}. */ + private static int readDigits(String text, int start, int count) { + int value = 0; + for (int i = start; i < start + count; i++) { + char c = text.charAt(i); + if (!isDigit(c)) { + return INVALID; + } + value = value * 10 + (c - '0'); + } + return value; + } + + private static boolean isDigit(char c) { + return c >= '0' && c <= '9'; + } +} diff --git a/src/main/java/org/rutebanken/util/LocalDateTimeISO8601XmlAdapter.java b/src/main/java/org/rutebanken/util/LocalDateTimeISO8601XmlAdapter.java index 54894b7..8838ad6 100644 --- a/src/main/java/org/rutebanken/util/LocalDateTimeISO8601XmlAdapter.java +++ b/src/main/java/org/rutebanken/util/LocalDateTimeISO8601XmlAdapter.java @@ -34,6 +34,11 @@ public class LocalDateTimeISO8601XmlAdapter extends XmlAdapter { @Override public LocalDateTime unmarshal(String inputDate) { + // fast path for the common forms, the formatter handles everything else + LocalDate fast = FastLocalDateTimeParser.parseDate(inputDate); + if (fast != null) { + return fast.atStartOfDay(); + } return LocalDateTime.parse(inputDate, formatter); } diff --git a/src/main/java/org/rutebanken/util/LocalTimeISO8601XmlAdapter.java b/src/main/java/org/rutebanken/util/LocalTimeISO8601XmlAdapter.java index bdde2eb..b86c7e0 100644 --- a/src/main/java/org/rutebanken/util/LocalTimeISO8601XmlAdapter.java +++ b/src/main/java/org/rutebanken/util/LocalTimeISO8601XmlAdapter.java @@ -21,7 +21,6 @@ import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatterBuilder; import java.time.temporal.ChronoField; -import java.util.HashMap; import java.util.concurrent.ConcurrentHashMap; public class LocalTimeISO8601XmlAdapter extends XmlAdapter { @@ -45,7 +44,11 @@ public class LocalTimeISO8601XmlAdapter extends XmlAdapter { @Override public LocalTime unmarshal(String input) { - var key = LocalTime.parse(input, formatter); + // fast path for the common forms, the formatter handles everything else + var key = FastLocalDateTimeParser.parseTime(input); + if (key == null) { + key = LocalTime.parse(input, formatter); + } // only cache if nano is zero if(key.getNano() == 0){ return cache.computeIfAbsent(key, time -> time); diff --git a/src/test/java/org/rutebanken/util/FastLocalDateTimeParserTest.java b/src/test/java/org/rutebanken/util/FastLocalDateTimeParserTest.java new file mode 100644 index 0000000..7953cc5 --- /dev/null +++ b/src/test/java/org/rutebanken/util/FastLocalDateTimeParserTest.java @@ -0,0 +1,128 @@ +package org.rutebanken.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; +import org.junit.jupiter.api.Test; + +class FastLocalDateTimeParserTest { + + @Test + void testParseDate() { + assertEquals(LocalDate.of(2026, 5, 3), FastLocalDateTimeParser.parseDate("2026-05-03")); + assertEquals(LocalDate.of(2026, 5, 3), FastLocalDateTimeParser.parseDate("2026-05-03Z")); + assertEquals(LocalDate.of(2026, 5, 3), FastLocalDateTimeParser.parseDate("2026-05-03+02:00")); + assertEquals(LocalDate.of(2024, 2, 29), FastLocalDateTimeParser.parseDate("2024-02-29-18:00")); + } + + @Test + void testParseTime() { + assertEquals(LocalTime.of(14, 30, 0), FastLocalDateTimeParser.parseTime("14:30:00")); + assertEquals(LocalTime.of(0, 0, 0), FastLocalDateTimeParser.parseTime("00:00:00")); + assertEquals(LocalTime.of(23, 59, 59), FastLocalDateTimeParser.parseTime("23:59:59Z")); + assertEquals(LocalTime.of(14, 30, 0, 100_000_000), FastLocalDateTimeParser.parseTime("14:30:00.1")); + assertEquals(LocalTime.of(14, 30, 0, 123_456_789), FastLocalDateTimeParser.parseTime("14:30:00.123456789-05:30")); + } + + @Test + void testParseDateTime() { + assertEquals(LocalDateTime.of(2026, 5, 3, 23, 30, 9), FastLocalDateTimeParser.parseDateTime("2026-05-03T23:30:09")); + assertEquals( + LocalDateTime.of(2026, 5, 3, 23, 30, 9, 398_000_000), + FastLocalDateTimeParser.parseDateTime("2026-05-03T23:30:09.398") + ); + assertEquals( + LocalDateTime.of(2026, 5, 3, 23, 30, 9, 398_629_000), + FastLocalDateTimeParser.parseDateTime("2026-05-03T23:30:09.398629+02:00") + ); + assertEquals(LocalDateTime.of(2026, 5, 3, 23, 30, 9), FastLocalDateTimeParser.parseDateTime("2026-05-03T23:30:09Z")); + } + + /** A null input is declined, so the adapters keep throwing the formatter's NullPointerException. */ + @Test + void testNullIsDeclined() { + assertNull(FastLocalDateTimeParser.parseDate(null)); + assertNull(FastLocalDateTimeParser.parseTime(null)); + assertNull(FastLocalDateTimeParser.parseDateTime(null)); + + assertEquals("text", assertThrows(NullPointerException.class, () -> new LocalDateXmlAdapter().unmarshal(null)).getMessage()); + assertEquals("text", assertThrows(NullPointerException.class, () -> new LocalTimeISO8601XmlAdapter().unmarshal(null)).getMessage()); + assertEquals( + "text", + assertThrows(NullPointerException.class, () -> new LocalDateTimeISO8601XmlAdapter().unmarshal(null)).getMessage() + ); + } + + /** Forms the fast parser declines, leaving them to the DateTimeFormatter fallback. */ + @Test + void testParseDateDeclines() { + List inputs = List.of( + "", + "2023-02-30", + "2023-04-31", + "2023-13-01", + "0000-01-01", + "+2023-01-01", + "12023-01-01", + "2023-1-01", + "2023-01-01z", + "2023-01-01+19:00", + "2023-01-01+18:30", + "2023-01-01+02", + "2023-01-01+0200", + "2023-01-01+02:00:00", + "2023-01-01 ", + "2023-01-01T00:00:00" + ); + for (String text : inputs) { + assertNull(FastLocalDateTimeParser.parseDate(text), () -> "Input '" + text + "'"); + } + } + + @Test + void testParseTimeDeclines() { + List inputs = List.of( + "", + "24:00:00", + "23:60:00", + "23:59:60", + "10:20", + "1:20:30", + "10:20:30.", + "10:20:30.1234567890", + "10:20:30.Z", + "10:20:30+02:60", + "10:20:30+02:00:00", + " 10:20:30", + "10:20:30 ", + "١٠:20:30" + ); + for (String text : inputs) { + assertNull(FastLocalDateTimeParser.parseTime(text), () -> "Input '" + text + "'"); + } + } + + @Test + void testParseDateTimeDeclines() { + List inputs = List.of( + "", + "2024-01-01 10:00:00", + "2024-01-01t10:00:00", + "2024-01-01T10:00", + "2024-01-01T24:00:00", + "2023-02-30T10:00:00", + "0000-01-01T00:00:00", + "2024-01-01T10:00:00.", + "2024-01-01T10:00:00.1234567891", + "2024-01-01T10:00:00+19:00" + ); + for (String text : inputs) { + assertNull(FastLocalDateTimeParser.parseDateTime(text), () -> "Input '" + text + "'"); + } + } +}