Skip to content

Commit 300556b

Browse files
committed
[695] Fix date deserialization for date-only patterns
DateDeserializer previously required both date and time components, causing failures with patterns like yyyy-MM-dd. Now uses parseBest() to handle ZonedDateTime, LocalDateTime, LocalDate, and YearMonth inputs. Signed-off-by: James R. Perkins <jperkins@ibm.com>
1 parent 40c0444 commit 300556b

File tree

2 files changed

+142
-6
lines changed

2 files changed

+142
-6
lines changed

src/main/java/org/eclipse/yasson/internal/deserializer/types/DateDeserializer.java

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,33 @@
1313
package org.eclipse.yasson.internal.deserializer.types;
1414

1515
import java.time.Instant;
16+
import java.time.LocalDate;
17+
import java.time.LocalDateTime;
18+
import java.time.YearMonth;
19+
import java.time.ZoneId;
20+
import java.time.ZoneOffset;
1621
import java.time.ZonedDateTime;
1722
import java.time.format.DateTimeFormatter;
23+
import java.time.temporal.TemporalAccessor;
1824
import java.util.Date;
1925
import java.util.Locale;
2026

2127
/**
2228
* Deserializer of the {@link Date} type.
29+
*
30+
* <p>
31+
* For date-only patterns (e.g., "yyyy-MM-dd"), this deserializer uses {@link DateTimeFormatter#parseBest} to detect the
32+
* appropriate temporal type (LocalDate, LocalDateTime, etc.) and creates the Date object at midnight in the specified
33+
* timezone. When no timezone is specified in the pattern, UTC is used as required by Jakarta JSON Binding specification
34+
* section 3.5.
35+
* </p>
36+
* <p>
37+
* <b>Important:</b> Date objects created from date-only patterns represent midnight UTC, which may display as a
38+
* different calendar day when viewed in local timezone. For date values where preserving the local calendar date is
39+
* critical, use {@link java.sql.Date} or better {@link java.time.LocalDate} instead.
40+
* </p>
41+
*
42+
* @author <a href="mailto:jperkins@ibm.com">James R. Perkins</a>
2343
*/
2444
class DateDeserializer extends AbstractDateDeserializer<Date> {
2545

@@ -45,13 +65,31 @@ Date parseWithFormatter(String jsonValue, DateTimeFormatter formatter) {
4565
}
4666

4767
private static Date parseWithOrWithoutZone(String jsonValue, DateTimeFormatter formatter) {
48-
ZonedDateTime parsed;
49-
if (formatter.getZone() == null) {
50-
parsed = ZonedDateTime.parse(jsonValue, formatter.withZone(UTC));
68+
final TemporalAccessor best = formatter.parseBest(jsonValue,
69+
ZonedDateTime::from,
70+
LocalDateTime::from,
71+
LocalDate::from,
72+
YearMonth::from);
73+
74+
// If no zone provided in string, use the formatter's zone or UTC per the Jakarta JSON Binding specification
75+
// section 3.5
76+
final ZoneId zone = formatter.getZone() != null ? formatter.getZone() : ZoneOffset.UTC;
77+
78+
// Determine the type of the best option
79+
final Instant instant;
80+
if (best instanceof ZonedDateTime) {
81+
instant = ((ZonedDateTime) best).toInstant();
82+
} else if (best instanceof LocalDateTime) {
83+
instant = ((LocalDateTime) best).atZone(zone).toInstant();
84+
} else if (best instanceof LocalDate) {
85+
instant = LocalDate.from(best).atStartOfDay(zone).toInstant();
86+
} else if (best instanceof YearMonth) {
87+
instant = ((YearMonth) best).atDay(1).atStartOfDay(zone).toInstant();
5188
} else {
52-
parsed = ZonedDateTime.parse(jsonValue, formatter);
89+
// Fallback
90+
instant = Instant.from(best);
5391
}
54-
return Date.from(parsed.toInstant());
92+
return Date.from(instant);
5593
}
5694

5795
}

src/test/java/org/eclipse/yasson/defaultmapping/dates/DatesTest.java

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ public static class SqlDateObj implements Serializable {
102102
public static class SqlDateFormatted {
103103
@JsonbDateFormat(value = "yyyy-MM-dd")
104104
public java.sql.Date sqlDate;
105+
@JsonbDateFormat(value = "yyyy-MM-dd")
106+
public java.util.Date utilDate;
107+
108+
105109
}
106110

107111
@Test
@@ -126,7 +130,101 @@ public void testUnmarshallSqlDate() {
126130
assertEquals("2018-01-31", result.sqlDate.toString());
127131
assertEquals("2018-01-31", result.utilDate.toString());
128132
}
129-
133+
134+
@Test
135+
public void testMarshallSqlDateFormatted() {
136+
final String date = "2026-02-25";
137+
final String expectedJson = String.format("{\"sqlDate\":\"%1$s\",\"utilDate\":\"%1$s\"}", date);
138+
139+
final SqlDateFormatted sqlDateFormatted = new SqlDateFormatted();
140+
sqlDateFormatted.sqlDate = java.sql.Date.valueOf(date);
141+
// We use a java.sql.Date here as we want to test as if this was a Jakarta Persistence temporal date
142+
sqlDateFormatted.utilDate = java.sql.Date.valueOf(date);
143+
String jsonString = bindingJsonb.toJson(sqlDateFormatted);
144+
assertEquals(expectedJson, jsonString);
145+
146+
// Unmarshal the object
147+
final SqlDateFormatted result = bindingJsonb.fromJson(jsonString, SqlDateFormatted.class);
148+
assertEquals(sqlDateFormatted.sqlDate, result.sqlDate);
149+
// The Date objects will not be equal unless user.timezone is set to UTC. The sqlDateFormatted.utilDate is
150+
// created at midnight in the current timezone (via valueOf()), while result.utilDate is created at midnight UTC
151+
// per the JSON-B specification. To verify both represent the same calendar date, we convert each to LocalDate
152+
// using its respective timezone: the original uses systemDefault(), the deserialized uses UTC.
153+
assertEquals(Instant.ofEpochMilli(sqlDateFormatted.utilDate.getTime()).atZone(ZoneId.systemDefault()).toLocalDate(),
154+
result.utilDate.toInstant().atZone(ZoneOffset.UTC).toLocalDate());
155+
}
156+
157+
@Test
158+
public void testUnmarshallSqlDateFormatted() {
159+
final String date = "2026-02-25";
160+
final String expectedString = String.format("{\"sqlDate\":\"%1$s\",\"utilDate\":\"%1$s\"}", date);
161+
162+
final SqlDateFormatted sqlDateFormatted = bindingJsonb.fromJson(expectedString, SqlDateFormatted.class);
163+
assertEquals(date, sqlDateFormatted.sqlDate.toString());
164+
// Convert java.util.Date to LocalDate for comparison
165+
final LocalDate resultDate = sqlDateFormatted.utilDate.toInstant()
166+
.atZone(ZoneOffset.UTC)
167+
.toLocalDate();
168+
assertEquals(LocalDate.parse(date), resultDate);
169+
170+
// Unmarshal the object
171+
final String result = bindingJsonb.toJson(sqlDateFormatted);
172+
assertEquals(expectedString, result);
173+
}
174+
175+
public static class YearMonthFormatted {
176+
@JsonbDateFormat(value = "yyyy-MM")
177+
public java.util.Date date;
178+
}
179+
180+
@Test
181+
public void testMarshallYearMonthFormat() {
182+
final YearMonthFormatted yearMonthFormatted = new YearMonthFormatted();
183+
yearMonthFormatted.date = java.sql.Date.valueOf("2026-02-25");
184+
String jsonString = bindingJsonb.toJson(yearMonthFormatted);
185+
assertEquals("{\"date\":\"2026-02\"}", jsonString);
186+
}
187+
188+
@Test
189+
public void testUnmarshallYearMonthFormat() {
190+
final YearMonthFormatted yearMonthFormatted = bindingJsonb.fromJson(
191+
"{\"date\":\"2026-02\"}",
192+
YearMonthFormatted.class);
193+
final LocalDate resultDate = yearMonthFormatted.date.toInstant()
194+
.atZone(ZoneOffset.UTC)
195+
.toLocalDate();
196+
assertEquals(LocalDate.of(2026, 2, 1), resultDate);
197+
}
198+
199+
@Test
200+
public void testDateOnlyPatternEdgeCases() {
201+
// Test various edge cases to ensure date values are preserved correctly
202+
testDateRoundTrip("2028-03-01"); // Day after leap year
203+
testDateRoundTrip("2026-12-31"); // Last day of year
204+
testDateRoundTrip("2028-02-29"); // Leap year day
205+
testDateRoundTrip("2027-01-01"); // First day of year
206+
testDateRoundTrip("2028-01-31"); // Last day of January
207+
testDateRoundTrip("2028-02-01"); // First day of February
208+
}
209+
210+
private void testDateRoundTrip(final String date) {
211+
final String json = String.format("{\"sqlDate\":\"%1$s\",\"utilDate\":\"%1$s\"}", date);
212+
213+
// Deserialize
214+
final SqlDateFormatted deserialized = bindingJsonb.fromJson(json, SqlDateFormatted.class);
215+
216+
// Verify utilDate represents midnight UTC for the specified date
217+
final LocalDate resultDate = deserialized.utilDate.toInstant()
218+
.atZone(ZoneOffset.UTC)
219+
.toLocalDate();
220+
assertEquals(LocalDate.parse(date), resultDate, () -> String.format("Date should be %s when viewed in UTC", date));
221+
222+
// Verify JSON round-trip
223+
final String roundTripped = bindingJsonb.toJson(deserialized);
224+
assertEquals(json, roundTripped, () -> String.format("JSON should round-trip correctly for %s", date));
225+
}
226+
227+
130228
@Test
131229
public void testSqlDateTimeZonesFormatted() {
132230
testSqlDateWithTZFormatted(TimeZone.getTimeZone(ZoneId.of("Europe/Sofia")));

0 commit comments

Comments
 (0)