Skip to content

Commit

Permalink
Improved DateUtilities parser to support redundant time zone specific…
Browse files Browse the repository at this point in the history
…ations, which ISO 8601 date time format permits. When offset and zone are both specified, offset is used instead of zone.
  • Loading branch information
jdereg committed Jan 21, 2024
1 parent 05bf55a commit 5234295
Show file tree
Hide file tree
Showing 2 changed files with 86 additions and 29 deletions.
66 changes: 41 additions & 25 deletions src/main/java/com/cedarsoftware/util/DateUtilities.java
Original file line number Diff line number Diff line change
Expand Up @@ -79,28 +79,24 @@
*/
public final class DateUtilities {
private static final Pattern allDigits = Pattern.compile("^\\d+$");
private static final String days = "(monday|mon|tuesday|tues|tue|wednesday|wed|thursday|thur|thu|friday|fri|saturday|sat|sunday|sun)"; // longer before shorter matters
private static final String mos = "(January|Jan|February|Feb|March|Mar|April|Apr|May|June|Jun|July|Jul|August|Aug|September|Sept|Sep|October|Oct|November|Nov|December|Dec)";
private static final String days = "\\b(monday|mon|tuesday|tues|tue|wednesday|wed|thursday|thur|thu|friday|fri|saturday|sat|sunday|sun)\\b"; // longer before shorter matters
private static final String mos = "\\b(January|Jan|February|Feb|March|Mar|April|Apr|May|June|Jun|July|Jul|August|Aug|September|Sept|Sep|October|Oct|November|Nov|December|Dec)\\b";
private static final String yr = "(\\d{4})";
private static final String dig1or2 = "\\d{1,2}";
private static final String dig1or2grp = "(" + dig1or2 + ")";
private static final String ord = dig1or2grp + "(st|nd|rd|th)?";
private static final String dig2 = "\\d{2}";
private static final String dig2gr = "(" + dig2 + ")";
private static final String sep = "([./-])";
private static final String ws = "\\s+";
private static final String wsOp = "\\s*";
private static final String wsOrComma = "[ ,]+";
private static final String tzUnix = "([A-Z]{1,3})?";
private static final String opNano = "(\\.\\d+)?";
private static final String nano = "\\.\\d+";
private static final String dayOfMon = dig1or2grp;
private static final String opSec = "(?:" + ":" + dig2gr + ")?";
private static final String hh = dig2gr;
private static final String mm = dig2gr;
private static final String tz_Hh_MM = "[+-]\\d{1,2}:\\d{2}";
private static final String tz_HHMM = "[+-]\\d{4}";
private static final String tz_Hh = "[+-]\\d{1,2}";
private static final String tzNamed = ws + "[A-Za-z][A-Za-z0-9~/._+-]+";
private static final String tzNamed = ws + "\\[?[A-Za-z][A-Za-z0-9~/._+-]+]?";

// Patterns defined in BNF-style using above named elements
private static final Pattern isoDatePattern = Pattern.compile( // Regex's using | (OR)
Expand All @@ -118,7 +114,7 @@ public final class DateUtilities {
Pattern.CASE_INSENSITIVE);

private static final Pattern timePattern = Pattern.compile(
hh + ":" + mm + opSec + opNano + "(" + tz_Hh_MM + "|" + tz_HHMM + "|" + tz_Hh + "|Z|" + tzNamed +")?",
"(" + dig2 + "):(" + dig2 + "):?(" + dig2 + ")?(" + nano + ")?(" + tz_Hh_MM + "|" + tz_HHMM + "|" + tz_Hh + "|Z|" + tzNamed + ")?", // 5 groups
Pattern.CASE_INSENSITIVE);

private static final Pattern dayPattern = Pattern.compile(days, Pattern.CASE_INSENSITIVE);
Expand Down Expand Up @@ -237,27 +233,13 @@ public static Date parseDate(String dateStr) {
milli = matcher.group(4).substring(1);
}
if (matcher.group(5) != null) {
tz = matcher.group(5).trim();
tz = stripBrackets(matcher.group(5).trim());
}
} else {
matcher = null; // indicates no "time" portion
}

remains = remnant;

// Clear out day of week (mon, tue, wed, ...)
if (StringUtilities.length(remains) > 0) {
Matcher dayMatcher = dayPattern.matcher(remains);
remains = dayMatcher.replaceFirst("").trim();
}

// Verify that nothing or , or T is all that remains
if (StringUtilities.length(remains) > 0) {
remains = remains.trim();
if (!remains.equals(",") && (!remains.equals("T"))) {
throw new IllegalArgumentException("Issue parsing data/time, other characters present: " + remains);
}
}
verifyNoGarbageLeft(remnant);

// Set Timezone into Calendar if one is supplied
Calendar c = Calendar.getInstance();
Expand Down Expand Up @@ -322,6 +304,40 @@ public static Date parseDate(String dateStr) {
return c.getTime();
}

private static void verifyNoGarbageLeft(String remnant) {
// Clear out day of week (mon, tue, wed, ...)
if (StringUtilities.length(remnant) > 0) {
Matcher dayMatcher = dayPattern.matcher(remnant);
remnant = dayMatcher.replaceFirst("").trim();
if (remnant.startsWith("T")) {
remnant = remnant.substring(1).trim();
}
}

// Verify that nothing or "," is all that remains
if (StringUtilities.length(remnant) > 0) {
remnant = remnant.replaceAll(",|\\[.*?\\]", "").trim();
if (!remnant.isEmpty()) {
try {
ZoneId.of(remnant);
}
catch (Exception e) {
TimeZone timeZone = TimeZone.getTimeZone(remnant);
if (timeZone.getRawOffset() == 0) {
throw new IllegalArgumentException("Issue parsing date-time, other characters present: " + remnant);
}
}
}
}
}

private static String stripBrackets(String input) {
if (input == null || input.isEmpty()) {
return input;
}
return input.replaceAll("^\\[|\\]$", "");
}

/**
* Calendar & Date are only accurate to milliseconds.
*/
Expand Down
49 changes: 45 additions & 4 deletions src/test/java/com/cedarsoftware/util/TestDateUtilities.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
import java.util.Calendar;
import java.util.Date;
import java.util.TimeZone;
import java.util.stream.Stream;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;

import static org.assertj.core.api.Assertions.assertThatThrownBy;
Expand Down Expand Up @@ -343,7 +345,7 @@ void testDayOfWeek()
DateUtilities.parseDate(" Dec 25, 2014, thursday ");
}
try {
DateUtilities.parseDate("text Dec 25, 2014");
Date date = DateUtilities.parseDate("text Dec 25, 2014");
fail();
} catch (Exception ignored) { }

