From 89b68abca758f808d799f2d1ea65e9a3993dffe4 Mon Sep 17 00:00:00 2001 From: Morten Haraldsen Date: Tue, 23 Jan 2024 12:00:25 +0100 Subject: [PATCH] Added error checking and throwing DateTimeParseException to include the parse position where the error occurred --- pom.xml | 4 +- src/main/java/com/ethlo/time/DateTime.java | 6 +- .../ethlo/time/DateTimeFormatException.java | 31 +++++++ .../java/com/ethlo/time/TimezoneOffset.java | 10 -- .../ethlo/time/internal/AbstractRfc3339.java | 4 +- .../com/ethlo/time/internal/EthloITU.java | 93 ++++++++++++------- .../internal/LimitedCharArrayIntegerUtil.java | 5 +- .../java/com/ethlo/time/CorrectnessTest.java | 2 +- .../java/com/ethlo/time/ErrorOffsetTest.java | 63 +++++++++++++ src/test/java/com/ethlo/time/ITUTest.java | 2 + .../com/ethlo/time/W3cCorrectnessTest.java | 7 -- 11 files changed, 169 insertions(+), 58 deletions(-) create mode 100644 src/main/java/com/ethlo/time/DateTimeFormatException.java create mode 100644 src/test/java/com/ethlo/time/ErrorOffsetTest.java diff --git a/pom.xml b/pom.xml index d320530..324a4af 100644 --- a/pom.xml +++ b/pom.xml @@ -23,9 +23,9 @@ limitations under the License. com.ethlo.time bundle itu - 1.7.8-SNAPSHOT + 1.8.0-SNAPSHOT Internet Time Utility - Very fast date-time parser and formatter - RFC 3339 (ISO 8601 profile) and W3C format + Extremely fast date-time parser and formatter - RFC 3339 (ISO 8601 profile) and W3C format diff --git a/src/main/java/com/ethlo/time/DateTime.java b/src/main/java/com/ethlo/time/DateTime.java index 25d3296..b16bb9e 100644 --- a/src/main/java/com/ethlo/time/DateTime.java +++ b/src/main/java/com/ethlo/time/DateTime.java @@ -280,7 +280,7 @@ public OffsetDateTime toOffsetDatetime() { return OffsetDateTime.of(year, month, day, hour, minute, second, nano, offset.toZoneOffset()); } - throw new DateTimeException("No zone offset information found"); + throw new DateTimeFormatException("No zone offset information found"); } /** @@ -308,7 +308,7 @@ private void assertMinGranularity(Field field) { if (!includesGranularity(field)) { - throw new DateTimeException("No " + field.name() + " field found"); + throw new DateTimeFormatException("No " + field.name() + " field found"); } } @@ -338,7 +338,7 @@ private String toString(final DateTime date, final Field lastIncluded, final int { if (lastIncluded.ordinal() > date.getMostGranularField().ordinal()) { - throw new DateTimeException("Requested granularity was " + lastIncluded.name() + ", but contains only granularity " + date.getMostGranularField().name()); + throw new DateTimeFormatException("Requested granularity was " + lastIncluded.name() + ", but contains only granularity " + date.getMostGranularField().name()); } final TimezoneOffset tz = date.getOffset().orElse(null); final char[] buffer = new char[35]; diff --git a/src/main/java/com/ethlo/time/DateTimeFormatException.java b/src/main/java/com/ethlo/time/DateTimeFormatException.java new file mode 100644 index 0000000..8c290cc --- /dev/null +++ b/src/main/java/com/ethlo/time/DateTimeFormatException.java @@ -0,0 +1,31 @@ +package com.ethlo.time; + +/*- + * #%L + * Internet Time Utility + * %% + * Copyright (C) 2017 - 2024 Morten Haraldsen (ethlo) + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import java.time.DateTimeException; + +public class DateTimeFormatException extends DateTimeException +{ + public DateTimeFormatException(final String message) + { + super(message); + } +} diff --git a/src/main/java/com/ethlo/time/TimezoneOffset.java b/src/main/java/com/ethlo/time/TimezoneOffset.java index 679a042..5dab233 100644 --- a/src/main/java/com/ethlo/time/TimezoneOffset.java +++ b/src/main/java/com/ethlo/time/TimezoneOffset.java @@ -20,7 +20,6 @@ * #L% */ -import java.time.DateTimeException; import java.time.ZoneOffset; import java.util.Objects; @@ -35,15 +34,6 @@ public class TimezoneOffset private TimezoneOffset(final int hours, final int minutes) { - if (hours > 0 && minutes < 0) - { - throw new DateTimeException("Zone offset minutes must be positive because hours is positive"); - } - else if (hours < 0 && minutes > 0) - { - throw new DateTimeException("Zone offset minutes must be negative because hours is negative"); - } - this.hours = hours; this.minutes = minutes; } diff --git a/src/main/java/com/ethlo/time/internal/AbstractRfc3339.java b/src/main/java/com/ethlo/time/internal/AbstractRfc3339.java index 2ad2e42..9b9c6c7 100644 --- a/src/main/java/com/ethlo/time/internal/AbstractRfc3339.java +++ b/src/main/java/com/ethlo/time/internal/AbstractRfc3339.java @@ -20,7 +20,7 @@ * #L% */ -import java.time.DateTimeException; +import com.ethlo.time.DateTimeFormatException; public abstract class AbstractRfc3339 implements Rfc3339 { @@ -30,7 +30,7 @@ protected void assertMaxFractionDigits(int fractionDigits) { if (fractionDigits > MAX_FRACTION_DIGITS) { - throw new DateTimeException("Maximum supported number of fraction digits in second is " + throw new DateTimeFormatException("Maximum supported number of fraction digits in second is " + MAX_FRACTION_DIGITS + ", got " + fractionDigits); } } diff --git a/src/main/java/com/ethlo/time/internal/EthloITU.java b/src/main/java/com/ethlo/time/internal/EthloITU.java index e6ff52d..1983ca7 100644 --- a/src/main/java/com/ethlo/time/internal/EthloITU.java +++ b/src/main/java/com/ethlo/time/internal/EthloITU.java @@ -30,6 +30,7 @@ import java.time.OffsetDateTime; import java.time.YearMonth; import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; import java.util.Arrays; import com.ethlo.time.DateTime; @@ -90,12 +91,12 @@ private static int writeTz(char[] buf, int start, TimezoneOffset tz) } } - private static int scale(int fractions, int len) + private static int scale(int fractions, int len, String parsedData, final int index) { switch (len) { case 0: - throw new DateTimeException("Must have at least 1 fraction digit"); + throw new DateTimeParseException("Must have at least 1 fraction digit", parsedData, index); case 1: return fractions * 100_000_000; case 2: @@ -132,24 +133,24 @@ private static Object handleTime(String chars, int year, int month, int day, int final TimezoneOffset zoneOffset = parseTimezone(chars, 16); if (!raw) { - throw raiseMissingField(Field.SECOND); + throw raiseMissingField(Field.SECOND, chars, 16); } return DateTime.of(year, month, day, hour, minute, zoneOffset); } - throw new DateTimeException(chars); + throw new DateTimeParseException("Unexpected character at position 16: " + chars.charAt(16), chars, 16); } private static void assertPositionContains(String chars, int offset, char expected) { if (offset >= chars.length()) { - raiseDateTimeException(chars, "Unexpected end of input"); + raiseDateTimeException(chars, "Unexpected end of input", offset); } if (chars.charAt(offset) != expected) { - throw new DateTimeException("Expected character " + expected - + " at position " + (offset + 1) + " '" + chars + "'"); + throw new DateTimeParseException("Expected character " + expected + + " at position " + (offset + 1) + " '" + chars + "'", chars, offset); } } @@ -157,7 +158,7 @@ private static void assertPositionContains(String chars, char... expected) { if (10 >= chars.length()) { - raiseDateTimeException(chars, "Unexpected end of input"); + raiseDateTimeException(chars, "Unexpected end of input", 10); } boolean found = false; @@ -172,8 +173,8 @@ private static void assertPositionContains(String chars, char... expected) } if (!found) { - throw new DateTimeException("Expected character " + Arrays.toString(expected) - + " at position " + (10 + 1) + " '" + chars + "'"); + throw new DateTimeParseException("Expected character " + Arrays.toString(expected) + + " at position " + (10 + 1) + " '" + chars + "'", chars, 10); } } @@ -181,7 +182,7 @@ private static TimezoneOffset parseTimezone(String chars, int offset) { if (offset >= chars.length()) { - throw new DateTimeException("No timezone information: " + chars); + throw new DateTimeParseException("No timezone information: " + chars, chars, offset); } final int len = chars.length(); final int left = len - offset; @@ -195,12 +196,12 @@ private static TimezoneOffset parseTimezone(String chars, int offset) final char sign = chars.charAt(offset); if (sign != PLUS && sign != MINUS) { - throw new DateTimeException("Invalid character starting at position " + offset + ": " + chars); + throw new DateTimeParseException("Invalid character starting at position " + offset + ": " + chars, chars, offset); } if (left != 6) { - throw new DateTimeException("Invalid timezone offset: " + new String(chars.toCharArray(), offset, left)); + throw new DateTimeParseException("Invalid timezone offset: " + chars, chars, offset); } int hours = parsePositiveInt(chars, offset + 1, offset + 3); @@ -213,7 +214,7 @@ private static TimezoneOffset parseTimezone(String chars, int offset) if (sign == MINUS && hours == 0 && minutes == 0) { - throw new DateTimeException("Unknown 'Local Offset Convention' date-time not allowed"); + throw new DateTimeParseException("Unknown 'Local Offset Convention' date-time not allowed", chars, offset); } return TimezoneOffset.ofHoursMinutes(hours, minutes); @@ -223,7 +224,7 @@ private static void assertNoMoreChars(String chars, int lastUsed) { if (chars.length() > lastUsed + 1) { - throw new DateTimeException("Trailing junk data after position " + (lastUsed + 1) + ": " + chars); + throw new DateTimeParseException("Trailing junk data after position " + (lastUsed + 1) + ": " + chars, chars, lastUsed + 1); } } @@ -242,6 +243,10 @@ private static Object parse(String chars, boolean raw) final int years = parsePositiveInt(chars, 0, 4); if (4 == len) { + if (!raw) + { + throw raiseMissingField(Field.YEAR, chars, 2); + } return DateTime.ofYear(years); } @@ -252,7 +257,7 @@ private static Object parse(String chars, boolean raw) { if (!raw) { - throw raiseMissingField(Field.MONTH); + throw raiseMissingField(Field.MONTH, chars, 5); } return DateTime.ofYearMonth(years, months); } @@ -264,7 +269,7 @@ private static Object parse(String chars, boolean raw) { if (!raw) { - throw raiseMissingField(Field.DAY); + throw raiseMissingField(Field.DAY, chars, 9); } return DateTime.ofDate(years, months, days); } @@ -287,36 +292,52 @@ private static Object parse(String chars, boolean raw) { return DateTime.of(years, months, days, hours, minutes, null); } - throw raiseMissingField(Field.SECOND); + throw raiseMissingField(Field.SECOND, chars, 16); } - private static DateTimeException raiseMissingField(Field field) + private static DateTimeException raiseMissingField(Field field, final String chars, final int offset) { - return new DateTimeException("No " + field.name() + " field found"); + return new DateTimeParseException("No " + field.name() + " field found", chars, offset); } private static Object handleTime(int year, int month, int day, int hour, int minute, String chars, boolean raw) { // From here the specification is more lenient final int len = chars.length(); - final int remaining = len - 19; - if (remaining == 0) + final int remaining = len - 17; + if (remaining == 2) { final int seconds = parsePositiveInt(chars, 17, 19); - leapSecondCheck(year, month, day, hour, minute, seconds, 0, null); + leapSecondCheck(year, month, day, hour, minute, 0, 0, null); if (raw) { return new DateTime(Field.SECOND, year, month, day, hour, minute, seconds, 0, null, 0); } - throw new DateTimeException("No timezone information: " + chars); + throw new DateTimeParseException("No timezone information: " + chars, chars, 19); + } + else if (remaining == 0) + { + if (raw) + { + return new DateTime(Field.SECOND, year, month, day, hour, minute, 0, 0, null, 0); + } + throw new DateTimeParseException("No timezone information: " + chars, chars, 16); } TimezoneOffset offset = null; int fractions = 0; int fractionDigits = 0; + if (chars.length() < 20) + { + throw new DateTimeParseException("Unexpected end of input: " + chars, chars, 16); + } char c = chars.charAt(19); if (c == FRACTION_SEPARATOR) { + if (chars.length() < 21) + { + throw new DateTimeParseException("Unexpected end of input: " + chars, chars, 20); + } // We have fractional seconds int result = 0; int idx = 20; @@ -328,11 +349,14 @@ private static Object handleTime(int year, int month, int day, int hour, int min { nonDigitFound = true; fractionDigits = idx - 20; - fractions = scale(-result, fractionDigits); + assertFractionDigits(chars, fractionDigits, idx); + fractions = scale(-result, fractionDigits, chars, idx); offset = parseTimezone(chars, idx); } else { + fractionDigits = idx - 19; + assertFractionDigits(chars, fractionDigits, idx); result = (result << 1) + (result << 3); result -= c - ZERO; } @@ -342,14 +366,14 @@ private static Object handleTime(int year, int month, int day, int hour, int min if (!nonDigitFound) { fractionDigits = idx - 20; - fractions = scale(-result, fractionDigits); + fractions = scale(-result, fractionDigits, chars, idx); if (!raw) { offset = parseTimezone(chars, idx); } } } - else if (remaining == 1 && (c == ZULU_UPPER || c == ZULU_LOWER)) + else if (c == ZULU_UPPER || c == ZULU_LOWER) { // Do nothing we are done offset = TimezoneOffset.UTC; @@ -361,11 +385,10 @@ else if (c == PLUS || c == MINUS) } else { - raiseDateTimeException(chars, "Unexpected character at position 19"); + raiseDateTimeException(chars, "Unexpected character at position 19", 19); } final int second = parsePositiveInt(chars, 17, 19); - leapSecondCheck(year, month, day, hour, minute, second, fractions, offset); if (!raw) @@ -375,6 +398,14 @@ else if (c == PLUS || c == MINUS) return fractionDigits > 0 ? DateTime.of(year, month, day, hour, minute, second, fractions, offset, fractionDigits) : DateTime.of(year, month, day, hour, minute, second, offset); } + private static void assertFractionDigits(String chars, int fractionDigits, int idx) + { + if (fractionDigits > MAX_FRACTION_DIGITS) + { + throw new DateTimeParseException("Too many fraction digits: " + chars, chars, idx); + } + } + private static void leapSecondCheck(int year, int month, int day, int hour, int minute, int second, int nanos, TimezoneOffset offset) { if (second == LEAP_SECOND_SECONDS) @@ -398,9 +429,9 @@ private static void leapSecondCheck(int year, int month, int day, int hour, int } } - private static void raiseDateTimeException(String chars, String message) + private static void raiseDateTimeException(String chars, String message, int index) { - throw new DateTimeException(message + ": " + chars); + throw new DateTimeParseException(message + ": " + chars, chars, index); } @Override diff --git a/src/main/java/com/ethlo/time/internal/LimitedCharArrayIntegerUtil.java b/src/main/java/com/ethlo/time/internal/LimitedCharArrayIntegerUtil.java index f5e1bbe..fee1e37 100644 --- a/src/main/java/com/ethlo/time/internal/LimitedCharArrayIntegerUtil.java +++ b/src/main/java/com/ethlo/time/internal/LimitedCharArrayIntegerUtil.java @@ -21,6 +21,7 @@ */ import java.time.DateTimeException; +import java.time.format.DateTimeParseException; import java.util.Arrays; public final class LimitedCharArrayIntegerUtil @@ -52,7 +53,7 @@ public static int parsePositiveInt(final String strNum, int startInclusive, int { if (endExclusive > strNum.length()) { - throw new DateTimeException("Unexpected end of expression at position " + strNum.length() + ": '" + strNum + "'"); + throw new DateTimeParseException("Unexpected end of expression at position " + strNum.length() + ": '" + strNum + "'", strNum, startInclusive); } int result = 0; @@ -61,7 +62,7 @@ public static int parsePositiveInt(final String strNum, int startInclusive, int final char c = strNum.charAt(i); if (c < ZERO || c > DIGIT_9) { - throw new DateTimeException("Character " + c + " is not a digit"); + throw new DateTimeParseException("Character " + c + " is not a digit", strNum, i); } result = (result << 1) + (result << 3); result -= c - ZERO; diff --git a/src/test/java/com/ethlo/time/CorrectnessTest.java b/src/test/java/com/ethlo/time/CorrectnessTest.java index e49ffe1..4962a9c 100644 --- a/src/test/java/com/ethlo/time/CorrectnessTest.java +++ b/src/test/java/com/ethlo/time/CorrectnessTest.java @@ -204,7 +204,7 @@ void testFormat4() void testParseMoreThanNanoResolutionFails() { final DateTimeException exception = assertThrows(DateTimeException.class, () -> parser.parseDateTime("2017-02-21T15:00:00.1234567891Z")); - assertThat(exception.getMessage()).isEqualTo("Invalid value for NanoOfSecond (valid values 0 - 999999999): 1234567891"); + assertThat(exception.getMessage()).isEqualTo("Too many fraction digits: 2017-02-21T15:00:00.1234567891Z"); } @Test diff --git a/src/test/java/com/ethlo/time/ErrorOffsetTest.java b/src/test/java/com/ethlo/time/ErrorOffsetTest.java new file mode 100644 index 0000000..61fccfa --- /dev/null +++ b/src/test/java/com/ethlo/time/ErrorOffsetTest.java @@ -0,0 +1,63 @@ +package com.ethlo.time; + +/*- + * #%L + * Internet Time Utility + * %% + * Copyright (C) 2017 - 2024 Morten Haraldsen (ethlo) + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.OffsetDateTime; +import java.time.format.DateTimeParseException; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +public class ErrorOffsetTest +{ + @ParameterizedTest + @ValueSource(strings = {"111", + "1111-", + "2012-2", + "2012-11-", + "2012-11-1", + "2012-11-11x", + "2012-11-11t12", + "2012-11-11t12:", + "2012-11-11t12:22", + "2012-11-11t12:22:", + "2012-11-11t12:22:1", + "2012-11-11t12:22:11", + "2012-11-11t12:22:11.", + "2012-11-11t12:22:11y", + "2012-11-11t12:22:11.1234567890", + "2012-11-11t12:22:11.1234567890+", + "2012-11-11t12:22:11.1234567890+8", + "2012-11-11t12:22:11.1234567890+08:", + "2012-11-11t12:22:11.1234567890+08:1", + "2012-11-11t12:22:11.1234567890+08:11x"}) + public void testParseUseErrorPosition(String arg) + { + // Compare that parser error-position with the JDK parser + final DateTimeParseException exc = assertThrows(DateTimeParseException.class, () -> ITU.parseDateTime(arg)); + final DateTimeParseException original = assertThrows(DateTimeParseException.class, () -> OffsetDateTime.parse(arg)); + assertThat(exc.getErrorIndex()).isEqualTo(original.getErrorIndex()); + } + +} diff --git a/src/test/java/com/ethlo/time/ITUTest.java b/src/test/java/com/ethlo/time/ITUTest.java index 4ec2628..a90dafb 100644 --- a/src/test/java/com/ethlo/time/ITUTest.java +++ b/src/test/java/com/ethlo/time/ITUTest.java @@ -36,10 +36,12 @@ import java.time.Year; import java.time.YearMonth; import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; import java.time.temporal.Temporal; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; @Tag("CorrectnessTest") public class ITUTest diff --git a/src/test/java/com/ethlo/time/W3cCorrectnessTest.java b/src/test/java/com/ethlo/time/W3cCorrectnessTest.java index 0704350..62638f7 100644 --- a/src/test/java/com/ethlo/time/W3cCorrectnessTest.java +++ b/src/test/java/com/ethlo/time/W3cCorrectnessTest.java @@ -150,13 +150,6 @@ public void testTimezoneOffset() assertThat(tz.getMinutes()).isEqualTo(-30); } - @Test - public void testMismatch() - { - assertThrows(DateTimeException.class, () -> TimezoneOffset.ofHoursMinutes(-17, 30)); - assertThrows(DateTimeException.class, () -> TimezoneOffset.ofHoursMinutes(17, -30)); - } - @Test public void testOfZoneOffset() {