Skip to content

Commit c2f86ca

Browse files
authored
Add feature for time-zone aware lenient date parsing (#354)
1 parent 98fc3d0 commit c2f86ca

File tree

8 files changed

+180
-8
lines changed

8 files changed

+180
-8
lines changed

datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/JavaTimeFeature.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,19 @@ public enum JavaTimeFeature implements JacksonFeature
1919
*/
2020
NORMALIZE_DESERIALIZED_ZONE_ID(true),
2121

22+
/**
23+
* Feature that determines whether the {@link java.util.TimeZone} of the
24+
* {@link com.fasterxml.jackson.databind.DeserializationContext} is used
25+
* when leniently deserializing {@link java.time.LocalDate} or
26+
* {@link java.time.LocalDateTime} from the UTC/ISO instant format.
27+
* <p>
28+
* Default setting is disabled, for backwards-compatibility with
29+
* Jackson 2.18.
30+
*
31+
* @since 2.19
32+
*/
33+
USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING(false),
34+
2235
/**
2336
* Feature that controls whether stringified numbers (Strings that without
2437
* quotes would be legal JSON Numbers) may be interpreted as

datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/JavaTimeModule.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,8 @@ public void setupModule(SetupContext context) {
131131

132132
// // Other deserializers
133133
desers.addDeserializer(Duration.class, DurationDeserializer.INSTANCE);
134-
desers.addDeserializer(LocalDateTime.class, LocalDateTimeDeserializer.INSTANCE);
135-
desers.addDeserializer(LocalDate.class, LocalDateDeserializer.INSTANCE);
134+
desers.addDeserializer(LocalDateTime.class, LocalDateTimeDeserializer.INSTANCE.withFeatures(_features));
135+
desers.addDeserializer(LocalDate.class, LocalDateDeserializer.INSTANCE.withFeatures(_features));
136136
desers.addDeserializer(LocalTime.class, LocalTimeDeserializer.INSTANCE);
137137
desers.addDeserializer(MonthDay.class, MonthDayDeserializer.INSTANCE);
138138
desers.addDeserializer(OffsetTime.class, OffsetTimeDeserializer.INSTANCE);

datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/deser/LocalDateDeserializer.java

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,19 @@
1818

1919
import java.io.IOException;
2020
import java.time.DateTimeException;
21+
import java.time.Instant;
2122
import java.time.LocalDate;
2223
import java.time.format.DateTimeFormatter;
2324

2425
import com.fasterxml.jackson.annotation.JsonFormat;
2526
import com.fasterxml.jackson.core.*;
27+
import com.fasterxml.jackson.core.util.JacksonFeatureSet;
2628
import com.fasterxml.jackson.databind.DeserializationContext;
2729
import com.fasterxml.jackson.databind.DeserializationFeature;
2830
import com.fasterxml.jackson.databind.JavaType;
2931
import com.fasterxml.jackson.databind.cfg.CoercionAction;
3032
import com.fasterxml.jackson.databind.cfg.CoercionInputShape;
33+
import com.fasterxml.jackson.datatype.jsr310.JavaTimeFeature;
3134

3235
/**
3336
* Deserializer for Java 8 temporal {@link LocalDate}s.
@@ -38,37 +41,63 @@ public class LocalDateDeserializer extends JSR310DateTimeDeserializerBase<LocalD
3841
{
3942
private static final long serialVersionUID = 1L;
4043

44+
private final static boolean DEFAULT_USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING
45+
= JavaTimeFeature.USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING.enabledByDefault();
46+
4147
private static final DateTimeFormatter DEFAULT_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE;
4248

4349
public static final LocalDateDeserializer INSTANCE = new LocalDateDeserializer();
4450

51+
/**
52+
* Flag set from
53+
* {@link com.fasterxml.jackson.datatype.jsr310.JavaTimeFeature#USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING}
54+
* to determine whether the {@link java.util.TimeZone} of the
55+
* {@link com.fasterxml.jackson.databind.DeserializationContext} is used
56+
* when leniently deserializing from the UTC/ISO instant format.
57+
*
58+
* @since 2.19
59+
*/
60+
protected final boolean _useTimeZoneForLenientDateParsing;
61+
4562
protected LocalDateDeserializer() {
4663
this(DEFAULT_FORMATTER);
4764
}
4865

4966
public LocalDateDeserializer(DateTimeFormatter dtf) {
5067
super(LocalDate.class, dtf);
68+
_useTimeZoneForLenientDateParsing = DEFAULT_USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING;
5169
}
5270

5371
/**
5472
* Since 2.10
5573
*/
5674
public LocalDateDeserializer(LocalDateDeserializer base, DateTimeFormatter dtf) {
5775
super(base, dtf);
76+
_useTimeZoneForLenientDateParsing = base._useTimeZoneForLenientDateParsing;
5877
}
5978

6079
/**
6180
* Since 2.10
6281
*/
6382
protected LocalDateDeserializer(LocalDateDeserializer base, Boolean leniency) {
6483
super(base, leniency);
84+
_useTimeZoneForLenientDateParsing = base._useTimeZoneForLenientDateParsing;
6585
}
6686

6787
/**
6888
* Since 2.11
6989
*/
7090
protected LocalDateDeserializer(LocalDateDeserializer base, JsonFormat.Shape shape) {
7191
super(base, shape);
92+
_useTimeZoneForLenientDateParsing = base._useTimeZoneForLenientDateParsing;
93+
}
94+
95+
/**
96+
* Since 2.19
97+
*/
98+
protected LocalDateDeserializer(LocalDateDeserializer base, JacksonFeatureSet<JavaTimeFeature> features) {
99+
super(LocalDate.class, base._formatter);
100+
_useTimeZoneForLenientDateParsing = features.isEnabled(JavaTimeFeature.USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING);
72101
}
73102

74103
@Override
@@ -84,6 +113,17 @@ protected LocalDateDeserializer withLeniency(Boolean leniency) {
84113
@Override
85114
protected LocalDateDeserializer withShape(JsonFormat.Shape shape) { return new LocalDateDeserializer(this, shape); }
86115

116+
/**
117+
* Since 2.19
118+
*/
119+
public LocalDateDeserializer withFeatures(JacksonFeatureSet<JavaTimeFeature> features) {
120+
if (_useTimeZoneForLenientDateParsing ==
121+
features.isEnabled(JavaTimeFeature.USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING)) {
122+
return this;
123+
}
124+
return new LocalDateDeserializer(this, features);
125+
}
126+
87127
@Override
88128
public LocalDate deserialize(JsonParser parser, DeserializationContext context) throws IOException
89129
{
@@ -161,6 +201,9 @@ protected LocalDate _fromString(JsonParser p, DeserializationContext ctxt,
161201
if (string.length() > 10 && string.charAt(10) == 'T') {
162202
if (isLenient()) {
163203
if (string.endsWith("Z")) {
204+
if (_useTimeZoneForLenientDateParsing) {
205+
return Instant.parse(string).atZone(ctxt.getTimeZone().toZoneId()).toLocalDate();
206+
}
164207
return LocalDate.parse(string.substring(0, string.length() - 1),
165208
DateTimeFormatter.ISO_LOCAL_DATE_TIME);
166209
}

datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/deser/LocalDateTimeDeserializer.java

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import java.io.IOException;
2020
import java.time.DateTimeException;
21+
import java.time.Instant;
2122
import java.time.LocalDateTime;
2223
import java.time.format.DateTimeFormatter;
2324
import java.util.Objects;
@@ -27,10 +28,12 @@
2728
import com.fasterxml.jackson.core.JsonParser;
2829
import com.fasterxml.jackson.core.JsonToken;
2930
import com.fasterxml.jackson.core.JsonTokenId;
31+
import com.fasterxml.jackson.core.util.JacksonFeatureSet;
3032
import com.fasterxml.jackson.databind.BeanProperty;
3133
import com.fasterxml.jackson.databind.DeserializationContext;
3234
import com.fasterxml.jackson.databind.DeserializationFeature;
3335
import com.fasterxml.jackson.databind.JavaType;
36+
import com.fasterxml.jackson.datatype.jsr310.JavaTimeFeature;
3437

3538
/**
3639
* Deserializer for Java 8 temporal {@link LocalDateTime}s.
@@ -43,6 +46,9 @@ public class LocalDateTimeDeserializer
4346
{
4447
private static final long serialVersionUID = 1L;
4548

49+
private final static boolean DEFAULT_USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING
50+
= JavaTimeFeature.USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING.enabledByDefault();
51+
4652
private static final DateTimeFormatter DEFAULT_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
4753

4854
public static final LocalDateTimeDeserializer INSTANCE = new LocalDateTimeDeserializer();
@@ -54,13 +60,25 @@ public class LocalDateTimeDeserializer
5460
*/
5561
protected final Boolean _readTimestampsAsNanosOverride;
5662

63+
/**
64+
* Flag set from
65+
* {@link com.fasterxml.jackson.datatype.jsr310.JavaTimeFeature#USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING}
66+
* to determine whether the {@link java.util.TimeZone} of the
67+
* {@link com.fasterxml.jackson.databind.DeserializationContext} is used
68+
* when leniently deserializing from the UTC/ISO instant format.
69+
*
70+
* @since 2.19
71+
*/
72+
protected final boolean _useTimeZoneForLenientDateParsing;
73+
5774
protected LocalDateTimeDeserializer() { // was private before 2.12
5875
this(DEFAULT_FORMATTER);
5976
}
6077

6178
public LocalDateTimeDeserializer(DateTimeFormatter formatter) {
6279
super(LocalDateTime.class, formatter);
6380
_readTimestampsAsNanosOverride = null;
81+
_useTimeZoneForLenientDateParsing = DEFAULT_USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING;
6482
}
6583

6684
/**
@@ -69,6 +87,7 @@ public LocalDateTimeDeserializer(DateTimeFormatter formatter) {
6987
protected LocalDateTimeDeserializer(LocalDateTimeDeserializer base, Boolean leniency) {
7088
super(base, leniency);
7189
_readTimestampsAsNanosOverride = base._readTimestampsAsNanosOverride;
90+
_useTimeZoneForLenientDateParsing = base._useTimeZoneForLenientDateParsing;
7291
}
7392

7493
/**
@@ -81,6 +100,16 @@ protected LocalDateTimeDeserializer(LocalDateTimeDeserializer base,
81100
Boolean readTimestampsAsNanosOverride) {
82101
super(base, leniency, formatter, shape);
83102
_readTimestampsAsNanosOverride = readTimestampsAsNanosOverride;
103+
_useTimeZoneForLenientDateParsing = base._useTimeZoneForLenientDateParsing;
104+
}
105+
106+
/**
107+
* Since 2.19
108+
*/
109+
protected LocalDateTimeDeserializer(LocalDateTimeDeserializer base, JacksonFeatureSet<JavaTimeFeature> features) {
110+
super(LocalDateTime.class, base._formatter);
111+
_readTimestampsAsNanosOverride = base._readTimestampsAsNanosOverride;
112+
_useTimeZoneForLenientDateParsing = features.isEnabled(JavaTimeFeature.USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING);
84113
}
85114

86115
@Override
@@ -107,6 +136,17 @@ protected JSR310DateTimeDeserializerBase<?> _withFormatOverrides(Deserialization
107136
return deser;
108137
}
109138

139+
/**
140+
* Since 2.19
141+
*/
142+
public LocalDateTimeDeserializer withFeatures(JacksonFeatureSet<JavaTimeFeature> features) {
143+
if (_useTimeZoneForLenientDateParsing ==
144+
features.isEnabled(JavaTimeFeature.USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING)) {
145+
return this;
146+
}
147+
return new LocalDateTimeDeserializer(this, features);
148+
}
149+
110150
@Override
111151
public LocalDateTime deserialize(JsonParser parser, DeserializationContext context) throws IOException
112152
{
@@ -195,11 +235,12 @@ protected LocalDateTime _fromString(JsonParser p, DeserializationContext ctxt,
195235
if (_formatter == DEFAULT_FORMATTER) {
196236
// ... only allow iff lenient mode enabled since
197237
// JavaScript by default includes time and zone in JSON serialized Dates (UTC/ISO instant format).
198-
// And if so, do NOT use zoned date parsing as that can easily produce
199-
// incorrect answer.
200238
if (string.length() > 10 && string.charAt(10) == 'T') {
201239
if (string.endsWith("Z")) {
202240
if (isLenient()) {
241+
if (_useTimeZoneForLenientDateParsing) {
242+
return Instant.parse(string).atZone(ctxt.getTimeZone().toZoneId()).toLocalDateTime();
243+
}
203244
return LocalDateTime.parse(string.substring(0, string.length()-1),
204245
_formatter);
205246
}

datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/LocalDateDeserTest.java

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@
99
import java.time.format.DateTimeParseException;
1010
import java.time.temporal.Temporal;
1111
import java.util.Map;
12+
import java.util.TimeZone;
1213

1314
import org.junit.jupiter.api.Test;
15+
import org.junit.jupiter.params.ParameterizedTest;
16+
import org.junit.jupiter.params.provider.CsvSource;
1417

1518
import com.fasterxml.jackson.annotation.OptBoolean;
1619
import com.fasterxml.jackson.databind.cfg.CoercionAction;
@@ -28,6 +31,9 @@
2831
import com.fasterxml.jackson.databind.ObjectMapper;
2932
import com.fasterxml.jackson.databind.ObjectReader;
3033
import com.fasterxml.jackson.databind.exc.MismatchedInputException;
34+
import com.fasterxml.jackson.databind.json.JsonMapper;
35+
import com.fasterxml.jackson.datatype.jsr310.JavaTimeFeature;
36+
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
3137
import com.fasterxml.jackson.datatype.jsr310.MockObjectConfiguration;
3238
import com.fasterxml.jackson.datatype.jsr310.ModuleTestBase;
3339

@@ -37,6 +43,11 @@ public class LocalDateDeserTest extends ModuleTestBase
3743
{
3844
private final ObjectMapper MAPPER = newMapper();
3945
private final ObjectReader READER = MAPPER.readerFor(LocalDate.class);
46+
private final ObjectReader READER_USING_TIME_ZONE = JsonMapper.builder()
47+
.addModule(new JavaTimeModule().enable(JavaTimeFeature.USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING))
48+
.build()
49+
.readerFor(LocalDate.class);
50+
4051
private final TypeReference<Map<String, LocalDate>> MAP_TYPE_REF = new TypeReference<Map<String, LocalDate>>() { };
4152

4253
final static class Wrapper {
@@ -138,12 +149,42 @@ public void testDeserializationAsString02() throws Exception
138149
}
139150

140151
@Test
141-
public void testDeserializationAsString03() throws Exception
152+
public void testLenientDeserializationAsString01() throws Exception
153+
{
154+
Instant instant = Instant.now();
155+
LocalDate value = READER.readValue(q(instant.toString()));
156+
assertEquals(LocalDateTime.ofInstant(instant, ZoneOffset.UTC).toLocalDate(), value);
157+
}
158+
159+
@Test
160+
public void testLenientDeserializationAsString02() throws Exception
142161
{
162+
ObjectReader reader = READER.with(TimeZone.getTimeZone(Z_BUDAPEST));
143163
Instant instant = Instant.now();
144-
LocalDate value = READER.readValue('"' + instant.toString() + '"');
145-
assertEquals(LocalDateTime.ofInstant(instant, ZoneOffset.UTC).toLocalDate(),
146-
value);
164+
LocalDate value = reader.readValue(q(instant.toString()));
165+
assertEquals(LocalDateTime.ofInstant(instant, ZoneOffset.UTC).toLocalDate(), value);
166+
}
167+
168+
@Test
169+
public void testLenientDeserializationAsString03() throws Exception
170+
{
171+
Instant instant = Instant.now();
172+
LocalDate value = READER_USING_TIME_ZONE.readValue(q(instant.toString()));
173+
assertEquals(LocalDateTime.ofInstant(instant, ZoneOffset.UTC).toLocalDate(), value);
174+
}
175+
176+
@ParameterizedTest
177+
@CsvSource({
178+
"Europe/Budapest, 2024-07-21T21:59:59Z, 2024-07-21",
179+
"Europe/Budapest, 2024-07-21T22:00:00Z, 2024-07-22",
180+
"America/Chicago, 2024-07-22T04:59:59Z, 2024-07-21",
181+
"America/Chicago, 2024-07-22T05:00:00Z, 2024-07-22"
182+
})
183+
public void testLenientDeserializationAsString04(TimeZone zone, String string, LocalDate expected) throws Exception
184+
{
185+
ObjectReader reader = READER_USING_TIME_ZONE.with(zone);
186+
LocalDate value = reader.readValue(q(string));
187+
assertEquals(expected, value);
147188
}
148189

149190
@Test

datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/LocalDateTimeDeserTest.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
import java.util.TimeZone;
2828

2929
import org.junit.jupiter.api.Test;
30+
import org.junit.jupiter.params.ParameterizedTest;
31+
import org.junit.jupiter.params.provider.CsvSource;
3032

3133
import com.fasterxml.jackson.annotation.JsonFormat;
3234
import com.fasterxml.jackson.annotation.JsonFormat.Feature;
@@ -40,6 +42,9 @@
4042
import com.fasterxml.jackson.databind.deser.DeserializationProblemHandler;
4143
import com.fasterxml.jackson.databind.exc.InvalidFormatException;
4244
import com.fasterxml.jackson.databind.exc.MismatchedInputException;
45+
import com.fasterxml.jackson.databind.json.JsonMapper;
46+
import com.fasterxml.jackson.datatype.jsr310.JavaTimeFeature;
47+
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
4348
import com.fasterxml.jackson.datatype.jsr310.MockObjectConfiguration;
4449
import com.fasterxml.jackson.datatype.jsr310.ModuleTestBase;
4550

@@ -51,6 +56,11 @@ public class LocalDateTimeDeserTest
5156
private final static ObjectMapper MAPPER = newMapper();
5257
private final static ObjectReader READER = MAPPER.readerFor(LocalDateTime.class);
5358

59+
private final ObjectReader READER_USING_TIME_ZONE = JsonMapper.builder()
60+
.addModule(new JavaTimeModule().enable(JavaTimeFeature.USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING))
61+
.build()
62+
.readerFor(LocalDateTime.class);
63+
5464
private final static ObjectMapper STRICT_MAPPER;
5565
static {
5666
STRICT_MAPPER = newMapper();
@@ -272,6 +282,23 @@ public void testAllowZuluIfLenient() throws Exception
272282
assertEquals(EXP, value, "The value is not correct.");
273283
}
274284

285+
@ParameterizedTest
286+
@CsvSource({
287+
"UTC, 2020-10-22T04:16:20.504Z, 2020-10-22T04:16:20.504",
288+
"Europe/Budapest, 2020-10-22T04:16:20.504Z, 2020-10-22T06:16:20.504",
289+
"Europe/Budapest, 2020-10-25T00:16:20.504Z, 2020-10-25T02:16:20.504",
290+
"Europe/Budapest, 2020-10-25T01:16:20.504Z, 2020-10-25T02:16:20.504",
291+
"America/Chicago, 2020-10-22T04:16:20.504Z, 2020-10-21T23:16:20.504",
292+
"America/Chicago, 2020-11-01T06:16:20.504Z, 2020-11-01T01:16:20.504",
293+
"America/Chicago, 2020-11-01T07:16:20.504Z, 2020-11-01T01:16:20.504"
294+
})
295+
public void testUseTimeZoneForZuluIfEnabled(TimeZone zone, String string, LocalDateTime expected) throws Exception
296+
{
297+
ObjectReader reader = READER_USING_TIME_ZONE.with(zone);
298+
LocalDateTime value = reader.readValue(q(string));
299+
assertEquals(expected, value);
300+
}
301+
275302
// [modules-java#94]: "Z" offset not allowed if strict mode
276303
@Test
277304
public void testFailOnZuluIfStrict() throws Exception

0 commit comments

Comments
 (0)