Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>
* 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.
* </p>
* <p>
* critical, use {@link java.time.LocalDate} (recommended) or {@link java.sql.Date}.
* </p>
*/
class DateDeserializer extends AbstractDateDeserializer<Date> {

Expand All @@ -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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")));
Expand Down
Loading