diff --git a/src/main/java/com/cedarsoftware/util/DateUtilities.java b/src/main/java/com/cedarsoftware/util/DateUtilities.java index 95ad30f5..537b2bdf 100644 --- a/src/main/java/com/cedarsoftware/util/DateUtilities.java +++ b/src/main/java/com/cedarsoftware/util/DateUtilities.java @@ -366,6 +366,7 @@ 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); } @@ -373,7 +374,7 @@ public static ZonedDateTime parseDate(String dateStr, ZoneId defaultZoneId, bool 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()) { @@ -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()) { @@ -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"); @@ -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); } @@ -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, diff --git a/src/main/java/com/cedarsoftware/util/convert/CalendarConversions.java b/src/main/java/com/cedarsoftware/util/convert/CalendarConversions.java index bfbbc8af..2df61e39 100644 --- a/src/main/java/com/cedarsoftware/util/convert/CalendarConversions.java +++ b/src/main/java/com/cedarsoftware/util/convert/CalendarConversions.java @@ -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 (kpartlow@gmail.com) *
@@ -122,11 +122,22 @@ static OffsetDateTime toOffsetDateTime(Object from, Converter converter) { static Map toMap(Object from, Converter converter) { Calendar cal = (Calendar) from; - Map target = CompactMap.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 target = new LinkedHashMap<>(); + target.put(MapConversions.CALENDAR, formatted); return target; } } diff --git a/src/main/java/com/cedarsoftware/util/convert/MapConversions.java b/src/main/java/com/cedarsoftware/util/convert/MapConversions.java index 14b68d2e..06e23178 100644 --- a/src/main/java/com/cedarsoftware/util/convert/MapConversions.java +++ b/src/main/java/com/cedarsoftware/util/convert/MapConversions.java @@ -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"; @@ -292,47 +293,18 @@ static TimeZone toTimeZone(Object from, Converter converter) { static Calendar toCalendar(Object from, Converter converter) { Map map = (Map) 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 map = (Map) from; diff --git a/src/main/java/com/cedarsoftware/util/convert/TimestampConversions.java b/src/main/java/com/cedarsoftware/util/convert/TimestampConversions.java index f95a6632..b8cd5ac0 100644 --- a/src/main/java/com/cedarsoftware/util/convert/TimestampConversions.java +++ b/src/main/java/com/cedarsoftware/util/convert/TimestampConversions.java @@ -115,7 +115,6 @@ static String toString(Object from, Converter converter) { static Map 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); diff --git a/src/test/java/com/cedarsoftware/util/DateUtilitiesTest.java b/src/test/java/com/cedarsoftware/util/DateUtilitiesTest.java index 2cc839f9..a8ed57bd 100644 --- a/src/test/java/com/cedarsoftware/util/DateUtilitiesTest.java +++ b/src/test/java/com/cedarsoftware/util/DateUtilitiesTest.java @@ -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; @@ -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( diff --git a/src/test/java/com/cedarsoftware/util/convert/ConverterEverythingTest.java b/src/test/java/com/cedarsoftware/util/convert/ConverterEverythingTest.java index 269b2b9a..9ff42c06 100644 --- a/src/test/java/com/cedarsoftware/util/convert/ConverterEverythingTest.java +++ b/src/test/java/com/cedarsoftware/util/convert/ConverterEverythingTest.java @@ -53,7 +53,6 @@ import com.cedarsoftware.io.WriteOptions; import com.cedarsoftware.io.WriteOptionsBuilder; import com.cedarsoftware.util.ClassUtilities; -import com.cedarsoftware.util.CompactMap; import com.cedarsoftware.util.DeepEquals; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -64,6 +63,7 @@ import static com.cedarsoftware.util.MapUtilities.mapOf; import static com.cedarsoftware.util.convert.Converter.VALUE; +import static com.cedarsoftware.util.convert.MapConversions.CALENDAR; import static com.cedarsoftware.util.convert.MapConversions.CAUSE; import static com.cedarsoftware.util.convert.MapConversions.CAUSE_MESSAGE; import static com.cedarsoftware.util.convert.MapConversions.CLASS; @@ -1913,68 +1913,52 @@ private static void loadCalendarTests() { {new BigDecimal(1), cal(1000), true}, }); TEST_DB.put(pair(Map.class, Calendar.class), new Object[][]{ - {mapOf(VALUE, "2024-02-05T22:31:17.409[" + TOKYO + "]"), (Supplier) () -> { + // Test with timezone name format + {mapOf(CALENDAR, "2024-02-05T22:31:17.409[Asia/Tokyo]"), (Supplier) () -> { Calendar cal = Calendar.getInstance(TOKYO_TZ); cal.set(2024, Calendar.FEBRUARY, 5, 22, 31, 17); cal.set(Calendar.MILLISECOND, 409); return cal; - }}, - {mapOf(VALUE, "2024-02-05T22:31:17.409" + TOKYO_ZO.toString()), (Supplier) () -> { + }, true}, + + // Test with offset format + {mapOf(CALENDAR, "2024-02-05T22:31:17.409+09:00"), (Supplier) () -> { Calendar cal = Calendar.getInstance(TOKYO_TZ); cal.set(2024, Calendar.FEBRUARY, 5, 22, 31, 17); cal.set(Calendar.MILLISECOND, 409); return cal; - }}, - {(Supplier>) () -> { - Map map = CompactMap.builder().insertionOrder().build(); + }, false}, // re-writing it out, will go from offset back to zone name, hence not bi-directional + // Test with no milliseconds + {mapOf(CALENDAR, "2024-02-05T22:31:17[Asia/Tokyo]"), (Supplier) () -> { Calendar cal = Calendar.getInstance(TOKYO_TZ); cal.set(2024, Calendar.FEBRUARY, 5, 22, 31, 17); - cal.set(Calendar.MILLISECOND, 409); - map.put(VALUE, cal); - return map; - }, (Supplier) () -> { - Calendar cal = Calendar.getInstance(TOKYO_TZ); - cal.set(2024, Calendar.FEBRUARY, 5, 22, 31, 17); - cal.set(Calendar.MILLISECOND, 409); - return cal; - }}, - {mapOf(DATE, "1970-01-01", TIME, "00:00:00"), (Supplier) () -> { - Calendar cal = Calendar.getInstance(TOKYO_TZ); - cal.set(1970, Calendar.JANUARY, 1, 0, 0, 0); - cal.set(Calendar.MILLISECOND, 0); - return cal; - }}, - {mapOf(DATE, "1970-01-01", TIME, "00:00:00", ZONE, "America/New_York"), (Supplier) () -> { - Calendar cal = Calendar.getInstance(TimeZone.getTimeZone(ZoneId.of("America/New_York"))); - cal.set(1970, Calendar.JANUARY, 1, 0, 0, 0); cal.set(Calendar.MILLISECOND, 0); return cal; - }}, - {mapOf(TIME, "1970-01-01T00:00:00"), (Supplier) () -> { - Calendar cal = Calendar.getInstance(TOKYO_TZ); - cal.set(1970, Calendar.JANUARY, 1, 0, 0, 0); - cal.set(Calendar.MILLISECOND, 0); - return cal; - }}, - {mapOf(TIME, "1970-01-01T00:00:00", ZONE, "America/New_York"), (Supplier) () -> { + }, true}, + + // Test New York timezone + {mapOf(CALENDAR, "1970-01-01T00:00:00[America/New_York]"), (Supplier) () -> { Calendar cal = Calendar.getInstance(TimeZone.getTimeZone(ZoneId.of("America/New_York"))); cal.set(1970, Calendar.JANUARY, 1, 0, 0, 0); cal.set(Calendar.MILLISECOND, 0); return cal; - }}, - {(Supplier>) () -> { - Map map = CompactMap.builder().insertionOrder().build(); - map.put(DATE, "2024-02-05"); - map.put(TIME, "22:31:17.409"); - map.put(ZONE, TOKYO); - map.put(EPOCH_MILLIS, 1707139877409L); - return map; - }, (Supplier) () -> { + }, true}, + + // Test flexible parsing (space instead of T) - bidirectional false since it will normalize to T + {mapOf(CALENDAR, "2024-02-05 22:31:17.409[Asia/Tokyo]"), (Supplier) () -> { Calendar cal = Calendar.getInstance(TOKYO_TZ); cal.set(2024, Calendar.FEBRUARY, 5, 22, 31, 17); cal.set(Calendar.MILLISECOND, 409); return cal; - }, true}, + }, false}, + + // Test date with no time (will use start of day) + {mapOf(CALENDAR, "2024-02-05[Asia/Tokyo]"), (Supplier) () -> { + Calendar cal = Calendar.getInstance(TOKYO_TZ); + cal.set(2024, Calendar.FEBRUARY, 5, 0, 0, 0); + cal.set(Calendar.MILLISECOND, 0); + return cal; + }, false} }); TEST_DB.put(pair(ZonedDateTime.class, Calendar.class), new Object[][] { {zdt("1969-12-31T23:59:59.999Z"), cal(-1), true},