Skip to content

Commit

Permalink
Cleaning up Calendar conversions and strengthening DateUtilities.pars…
Browse files Browse the repository at this point in the history
…eDate
  • Loading branch information
jdereg committed Jan 26, 2025
1 parent 41891dc commit 16ca31a
Show file tree
Hide file tree
Showing 6 changed files with 154 additions and 99 deletions.
38 changes: 27 additions & 11 deletions src/main/java/com/cedarsoftware/util/DateUtilities.java
Original file line number Diff line number Diff line change
Expand Up @@ -366,14 +366,15 @@ public static ZonedDateTime parseDate(String dateStr, ZoneId defaultZoneId, bool
}
Convention.throwIfNull(defaultZoneId, "ZoneId cannot be null. Use ZoneId.of(\"America/New_York\"), ZoneId.systemDefault(), etc.");

// If purely digits => epoch millis
if (allDigits.matcher(dateStr).matches()) {
return Instant.ofEpochMilli(Long.parseLong(dateStr)).atZone(defaultZoneId);
}

String year, day, remains, tz = null;
int month;

// Determine which date pattern to use
// 1) Try matching ISO or numeric style date
Matcher matcher = isoDatePattern.matcher(dateStr);
String remnant = matcher.replaceFirst("");
if (remnant.length() < dateStr.length()) {
Expand All @@ -388,6 +389,7 @@ public static ZonedDateTime parseDate(String dateStr, ZoneId defaultZoneId, bool
}
remains = remnant;
} else {
// 2) Try alphaMonthPattern
matcher = alphaMonthPattern.matcher(dateStr);
remnant = matcher.replaceFirst("");
if (remnant.length() < dateStr.length()) {
Expand All @@ -410,6 +412,7 @@ public static ZonedDateTime parseDate(String dateStr, ZoneId defaultZoneId, bool
}
month = months.get(mon.trim().toLowerCase());
} else {
// 3) Try unixDateTimePattern
matcher = unixDateTimePattern.matcher(dateStr);
if (matcher.replaceFirst("").length() == dateStr.length()) {
throw new IllegalArgumentException("Unable to parse: " + dateStr + " as a date-time");
Expand All @@ -418,20 +421,24 @@ public static ZonedDateTime parseDate(String dateStr, ZoneId defaultZoneId, bool
String mon = matcher.group(2);
month = months.get(mon.trim().toLowerCase());
day = matcher.group(3);

// e.g. "EST"
tz = matcher.group(5);
remains = matcher.group(4); // leave optional time portion remaining

// time portion remains to parse
remains = matcher.group(4);
}
}

// For the remaining String, match the time portion (which could have appeared ahead of the date portion)
// 4) Parse time portion (could appear before or after date)
String hour = null, min = null, sec = "00", fracSec = "0";
remains = remains.trim();
matcher = timePattern.matcher(remains);
remnant = matcher.replaceFirst("");

if (remnant.length() < remains.length()) {
hour = matcher.group(1);
min = matcher.group(2);
min = matcher.group(2);
if (matcher.group(3) != null) {
sec = matcher.group(3);
}
Expand All @@ -442,20 +449,29 @@ public static ZonedDateTime parseDate(String dateStr, ZoneId defaultZoneId, bool
tz = matcher.group(5).trim();
}
if (matcher.group(6) != null) {
// to make round trip of ZonedDateTime equivalent we need to use the original Zone as ZoneId
// ZoneId is a much broader definition handling multiple possible dates, and we want this to
// be equivalent to the original zone that was used if one was present.
tz = stripBrackets(matcher.group(6).trim());
}
}

// 5) If strict, verify no leftover text
if (ensureDateTimeAlone) {
verifyNoGarbageLeft(remnant);
}

ZoneId zoneId = StringUtilities.isEmpty(tz) ? defaultZoneId : getTimeZone(tz);
ZonedDateTime dateTime = getDate(dateStr, zoneId, year, month, day, hour, min, sec, fracSec);
return dateTime;
ZoneId zoneId;
try {
zoneId = StringUtilities.isEmpty(tz) ? defaultZoneId : getTimeZone(tz);
} catch (Exception e) {
if (ensureDateTimeAlone) {
// In strict mode, rethrow
throw e;
}
// else in non-strict mode, ignore the invalid zone and default
zoneId = defaultZoneId;
}

// 6) Build the ZonedDateTime
return getDate(dateStr, zoneId, year, month, day, hour, min, sec, fracSec);
}

private static ZonedDateTime getDate(String dateStr,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Calendar;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;

import com.cedarsoftware.util.CompactMap;

/**
* @author Kenny Partlow ([email protected])
* <br>
Expand Down Expand Up @@ -122,11 +122,22 @@ static OffsetDateTime toOffsetDateTime(Object from, Converter converter) {

static Map<String, Object> toMap(Object from, Converter converter) {
Calendar cal = (Calendar) from;
Map<String, Object> target = CompactMap.<String, Object>builder().insertionOrder().build();
target.put(MapConversions.DATE, LocalDate.of(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH)).toString());
target.put(MapConversions.TIME, LocalTime.of(cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE), cal.get(Calendar.SECOND), cal.get(Calendar.MILLISECOND) * 1_000_000).toString());
target.put(MapConversions.ZONE, cal.getTimeZone().toZoneId().toString());
target.put(MapConversions.EPOCH_MILLIS, cal.getTimeInMillis());
ZonedDateTime zdt = cal.toInstant().atZone(cal.getTimeZone().toZoneId());

// Format with timezone keeping DST information
String formatted;
int ms = zdt.getNano() / 1_000_000; // Convert nanos to millis
if (ms == 0) {
// No fractional seconds
formatted = zdt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'['VV']'"));
} else {
// Millisecond precision
formatted = zdt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"))
+ String.format(".%03d[%s]", ms, zdt.getZone());
}

Map<String, Object> target = new LinkedHashMap<>();
target.put(MapConversions.CALENDAR, formatted);
return target;
}
}
46 changes: 9 additions & 37 deletions src/main/java/com/cedarsoftware/util/convert/MapConversions.java
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ final class MapConversions {
static final String VALUE = "value";
static final String DATE = "date";
static final String SQL_DATE = "sqlDate";
static final String CALENDAR = "calendar";
static final String TIME = "time";
static final String TIMESTAMP = "timestamp";
static final String ZONE = "zone";
Expand Down Expand Up @@ -292,47 +293,18 @@ static TimeZone toTimeZone(Object from, Converter converter) {

static Calendar toCalendar(Object from, Converter converter) {
Map<String, Object> map = (Map<String, Object>) from;
Object epochMillis = map.get(EPOCH_MILLIS);
if (epochMillis != null) {
return converter.convert(epochMillis, Calendar.class);
}

Object date = map.get(DATE);
Object time = map.get(TIME);
Object zone = map.get(ZONE); // optional
ZoneId zoneId;
if (zone != null) {
zoneId = converter.convert(zone, ZoneId.class);
} else {
zoneId = converter.getOptions().getZoneId();
}

if (date != null && time != null) {
LocalDate localDate = converter.convert(date, LocalDate.class);
LocalTime localTime = converter.convert(time, LocalTime.class);
LocalDateTime ldt = LocalDateTime.of(localDate, localTime);
ZonedDateTime zdt = ldt.atZone(zoneId);
Calendar cal = Calendar.getInstance(TimeZone.getTimeZone(zoneId));
cal.set(Calendar.YEAR, zdt.getYear());
cal.set(Calendar.MONTH, zdt.getMonthValue() - 1);
cal.set(Calendar.DAY_OF_MONTH, zdt.getDayOfMonth());
cal.set(Calendar.HOUR_OF_DAY, zdt.getHour());
cal.set(Calendar.MINUTE, zdt.getMinute());
cal.set(Calendar.SECOND, zdt.getSecond());
cal.set(Calendar.MILLISECOND, zdt.getNano() / 1_000_000);
cal.getTime();
return cal;
}

if (time != null && date == null) {
Calendar cal = Calendar.getInstance(TimeZone.getTimeZone(zoneId));
ZonedDateTime zdt = DateUtilities.parseDate((String)time, zoneId, true);
Object calStr = map.get(CALENDAR);
if (calStr instanceof String && StringUtilities.hasContent((String)calStr)) {
ZonedDateTime zdt = DateUtilities.parseDate((String)calStr, converter.getOptions().getZoneId(), true);
Calendar cal = Calendar.getInstance(TimeZone.getTimeZone(zdt.getZone()));
cal.setTimeInMillis(zdt.toInstant().toEpochMilli());
return cal;
}
return fromMap(from, converter, Calendar.class, new String[]{EPOCH_MILLIS}, new String[]{TIME, ZONE + OPTIONAL}, new String[]{DATE, TIME, ZONE + OPTIONAL});
}

// Handle legacy/alternate formats via fromMap
return fromMap(from, converter, Calendar.class, new String[]{CALENDAR});
}

static Locale toLocale(Object from, Converter converter) {
Map<String, String> map = (Map<String, String>) from;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,6 @@ static String toString(Object from, Converter converter) {

static Map<String, Object> toMap(Object from, Converter converter) {
Timestamp timestamp = (Timestamp) from;
long millis = timestamp.getTime();

// 1) Convert Timestamp -> Instant -> UTC ZonedDateTime
ZonedDateTime zdt = timestamp.toInstant().atZone(ZoneOffset.UTC);
Expand Down
73 changes: 73 additions & 0 deletions src/test/java/com/cedarsoftware/util/DateUtilitiesTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;

Expand Down Expand Up @@ -783,6 +784,78 @@ void testEpochMillis()
assertEquals("31690708-07-05 01:46:39.999", gmtDateString);
}

@Test
void testParseInvalidTimeZoneFormats() {
// Test with named timezone without time - should fail
assertThrows(IllegalArgumentException.class, () ->
DateUtilities.parseDate("2024-02-05[Asia/Tokyo]", ZoneId.of("Z"), false),
"Should fail with timezone but no time");

assertThrows(IllegalArgumentException.class, () ->
DateUtilities.parseDate("2024-02-05[Asia/Tokyo]", ZoneId.of("Z"), true),
"Should fail with timezone but no time");

// Test with offset without time - should fail
assertThrows(IllegalArgumentException.class, () ->
DateUtilities.parseDate("2024-02-05+09:00", ZoneId.of("Z"), false),
"Should fail with offset but no time");

assertThrows(IllegalArgumentException.class, () ->
DateUtilities.parseDate("2024-02-05+09:00", ZoneId.of("Z"), true),
"Should fail with offset but no time");

// Test with Z without time - should fail
assertThrows(IllegalArgumentException.class, () ->
DateUtilities.parseDate("2024-02-05Z", ZoneId.of("Z"), false),
"Should fail with Z but no time");

assertThrows(IllegalArgumentException.class, () ->
DateUtilities.parseDate("2024-02-05Z", ZoneId.of("Z"), true),
"Should fail with Z but no time");

// Test with T but no time - should fail
assertThrows(IllegalArgumentException.class, () ->
DateUtilities.parseDate("2024-02-05T[Asia/Tokyo]", ZoneId.of("Z"), false),
"Should fail with T but no time");

assertThrows(IllegalArgumentException.class, () ->
DateUtilities.parseDate("2024-02-05T[Asia/Tokyo]", ZoneId.of("Z"), true),
"Should fail with T but no time");
}

@Test
void testParseWithTrailingText() {
// Test with trailing text - should pass with strict=false
ZonedDateTime zdt = DateUtilities.parseDate("2024-02-05 is a great day", ZoneId.of("Z"), false);
assertEquals(2024, zdt.getYear());
assertEquals(2, zdt.getMonthValue());
assertEquals(5, zdt.getDayOfMonth());
assertEquals(ZoneId.of("Z"), zdt.getZone());
assertEquals(0, zdt.getHour());
assertEquals(0, zdt.getMinute());
assertEquals(0, zdt.getSecond());

// Test with trailing text - should fail with strict=true
assertThrows(IllegalArgumentException.class, () ->
DateUtilities.parseDate("2024-02-05 is a great day", ZoneId.of("Z"), true),
"Should fail with trailing text in strict mode");

// Test with trailing text after full datetime - should pass with strict=false
zdt = DateUtilities.parseDate("2024-02-05T10:30:45Z and then some text", ZoneId.of("Z"), false);
assertEquals(2024, zdt.getYear());
assertEquals(2, zdt.getMonthValue());
assertEquals(5, zdt.getDayOfMonth());
assertEquals(10, zdt.getHour());
assertEquals(30, zdt.getMinute());
assertEquals(45, zdt.getSecond());
assertEquals(ZoneId.of("Z"), zdt.getZone());

// Test with trailing text after full datetime - should fail with strict=true
assertThrows(IllegalArgumentException.class, () ->
DateUtilities.parseDate("2024-02-05T10:30:45Z and then some text", ZoneId.of("Z"), true),
"Should fail with trailing text in strict mode");
}

private static Stream provideTimeZones()
{
return Stream.of(
Expand Down
Loading

0 comments on commit 16ca31a

Please sign in to comment.