diff --git a/datetime/src/main/java/tools/jackson/datatype/jsr310/JavaTimeFeature.java b/datetime/src/main/java/tools/jackson/datatype/jsr310/JavaTimeFeature.java index 42daac42..c2fc4473 100644 --- a/datetime/src/main/java/tools/jackson/datatype/jsr310/JavaTimeFeature.java +++ b/datetime/src/main/java/tools/jackson/datatype/jsr310/JavaTimeFeature.java @@ -19,6 +19,19 @@ public enum JavaTimeFeature implements JacksonFeature */ NORMALIZE_DESERIALIZED_ZONE_ID(true), + /** + * Feature that determines whether the {@link java.util.TimeZone} of the + * {@link tools.jackson.databind.DeserializationContext} is used + * when leniently deserializing {@link java.time.LocalDate} or + * {@link java.time.LocalDateTime} from the UTC/ISO instant format. + *

+ * Default setting is disabled, for backwards-compatibility with + * Jackson 2.18. + * + * @since 2.19 + */ + USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING(false), + /** * Feature that controls whether stringified numbers (Strings that without * quotes would be legal JSON Numbers) may be interpreted as diff --git a/datetime/src/main/java/tools/jackson/datatype/jsr310/JavaTimeModule.java b/datetime/src/main/java/tools/jackson/datatype/jsr310/JavaTimeModule.java index e22dc897..40fab5c6 100644 --- a/datetime/src/main/java/tools/jackson/datatype/jsr310/JavaTimeModule.java +++ b/datetime/src/main/java/tools/jackson/datatype/jsr310/JavaTimeModule.java @@ -124,8 +124,10 @@ public void setupModule(SetupContext context) { // // Other deserializers .addDeserializer(Duration.class, DurationDeserializer.INSTANCE) - .addDeserializer(LocalDateTime.class, LocalDateTimeDeserializer.INSTANCE) - .addDeserializer(LocalDate.class, LocalDateDeserializer.INSTANCE) + .addDeserializer(LocalDateTime.class, + LocalDateTimeDeserializer.INSTANCE.withFeatures(_features)) + .addDeserializer(LocalDate.class, + LocalDateDeserializer.INSTANCE.withFeatures(_features)) .addDeserializer(LocalTime.class, LocalTimeDeserializer.INSTANCE) .addDeserializer(MonthDay.class, MonthDayDeserializer.INSTANCE) .addDeserializer(OffsetTime.class, OffsetTimeDeserializer.INSTANCE) diff --git a/datetime/src/main/java/tools/jackson/datatype/jsr310/deser/LocalDateDeserializer.java b/datetime/src/main/java/tools/jackson/datatype/jsr310/deser/LocalDateDeserializer.java index 86844062..c641e8db 100644 --- a/datetime/src/main/java/tools/jackson/datatype/jsr310/deser/LocalDateDeserializer.java +++ b/datetime/src/main/java/tools/jackson/datatype/jsr310/deser/LocalDateDeserializer.java @@ -17,12 +17,14 @@ package tools.jackson.datatype.jsr310.deser; import java.time.DateTimeException; +import java.time.Instant; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import com.fasterxml.jackson.annotation.JsonFormat; import tools.jackson.core.*; +import tools.jackson.core.util.JacksonFeatureSet; import tools.jackson.databind.DeserializationContext; import tools.jackson.databind.DeserializationFeature; @@ -30,6 +32,8 @@ import tools.jackson.databind.cfg.CoercionAction; import tools.jackson.databind.cfg.CoercionInputShape; +import tools.jackson.datatype.jsr310.JavaTimeFeature; + /** * Deserializer for Java 8 temporal {@link LocalDate}s. * @@ -37,37 +41,52 @@ */ public class LocalDateDeserializer extends JSR310DateTimeDeserializerBase { + private final static boolean DEFAULT_USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING + = JavaTimeFeature.USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING.enabledByDefault(); + private static final DateTimeFormatter DEFAULT_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE; public static final LocalDateDeserializer INSTANCE = new LocalDateDeserializer(); + /** + * Flag set from + * {@link tools.jackson.datatype.jsr310.JavaTimeFeature#USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING} + * to determine whether the {@link java.util.TimeZone} of the + * {@link tools.jackson.databind.DeserializationContext} is used + * when leniently deserializing from the UTC/ISO instant format. + */ + protected final boolean _useTimeZoneForLenientDateParsing; + protected LocalDateDeserializer() { this(DEFAULT_FORMATTER); } public LocalDateDeserializer(DateTimeFormatter dtf) { super(LocalDate.class, dtf); + _useTimeZoneForLenientDateParsing = DEFAULT_USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING; } - /** - * Since 2.10 - */ public LocalDateDeserializer(LocalDateDeserializer base, DateTimeFormatter dtf) { super(base, dtf); + _useTimeZoneForLenientDateParsing = base._useTimeZoneForLenientDateParsing; } - /** - * Since 2.10 - */ protected LocalDateDeserializer(LocalDateDeserializer base, Boolean leniency) { super(base, leniency); + _useTimeZoneForLenientDateParsing = base._useTimeZoneForLenientDateParsing; } - /** - * Since 2.11 - */ protected LocalDateDeserializer(LocalDateDeserializer base, JsonFormat.Shape shape) { super(base, shape); + _useTimeZoneForLenientDateParsing = base._useTimeZoneForLenientDateParsing; + } + + /** + * Since 2.19 + */ + protected LocalDateDeserializer(LocalDateDeserializer base, JacksonFeatureSet features) { + super(LocalDate.class, base._formatter); + _useTimeZoneForLenientDateParsing = features.isEnabled(JavaTimeFeature.USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING); } @Override @@ -83,6 +102,17 @@ protected LocalDateDeserializer withLeniency(Boolean leniency) { @Override protected LocalDateDeserializer withShape(JsonFormat.Shape shape) { return new LocalDateDeserializer(this, shape); } + /** + * Since 2.19 + */ + public LocalDateDeserializer withFeatures(JacksonFeatureSet features) { + if (_useTimeZoneForLenientDateParsing == + features.isEnabled(JavaTimeFeature.USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING)) { + return this; + } + return new LocalDateDeserializer(this, features); + } + @Override public LocalDate deserialize(JsonParser parser, DeserializationContext context) throws JacksonException @@ -162,6 +192,9 @@ protected LocalDate _fromString(JsonParser p, DeserializationContext ctxt, if (string.length() > 10 && string.charAt(10) == 'T') { if (isLenient()) { if (string.endsWith("Z")) { + if (_useTimeZoneForLenientDateParsing) { + return Instant.parse(string).atZone(ctxt.getTimeZone().toZoneId()).toLocalDate(); + } return LocalDate.parse(string.substring(0, string.length() - 1), DateTimeFormatter.ISO_LOCAL_DATE_TIME); } diff --git a/datetime/src/main/java/tools/jackson/datatype/jsr310/deser/LocalDateTimeDeserializer.java b/datetime/src/main/java/tools/jackson/datatype/jsr310/deser/LocalDateTimeDeserializer.java index 9602a225..7e2c80d5 100644 --- a/datetime/src/main/java/tools/jackson/datatype/jsr310/deser/LocalDateTimeDeserializer.java +++ b/datetime/src/main/java/tools/jackson/datatype/jsr310/deser/LocalDateTimeDeserializer.java @@ -17,20 +17,17 @@ package tools.jackson.datatype.jsr310.deser; import java.time.DateTimeException; +import java.time.Instant; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Objects; import com.fasterxml.jackson.annotation.JsonFormat; -import tools.jackson.core.JacksonException; -import tools.jackson.core.JsonParser; -import tools.jackson.core.JsonToken; -import tools.jackson.core.JsonTokenId; -import tools.jackson.databind.BeanProperty; -import tools.jackson.databind.DeserializationContext; -import tools.jackson.databind.DeserializationFeature; -import tools.jackson.databind.JavaType; +import tools.jackson.core.*; +import tools.jackson.core.util.JacksonFeatureSet; +import tools.jackson.databind.*; +import tools.jackson.datatype.jsr310.JavaTimeFeature; /** * Deserializer for Java 8 temporal {@link LocalDateTime}s. @@ -40,6 +37,9 @@ public class LocalDateTimeDeserializer extends JSR310DateTimeDeserializerBase { + private final static boolean DEFAULT_USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING + = JavaTimeFeature.USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING.enabledByDefault(); + private static final DateTimeFormatter DEFAULT_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME; public static final LocalDateTimeDeserializer INSTANCE = new LocalDateTimeDeserializer(); @@ -51,6 +51,17 @@ public class LocalDateTimeDeserializer */ protected final Boolean _readTimestampsAsNanosOverride; + /** + * Flag set from + * {@link tools.jackson.datatype.jsr310.JavaTimeFeature#USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING} + * to determine whether the {@link java.util.TimeZone} of the + * {@link tools.jackson.databind.DeserializationContext} is used + * when leniently deserializing from the UTC/ISO instant format. + * + * @since 2.19 + */ + protected final boolean _useTimeZoneForLenientDateParsing; + protected LocalDateTimeDeserializer() { // was private before 2.12 this(DEFAULT_FORMATTER); } @@ -58,6 +69,7 @@ protected LocalDateTimeDeserializer() { // was private before 2.12 public LocalDateTimeDeserializer(DateTimeFormatter formatter) { super(LocalDateTime.class, formatter); _readTimestampsAsNanosOverride = null; + _useTimeZoneForLenientDateParsing = DEFAULT_USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING; } /** @@ -66,6 +78,7 @@ public LocalDateTimeDeserializer(DateTimeFormatter formatter) { protected LocalDateTimeDeserializer(LocalDateTimeDeserializer base, Boolean leniency) { super(base, leniency); _readTimestampsAsNanosOverride = base._readTimestampsAsNanosOverride; + _useTimeZoneForLenientDateParsing = base._useTimeZoneForLenientDateParsing; } /** @@ -78,6 +91,16 @@ protected LocalDateTimeDeserializer(LocalDateTimeDeserializer base, Boolean readTimestampsAsNanosOverride) { super(base, leniency, formatter, shape); _readTimestampsAsNanosOverride = readTimestampsAsNanosOverride; + _useTimeZoneForLenientDateParsing = base._useTimeZoneForLenientDateParsing; + } + + /** + * Since 2.19 + */ + protected LocalDateTimeDeserializer(LocalDateTimeDeserializer base, JacksonFeatureSet features) { + super(LocalDateTime.class, base._formatter); + _readTimestampsAsNanosOverride = base._readTimestampsAsNanosOverride; + _useTimeZoneForLenientDateParsing = features.isEnabled(JavaTimeFeature.USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING); } @Override @@ -105,6 +128,17 @@ protected JSR310DateTimeDeserializerBase _withFormatOverrides(Deserialization return deser; } + /** + * Since 2.19 + */ + public LocalDateTimeDeserializer withFeatures(JacksonFeatureSet features) { + if (_useTimeZoneForLenientDateParsing == + features.isEnabled(JavaTimeFeature.USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING)) { + return this; + } + return new LocalDateTimeDeserializer(this, features); + } + @Override public LocalDateTime deserialize(JsonParser parser, DeserializationContext context) throws JacksonException { @@ -195,11 +229,12 @@ protected LocalDateTime _fromString(JsonParser p, DeserializationContext ctxt, if (_formatter == DEFAULT_FORMATTER) { // ... only allow iff lenient mode enabled since // JavaScript by default includes time and zone in JSON serialized Dates (UTC/ISO instant format). - // And if so, do NOT use zoned date parsing as that can easily produce - // incorrect answer. if (string.length() > 10 && string.charAt(10) == 'T') { if (string.endsWith("Z")) { if (isLenient()) { + if (_useTimeZoneForLenientDateParsing) { + return Instant.parse(string).atZone(ctxt.getTimeZone().toZoneId()).toLocalDateTime(); + } return LocalDateTime.parse(string.substring(0, string.length()-1), _formatter); } diff --git a/datetime/src/main/java/tools/jackson/datatype/jsr310/ser/JSR310FormattedSerializerBase.java b/datetime/src/main/java/tools/jackson/datatype/jsr310/ser/JSR310FormattedSerializerBase.java index 23277948..20bea366 100644 --- a/datetime/src/main/java/tools/jackson/datatype/jsr310/ser/JSR310FormattedSerializerBase.java +++ b/datetime/src/main/java/tools/jackson/datatype/jsr310/ser/JSR310FormattedSerializerBase.java @@ -210,7 +210,6 @@ protected boolean useTimestamp(SerializationContext ctxt) { return (_formatter == null) && useTimestampFromGlobalDefaults(ctxt); } - // @since 2.19 protected boolean useTimestampFromGlobalDefaults(SerializationContext ctxt) { return (ctxt != null) && ctxt.isEnabled(getTimestampsFeature()); diff --git a/datetime/src/test/java/tools/jackson/datatype/jsr310/deser/LocalDateDeserTest.java b/datetime/src/test/java/tools/jackson/datatype/jsr310/deser/LocalDateDeserTest.java index b6ce67e3..5ca91351 100644 --- a/datetime/src/test/java/tools/jackson/datatype/jsr310/deser/LocalDateDeserTest.java +++ b/datetime/src/test/java/tools/jackson/datatype/jsr310/deser/LocalDateDeserTest.java @@ -3,8 +3,11 @@ import java.time.*; import java.time.temporal.Temporal; import java.util.Map; +import java.util.TimeZone; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import com.fasterxml.jackson.annotation.OptBoolean; @@ -18,7 +21,9 @@ import tools.jackson.databind.*; import tools.jackson.databind.exc.MismatchedInputException; - +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.datatype.jsr310.JavaTimeFeature; +import tools.jackson.datatype.jsr310.JavaTimeModule; import tools.jackson.datatype.jsr310.MockObjectConfiguration; import tools.jackson.datatype.jsr310.ModuleTestBase; @@ -28,6 +33,11 @@ public class LocalDateDeserTest extends ModuleTestBase { private final ObjectMapper MAPPER = newMapper(); private final ObjectReader READER = MAPPER.readerFor(LocalDate.class); + private final ObjectReader READER_USING_TIME_ZONE = JsonMapper.builder() + .addModule(new JavaTimeModule().enable(JavaTimeFeature.USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING)) + .build() + .readerFor(LocalDate.class); + private final TypeReference> MAP_TYPE_REF = new TypeReference>() { }; final static class Wrapper { @@ -120,12 +130,42 @@ public void testDeserializationAsString02() } @Test - public void testDeserializationAsString03() + public void testLenientDeserializationAsString01() throws Exception + { + Instant instant = Instant.now(); + LocalDate value = READER.readValue(q(instant.toString())); + assertEquals(LocalDateTime.ofInstant(instant, ZoneOffset.UTC).toLocalDate(), value); + } + + @Test + public void testLenientDeserializationAsString02() throws Exception { + ObjectReader reader = READER.with(TimeZone.getTimeZone(Z_BUDAPEST)); Instant instant = Instant.now(); - LocalDate value = READER.readValue('"' + instant.toString() + '"'); - assertEquals(LocalDateTime.ofInstant(instant, ZoneOffset.UTC).toLocalDate(), - value); + LocalDate value = reader.readValue(q(instant.toString())); + assertEquals(LocalDateTime.ofInstant(instant, ZoneOffset.UTC).toLocalDate(), value); + } + + @Test + public void testLenientDeserializationAsString03() throws Exception + { + Instant instant = Instant.now(); + LocalDate value = READER_USING_TIME_ZONE.readValue(q(instant.toString())); + assertEquals(LocalDateTime.ofInstant(instant, ZoneOffset.UTC).toLocalDate(), value); + } + + @ParameterizedTest + @CsvSource({ + "Europe/Budapest, 2024-07-21T21:59:59Z, 2024-07-21", + "Europe/Budapest, 2024-07-21T22:00:00Z, 2024-07-22", + "America/Chicago, 2024-07-22T04:59:59Z, 2024-07-21", + "America/Chicago, 2024-07-22T05:00:00Z, 2024-07-22" + }) + public void testLenientDeserializationAsString04(TimeZone zone, String string, LocalDate expected) throws Exception + { + ObjectReader reader = READER_USING_TIME_ZONE.with(zone); + LocalDate value = reader.readValue(q(string)); + assertEquals(expected, value); } @Test diff --git a/datetime/src/test/java/tools/jackson/datatype/jsr310/deser/LocalDateTimeDeserTest.java b/datetime/src/test/java/tools/jackson/datatype/jsr310/deser/LocalDateTimeDeserTest.java index 411c8bdb..d772a9c8 100644 --- a/datetime/src/test/java/tools/jackson/datatype/jsr310/deser/LocalDateTimeDeserTest.java +++ b/datetime/src/test/java/tools/jackson/datatype/jsr310/deser/LocalDateTimeDeserTest.java @@ -23,6 +23,8 @@ import java.util.*; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat.Feature; @@ -36,6 +38,9 @@ import tools.jackson.databind.deser.DeserializationProblemHandler; import tools.jackson.databind.exc.InvalidFormatException; import tools.jackson.databind.exc.MismatchedInputException; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.datatype.jsr310.JavaTimeFeature; +import tools.jackson.datatype.jsr310.JavaTimeModule; import tools.jackson.datatype.jsr310.MockObjectConfiguration; import tools.jackson.datatype.jsr310.ModuleTestBase; @@ -52,6 +57,11 @@ public class LocalDateTimeDeserTest c -> c.setFormat(JsonFormat.Value.forLeniency(false))) .build(); + private final ObjectReader READER_USING_TIME_ZONE = JsonMapper.builder() + .addModule(new JavaTimeModule().enable(JavaTimeFeature.USE_TIME_ZONE_FOR_LENIENT_DATE_PARSING)) + .build() + .readerFor(LocalDateTime.class); + private final TypeReference> MAP_TYPE_REF = new TypeReference>() { }; final static class StrictWrapper { @@ -266,6 +276,23 @@ public void testAllowZuluIfLenient() assertEquals(EXP, value, "The value is not correct."); } + @ParameterizedTest + @CsvSource({ + "UTC, 2020-10-22T04:16:20.504Z, 2020-10-22T04:16:20.504", + "Europe/Budapest, 2020-10-22T04:16:20.504Z, 2020-10-22T06:16:20.504", + "Europe/Budapest, 2020-10-25T00:16:20.504Z, 2020-10-25T02:16:20.504", + "Europe/Budapest, 2020-10-25T01:16:20.504Z, 2020-10-25T02:16:20.504", + "America/Chicago, 2020-10-22T04:16:20.504Z, 2020-10-21T23:16:20.504", + "America/Chicago, 2020-11-01T06:16:20.504Z, 2020-11-01T01:16:20.504", + "America/Chicago, 2020-11-01T07:16:20.504Z, 2020-11-01T01:16:20.504" + }) + public void testUseTimeZoneForZuluIfEnabled(TimeZone zone, String string, LocalDateTime expected) throws Exception + { + ObjectReader reader = READER_USING_TIME_ZONE.with(zone); + LocalDateTime value = reader.readValue(q(string)); + assertEquals(expected, value); + } + // [modules-java#94]: "Z" offset not allowed if strict mode @Test public void testFailOnZuluIfStrict() diff --git a/release-notes/CREDITS-2.x b/release-notes/CREDITS-2.x index 453c34f3..a86640e2 100644 --- a/release-notes/CREDITS-2.x +++ b/release-notes/CREDITS-2.x @@ -213,3 +213,7 @@ Joey Muia (@jmuia) * Reported #337: Negative `Duration` does not round-trip properly with `WRITE_DURATIONS_AS_TIMESTAMPS` enabled (2.19.0) + +Henning Pƶttker (@ hpoettker) + * Contributed #342: Lenient deserialization of `LocalDate` is not time-zone aware + (2.19.0) diff --git a/release-notes/VERSION-2.x b/release-notes/VERSION-2.x index 695af44d..676ba221 100644 --- a/release-notes/VERSION-2.x +++ b/release-notes/VERSION-2.x @@ -16,6 +16,9 @@ Modules: `WRITE_DURATIONS_AS_TIMESTAMPS` enabled (reported by Joey M) (fix by Joo-Hyuk K) +#342: Lenient deserialization of `LocalDate`, `LocalDateTime` + is not time-zone aware + (contributed by Henning P) 2.18.3 (not yet released)