diff --git a/src/main/java/com/cedarsoftware/util/DateUtilities.java b/src/main/java/com/cedarsoftware/util/DateUtilities.java index 537b2bdf..e5d95207 100644 --- a/src/main/java/com/cedarsoftware/util/DateUtilities.java +++ b/src/main/java/com/cedarsoftware/util/DateUtilities.java @@ -5,6 +5,7 @@ import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.Date; +import java.util.HashMap; import java.util.Map; import java.util.TimeZone; import java.util.concurrent.ConcurrentHashMap; @@ -152,16 +153,26 @@ public final class DateUtilities { Pattern.CASE_INSENSITIVE); private static final Pattern unixDateTimePattern = Pattern.compile( - "\\b(" + days + ")\\b" + ws + "\\b(" + mos + ")\\b" + ws + "(" + d1or2 + ")" + ws + "(" + d2 + ":" + d2 + ":" + d2 + ")" + wsOp + "(" + tzUnix + ")?" + wsOp + "(" + yr + ")", + "(?:\\b(" + days + ")\\b" + ws + ")?" + + "\\b(" + mos + ")\\b" + ws + + "(" + d1or2 + ")" + ws + + "(" + d2 + ":" + d2 + ":" + d2 + ")" + wsOp + + "(" + tzUnix + ")?" + + wsOp + + "(" + yr + ")", Pattern.CASE_INSENSITIVE); - + private static final Pattern timePattern = Pattern.compile( "(" + d2 + "):(" + d2 + ")(?::(" + d2 + ")(" + nano + ")?)?(" + tz_Hh_MM_SS + "|" + tz_Hh_MM + "|" + tz_HHMM + "|" + tz_Hh + "|Z)?(" + tzNamed + ")?", Pattern.CASE_INSENSITIVE); + private static final Pattern zonePattern = Pattern.compile( + "(" + tz_Hh_MM_SS + "|" + tz_Hh_MM + "|" + tz_HHMM + "|" + tz_Hh + "|Z|" + tzNamed + ")", + Pattern.CASE_INSENSITIVE); + private static final Pattern dayPattern = Pattern.compile("\\b(" + days + ")\\b", Pattern.CASE_INSENSITIVE); private static final Map months = new ConcurrentHashMap<>(); - private static final Map ABBREVIATION_TO_TIMEZONE = new ConcurrentHashMap<>(); + public static final Map ABBREVIATION_TO_TIMEZONE = new HashMap<>(); static { // Month name to number map @@ -388,6 +399,18 @@ public static ZonedDateTime parseDate(String dateStr, ZoneId defaultZoneId, bool day = matcher.group(7); } remains = remnant; + // Do we have a Date with a TimeZone after it, but no time? + if (remnant.startsWith("T")) { + matcher = zonePattern.matcher(remnant.substring(1)); + if (matcher.matches()) { + throw new IllegalArgumentException("Time zone information without time is invalid: " + dateStr); + } + } else { + matcher = zonePattern.matcher(remnant); + if (matcher.matches()) { + throw new IllegalArgumentException("Time zone information without time is invalid: " + dateStr); + } + } } else { // 2) Try alphaMonthPattern matcher = alphaMonthPattern.matcher(dateStr); @@ -523,27 +546,36 @@ private static long convertFractionToNanos(String fracSec) { } private static ZoneId getTimeZone(String tz) { - if (tz != null) { - if (tz.startsWith("-") || tz.startsWith("+")) { - ZoneOffset offset = ZoneOffset.of(tz); - return ZoneId.ofOffset("GMT", offset); - } else { - try { - return ZoneId.of(tz); - } catch (Exception e) { - TimeZone timeZone = TimeZone.getTimeZone(tz); - if (timeZone.getRawOffset() == 0) { - String zoneName = ABBREVIATION_TO_TIMEZONE.get(tz); - if (zoneName != null) { - return ZoneId.of(zoneName); - } - throw e; - } - return timeZone.toZoneId(); - } + if (tz == null || tz.isEmpty()) { + return ZoneId.systemDefault(); + } + + // 1) If tz starts with +/- => offset + if (tz.startsWith("-") || tz.startsWith("+")) { + ZoneOffset offset = ZoneOffset.of(tz); + return ZoneId.ofOffset("GMT", offset); + } + + // 2) Check custom abbreviation map first + String mappedZone = ABBREVIATION_TO_TIMEZONE.get(tz.toUpperCase()); + if (mappedZone != null) { + // e.g. "EST" => "America/New_York" + return ZoneId.of(mappedZone); + } + + // 3) Try ZoneId.of(tz) for full region IDs like "Europe/Paris" + try { + return ZoneId.of(tz); + } catch (Exception zoneIdEx) { + // 4) Fallback to TimeZone for weird short IDs or older JDK + TimeZone timeZone = TimeZone.getTimeZone(tz); + if (timeZone.getID().equals("GMT") && !tz.toUpperCase().equals("GMT")) { + // Means the JDK didn't recognize 'tz' (it fell back to "GMT") + throw zoneIdEx; // rethrow original } + // Otherwise, we accept whatever the JDK returned + return timeZone.toZoneId(); } - return ZoneId.systemDefault(); } private static void verifyNoGarbageLeft(String remnant) { diff --git a/src/main/java/com/cedarsoftware/util/convert/CalendarConversions.java b/src/main/java/com/cedarsoftware/util/convert/CalendarConversions.java index 2df61e39..c5e24b57 100644 --- a/src/main/java/com/cedarsoftware/util/convert/CalendarConversions.java +++ b/src/main/java/com/cedarsoftware/util/convert/CalendarConversions.java @@ -15,6 +15,7 @@ import java.util.Date; import java.util.LinkedHashMap; import java.util.Map; +import java.util.TimeZone; import java.util.concurrent.atomic.AtomicLong; /** @@ -111,7 +112,22 @@ static Calendar create(long epochMilli, Converter converter) { static String toString(Object from, Converter converter) { Calendar cal = (Calendar) from; - return DateConversions.toString(cal.getTime(), converter); + ZonedDateTime zdt = cal.toInstant().atZone(cal.getTimeZone().toZoneId()); + TimeZone tz = cal.getTimeZone(); + + String pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS"; + + // Only use named zones for IANA timezone IDs + String id = tz.getID(); + if (id.contains("/")) { // IANA timezones contain "/" + pattern += "['['VV']']"; + } else if ("GMT".equals(id) || "UTC".equals(id)) { + pattern += "X"; // Z for UTC/GMT + } else { + pattern += "xxx"; // Offsets for everything else (EST, GMT+02:00, etc) + } + + return zdt.format(DateTimeFormatter.ofPattern(pattern)); } static OffsetDateTime toOffsetDateTime(Object from, Converter converter) { @@ -121,23 +137,8 @@ static OffsetDateTime toOffsetDateTime(Object from, Converter converter) { } static Map toMap(Object from, Converter converter) { - Calendar cal = (Calendar) from; - 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); + target.put(MapConversions.CALENDAR, toString(from, converter)); return target; } } diff --git a/src/main/java/com/cedarsoftware/util/convert/Converter.java b/src/main/java/com/cedarsoftware/util/convert/Converter.java index a8c35110..c2058884 100644 --- a/src/main/java/com/cedarsoftware/util/convert/Converter.java +++ b/src/main/java/com/cedarsoftware/util/convert/Converter.java @@ -783,7 +783,7 @@ private static void buildFactoryConversions() { CONVERSION_DB.put(pair(CharBuffer.class, String.class), CharBufferConversions::toString); CONVERSION_DB.put(pair(Class.class, String.class), ClassConversions::toString); CONVERSION_DB.put(pair(Date.class, String.class), DateConversions::toString); - CONVERSION_DB.put(pair(java.sql.Date.class, String.class), DateConversions::sqlDateToString); + CONVERSION_DB.put(pair(java.sql.Date.class, String.class), DateConversions::toSqlDateString); CONVERSION_DB.put(pair(Timestamp.class, String.class), TimestampConversions::toString); CONVERSION_DB.put(pair(LocalDate.class, String.class), LocalDateConversions::toString); CONVERSION_DB.put(pair(LocalTime.class, String.class), LocalTimeConversions::toString); diff --git a/src/main/java/com/cedarsoftware/util/convert/DateConversions.java b/src/main/java/com/cedarsoftware/util/convert/DateConversions.java index c01b42f3..69f752b8 100644 --- a/src/main/java/com/cedarsoftware/util/convert/DateConversions.java +++ b/src/main/java/com/cedarsoftware/util/convert/DateConversions.java @@ -9,7 +9,6 @@ import java.time.LocalDateTime; import java.time.LocalTime; import java.time.OffsetDateTime; -import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatterBuilder; @@ -37,6 +36,9 @@ * limitations under the License. */ final class DateConversions { + static final DateTimeFormatter MILLIS_FMT = new DateTimeFormatterBuilder() + .appendInstant(3) // Force exactly 3 decimal places + .toFormatter(); private DateConversions() {} @@ -118,55 +120,27 @@ static AtomicLong toAtomicLong(Object from, Converter converter) { return new AtomicLong(toLong(from, converter)); } - static String sqlDateToString(Object from, Converter converter) { - java.sql.Date sqlDate = (java.sql.Date) from; - return toString(new Date(sqlDate.getTime()), converter); - } - static String toString(Object from, Converter converter) { Date date = (Date) from; - - // Convert Date to ZonedDateTime - ZonedDateTime zonedDateTime = date.toInstant().atZone(converter.getOptions().getZoneId()); - - // Build a formatter with optional milliseconds and always show timezone offset - DateTimeFormatter formatter = new DateTimeFormatterBuilder() - .appendPattern("yyyy-MM-dd'T'HH:mm:ss.SSS") - .appendOffset("+HH:MM", "Z") // Timezone offset - .toFormatter(); - - return zonedDateTime.format(formatter); + Instant instant = date.toInstant(); // Convert legacy Date to Instant + return MILLIS_FMT.format(instant); } + static String toSqlDateString(Object from, Converter converter) { + java.sql.Date sqlDate = (java.sql.Date) from; + // java.sql.Date.toString() returns the date in "yyyy-MM-dd" format. + return sqlDate.toString(); + } + static Map toMap(Object from, Converter converter) { Date date = (Date) from; - String formatted; Map map = new LinkedHashMap<>(); if (date instanceof java.sql.Date) { - // Convert millis to Instant then LocalDate in UTC - LocalDate localDate = Instant.ofEpochMilli(date.getTime()) - .atZone(ZoneOffset.UTC) - .toLocalDate(); - - // Place that LocalDate at midnight in UTC, then format - ZonedDateTime zdt = localDate.atStartOfDay(ZoneOffset.UTC); - formatted = zdt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")); - - map.put(MapConversions.SQL_DATE, formatted); + map.put(MapConversions.SQL_DATE, toSqlDateString(date, converter)); } else { // Regular util.Date - format with time - ZonedDateTime zdt = date.toInstant().atZone(ZoneOffset.UTC); - 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'Z'")); - } else { - // Millisecond precision - formatted = zdt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")) - + String.format(".%03dZ", ms); - } - map.put(MapConversions.DATE, formatted); + map.put(MapConversions.DATE, toString(from, converter)); } return map; diff --git a/src/main/java/com/cedarsoftware/util/convert/MapConversions.java b/src/main/java/com/cedarsoftware/util/convert/MapConversions.java index 06e23178..52ea0919 100644 --- a/src/main/java/com/cedarsoftware/util/convert/MapConversions.java +++ b/src/main/java/com/cedarsoftware/util/convert/MapConversions.java @@ -41,6 +41,8 @@ import com.cedarsoftware.util.ReflectionUtils; import com.cedarsoftware.util.StringUtilities; +import static com.cedarsoftware.util.DateUtilities.ABBREVIATION_TO_TIMEZONE; + /** * @author John DeRegnaucourt (jdereg@gmail.com) * @author Kenny Partlow (kpartlow@gmail.com) @@ -187,51 +189,50 @@ static AtomicBoolean toAtomicBoolean(Object from, Converter converter) { return fromMap(from, converter, AtomicBoolean.class); } + // TODO: Need to look at toDate(), toTimeStamp(), (calendar - check), (sqlDate check) + // TODO: ZonedDateTime, OffsetDateTie, LocalDateTime, LocalDate, LocalTime write, single String, robust Map to x + private static final String[] SQL_DATE_KEYS = {SQL_DATE, VALUE, V, EPOCH_MILLIS}; + static java.sql.Date toSqlDate(Object from, Converter converter) { Map map = (Map) from; - Object time = map.get(SQL_DATE); - if (time == null) { - time = map.get(VALUE); // allow "value" - } - if (time == null) { - time = map.get(V); // allow "_v" - } - if (time == null) { - time = map.get(EPOCH_MILLIS); - } + Object time = null; - if (time instanceof String && StringUtilities.hasContent((String)time)) { - String timeStr = (String)time; - ZoneId zoneId; - if (timeStr.endsWith("Z")) { - zoneId = ZoneId.of("Z"); - } else { - zoneId = converter.getOptions().getZoneId(); + for (String key : SQL_DATE_KEYS) { + Object candidate = map.get(key); + if (candidate != null && (!(candidate instanceof String) || StringUtilities.hasContent((String) candidate))) { + time = candidate; + break; } - ZonedDateTime zdt = DateUtilities.parseDate((String) time, zoneId, true); - return new java.sql.Date(zdt.toInstant().toEpochMilli()); } - // Handle case where value is a number (epoch millis) + // Handle numeric values as UTC-based if (time instanceof Number) { - return new java.sql.Date(((Number)time).longValue()); + long num = ((Number) time).longValue(); + LocalDate ld = Instant.ofEpochMilli(num) + .atZone(ZoneOffset.UTC) + .toLocalDate(); + return java.sql.Date.valueOf(ld.toString()); } - // Map.Entry return has key of epoch-millis + // Handle strings by delegating to the String conversion method. + if (time instanceof String) { + return StringConversions.toSqlDate(time, converter); + } + + // Fallback conversion if no valid key/value is found. return fromMap(from, converter, java.sql.Date.class, new String[]{SQL_DATE}, new String[]{EPOCH_MILLIS}); } + private static final String[] DATE_KEYS = {DATE, VALUE, V, EPOCH_MILLIS}; + static Date toDate(Object from, Converter converter) { Map map = (Map) from; - Object time = map.get(DATE); - if (time == null) { - time = map.get(VALUE); // allow "value" - } - if (time == null) { - time = map.get(V); // allow "_v" - } - if (time == null) { - time = map.get(EPOCH_MILLIS); + Object time = null; + for (String key : DATE_KEYS) { + time = map.get(key); + if (time != null && (!(time instanceof String) || StringUtilities.hasContent((String)time))) { + break; + } } if (time instanceof String && StringUtilities.hasContent((String)time)) { @@ -248,6 +249,8 @@ static Date toDate(Object from, Converter converter) { return fromMap(from, converter, Date.class, new String[]{DATE}, new String[]{EPOCH_MILLIS}); } + private static final String[] TIMESTAMP_KEYS = {TIMESTAMP, VALUE, V, EPOCH_MILLIS}; + /** * If the time String contains seconds resolution better than milliseconds, it will be kept. For example, * If the time was "08.37:16.123456789" the sub-millisecond portion here will take precedence over a separate @@ -257,23 +260,42 @@ static Date toDate(Object from, Converter converter) { */ static Timestamp toTimestamp(Object from, Converter converter) { Map map = (Map) from; - Object time = map.get(TIMESTAMP); - if (time == null) { - time = map.get(VALUE); // allow "value" + Object time = null; + for (String key : TIMESTAMP_KEYS) { + time = map.get(key); + if (time != null && (!(time instanceof String) || StringUtilities.hasContent((String) time))) { + break; + } } - if (time == null) { - time = map.get(V); // allow "_v" + + // First, try to obtain the nanos value from the map + int ns = 0; + Object nanosObj = map.get(NANOS); + if (nanosObj != null) { + if (nanosObj instanceof Number) { + ns = ((Number) nanosObj).intValue(); + } else { + try { + ns = Integer.parseInt(nanosObj.toString()); + } catch (NumberFormatException e) { + ns = 0; + } + } } - if (time instanceof String && StringUtilities.hasContent((String)time)) { + // If the 'time' value is a non-empty String, parse it + if (time instanceof String && StringUtilities.hasContent((String) time)) { ZonedDateTime zdt = DateUtilities.parseDate((String) time, converter.getOptions().getZoneId(), true); Timestamp timestamp = Timestamp.from(zdt.toInstant()); + // Update with nanos if present + if (ns != 0) { + timestamp.setNanos(ns); + } return timestamp; } - // Allow epoch_millis with optional nanos + // Otherwise, if epoch_millis is provided, use it with the nanos (if any) Object epochMillis = map.get(EPOCH_MILLIS); - int ns = converter.convert(map.get(NANOS), int.class); // optional if (epochMillis != null) { long ms = converter.convert(epochMillis, long.class); Timestamp timeStamp = new Timestamp(ms); @@ -282,8 +304,8 @@ static Timestamp toTimestamp(Object from, Converter converter) { } return timeStamp; } - - // Map.Entry return has key of epoch-millis and value of nanos-of-second + + // Fallback conversion if none of the above worked return fromMap(from, converter, Timestamp.class, new String[]{TIMESTAMP}, new String[]{EPOCH_MILLIS, NANOS + OPTIONAL}); } @@ -291,17 +313,32 @@ static TimeZone toTimeZone(Object from, Converter converter) { return fromMap(from, converter, TimeZone.class, new String[]{ZONE}); } + private static final String[] CALENDAR_KEYS = {CALENDAR, VALUE, V, EPOCH_MILLIS}; + static Calendar toCalendar(Object from, Converter converter) { Map map = (Map) from; - Object calStr = map.get(CALENDAR); + + Object calStr = null; + for (String key : CALENDAR_KEYS) { + calStr = map.get(key); + if (calStr != null && (!(calStr instanceof String) || StringUtilities.hasContent((String)calStr))) { + break; + } + } 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())); + String zoneId = zdt.getZone().getId(); + TimeZone tz = TimeZone.getTimeZone(ABBREVIATION_TO_TIMEZONE.getOrDefault(zoneId, zoneId)); + Calendar cal = Calendar.getInstance(tz); cal.setTimeInMillis(zdt.toInstant().toEpochMilli()); return cal; } + if (calStr instanceof Number) { + Calendar cal = Calendar.getInstance(TimeZone.getTimeZone(ZoneOffset.UTC)); + cal.setTimeInMillis(((Number)calStr).longValue()); + return cal; + } - // Handle legacy/alternate formats via fromMap return fromMap(from, converter, Calendar.class, new String[]{CALENDAR}); } diff --git a/src/main/java/com/cedarsoftware/util/convert/StringConversions.java b/src/main/java/com/cedarsoftware/util/convert/StringConversions.java index c585df23..1dde454e 100644 --- a/src/main/java/com/cedarsoftware/util/convert/StringConversions.java +++ b/src/main/java/com/cedarsoftware/util/convert/StringConversions.java @@ -371,8 +371,21 @@ static Date toDate(Object from, Converter converter) { } static java.sql.Date toSqlDate(Object from, Converter converter) { - Instant instant = toInstant(from, converter); - return instant == null ? null : new java.sql.Date(instant.toEpochMilli()); + String dateStr = ((String) from).trim(); + + try { + return java.sql.Date.valueOf(dateStr); + } catch (Exception e) { + // If direct conversion fails, try parsing using DateUtilities. + ZonedDateTime zdt = DateUtilities.parseDate(dateStr, converter.getOptions().getZoneId(), true); + if (zdt == null) { + return null; + } + // Convert ZonedDateTime to Instant, then to java.util.Date, then to java.sql.Date. + Instant instant = zdt.toInstant(); + Date utilDate = Date.from(instant); + return new java.sql.Date(utilDate.getTime()); + } } static Timestamp toTimestamp(Object from, Converter converter) { diff --git a/src/main/java/com/cedarsoftware/util/convert/TimestampConversions.java b/src/main/java/com/cedarsoftware/util/convert/TimestampConversions.java index b8cd5ac0..fb23a89b 100644 --- a/src/main/java/com/cedarsoftware/util/convert/TimestampConversions.java +++ b/src/main/java/com/cedarsoftware/util/convert/TimestampConversions.java @@ -7,7 +7,6 @@ import java.time.Instant; import java.time.LocalDateTime; import java.time.OffsetDateTime; -import java.time.ZoneId; import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; @@ -97,51 +96,25 @@ static String toString(Object from, Converter converter) { Timestamp timestamp = (Timestamp) from; int nanos = timestamp.getNanos(); - String pattern; - if (nanos == 0) { - pattern = "yyyy-MM-dd'T'HH:mm:ssXXX"; // whole seconds - } else if (nanos % 1_000_000 == 0) { - pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX"; // milliseconds + // Decide whether we need 3 decimals or 9 decimals + final String pattern; + if (nanos % 1_000_000 == 0) { + // Exactly millisecond precision + pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX"; } else { - pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSSXXX"; // nanoseconds + // Nanosecond precision + pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSSXXX"; } - // Timestamps are always UTC internally - String ts = timestamp.toInstant() - .atZone(ZoneId.of("Z")) + // Format the Timestamp in UTC using the chosen pattern + return timestamp + .toInstant() + .atZone(ZoneOffset.UTC) .format(DateTimeFormatter.ofPattern(pattern)); - return ts; } static Map toMap(Object from, Converter converter) { - Timestamp timestamp = (Timestamp) from; - - // 1) Convert Timestamp -> Instant -> UTC ZonedDateTime - ZonedDateTime zdt = timestamp.toInstant().atZone(ZoneOffset.UTC); - - // 2) Extract nanoseconds - int nanos = zdt.getNano(); // 0 to 999,999,999 - - // 3) Build the output string in ISO-8601 w/ "Z" at the end - String formatted; - if (nanos == 0) { - // No fractional seconds - // e.g. 2025-01-01T10:15:30Z - // Pattern approach: - formatted = zdt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")); - } else if (nanos % 1_000_000 == 0) { - // Exactly millisecond precision - // e.g. 2025-01-01T10:15:30.123Z - int ms = nanos / 1_000_000; - formatted = zdt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")) - + String.format(".%03dZ", ms); - } else { - // Full nanosecond precision - // e.g. 2025-01-01T10:15:30.123456789Z - formatted = zdt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")) - + String.format(".%09dZ", nanos); - } - + String formatted = toString(from, converter); Map map = new LinkedHashMap<>(); map.put(MapConversions.TIMESTAMP, formatted); return map; diff --git a/src/test/java/com/cedarsoftware/util/DateUtilitiesNegativeTest.java b/src/test/java/com/cedarsoftware/util/DateUtilitiesNegativeTest.java new file mode 100644 index 00000000..afdf6812 --- /dev/null +++ b/src/test/java/com/cedarsoftware/util/DateUtilitiesNegativeTest.java @@ -0,0 +1,154 @@ +package com.cedarsoftware.util; + +import java.time.DateTimeException; +import java.time.ZoneId; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +class DateUtilitiesNegativeTests { + + /** + * 2) Garbled content or random text. This is 'unparseable' because + * it doesn’t match any recognized date or time pattern. + */ + @Test + void testRandomText() { + assertThrows(IllegalArgumentException.class, () -> + DateUtilities.parseDate("sdklfjskldjf", ZoneId.of("UTC"), true)); + } + + /** + * 3) "Month" out of range. The parser expects 1..12. + * E.g. 13 for month => fail. + */ + @Test + void testMonthOutOfRange() { + // ISO style + assertThrows(IllegalArgumentException.class, () -> + DateUtilities.parseDate("2024-13-10", ZoneId.of("UTC"), true)); + + // alpha style + assertThrows(IllegalArgumentException.class, () -> + DateUtilities.parseDate("Foo 10, 2024", ZoneId.of("UTC"), true)); + } + + /** + * 4) "Day" out of range. E.g. 32 for day => fail. + */ + @Test + void testDayOutOfRange() { + // ISO style + assertThrows(IllegalArgumentException.class, () -> + DateUtilities.parseDate("2024-01-32", ZoneId.of("UTC"), true)); + } + + /** + * 5) "Hour" out of range. E.g. 24 for hour => fail. + */ + @Test + void testHourOutOfRange() { + // Basic time after date + assertThrows(IllegalArgumentException.class, () -> + DateUtilities.parseDate("2024-01-10 24:30:00", ZoneId.of("UTC"), true)); + } + + /** + * 6) "Minute" out of range. E.g. 60 => fail. + */ + @Test + void testMinuteOutOfRange() { + assertThrows(IllegalArgumentException.class, () -> + DateUtilities.parseDate("2024-01-10 23:60:00", ZoneId.of("UTC"), true)); + } + + /** + * 7) "Second" out of range. E.g. 60 => fail. + */ + @Test + void testSecondOutOfRange() { + assertThrows(IllegalArgumentException.class, () -> + DateUtilities.parseDate("2024-01-10 23:59:60", ZoneId.of("UTC"), true)); + } + + /** + * 8) Time with offset beyond valid range, e.g. +30:00 + * (the parser should fail with ZoneOffset.of(...) if it’s outside +/-18) + */ + @Test + void testInvalidZoneOffset() { + assertThrows(DateTimeException.class, () -> + DateUtilities.parseDate("2024-01-10T10:30+30:00", ZoneId.systemDefault(), true)); + } + + /** + * 9) A bracketed zone that is unparseable + * (like "[not/valid/???]" or "[some junk]"). + */ + @Test + void testInvalidBracketZone() { + // If your code tries to parse "[some junk]" and fails => + // you expect exception + assertThrows(IllegalArgumentException.class, () -> + DateUtilities.parseDate("2024-01-10T10:30:00[some junk]", ZoneId.systemDefault(), true)); + } + + /** + * 10) Time zone with no time => fail if we enforce that rule + * (like "2024-02-05Z" or "2024-02-05+09:00"). + */ + @Test + void testZoneButNoTime() { + // If your code is set to throw on zone-without-time: + assertThrows(IllegalArgumentException.class, () -> + DateUtilities.parseDate("2024-02-05Z", ZoneId.of("UTC"), true)); + assertThrows(IllegalArgumentException.class, () -> + DateUtilities.parseDate("2024-02-05+09:00", ZoneId.of("UTC"), true)); + assertThrows(IllegalArgumentException.class, () -> + DateUtilities.parseDate("2024-02-05[Asia/Tokyo]", ZoneId.of("UTC"), true)); + } + + /** + * 11) Found a 'T' but no actual time after it => fail + * (like "2024-02-05T[Asia/Tokyo]"). + */ + @Test + void testTButNoTime() { + assertThrows(IllegalArgumentException.class, () -> + DateUtilities.parseDate("2024-02-05T[Asia/Tokyo]", ZoneId.of("UTC"), true)); + } + + /** + * 12) Ambiguous leftover text in strict mode => fail. + * e.g. "2024-02-05 10:30:00 some leftover" with ensureDateTimeAlone=true + */ + @Test + void testTrailingGarbageStrictMode() { + assertThrows(IllegalArgumentException.class, () -> + DateUtilities.parseDate("2024-02-05 10:30:00 some leftover", ZoneId.of("UTC"), true)); + } + + /** + * 13) For strings that appear to be 'epoch millis' but actually overflow + * (like "999999999999999999999"). + * This might cause a NumberFormatException or an invalid epoch parse + * if your code tries to parse them as a long. + * If you want to confirm that it fails... + */ + @Test + void testOverflowEpochMillis() { + assertThrows(NumberFormatException.class, () -> + DateUtilities.parseDate("999999999999999999999", ZoneId.of("UTC"), true)); + } + + /** + * 15) A partial fraction "2024-02-05T10:30:45." => fail, + * if your code doesn't allow fraction with no digits after the dot. + */ + @Test + void testIncompleteFraction() { + assertThrows(IllegalArgumentException.class, () -> + DateUtilities.parseDate("2024-02-05T10:30:45.", ZoneId.of("UTC"), true)); + } +} diff --git a/src/test/java/com/cedarsoftware/util/DateUtilitiesTest.java b/src/test/java/com/cedarsoftware/util/DateUtilitiesTest.java index a8ed57bd..39861565 100644 --- a/src/test/java/com/cedarsoftware/util/DateUtilitiesTest.java +++ b/src/test/java/com/cedarsoftware/util/DateUtilitiesTest.java @@ -3,7 +3,9 @@ import java.lang.reflect.Constructor; import java.lang.reflect.Modifier; import java.text.SimpleDateFormat; +import java.time.Instant; import java.time.ZoneId; +import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.temporal.TemporalAccessor; import java.util.Arrays; @@ -19,6 +21,8 @@ import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; +import static com.cedarsoftware.util.DateUtilities.ABBREVIATION_TO_TIMEZONE; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -571,10 +575,16 @@ void testDateToStringFormat() @ParameterizedTest @ValueSource(strings = {"JST", "IST", "CET", "BST", "EST", "CST", "MST", "PST", "CAT", "EAT", "ART", "ECT", "NST", "AST", "HST"}) void testTimeZoneValidShortNames(String timeZoneId) { + String resolvedId = ABBREVIATION_TO_TIMEZONE.get(timeZoneId); + if (resolvedId == null) { + // fallback + resolvedId = timeZoneId; + } + // Support for some of the oldie but goodies (when the TimeZone returned does not have a 0 offset) Date date = DateUtilities.parseDate("2021-01-13T13:01:54.6747552 " + timeZoneId); Calendar calendar = Calendar.getInstance(); - calendar.setTimeZone(TimeZone.getTimeZone(timeZoneId)); + calendar.setTimeZone(TimeZone.getTimeZone(resolvedId)); calendar.clear(); calendar.set(2021, Calendar.JANUARY, 13, 13, 1, 54); assert date.getTime() - calendar.getTime().getTime() == 674; // less than 1000 millis @@ -705,9 +715,15 @@ void testParseErrors() @ValueSource(strings = {"JST", "IST", "CET", "BST", "EST", "CST", "MST", "PST", "CAT", "EAT", "ART", "ECT", "NST", "AST", "HST"}) void testMacUnixDateFormat(String timeZoneId) { + String resolvedId = ABBREVIATION_TO_TIMEZONE.get(timeZoneId); + if (resolvedId == null) { + // fallback + resolvedId = timeZoneId; + } + Date date = DateUtilities.parseDate("Sat Jan 6 20:06:58 " + timeZoneId + " 2024"); Calendar calendar = Calendar.getInstance(); - calendar.setTimeZone(TimeZone.getTimeZone(timeZoneId)); + calendar.setTimeZone(TimeZone.getTimeZone(resolvedId)); calendar.clear(); calendar.set(2024, Calendar.JANUARY, 6, 20, 6, 58); assertEquals(calendar.getTime(), date); @@ -758,7 +774,7 @@ void testBadTimeSeparators() } @Test - void testEpochMillis() + void testEpochMillis2() { SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS"); sdf.setTimeZone(TimeZone.getTimeZone("GMT")); @@ -1027,4 +1043,241 @@ void testFormatsThatShouldNotWork(String badFormat) { DateUtilities.parseDate(badFormat, ZoneId.systemDefault(), true); } + + /** + * Basic ISO 8601 date-times (strictly valid), with or without time, + * fractional seconds, and 'T' separators. + */ + @Test + void testBasicIso8601() { + // 1) Simple date + time with 'T' + ZonedDateTime zdt1 = DateUtilities.parseDate("2025-02-15T10:30:00", ZoneId.of("UTC"), true); + assertNotNull(zdt1); + assertEquals(2025, zdt1.getYear()); + assertEquals(2, zdt1.getMonthValue()); + assertEquals(15, zdt1.getDayOfMonth()); + assertEquals(10, zdt1.getHour()); + assertEquals(30, zdt1.getMinute()); + assertEquals(0, zdt1.getSecond()); + assertEquals(ZoneId.of("UTC"), zdt1.getZone()); + + // 2) Date + time with fractional seconds + ZonedDateTime zdt2 = DateUtilities.parseDate("2025-02-15T10:30:45.123", ZoneId.of("UTC"), true); + assertNotNull(zdt2); + assertEquals(45, zdt2.getSecond()); + // We can't do an exact nanos compare easily, but let's do: + assertEquals(123_000_000, zdt2.getNano()); + + // 3) Using '/' separators + ZonedDateTime zdt3 = DateUtilities.parseDate("2025/02/15 10:30:00", ZoneId.of("UTC"), true); + assertNotNull(zdt3); + assertEquals(10, zdt3.getHour()); + + // 4) Only date (no time). Should default to 00:00:00 in UTC + ZonedDateTime zdt4 = DateUtilities.parseDate("2025-02-15", ZoneId.of("UTC"), true); + assertNotNull(zdt4); + assertEquals(0, zdt4.getHour()); + assertEquals(0, zdt4.getMinute()); + assertEquals(0, zdt4.getSecond()); + assertEquals(ZoneId.of("UTC"), zdt4.getZone()); + } + + /** + * Test Java's ZonedDateTime.toString() style, e.g. "YYYY-MM-DDTHH:mm:ss-05:00[America/New_York]". + */ + @Test + void testZonedDateTimeToString() { + // Example from Java's ZonedDateTime + // Typically: "2025-05-10T13:15:30-04:00[America/New_York]" + String javaString = "2025-05-10T13:15:30-04:00[America/New_York]"; + ZonedDateTime zdt = DateUtilities.parseDate(javaString, ZoneId.systemDefault(), true); + assertNotNull(zdt); + assertEquals(2025, zdt.getYear()); + assertEquals(5, zdt.getMonthValue()); + assertEquals(10, zdt.getDayOfMonth()); + assertEquals(13, zdt.getHour()); + assertEquals("America/New_York", zdt.getZone().getId()); + // -04:00 offset is inside the bracketed zone. + // The final zone is "America/New_York" with whatever offset it has on that date. + } + + /** + * Unix / Linux style strings, like: "Thu Jan 6 11:06:10 EST 2024". + */ + @Test + void testUnixStyle() { + // 1) Basic Unix date + ZonedDateTime zdt1 = DateUtilities.parseDate("Thu Jan 6 11:06:10 EST 2024", ZoneId.of("UTC"), true); + assertNotNull(zdt1); + assertEquals(2024, zdt1.getYear()); + assertEquals(1, zdt1.getMonthValue()); // January + assertEquals(6, zdt1.getDayOfMonth()); + assertEquals(11, zdt1.getHour()); + assertEquals(6, zdt1.getMinute()); + assertEquals(10, zdt1.getSecond()); + // "EST" should become "America/New_York" + assertEquals("America/New_York", zdt1.getZone().getId()); + + // 2) Variation in day-of-week + ZonedDateTime zdt2 = DateUtilities.parseDate("Friday Apr 1 07:10:00 CST 2022", ZoneId.of("UTC"), true); + assertNotNull(zdt2); + assertEquals(4, zdt2.getMonthValue()); // April + assertEquals("America/Chicago", zdt2.getZone().getId()); + } + + /** + * Test zone offsets in various legal formats, e.g. +HH, +HH:mm, -HHmm, etc. + * Also test Z for UTC. + */ + @Test + void testZoneOffsets() { + // 1) +HH:mm + ZonedDateTime zdt1 = DateUtilities.parseDate("2025-06-15T08:30+02:00", ZoneId.of("UTC"), true); + assertNotNull(zdt1); + // The final zone is "GMT+02:00" internally + assertEquals(8, zdt1.getHour()); + assertEquals(30, zdt1.getMinute()); + // Because we used +02:00, the local time is 08:30 in that offset + assertEquals(ZoneOffset.ofHours(2), zdt1.getOffset()); + + // 2) -HH + ZonedDateTime zdt2 = DateUtilities.parseDate("2025-06-15 08:30-5", ZoneId.of("UTC"), true); + assertNotNull(zdt2); + assertEquals(ZoneOffset.ofHours(-5), zdt2.getOffset()); + + // 3) +HHmm (4-digit) + ZonedDateTime zdt3 = DateUtilities.parseDate("2025-06-15T08:30+0230", ZoneId.of("UTC"), true); + assertNotNull(zdt3); + assertEquals(ZoneOffset.ofHoursMinutes(2, 30), zdt3.getOffset()); + + // 4) Z for UTC + ZonedDateTime zdt4 = DateUtilities.parseDate("2025-06-15T08:30Z", ZoneId.systemDefault(), true); + assertNotNull(zdt4); + // Should parse as UTC + assertEquals(ZoneOffset.UTC, zdt4.getOffset()); + } + + /** + * Test old-fashioned full month name, day, year, with or without ordinal suffix + * (like "January 21st, 2024"). + */ + @Test + void testFullMonthName() { + // 1) "January 21, 2024" + ZonedDateTime zdt1 = DateUtilities.parseDate("January 21, 2024", ZoneId.of("UTC"), true); + assertNotNull(zdt1); + assertEquals(2024, zdt1.getYear()); + assertEquals(1, zdt1.getMonthValue()); + assertEquals(21, zdt1.getDayOfMonth()); + + // 2) With an ordinal suffix + ZonedDateTime zdt2 = DateUtilities.parseDate("January 21st, 2024", ZoneId.of("UTC"), true); + assertNotNull(zdt2); + assertEquals(21, zdt2.getDayOfMonth()); + + // 3) Mixed upper/lower on suffix + ZonedDateTime zdt3 = DateUtilities.parseDate("January 21ST, 2024", ZoneId.of("UTC"), true); + assertNotNull(zdt3); + assertEquals(21, zdt3.getDayOfMonth()); + } + + /** + * Test random but valid combos: day-of-week + alpha month + leftover spacing, + * with time possibly preceding the date, or date first, etc. + */ + @Test + void testMiscFlexibleCombos() { + // 1) Day-of-week up front, alpha month, year + ZonedDateTime zdt1 = DateUtilities.parseDate("thu, Dec 25, 2014", ZoneId.systemDefault(), true); + assertNotNull(zdt1); + assertEquals(2014, zdt1.getYear()); + assertEquals(12, zdt1.getMonthValue()); + assertEquals(25, zdt1.getDayOfMonth()); + + // 2) Time first, then date + ZonedDateTime zdt2 = DateUtilities.parseDate("07:45:33 2024-11-23", ZoneId.of("UTC"), true); + assertNotNull(zdt2); + assertEquals(2024, zdt2.getYear()); + assertEquals(11, zdt2.getMonthValue()); + assertEquals(23, zdt2.getDayOfMonth()); + assertEquals(7, zdt2.getHour()); + assertEquals(45, zdt2.getMinute()); + assertEquals(33, zdt2.getSecond()); + } + + /** + * Test Unix epoch-millis (all digits). + */ + @Test + void testEpochMillis() { + // Let's pick an arbitrary timestamp: 1700000000000 => + // Wed Nov 15 2023 06:13:20 UTC (for example) + long epochMillis = 1700000000000L; + ZonedDateTime zdt = DateUtilities.parseDate(String.valueOf(epochMillis), ZoneId.of("UTC"), true); + assertNotNull(zdt); + // Re-verify the instant + Instant inst = Instant.ofEpochMilli(epochMillis); + assertEquals(inst, zdt.toInstant()); + } + + /** + * Confirm that a parseDate(String) -> Date (old Java date) also works + * for some old-style or common formats. + */ + @Test + void testLegacyDateApi() { + // parseDate(String) returns a Date (overloaded method). + // e.g. "Mar 15 1997 13:55:44 PDT" + Date d1 = DateUtilities.parseDate("Mar 15 13:55:44 PDT 1997"); + assertNotNull(d1); + + // Check the time + ZonedDateTime zdt1 = d1.toInstant().atZone(ZoneId.of("UTC")); + // 1997-03-15T20:55:44Z = 13:55:44 PDT is UTC-7 + assertEquals(1997, zdt1.getYear()); + assertEquals(3, zdt1.getMonthValue()); + assertEquals(15, zdt1.getDayOfMonth()); + } + + @Test + void testTokyoOffset() { + // Input string has explicit Asia/Tokyo zone + String input = "2024-02-05T22:31:17.409[Asia/Tokyo]"; + + // When parseDate sees an explicit zone, it should keep it, + // ignoring the "default" zone (ZoneId.of("UTC")) because the string + // already contains a zone or offset. + ZonedDateTime zdt = DateUtilities.parseDate(input, ZoneId.of("UTC"), true); + + // Also convert the same string to a Calendar + Calendar cal = Converter.convert(input, Calendar.class); + + // Check that the utility did NOT "force" UTC, + // because the string has an explicit zone: Asia/Tokyo + assertThat(zdt).isNotNull(); + assertThat(zdt.getZone()).isEqualTo(ZoneId.of("Asia/Tokyo")); + // The local date-time portion should remain 2024-02-05T22:31:17.409 + assertThat(zdt.getHour()).isEqualTo(22); + assertThat(zdt.getMinute()).isEqualTo(31); + assertThat(zdt.getSecond()).isEqualTo(17); + // And the offset from UTC should be +09:00 + assertThat(zdt.getOffset()).isEqualTo(ZoneOffset.ofHours(9)); + + // The actual instant in UTC is 9 hours earlier: 2024-02-05T13:31:17.409Z + Instant expectedInstant = Instant.parse("2024-02-05T13:31:17.409Z"); + assertThat(zdt.toInstant()).isEqualTo(expectedInstant); + + // Now check the Calendar result + assertThat(cal).isNotNull(); + // The Calendar might have a different TimeZone internally, + // but it should still represent the same Instant. + Instant calInstant = cal.toInstant(); + assertThat(calInstant).isEqualTo(expectedInstant); + + // Round-trip check: convert the Calendar back to String, parse again, + // and verify we land on the same Instant. + String roundTripped = Converter.convert(cal, String.class); + ZonedDateTime roundTrippedZdt = DateUtilities.parseDate(roundTripped, ZoneId.of("UTC"), true); + assertThat(roundTrippedZdt.toInstant()).isEqualTo(expectedInstant); + } } diff --git a/src/test/java/com/cedarsoftware/util/convert/ConverterEverythingTest.java b/src/test/java/com/cedarsoftware/util/convert/ConverterEverythingTest.java index 9ff42c06..abbae041 100644 --- a/src/test/java/com/cedarsoftware/util/convert/ConverterEverythingTest.java +++ b/src/test/java/com/cedarsoftware/util/convert/ConverterEverythingTest.java @@ -57,6 +57,7 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -800,13 +801,29 @@ private static void loadStringTests() { {ByteBuffer.wrap(new byte[]{(byte) 0x30, (byte) 0x31, (byte) 0x32, (byte) 0x33}), "0123", true} }); TEST_DB.put(pair(java.sql.Date.class, String.class), new Object[][]{ - {new java.sql.Date(-1), "1970-01-01T08:59:59.999+09:00", true}, // Tokyo (set in options - defaults to system when not set explicitly) - {new java.sql.Date(0), "1970-01-01T09:00:00.000+09:00", true}, - {new java.sql.Date(1), "1970-01-01T09:00:00.001+09:00", true}, + // Basic cases around epoch + {java.sql.Date.valueOf("1969-12-31"), "1969-12-31", true}, + {java.sql.Date.valueOf("1970-01-01"), "1970-01-01", true}, + + // Modern dates + {java.sql.Date.valueOf("2025-01-29"), "2025-01-29", true}, + {java.sql.Date.valueOf("2025-12-31"), "2025-12-31", true}, + + // Edge cases + {java.sql.Date.valueOf("0001-01-01"), "0001-01-01", true}, + {java.sql.Date.valueOf("9999-12-31"), "9999-12-31", true}, + + // Leap year cases + {java.sql.Date.valueOf("2024-02-29"), "2024-02-29", true}, + {java.sql.Date.valueOf("2000-02-29"), "2000-02-29", true}, + + // Month boundaries + {java.sql.Date.valueOf("2025-01-01"), "2025-01-01", true}, + {java.sql.Date.valueOf("2025-12-31"), "2025-12-31", true} }); TEST_DB.put(pair(Timestamp.class, String.class), new Object[][]{ {new Timestamp(-1), "1969-12-31T23:59:59.999Z", true}, - {new Timestamp(0), "1970-01-01T00:00:00Z", true}, + {new Timestamp(0), "1970-01-01T00:00:00.000Z", true}, {new Timestamp(1), "1970-01-01T00:00:00.001Z", true}, }); TEST_DB.put(pair(ZonedDateTime.class, String.class), new Object[][]{ @@ -1381,7 +1398,7 @@ private static void loadZoneIdTests() { {mapOf(ID, NY_Z), NY_Z}, {mapOf("_v", "Asia/Tokyo"), TOKYO_Z}, {mapOf("_v", TOKYO_Z), TOKYO_Z}, - {mapOf("zone", mapOf("_v", TOKYO_Z)), TOKYO_Z}, + {mapOf(ZONE, mapOf("_v", TOKYO_Z)), TOKYO_Z}, }); } @@ -1772,12 +1789,14 @@ private static void loadSqlDateTests() { {zdt("1970-01-01T00:00:00.999Z"), new java.sql.Date(999), true}, }); TEST_DB.put(pair(Map.class, java.sql.Date.class), new Object[][] { - { mapOf(SQL_DATE, 1703043551033L), new java.sql.Date(1703043551033L)}, - { mapOf(EPOCH_MILLIS, -1L), new java.sql.Date(-1L)}, - { mapOf(EPOCH_MILLIS, 0L), new java.sql.Date(0L)}, - { mapOf(EPOCH_MILLIS, 1L), new java.sql.Date(1L)}, + { mapOf(SQL_DATE, 1703043551033L), java.sql.Date.valueOf("2023-12-20")}, + { mapOf(EPOCH_MILLIS, -1L), java.sql.Date.valueOf("1969-12-31")}, + { mapOf(EPOCH_MILLIS, 0L), java.sql.Date.valueOf("1970-01-01")}, + { mapOf(EPOCH_MILLIS, 1L), java.sql.Date.valueOf("1970-01-01")}, { mapOf(EPOCH_MILLIS, 1710714535152L), new java.sql.Date(1710714535152L)}, - { mapOf(SQL_DATE, "1970-01-01T00:00:00Z"), new java.sql.Date(0L), true}, + { mapOf(SQL_DATE, "1969-12-31"), java.sql.Date.valueOf("1969-12-31"), true}, // One day before epoch + { mapOf(SQL_DATE, "1970-01-01"), java.sql.Date.valueOf("1970-01-01"), true}, // Epoch + { mapOf(SQL_DATE, "1970-01-02"), java.sql.Date.valueOf("1970-01-02"), true}, // One day after epoch { mapOf(SQL_DATE, "X1970-01-01T00:00:00Z"), new IllegalArgumentException("Issue parsing date-time, other characters present: X")}, { mapOf(SQL_DATE, "1970-01-01X00:00:00Z"), new IllegalArgumentException("Issue parsing date-time, other characters present: X")}, { mapOf(SQL_DATE, "1970-01-01T00:00bad zone"), new IllegalArgumentException("Issue parsing date-time, other characters present: zone")}, @@ -1857,16 +1876,16 @@ private static void loadDateTests() { }); TEST_DB.put(pair(String.class, Date.class), new Object[][]{ {"", null}, - {"1970-01-01T08:59:59.999+09:00", new Date(-1), true}, // Tokyo (set in options - defaults to system when not set explicitly) - {"1970-01-01T09:00:00.000+09:00", new Date(0), true}, - {"1970-01-01T09:00:00.001+09:00", new Date(1), true}, + {"1969-12-31T23:59:59.999Z", new Date(-1), true}, + {"1970-01-01T00:00:00.000Z", new Date(0), true}, + {"1970-01-01T00:00:00.001Z", new Date(1), true}, }); TEST_DB.put(pair(Map.class, Date.class), new Object[][] { { mapOf(EPOCH_MILLIS, -1L), new Date(-1L)}, { mapOf(EPOCH_MILLIS, 0L), new Date(0L)}, { mapOf(EPOCH_MILLIS, 1L), new Date(1L)}, { mapOf(EPOCH_MILLIS, 1710714535152L), new Date(1710714535152L)}, - { mapOf(DATE, "1970-01-01T00:00:00Z"), new Date(0L), true}, + { mapOf(DATE, "1970-01-01T00:00:00.000Z"), new Date(0L), true}, { mapOf(DATE, "X1970-01-01T00:00:00Z"), new IllegalArgumentException("Issue parsing date-time, other characters present: X")}, { mapOf(DATE, "1970-01-01X00:00:00Z"), new IllegalArgumentException("Issue parsing date-time, other characters present: X")}, { mapOf(DATE, "1970-01-01T00:00bad zone"), new IllegalArgumentException("Issue parsing date-time, other characters present: zone")}, @@ -1923,13 +1942,14 @@ private static void loadCalendarTests() { // Test with offset format {mapOf(CALENDAR, "2024-02-05T22:31:17.409+09:00"), (Supplier) () -> { - Calendar cal = Calendar.getInstance(TOKYO_TZ); + Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("GMT+09:00")); cal.set(2024, Calendar.FEBRUARY, 5, 22, 31, 17); cal.set(Calendar.MILLISECOND, 409); return cal; }, 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) () -> { + {mapOf(CALENDAR, "2024-02-05T22:31:17.000[Asia/Tokyo]"), (Supplier) () -> { Calendar cal = Calendar.getInstance(TOKYO_TZ); cal.set(2024, Calendar.FEBRUARY, 5, 22, 31, 17); cal.set(Calendar.MILLISECOND, 0); @@ -1937,7 +1957,7 @@ private static void loadCalendarTests() { }, true}, // Test New York timezone - {mapOf(CALENDAR, "1970-01-01T00:00:00[America/New_York]"), (Supplier) () -> { + {mapOf(CALENDAR, "1970-01-01T00:00:00.000[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); @@ -1953,12 +1973,7 @@ private static void loadCalendarTests() { }, 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} + {mapOf(CALENDAR, "2024-02-05[Asia/Tokyo]"), new IllegalArgumentException("time"), false} }); TEST_DB.put(pair(ZonedDateTime.class, Calendar.class), new Object[][] { {zdt("1969-12-31T23:59:59.999Z"), cal(-1), true}, @@ -1972,9 +1987,12 @@ private static void loadCalendarTests() { }); TEST_DB.put(pair(String.class, Calendar.class), new Object[][]{ { "", null}, - {"1970-01-01T08:59:59.999+09:00", cal(-1), true}, - {"1970-01-01T09:00:00.000+09:00", cal(0), true}, - {"1970-01-01T09:00:00.001+09:00", cal(1), true}, + {"1970-01-01T08:59:59.999[Asia/Tokyo]", cal(-1), true}, + {"1970-01-01T09:00:00.000[Asia/Tokyo]", cal(0), true}, + {"1970-01-01T09:00:00.001[Asia/Tokyo]", cal(1), true}, + {"1970-01-01T08:59:59.999+09:00", cal(-1), false}, // zone offset vs zone name + {"1970-01-01T09:00:00.000+09:00", cal(0), false}, + {"1970-01-01T09:00:00.001+09:00", cal(1), false}, }); } @@ -3799,6 +3817,7 @@ private static Stream generateTestEverythingParamsInReverse() { * * Need to wait for json-io 4.34.0 to enable. */ + @Disabled @ParameterizedTest(name = "{0}[{2}] ==> {1}[{3}]") @MethodSource("generateTestEverythingParams") void testJsonIo(String shortNameSource, String shortNameTarget, Object source, Object target, Class sourceClass, Class targetClass, int index) { @@ -3926,6 +3945,14 @@ void testConvert(String shortNameSource, String shortNameTarget, Object source, assertEquals(target, actual); } updateStat(pair(sourceClass, targetClass), true); + } + else if (targetClass.equals(java.sql.Date.class)) { + // Compare java.sql.Date values using their toString() values, + // since we treat them as literal "yyyy-MM-dd" values. + if (actual != null) { + assertEquals(target.toString(), actual.toString()); + } + updateStat(pair(sourceClass, targetClass), true); } else { assertEquals(target, actual); updateStat(pair(sourceClass, targetClass), true); diff --git a/src/test/java/com/cedarsoftware/util/convert/ConverterTest.java b/src/test/java/com/cedarsoftware/util/convert/ConverterTest.java index 8347f1f8..80c8b366 100644 --- a/src/test/java/com/cedarsoftware/util/convert/ConverterTest.java +++ b/src/test/java/com/cedarsoftware/util/convert/ConverterTest.java @@ -14,6 +14,7 @@ import java.time.LocalTime; import java.time.OffsetDateTime; import java.time.ZoneId; +import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Calendar; @@ -1797,16 +1798,16 @@ void testConvertString_withIllegalArguments(Object value, String partialMessage) } @Test - void testString_fromDate() - { - Calendar cal = Calendar.getInstance(); + void testString_fromDate() { + Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")); cal.clear(); - cal.set(2015, 0, 17, 8, 34, 49); + // Now '8:34:49' is in UTC, not local time + cal.set(2015, Calendar.JANUARY, 17, 8, 34, 49); Date date = cal.getTime(); String converted = this.converter.convert(date, String.class); - assertThat(converted).startsWith("2015-01-17T08:34:49"); + assertThat(converted).startsWith("2015-01-17T08:34:49.000Z"); } @Test @@ -2865,7 +2866,7 @@ void testMapToCalendar(Object value) map.clear(); assertThatThrownBy(() -> this.converter.convert(map, Calendar.class)) .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Map to 'Calendar' the map must include: [epochMillis], [time, zone (optional)], [date, time, zone (optional)], [value], or [_v] as keys with associated values"); + .hasMessageContaining("Map to 'Calendar' the map must include: [calendar], [value], or [_v] as keys with associated values"); } @Test @@ -2881,9 +2882,7 @@ void testMapToCalendarWithTimeZone() // System.out.println("zdt = " + zdt); final Map map = new HashMap<>(); - map.put("date", zdt.toLocalDate()); - map.put("time", zdt.toLocalTime()); - map.put("zone", cal.getTimeZone().toZoneId()); + map.put("calendar", zdt.toString()); // System.out.println("map = " + map); Calendar newCal = this.converter.convert(map, Calendar.class); @@ -2906,8 +2905,7 @@ void testMapToCalendarWithTimeNoZone() ZonedDateTime zdt = ZonedDateTime.ofInstant(instant, tz.toZoneId()); final Map map = new HashMap<>(); - map.put("date", zdt.toLocalDate()); - map.put("time", zdt.toLocalTime()); + map.put("calendar", zdt.toLocalDateTime()); Calendar newCal = this.converter.convert(map, Calendar.class); assert cal.equals(newCal); assert DeepEquals.deepEquals(cal, newCal); @@ -2933,7 +2931,7 @@ void testMapToGregCalendar() map.clear(); assertThatThrownBy(() -> this.converter.convert(map, GregorianCalendar.class)) .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Map to 'Calendar' the map must include: [epochMillis], [time, zone (optional)], [date, time, zone (optional)], [value], or [_v] as keys with associated values"); + .hasMessageContaining("ap to 'Calendar' the map must include: [calendar], [value], or [_v] as keys with associated values"); } @Test @@ -2960,21 +2958,31 @@ void testMapToDate() { } @Test - void testMapToSqlDate() - { + void testMapToSqlDate() { long now = System.currentTimeMillis(); - final Map map = new HashMap<>(); + final Map map = new HashMap<>(); map.put("value", now); - java.sql.Date date = this.converter.convert(map, java.sql.Date.class); - assert now == date.getTime(); + // Convert using your converter + java.sql.Date actualDate = this.converter.convert(map, java.sql.Date.class); + + // Compute the expected date by interpreting 'now' in UTC and normalizing it. + LocalDate expectedLD = Instant.ofEpochMilli(now) + .atZone(ZoneOffset.UTC) + .toLocalDate(); + java.sql.Date expectedDate = java.sql.Date.valueOf(expectedLD.toString()); + + // Compare the literal date strings (or equivalently, the normalized LocalDates). + assertEquals(expectedDate.toString(), actualDate.toString()); + + // The rest of the tests: map.clear(); map.put("value", ""); - assert null == this.converter.convert(map, java.sql.Date.class); + assertNull(this.converter.convert(map, java.sql.Date.class)); map.clear(); map.put("value", null); - assert null == this.converter.convert(map, java.sql.Date.class); + assertNull(this.converter.convert(map, java.sql.Date.class)); map.clear(); assertThatThrownBy(() -> this.converter.convert(map, java.sql.Date.class)) @@ -3502,15 +3510,39 @@ void testClassToString() } @Test - void testSqlDateToString() - { - long now = System.currentTimeMillis(); - java.sql.Date date = new java.sql.Date(now); - String strDate = this.converter.convert(date, String.class); - Date x = this.converter.convert(strDate, Date.class); - LocalDate l1 = this.converter.convert(date, LocalDate.class); - LocalDate l2 = this.converter.convert(x, LocalDate.class); - assertEquals(l1, l2); + void testSqlDateToString_LocalMidnight() { + // Create the sql.Date as a local date using valueOf. + java.sql.Date date = java.sql.Date.valueOf("2025-01-29"); + + // Convert to String using your converter. + String strDate = converter.convert(date, String.class); + + // Convert back to a java.util.Date (or java.sql.Date) using your converter. + Date x = converter.convert(strDate, Date.class); + + // Convert both dates to LocalDate in the system default time zone. + LocalDate l1 = Instant.ofEpochMilli(date.getTime()) + .atZone(ZoneId.systemDefault()) + .toLocalDate(); + LocalDate l2 = Instant.ofEpochMilli(x.getTime()) + .atZone(ZoneId.systemDefault()) + .toLocalDate(); + + // --- Debug prints (optional) --- +// System.out.println("date (sql) = " + date); // e.g. "2025-01-29" +// System.out.println("strDate = " + strDate); // e.g. "2025-01-29" +// System.out.println("x (util.Date) = " + x); // local time representation +// System.out.println("l1 (local) = " + l1); // "2025-01-29" +// System.out.println("l2 (local) = " + l2); // "2025-01-29" + + // Assert that the local dates match. + assertEquals(l1, l2, "Local dates should match in system default interpretation"); + + // Parse the string as a LocalDate (since it is "YYYY-MM-DD"). + LocalDate ld = LocalDate.parse(strDate); + ZonedDateTime parsedZdt = ld.atStartOfDay(ZoneOffset.systemDefault()); + // Check that the parsed date has the correct local date. + assertEquals(l1, parsedZdt.toLocalDate()); } @Test @@ -3725,36 +3757,15 @@ void testUUIDToMap() void testCalendarToMap() { Calendar cal = Calendar.getInstance(); Map map = this.converter.convert(cal, Map.class); - assert map.size() == 4; - - // Verify map has all required keys - assert map.containsKey(MapConversions.DATE); - assert map.containsKey(MapConversions.TIME); - assert map.containsKey(MapConversions.ZONE); - assert map.containsKey(MapConversions.EPOCH_MILLIS); - - // Verify values match original calendar - String date = (String) map.get(MapConversions.DATE); - String time = (String) map.get(MapConversions.TIME); - String zone = (String) map.get(MapConversions.ZONE); - Long epochMillis = (Long) map.get(MapConversions.EPOCH_MILLIS); - - // Check date components - LocalDate localDate = LocalDate.parse(date); - assert localDate.getYear() == cal.get(Calendar.YEAR); - assert localDate.getMonthValue() == cal.get(Calendar.MONTH) + 1; // Calendar months are 0-based - assert localDate.getDayOfMonth() == cal.get(Calendar.DAY_OF_MONTH); - - // Check time components - LocalTime localTime = LocalTime.parse(time); - assert localTime.getHour() == cal.get(Calendar.HOUR_OF_DAY); - assert localTime.getMinute() == cal.get(Calendar.MINUTE); - assert localTime.getSecond() == cal.get(Calendar.SECOND); - assert localTime.getNano() == cal.get(Calendar.MILLISECOND) * 1_000_000; - - // Check zone and epochMillis - assert zone.equals(cal.getTimeZone().toZoneId().toString()); - assert epochMillis == cal.getTimeInMillis(); + + assert map.size() == 1; + assert map.containsKey(MapConversions.CALENDAR); + + Calendar reconstructed = this.converter.convert(map, Calendar.class); + + assert cal.getTimeInMillis() == reconstructed.getTimeInMillis(); + assert cal.getTimeZone().getID().equals(reconstructed.getTimeZone().getID()); + assert DeepEquals.deepEquals(cal, reconstructed); } @Test @@ -3781,20 +3792,22 @@ void testDateToMap() { } @Test - void testSqlDateToMap() - { - java.sql.Date now = new java.sql.Date(System.currentTimeMillis()); - Map map = this.converter.convert(now, Map.class); + void testSqlDateToMap() { + // Create a specific UTC instant that won't have timezone issues + Instant utcInstant = Instant.parse("2024-01-15T23:09:00Z"); + java.sql.Date sqlDate = new java.sql.Date(utcInstant.toEpochMilli()); + + Map map = this.converter.convert(sqlDate, Map.class); assert map.size() == 1; String dateStr = (String) map.get(MapConversions.SQL_DATE); assert dateStr != null; - assert dateStr.endsWith("T00:00:00Z"); // SQL Date should have no time component + assert !dateStr.contains("00:00:00"); // SQL Date should have no time component - // Parse back and verify date components match - LocalDate original = now.toLocalDate(); - LocalDate converted = LocalDate.parse(dateStr.substring(0, 10)); // Get yyyy-MM-dd part - assert original.equals(converted); + // Parse both as UTC and compare + LocalDate expectedDate = LocalDate.parse("2024-01-15"); + LocalDate convertedDate = LocalDate.parse(dateStr.substring(0, 10)); + assert expectedDate.equals(convertedDate); // Verify no milliseconds are present in string assert !dateStr.contains("."); diff --git a/src/test/java/com/cedarsoftware/util/convert/MapConversionTests.java b/src/test/java/com/cedarsoftware/util/convert/MapConversionTests.java index 656e1193..ff9a0254 100644 --- a/src/test/java/com/cedarsoftware/util/convert/MapConversionTests.java +++ b/src/test/java/com/cedarsoftware/util/convert/MapConversionTests.java @@ -173,7 +173,11 @@ public void testToSqlDate() { Map map = new HashMap<>(); long currentTime = System.currentTimeMillis(); map.put("epochMillis", currentTime); - assertEquals(new java.sql.Date(currentTime), MapConversions.toSqlDate(map, converter)); + LocalDate expectedLD = Instant.ofEpochMilli(currentTime) + .atZone(ZoneOffset.UTC) + .toLocalDate(); + java.sql.Date expected = java.sql.Date.valueOf(expectedLD.toString()); + assertEquals(expected, MapConversions.toSqlDate(map, converter)); // Test with date/time components map.clear(); @@ -217,7 +221,7 @@ public void testToTimeZone() { public void testToCalendar() { Map map = new HashMap<>(); long currentTime = System.currentTimeMillis(); - map.put("epochMillis", currentTime); + map.put("calendar", currentTime); Calendar cal = MapConversions.toCalendar(map, converter); assertEquals(currentTime, cal.getTimeInMillis()); }