diff --git a/src/main/java/org/eclipse/yasson/internal/deserializer/types/DateDeserializer.java b/src/main/java/org/eclipse/yasson/internal/deserializer/types/DateDeserializer.java
index 723cdbe7..4e3704a0 100644
--- a/src/main/java/org/eclipse/yasson/internal/deserializer/types/DateDeserializer.java
+++ b/src/main/java/org/eclipse/yasson/internal/deserializer/types/DateDeserializer.java
@@ -13,13 +13,30 @@
package org.eclipse.yasson.internal.deserializer.types;
import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.YearMonth;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
+import java.time.temporal.TemporalAccessor;
import java.util.Date;
import java.util.Locale;
/**
* Deserializer of the {@link Date} type.
+ *
+ *
+ * For date-only patterns (e.g., "yyyy-MM-dd"), this deserializer uses {@link DateTimeFormatter#parseBest} to detect the
+ * appropriate temporal type [ZonedDateTime, LocalDateTime, LocalDate, or YearMonth]
+ * and creates the Date object at midnight in the specified
+ * timezone. When no timezone is specified in the pattern, UTC is used as required by Jakarta JSON Binding specification
+ * section 3.5.
+ *
+ *
+ * critical, use {@link java.time.LocalDate} (recommended) or {@link java.sql.Date}.
+ *
*/
class DateDeserializer extends AbstractDateDeserializer {
@@ -45,13 +62,31 @@ Date parseWithFormatter(String jsonValue, DateTimeFormatter formatter) {
}
private static Date parseWithOrWithoutZone(String jsonValue, DateTimeFormatter formatter) {
- ZonedDateTime parsed;
- if (formatter.getZone() == null) {
- parsed = ZonedDateTime.parse(jsonValue, formatter.withZone(UTC));
+ final TemporalAccessor best = formatter.parseBest(jsonValue,
+ ZonedDateTime::from,
+ LocalDateTime::from,
+ LocalDate::from,
+ YearMonth::from);
+
+ // If no zone provided in string, use the formatter's zone or UTC per the Jakarta JSON Binding specification
+ // section 3.5
+ final ZoneId zone = formatter.getZone() != null ? formatter.getZone() : ZoneOffset.UTC;
+
+ // Determine the type of the best option
+ final Instant instant;
+ if (best instanceof ZonedDateTime) {
+ instant = ((ZonedDateTime) best).toInstant();
+ } else if (best instanceof LocalDateTime) {
+ instant = ((LocalDateTime) best).atZone(zone).toInstant();
+ } else if (best instanceof LocalDate) {
+ instant = LocalDate.from(best).atStartOfDay(zone).toInstant();
+ } else if (best instanceof YearMonth) {
+ instant = ((YearMonth) best).atDay(1).atStartOfDay(zone).toInstant();
} else {
- parsed = ZonedDateTime.parse(jsonValue, formatter);
+ // Fallback
+ instant = Instant.from(best);
}
- return Date.from(parsed.toInstant());
+ return Date.from(instant);
}
}
diff --git a/src/test/java/org/eclipse/yasson/defaultmapping/dates/DatesTest.java b/src/test/java/org/eclipse/yasson/defaultmapping/dates/DatesTest.java
index 287afca8..79ad21e9 100644
--- a/src/test/java/org/eclipse/yasson/defaultmapping/dates/DatesTest.java
+++ b/src/test/java/org/eclipse/yasson/defaultmapping/dates/DatesTest.java
@@ -102,6 +102,10 @@ public static class SqlDateObj implements Serializable {
public static class SqlDateFormatted {
@JsonbDateFormat(value = "yyyy-MM-dd")
public java.sql.Date sqlDate;
+ @JsonbDateFormat(value = "yyyy-MM-dd")
+ public java.util.Date utilDate;
+
+
}
@Test
@@ -126,7 +130,101 @@ public void testUnmarshallSqlDate() {
assertEquals("2018-01-31", result.sqlDate.toString());
assertEquals("2018-01-31", result.utilDate.toString());
}
-
+
+ @Test
+ public void testMarshallSqlDateFormatted() {
+ final String date = "2026-02-25";
+ final String expectedJson = String.format("{\"sqlDate\":\"%1$s\",\"utilDate\":\"%1$s\"}", date);
+
+ final SqlDateFormatted sqlDateFormatted = new SqlDateFormatted();
+ sqlDateFormatted.sqlDate = java.sql.Date.valueOf(date);
+ // We use a java.sql.Date here as we want to test as if this was a Jakarta Persistence temporal date
+ sqlDateFormatted.utilDate = java.sql.Date.valueOf(date);
+ String jsonString = bindingJsonb.toJson(sqlDateFormatted);
+ assertEquals(expectedJson, jsonString);
+
+ // Unmarshal the object
+ final SqlDateFormatted result = bindingJsonb.fromJson(jsonString, SqlDateFormatted.class);
+ assertEquals(sqlDateFormatted.sqlDate, result.sqlDate);
+ // The Date objects will not be equal unless user.timezone is set to UTC. The sqlDateFormatted.utilDate is
+ // created at midnight in the current timezone (via valueOf()), while result.utilDate is created at midnight UTC
+ // per the JSON-B specification. To verify both represent the same calendar date, we convert each to LocalDate
+ // using its respective timezone: the original uses systemDefault(), the deserialized uses UTC.
+ assertEquals(Instant.ofEpochMilli(sqlDateFormatted.utilDate.getTime()).atZone(ZoneId.systemDefault()).toLocalDate(),
+ result.utilDate.toInstant().atZone(ZoneOffset.UTC).toLocalDate());
+ }
+
+ @Test
+ public void testUnmarshallSqlDateFormatted() {
+ final String date = "2026-02-25";
+ final String expectedString = String.format("{\"sqlDate\":\"%1$s\",\"utilDate\":\"%1$s\"}", date);
+
+ final SqlDateFormatted sqlDateFormatted = bindingJsonb.fromJson(expectedString, SqlDateFormatted.class);
+ assertEquals(date, sqlDateFormatted.sqlDate.toString());
+ // Convert java.util.Date to LocalDate for comparison
+ final LocalDate resultDate = sqlDateFormatted.utilDate.toInstant()
+ .atZone(ZoneOffset.UTC)
+ .toLocalDate();
+ assertEquals(LocalDate.parse(date), resultDate);
+
+ // Unmarshal the object
+ final String result = bindingJsonb.toJson(sqlDateFormatted);
+ assertEquals(expectedString, result);
+ }
+
+ public static class YearMonthFormatted {
+ @JsonbDateFormat(value = "yyyy-MM")
+ public java.util.Date date;
+ }
+
+ @Test
+ public void testMarshallYearMonthFormat() {
+ final YearMonthFormatted yearMonthFormatted = new YearMonthFormatted();
+ yearMonthFormatted.date = java.sql.Date.valueOf("2026-02-25");
+ String jsonString = bindingJsonb.toJson(yearMonthFormatted);
+ assertEquals("{\"date\":\"2026-02\"}", jsonString);
+ }
+
+ @Test
+ public void testUnmarshallYearMonthFormat() {
+ final YearMonthFormatted yearMonthFormatted = bindingJsonb.fromJson(
+ "{\"date\":\"2026-02\"}",
+ YearMonthFormatted.class);
+ final LocalDate resultDate = yearMonthFormatted.date.toInstant()
+ .atZone(ZoneOffset.UTC)
+ .toLocalDate();
+ assertEquals(LocalDate.of(2026, 2, 1), resultDate);
+ }
+
+ @Test
+ public void testDateOnlyPatternEdgeCases() {
+ // Test various edge cases to ensure date values are preserved correctly
+ testDateRoundTrip("2028-03-01"); // Day after leap year
+ testDateRoundTrip("2026-12-31"); // Last day of year
+ testDateRoundTrip("2028-02-29"); // Leap year day
+ testDateRoundTrip("2027-01-01"); // First day of year
+ testDateRoundTrip("2028-01-31"); // Last day of January
+ testDateRoundTrip("2028-02-01"); // First day of February
+ }
+
+ private void testDateRoundTrip(final String date) {
+ final String json = String.format("{\"sqlDate\":\"%1$s\",\"utilDate\":\"%1$s\"}", date);
+
+ // Deserialize
+ final SqlDateFormatted deserialized = bindingJsonb.fromJson(json, SqlDateFormatted.class);
+
+ // Verify utilDate represents midnight UTC for the specified date
+ final LocalDate resultDate = deserialized.utilDate.toInstant()
+ .atZone(ZoneOffset.UTC)
+ .toLocalDate();
+ assertEquals(LocalDate.parse(date), resultDate, () -> String.format("Date should be %s when viewed in UTC", date));
+
+ // Verify JSON round-trip
+ final String roundTripped = bindingJsonb.toJson(deserialized);
+ assertEquals(json, roundTripped, () -> String.format("JSON should round-trip correctly for %s", date));
+ }
+
+
@Test
public void testSqlDateTimeZonesFormatted() {
testSqlDateWithTZFormatted(TimeZone.getTimeZone(ZoneId.of("Europe/Sofia")));