diff --git a/datetime/README.md b/datetime/README.md index 4c8bc5ad..68faa0b8 100644 --- a/datetime/README.md +++ b/datetime/README.md @@ -47,6 +47,17 @@ times but are supported with this module nonetheless. [`LocalDateTime`](https://docs.oracle.com/javase/8/docs/api/java/time/LocalDateTime.html), and [`OffsetTime`](https://docs.oracle.com/javase/8/docs/api/java/time/OffsetTime.html), which cannot portably be converted to timestamps and are instead represented as arrays when `WRITE_DATES_AS_TIMESTAMPS` is enabled. +* [`Duration`](https://docs.oracle.com/javase/8/docs/api/java/time/Duration.html), which unit can be configured in `JsonFormat` using a [`ChronoUnit`](https://docs.oracle.com/javase/8/docs/api/java/time/temporal/ChronoUnit.html) as `pattern`. For instance: + ```java + @JsonFormat(pattern="MILLIS") + long millis; + + @JsonFormat(pattern="SECONDS") + long seconds; + + @JsonFormat(pattern="DAYS") + long days; + ``` ## Usage diff --git a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationDeserializer.java b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationDeserializer.java index ec1627d6..da32c955 100644 --- a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationDeserializer.java +++ b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationDeserializer.java @@ -34,6 +34,10 @@ import java.math.BigDecimal; import java.time.DateTimeException; import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalUnit; +import java.util.Optional; +import java.util.stream.Stream; /** @@ -49,6 +53,15 @@ public class DurationDeserializer extends JSR310DeserializerBase public static final DurationDeserializer INSTANCE = new DurationDeserializer(); + /** + * Since 2.12 + * When set, values will be deserialized using the specified unit. Using this parser will tipically + * override the value specified in {@link DeserializationFeature.READ_DATE_TIMESTAMPS_AS_NANOSECONDS} as it is + * considered that the unit set in {@link JsonFormat#pattern()} has precedence since is more specific. + * @see [jackson-modules-java8#184] for more info + */ + private DurationUnitParser _durationUnitParser; + private DurationDeserializer() { super(Duration.class); @@ -61,6 +74,11 @@ protected DurationDeserializer(DurationDeserializer base, Boolean leniency) { super(base, leniency); } + protected DurationDeserializer(DurationDeserializer base, DurationUnitParser durationUnitParser) { + super(base, base._isLenient); + _durationUnitParser = durationUnitParser; + } + @Override protected DurationDeserializer withLeniency(Boolean leniency) { return new DurationDeserializer(this, leniency); @@ -79,20 +97,36 @@ public JsonDeserializer createContextual(DeserializationContext ctxt, deser = deser.withLeniency(leniency); } } + if (format.hasPattern()) { + deser = DurationUnitParser.from(format.getPattern()) + .map(deser::withPattern) + .orElse(deser); + } } return deser; } + private DurationDeserializer withPattern(DurationUnitParser pattern) { + return new DurationDeserializer(this, pattern); + } + @Override public Duration deserialize(JsonParser parser, DeserializationContext context) throws IOException { switch (parser.currentTokenId()) { case JsonTokenId.ID_NUMBER_FLOAT: - BigDecimal value = parser.getDecimalValue(); - return DecimalUtils.extractSecondsAndNanos(value, Duration::ofSeconds); + BigDecimal decValue = parser.getDecimalValue(); + if (_durationUnitParser != null) { + return _durationUnitParser.parse(decValue.longValue()); + } + return DecimalUtils.extractSecondsAndNanos(decValue, Duration::ofSeconds); case JsonTokenId.ID_NUMBER_INT: - return _fromTimestamp(context, parser.getLongValue()); + long intValue = parser.getLongValue(); + if (_durationUnitParser != null) { + return _durationUnitParser.parse(intValue); + } + return _fromTimestamp(context, intValue); case JsonTokenId.ID_STRING: return _fromString(parser, context, parser.getText()); // 30-Sep-2020, tatu: New! "Scalar from Object" (mostly for XML) @@ -103,7 +137,7 @@ public Duration deserialize(JsonParser parser, DeserializationContext context) t // 20-Apr-2016, tatu: Related to [databind#1208], can try supporting embedded // values quite easily return (Duration) parser.getEmbeddedObject(); - + case JsonTokenId.ID_START_ARRAY: return _deserializeFromArray(parser, context); } @@ -121,6 +155,7 @@ protected Duration _fromString(JsonParser parser, DeserializationContext ctxt, } return null; } + // 30-Sep-2020: Should allow use of "Timestamp as String" for // some textual formats if (ctxt.isEnabled(StreamReadCapability.UNTYPED_SCALARS) @@ -129,9 +164,13 @@ && _isValidTimestampString(value)) { } try { + if (_durationUnitParser != null) { + return _durationUnitParser.parse(NumberInput.parseLong(value)); + } + return Duration.parse(value); - } catch (DateTimeException e) { - return _handleDateTimeException(ctxt, e, value); + } catch (NumberFormatException | DateTimeException e) { + return _handleWeirdStringValue(ctxt, e, value); } } @@ -141,4 +180,23 @@ protected Duration _fromTimestamp(DeserializationContext ctxt, long ts) { } return Duration.ofMillis(ts); } + + protected static class DurationUnitParser { + final TemporalUnit unit; + + DurationUnitParser(TemporalUnit unit) { + this.unit = unit; + } + + Duration parse(long value) { + return Duration.of(value, unit); + } + + static Optional from(String unit) { + return Stream.of(ChronoUnit.values()) + .filter(u -> u.toString().equalsIgnoreCase(unit)) + .map(DurationUnitParser::new) + .findFirst(); + } + } } diff --git a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/deser/JSR310DeserializerBase.java b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/deser/JSR310DeserializerBase.java index ec208b72..040a4452 100644 --- a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/deser/JSR310DeserializerBase.java +++ b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/deser/JSR310DeserializerBase.java @@ -141,6 +141,13 @@ protected BOGUS _reportWrongToken(JsonParser parser, DeserializationCont @SuppressWarnings("unchecked") protected R _handleDateTimeException(DeserializationContext context, DateTimeException e0, String value) throws JsonMappingException + { + return _handleWeirdStringValue(context, e0, value); + } + + @SuppressWarnings("unchecked") + protected R _handleWeirdStringValue(DeserializationContext context, + E e0, String value) throws JsonMappingException { try { return (R) context.handleWeirdStringValue(handledType(), value, diff --git a/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationDeserTest.java b/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationDeserTest.java index daea4b83..a33c2ed4 100644 --- a/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationDeserTest.java +++ b/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationDeserTest.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; import com.fasterxml.jackson.databind.exc.MismatchedInputException; import com.fasterxml.jackson.datatype.jsr310.MockObjectConfiguration; import com.fasterxml.jackson.datatype.jsr310.ModuleTestBase; @@ -29,6 +30,15 @@ public class DurationDeserTest extends ModuleTestBase private final TypeReference> MAP_TYPE_REF = new TypeReference>() { }; + final static class Wrapper { + @JsonFormat(pattern="HOURS") + public Duration value; + + public Wrapper() { } + public Wrapper(Duration v) { value = v; } + } + + @Test public void testDeserializationAsFloat01() throws Exception { @@ -420,4 +430,45 @@ public void testStrictDeserializeFromEmptyString() throws Exception { String valueFromEmptyStr = mapper.writeValueAsString(asMap(key, dateValAsEmptyStr)); objectReader.readValue(valueFromEmptyStr); } + + @Test + public void shouldDeserializeInHours_whenUnitAsPattern_andValueIsString() throws Exception { + ObjectMapper mapper = newMapper(); + ObjectReader reader = mapper.readerFor(MAP_TYPE_REF); + + Wrapper wrapper = reader.readValue("{\"value\":\"25\"}", Wrapper.class); + + assertEquals(Duration.ofHours(25), wrapper.value); + } + + @Test(expected = InvalidFormatException.class) + public void shouldHandleException_whenUsingUnitAsPattern_andValueIsString() throws Exception { + ObjectMapper mapper = newMapper(); + ObjectReader reader = mapper.readerFor(MAP_TYPE_REF); + + Wrapper wrapper = reader.readValue("{\"value\":\"FAIL\"}", Wrapper.class); + + assertEquals(Duration.ofHours(25), wrapper.value); + } + + @Test + public void shouldDeserializeInHours_whenUnitAsPattern_andValueIsInteger() throws Exception { + ObjectMapper mapper = newMapper(); + ObjectReader reader = mapper.readerFor(MAP_TYPE_REF); + + Wrapper wrapper = reader.readValue("{\"value\":25}", Wrapper.class); + + assertEquals(Duration.ofHours(25), wrapper.value); + } + + @Test + public void shouldDeserializeInHours_whenUnitAsPattern_andValueIsFloat() throws Exception { + ObjectMapper mapper = newMapper(); + ObjectReader reader = mapper.readerFor(MAP_TYPE_REF); + + Wrapper wrapper = reader.readValue("{\"value\":25.3}", Wrapper.class); + + assertEquals(Duration.ofHours(25), wrapper.value); + } + } diff --git a/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationUnitParserEmptyTest.java b/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationUnitParserEmptyTest.java new file mode 100644 index 00000000..efeb9fda --- /dev/null +++ b/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationUnitParserEmptyTest.java @@ -0,0 +1,30 @@ +package com.fasterxml.jackson.datatype.jsr310.deser; + +import com.fasterxml.jackson.datatype.jsr310.deser.DurationDeserializer.DurationUnitParser; +import org.junit.Test; + +import static java.util.Optional.empty; +import static org.junit.Assert.assertEquals; + +public class DurationUnitParserEmptyTest { + + @Test + public void shouldReturnEmpty_whenNull() { + assertEquals(empty(), DurationUnitParser.from(null)); + } + + @Test + public void shouldReturnEmpty_whenEmptyString() { + assertEquals(empty(), DurationUnitParser.from("")); + } + + @Test + public void shouldReturnEmpty_whenSpaces() { + assertEquals(empty(), DurationUnitParser.from(" ")); + } + + @Test + public void shouldReturnEmpty_whenDoesNotMatchAnyTemporalUnit() { + assertEquals(empty(), DurationUnitParser.from("DOESNOTMATCH")); + } +} \ No newline at end of file diff --git a/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationUnitParserTest.java b/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationUnitParserTest.java new file mode 100644 index 00000000..906daa9d --- /dev/null +++ b/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationUnitParserTest.java @@ -0,0 +1,77 @@ +package com.fasterxml.jackson.datatype.jsr310.deser; + +import com.fasterxml.jackson.datatype.jsr310.deser.DurationDeserializer.DurationUnitParser; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; + +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalUnit; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import static java.util.Arrays.asList; +import static java.util.Optional.of; +import static org.junit.Assert.assertEquals; + +@RunWith(Parameterized.class) +public class DurationUnitParserTest { + + private final String stringPattern; + private final TemporalUnit temporalUnit; + + public DurationUnitParserTest(String stringPattern, TemporalUnit temporalUnit) { + this.stringPattern = stringPattern; + this.temporalUnit = temporalUnit; + } + + @Test + public void shouldMapToTemporalUnit() { + Optional durationPattern = DurationUnitParser.from(stringPattern); + + assertEquals(of(temporalUnit), durationPattern.map(dp -> dp.unit)); + } + + @Parameters + public static Collection testCases() { + List baseTestCases = asList( + asArray("Nanos", ChronoUnit.NANOS), + asArray("Micros", ChronoUnit.MICROS), + asArray("Millis", ChronoUnit.MILLIS), + asArray("Seconds", ChronoUnit.SECONDS), + asArray("Minutes", ChronoUnit.MINUTES), + asArray("Hours", ChronoUnit.HOURS), + asArray("HalfDays", ChronoUnit.HALF_DAYS), + asArray("Days", ChronoUnit.DAYS), + asArray("Weeks", ChronoUnit.WEEKS), + asArray("Months", ChronoUnit.MONTHS), + asArray("Years", ChronoUnit.YEARS), + asArray("Decades", ChronoUnit.DECADES), + asArray("Centuries", ChronoUnit.CENTURIES), + asArray("Millennia", ChronoUnit.MILLENNIA), + asArray("Eras", ChronoUnit.ERAS), + asArray("Forever", ChronoUnit.FOREVER) + ); + + List lowerCaseTestCases = baseTestCases.stream() + .map(testCase -> asArray(testCase[0].toString().toLowerCase(), testCase[1])) + .collect(Collectors.toList()); + + List upperCaseTestCases = baseTestCases.stream() + .map(testCase -> asArray(testCase[0].toString().toUpperCase(), testCase[1])) + .collect(Collectors.toList()); + + List testCases = new ArrayList<>(baseTestCases); + testCases.addAll(lowerCaseTestCases); + testCases.addAll(upperCaseTestCases); + return testCases; + } + + private static Object[] asArray(Object... values) { + return values; + } +}