Expand Down Expand Up @@ -714,11 +716,11 @@ void testBadTimeSeparators()
{
assertThatThrownBy(() -> DateUtilities.parseDate("12/24/1996 12.49.58"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Issue parsing data/time, other characters present: 12.49.58");
.hasMessageContaining("Issue parsing date-time, other characters present: 12.49.58");

assertThatThrownBy(() -> DateUtilities.parseDate("12.49.58 12/24/1996"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Issue parsing data/time, other characters present: 12.49.58");
.hasMessageContaining("Issue parsing date-time, other characters present: 12.49.58");

Date date = DateUtilities.parseDate("12:49:58 12/24/1996"); // time with valid separators before date
Calendar calendar = Calendar.getInstance();
Expand All @@ -728,7 +730,7 @@ void testBadTimeSeparators()

assertThatThrownBy(() -> DateUtilities.parseDate("12/24/1996 12-49-58"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Issue parsing data/time, other characters present: 12-49-58");
.hasMessageContaining("Issue parsing date-time, other characters present: 12-49-58");
}

@Test
Expand Down Expand Up @@ -757,4 +759,43 @@ void testEpochMillis()
gmtDateString = sdf.format(date);
assertEquals("31690708-07-05 01:46:39.999", gmtDateString);
}

private static Stream<String> provideTimeZones()
{
return Stream.of(
"2024-01-19T15:30:45[Europe/London]",
"2024-01-19T10:15:30[Asia/Tokyo]",
"2024-01-19T20:45:00[America/New_York]",
"2024-01-19T12:00:00-08:00[America/Los_Angeles]",
"2024-01-19T22:30:00+01:00[Europe/Paris]",
"2024-01-19T18:15:45+10:00[Australia/Sydney]",
"2024-01-19T05:00:00-03:00[America/Sao_Paulo]",
"2024-01-19T23:59:59Z[UTC]",
"2024-01-19T14:30:00+05:30[Asia/Kolkata]",
"2024-01-19T21:45:00-05:00[America/Toronto]",
"2024-01-19T16:00:00+02:00[Africa/Cairo]",
"2024-01-19T07:30:00-07:00[America/Denver]",
"2024-01-19T15:30:45 Europe/London",
"2024-01-19T10:15:30 Asia/Tokyo",
"2024-01-19T20:45:00 America/New_York",
"2024-01-19T12:00:00-08:00 America/Los_Angeles",
"2024-01-19T22:30:00+01:00 Europe/Paris",
"2024-01-19T18:15:45+10:00 Australia/Sydney",
"2024-01-19T05:00:00-03:00 America/Sao_Paulo",
"2024-01-19T23:59:59Z UTC",
"2024-01-19T14:30:00+05:30 Asia/Kolkata",
"2024-01-19T21:45:00-05:00 America/Toronto",
"2024-01-19T16:00:00+02:00 Africa/Cairo",
"2024-01-19T07:30:00-07:00 America/Denver",
"2024-01-19T07:30:00 CST",
"2024-01-19T07:30:00+10 EST"
);
}

@ParameterizedTest
@MethodSource("provideTimeZones")
void testTimeZoneParsing(String exampleZone)
{
DateUtilities.parseDate(exampleZone);
}
}

0 comments on commit 5234295

Please sign in to comment.