From 1754c4b9fbbe2d27e2adf0254c3607f966d665c1 Mon Sep 17 00:00:00 2001 From: Oriol Barcelona Date: Wed, 21 Oct 2020 01:06:24 +0200 Subject: [PATCH] Support for jsonformat in duration deserializer based on Duration::of(long,TemporalUnit). ref #184 (#187) --- datetime/README.md | 14 ++ .../jsr310/deser/DurationDeserializer.java | 73 +++++++- .../jsr310/deser/DurationDeserTest.java | 168 ++++++++++++++++-- .../deser/DurationUnitParserEmptyTest.java | 59 ++++++ .../jsr310/deser/DurationUnitParserTest.java | 53 ++++++ 5 files changed, 350 insertions(+), 17 deletions(-) create mode 100644 datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationUnitParserEmptyTest.java create mode 100644 datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationUnitParserTest.java diff --git a/datetime/README.md b/datetime/README.md index 4c8bc5ad..0f98f165 100644 --- a/datetime/README.md +++ b/datetime/README.md @@ -47,6 +47,20 @@ 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 subset of [`ChronoUnit`](https://docs.oracle.com/javase/8/docs/api/java/time/temporal/ChronoUnit.html) as `pattern`. +As the underlying implementation is based on `Duration::of` supported units are: `NANOS`, `MICROS`, `MILLIS`, `SECONDS`, `MINUTES`, `HOURS`, `HALF_DAYS` and `DAYS`. +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..1f768ed8 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,13 @@ 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.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; /** @@ -49,8 +56,17 @@ public class DurationDeserializer extends JSR310DeserializerBase public static final DurationDeserializer INSTANCE = new DurationDeserializer(); - private DurationDeserializer() - { + /** + * Since 2.12 + * When set, integer 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 +77,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,10 +100,19 @@ 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 { @@ -92,7 +122,11 @@ public Duration deserialize(JsonParser parser, DeserializationContext context) t BigDecimal value = parser.getDecimalValue(); return DecimalUtils.extractSecondsAndNanos(value, 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,9 +137,9 @@ 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); + return _deserializeFromArray(parser, context); } return _handleUnexpectedToken(context, parser, JsonToken.VALUE_STRING, JsonToken.VALUE_NUMBER_INT, JsonToken.VALUE_NUMBER_FLOAT); @@ -141,4 +175,33 @@ protected Duration _fromTimestamp(DeserializationContext ctxt, long ts) { } return Duration.ofMillis(ts); } + + protected static class DurationUnitParser { + final static Set PARSEABLE_UNITS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList( + ChronoUnit.NANOS, + ChronoUnit.MICROS, + ChronoUnit.MILLIS, + ChronoUnit.SECONDS, + ChronoUnit.MINUTES, + ChronoUnit.HOURS, + ChronoUnit.HALF_DAYS, + ChronoUnit.DAYS + ))); + final TemporalUnit unit; + + DurationUnitParser(TemporalUnit unit) { + this.unit = unit; + } + + Duration parse(long value) { + return Duration.of(value, unit); + } + + static Optional from(String unit) { + return PARSEABLE_UNITS.stream() + .filter(u -> u.name().equals(unit)) + .map(DurationUnitParser::new) + .findFirst(); + } + } } 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..ec2ffe41 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 @@ -1,34 +1,42 @@ package com.fasterxml.jackson.datatype.jsr310.deser; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; +import com.fasterxml.jackson.databind.exc.MismatchedInputException; +import com.fasterxml.jackson.datatype.jsr310.MockObjectConfiguration; +import com.fasterxml.jackson.datatype.jsr310.ModuleTestBase; +import org.junit.Test; + import java.math.BigInteger; import java.time.Duration; +import java.time.temporal.ChronoUnit; import java.time.temporal.TemporalAmount; import java.util.Map; -import com.fasterxml.jackson.annotation.JsonFormat; -import com.fasterxml.jackson.core.type.TypeReference; -import org.junit.Test; - import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.JsonMappingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.ObjectReader; -import com.fasterxml.jackson.databind.exc.MismatchedInputException; -import com.fasterxml.jackson.datatype.jsr310.MockObjectConfiguration; -import com.fasterxml.jackson.datatype.jsr310.ModuleTestBase; - public class DurationDeserTest extends ModuleTestBase { private final ObjectReader READER = newMapper().readerFor(Duration.class); private final TypeReference> MAP_TYPE_REF = new TypeReference>() { }; + final static class Wrapper { + public Duration value; + + public Wrapper() { } + public Wrapper(Duration v) { value = v; } + } + + @Test public void testDeserializationAsFloat01() throws Exception { @@ -420,4 +428,140 @@ public void testStrictDeserializeFromEmptyString() throws Exception { String valueFromEmptyStr = mapper.writeValueAsString(asMap(key, dateValAsEmptyStr)); objectReader.readValue(valueFromEmptyStr); } + + @Test + public void shouldDeserializeInNanos_whenNanosUnitAsPattern_andValueIsInteger() throws Exception { + ObjectMapper mapper = newMapper(); + mapper.configOverride(Duration.class) + .setFormat(JsonFormat.Value.forPattern("NANOS")); + ObjectReader reader = mapper.readerFor(MAP_TYPE_REF); + + Wrapper wrapper = reader.readValue(wrapperPayload(25), Wrapper.class); + + assertEquals(Duration.ofNanos(25), wrapper.value); + } + + @Test + public void shouldDeserializeInMicros_whenMicrosUnitAsPattern_andValueIsInteger() throws Exception { + ObjectMapper mapper = newMapper(); + mapper.configOverride(Duration.class) + .setFormat(JsonFormat.Value.forPattern("MICROS")); + ObjectReader reader = mapper.readerFor(MAP_TYPE_REF); + + Wrapper wrapper = reader.readValue(wrapperPayload(25), Wrapper.class); + + assertEquals(Duration.of(25, ChronoUnit.MICROS), wrapper.value); + } + + @Test + public void shouldDeserializeInMillis_whenMillisUnitAsPattern_andValueIsInteger() throws Exception { + ObjectMapper mapper = newMapper(); + mapper.configOverride(Duration.class) + .setFormat(JsonFormat.Value.forPattern("MILLIS")); + ObjectReader reader = mapper.readerFor(MAP_TYPE_REF); + + Wrapper wrapper = reader.readValue(wrapperPayload(25), Wrapper.class); + + assertEquals(Duration.ofMillis(25), wrapper.value); + } + + @Test + public void shouldDeserializeInSeconds_whenSecondsUnitAsPattern_andValueIsInteger() throws Exception { + ObjectMapper mapper = newMapper(); + mapper.configOverride(Duration.class) + .setFormat(JsonFormat.Value.forPattern("SECONDS")); + ObjectReader reader = mapper.readerFor(MAP_TYPE_REF); + + Wrapper wrapper = reader.readValue(wrapperPayload(25), Wrapper.class); + + assertEquals(Duration.ofSeconds(25), wrapper.value); + } + + @Test + public void shouldDeserializeInMinutes_whenMinutesUnitAsPattern_andValueIsInteger() throws Exception { + ObjectMapper mapper = newMapper(); + mapper.configOverride(Duration.class) + .setFormat(JsonFormat.Value.forPattern("MINUTES")); + ObjectReader reader = mapper.readerFor(MAP_TYPE_REF); + + Wrapper wrapper = reader.readValue(wrapperPayload(25), Wrapper.class); + + assertEquals(Duration.ofMinutes(25), wrapper.value); + } + + @Test + public void shouldDeserializeInHours_whenHoursUnitAsPattern_andValueIsInteger() throws Exception { + ObjectMapper mapper = newMapper(); + mapper.configOverride(Duration.class) + .setFormat(JsonFormat.Value.forPattern("HOURS")); + ObjectReader reader = mapper.readerFor(MAP_TYPE_REF); + + Wrapper wrapper = reader.readValue(wrapperPayload(25), Wrapper.class); + + assertEquals(Duration.ofHours(25), wrapper.value); + } + + @Test + public void shouldDeserializeInHalfDays_whenHalfDaysUnitAsPattern_andValueIsInteger() throws Exception { + ObjectMapper mapper = newMapper(); + mapper.configOverride(Duration.class) + .setFormat(JsonFormat.Value.forPattern("HALF_DAYS")); + ObjectReader reader = mapper.readerFor(MAP_TYPE_REF); + + Wrapper wrapper = reader.readValue(wrapperPayload(25), Wrapper.class); + + assertEquals(Duration.of(25, ChronoUnit.HALF_DAYS), wrapper.value); + } + + @Test + public void shouldDeserializeInDays_whenDaysUnitAsPattern_andValueIsInteger() throws Exception { + ObjectMapper mapper = newMapper(); + mapper.configOverride(Duration.class) + .setFormat(JsonFormat.Value.forPattern("DAYS")); + ObjectReader reader = mapper.readerFor(MAP_TYPE_REF); + + Wrapper wrapper = reader.readValue(wrapperPayload(25), Wrapper.class); + + assertEquals(Duration.ofDays(25), wrapper.value); + } + + @Test + public void shouldIgnoreUnitPattern_whenValueIsFloat() throws Exception { + ObjectMapper mapper = newMapper(); + mapper.configOverride(Duration.class) + .setFormat(JsonFormat.Value.forPattern("MINUTES")); + ObjectReader reader = mapper.readerFor(MAP_TYPE_REF); + + Wrapper wrapper = reader.readValue(wrapperPayload(25.5), Wrapper.class); + + assertEquals(Duration.parse("PT25.5S"), wrapper.value); + } + + @Test + public void shouldIgnoreUnitPattern_whenValueIsString() throws Exception { + ObjectMapper mapper = newMapper(); + mapper.configOverride(Duration.class) + .setFormat(JsonFormat.Value.forPattern("MINUTES")); + ObjectReader reader = mapper.readerFor(MAP_TYPE_REF); + + Wrapper wrapper = reader.readValue("{\"value\":\"PT25S\"}", Wrapper.class); + + assertEquals(Duration.parse("PT25S"), wrapper.value); + } + + @Test + public void shouldIgnoreUnitPattern_whenUnitPatternDoesNotMatchExactly() throws Exception { + ObjectMapper mapper = newMapper(); + mapper.configOverride(Duration.class) + .setFormat(JsonFormat.Value.forPattern("Nanos")); + ObjectReader reader = mapper.readerFor(MAP_TYPE_REF); + + Wrapper wrapper = reader.readValue(wrapperPayload(25), Wrapper.class); + + assertEquals(Duration.ofSeconds(25), wrapper.value); + } + + private String wrapperPayload(Number number) { + return "{\"value\":" + number + "}"; + } } 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..b476eba1 --- /dev/null +++ b/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationUnitParserEmptyTest.java @@ -0,0 +1,59 @@ +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.util.Collection; +import java.util.Optional; + +import static java.util.Arrays.asList; +import static java.util.Optional.empty; +import static org.junit.Assert.assertEquals; + +@RunWith(Parameterized.class) +public class DurationUnitParserEmptyTest { + + private final String stringPattern; + + public DurationUnitParserEmptyTest(String stringPattern) { + this.stringPattern = stringPattern; + } + + @Test + public void shouldNotMapToTemporalUnit() { + Optional durationPattern = DurationUnitParser.from(stringPattern); + + assertEquals(empty(), durationPattern); + } + + @Parameters + public static Collection testCases() { + return asList( + // Estimated units + asArray("WEEKS"), + asArray("MONTHS"), + asArray("YEARS"), + asArray("DECADES"), + asArray("CENTURIES"), + asArray("MILLENNIA"), + asArray("ERAS"), + asArray("FOREVER"), + // Is case sensitive + asArray("Nanos"), + asArray("nanos"), + // Not matching at all + asArray("DOESNOTMATCH"), + // Nilables + asArray(null), + asArray(""), + asArray(" ") + ); + } + + private static Object[] asArray(Object... values) { + return values; + } +} \ 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..3c065631 --- /dev/null +++ b/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationUnitParserTest.java @@ -0,0 +1,53 @@ +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.Collection; +import java.util.Optional; + +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() { + return 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("HALF_DAYS", ChronoUnit.HALF_DAYS), + asArray("DAYS", ChronoUnit.DAYS) + ); + } + + private static Object[] asArray(Object... values) { + return values; + } +}