From e404c02f21fb578b8cf47afca17cba61d17594d7 Mon Sep 17 00:00:00 2001 From: Oriol Barcelona Date: Thu, 29 Oct 2020 06:26:28 +0100 Subject: [PATCH] Support pattern for duration serializer (#194) * Extract duration unit converter to util package. ref #189 * Support unit pattern in duration serializer. ref #189 --- .../jsr310/deser/DurationDeserializer.java | 54 +----- .../jsr310/ser/DurationSerializer.java | 83 ++++++-- .../ser/JSR310FormattedSerializerBase.java | 33 ++-- .../jsr310/util/DurationUnitConverter.java | 82 ++++++++ .../datatype/jsr310/ser/DurationSerTest.java | 182 ++++++++++++++++++ .../DurationUnitConverterTest.java | 5 +- 6 files changed, 358 insertions(+), 81 deletions(-) create mode 100644 datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/util/DurationUnitConverter.java rename datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/{deser => util}/DurationUnitConverterTest.java (86%) 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 23fca40d..48d01845 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 @@ -36,6 +36,7 @@ import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.databind.deser.ContextualDeserializer; import com.fasterxml.jackson.datatype.jsr310.DecimalUtils; +import com.fasterxml.jackson.datatype.jsr310.util.DurationUnitConverter; /** * Deserializer for Java 8 temporal {@link Duration}s. @@ -90,8 +91,8 @@ protected DurationDeserializer withLeniency(Boolean leniency) { return new DurationDeserializer(this, leniency); } - protected DurationDeserializer withConverter(DurationUnitConverter pattern) { - return new DurationDeserializer(this, pattern); + protected DurationDeserializer withConverter(DurationUnitConverter converter) { + return new DurationDeserializer(this, converter); } @Override @@ -113,8 +114,8 @@ public JsonDeserializer createContextual(DeserializationContext ctxt, if (p == null) { ctxt.reportBadDefinition(getValueType(ctxt), String.format( -"Bad 'pattern' definition (\"%s\") for `Duration`: expected one of [%s]", -pattern, DurationUnitConverter.descForAllowed())); + "Bad 'pattern' definition (\"%s\") for `Duration`: expected one of [%s]", + pattern, DurationUnitConverter.descForAllowed())); } deser = deser.withConverter(p); } @@ -185,49 +186,4 @@ protected Duration _fromTimestamp(DeserializationContext ctxt, long ts) { } return Duration.ofMillis(ts); } - - protected static class DurationUnitConverter { - private final static Map PARSEABLE_UNITS; - static { - Map units = new LinkedHashMap<>(); - for (ChronoUnit unit : new ChronoUnit[] { - ChronoUnit.NANOS, - ChronoUnit.MICROS, - ChronoUnit.MILLIS, - ChronoUnit.SECONDS, - ChronoUnit.MINUTES, - ChronoUnit.HOURS, - ChronoUnit.HALF_DAYS, - ChronoUnit.DAYS - }) { - units.put(unit.name(), unit); - } - PARSEABLE_UNITS = units; - } - - final TemporalUnit unit; - - DurationUnitConverter(TemporalUnit unit) { - this.unit = unit; - } - - public Duration convert(long value) { - return Duration.of(value, unit); - } - - /** - * @return Description of all allowed valued as a sequence of - * double-quoted values separated by comma - */ - public static String descForAllowed() { - return "\"" + PARSEABLE_UNITS.keySet().stream() - .collect(Collectors.joining("\", \"")) - +"\""; - } - - static DurationUnitConverter from(String unit) { - ChronoUnit chr = PARSEABLE_UNITS.get(unit); - return (chr == null) ? null : new DurationUnitConverter(chr); - } - } } diff --git a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/DurationSerializer.java b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/DurationSerializer.java index c7a08df1..c7d909a7 100644 --- a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/DurationSerializer.java +++ b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/DurationSerializer.java @@ -21,14 +21,17 @@ import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.BeanProperty; import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonFormatVisitorWrapper; import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonIntegerFormatVisitor; import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonValueFormat; import com.fasterxml.jackson.datatype.jsr310.DecimalUtils; +import com.fasterxml.jackson.datatype.jsr310.util.DurationUnitConverter; import java.io.IOException; import java.math.BigDecimal; @@ -52,6 +55,16 @@ public class DurationSerializer extends JSR310FormattedSerializerBase public static final DurationSerializer INSTANCE = new DurationSerializer(); + /** + * When defined (not {@code null}) duration values will be converted into integers + * with the unit configured for the converter. + * Only available when {@link SerializationFeature#WRITE_DURATIONS_AS_TIMESTAMPS} is enabled + * and {@link SerializationFeature#WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS} is not enabled + * since the duration converters do not support fractions + * @since 2.12 + */ + private DurationUnitConverter _durationUnitConverter; + private DurationSerializer() { super(Duration.class); } @@ -66,45 +79,80 @@ protected DurationSerializer(DurationSerializer base, super(base, useTimestamp, useNanoseconds, dtf, null); } + protected DurationSerializer(DurationSerializer base, DurationUnitConverter converter) { + super(base, base._useTimestamp, base._useNanoseconds, base._formatter, base._shape); + _durationUnitConverter = converter; + } + @Override protected DurationSerializer withFormat(Boolean useTimestamp, DateTimeFormatter dtf, JsonFormat.Shape shape) { return new DurationSerializer(this, useTimestamp, dtf); } + protected DurationSerializer withConverter(DurationUnitConverter converter) { + return new DurationSerializer(this, converter); + } + // @since 2.10 @Override protected SerializationFeature getTimestampsFeature() { return SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS; } + @Override + public JsonSerializer createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException { + DurationSerializer ser = (DurationSerializer) super.createContextual(prov, property); + JsonFormat.Value format = findFormatOverrides(prov, property, handledType()); + if (format != null && format.hasPattern()) { + final String pattern = format.getPattern(); + DurationUnitConverter p = DurationUnitConverter.from(pattern); + if (p == null) { + prov.reportBadDefinition(handledType(), + String.format( + "Bad 'pattern' definition (\"%s\") for `Duration`: expected one of [%s]", + pattern, DurationUnitConverter.descForAllowed())); + } + + ser = ser.withConverter(p); + } + return ser; + } + @Override public void serialize(Duration duration, JsonGenerator generator, SerializerProvider provider) throws IOException { if (useTimestamp(provider)) { if (useNanoseconds(provider)) { - // 20-Oct-2020, tatu: [modules-java8#165] Need to take care of - // negative values too, and without work-around values - // returned are wonky wrt conversions - BigDecimal bd; - if (duration.isNegative()) { - duration = duration.abs(); - bd = DecimalUtils.toBigDecimal(duration.getSeconds(), - duration.getNano()) - .negate(); + generator.writeNumber(_toNanos(duration)); + } else { + if (_durationUnitConverter != null) { + generator.writeNumber(_durationUnitConverter.convert(duration)); } else { - bd = DecimalUtils.toBigDecimal(duration.getSeconds(), - duration.getNano()); + generator.writeNumber(duration.toMillis()); } - generator.writeNumber(bd); - } else { - generator.writeNumber(duration.toMillis()); } } else { - // Does not look like we can make any use of DateTimeFormatter here? generator.writeString(duration.toString()); } } + // 20-Oct-2020, tatu: [modules-java8#165] Need to take care of + // negative values too, and without work-around values + // returned are wonky wrt conversions + private BigDecimal _toNanos(Duration duration) { + BigDecimal bd; + if (duration.isNegative()) { + duration = duration.abs(); + bd = DecimalUtils.toBigDecimal(duration.getSeconds(), + duration.getNano()) + .negate(); + } else { + bd = DecimalUtils.toBigDecimal(duration.getSeconds(), + duration.getNano()); + } + return bd; + } + @Override protected void _acceptTimestampVisitor(JsonFormatVisitorWrapper visitor, JavaType typeHint) throws JsonMappingException { @@ -135,4 +183,9 @@ protected JsonToken serializationShape(SerializerProvider provider) { protected JSR310FormattedSerializerBase withFeatures(Boolean writeZoneId, Boolean writeNanoseconds) { return new DurationSerializer(this, _useTimestamp, writeNanoseconds, _formatter); } + + @Override + protected DateTimeFormatter _useDateTimeFormatter(SerializerProvider prov, JsonFormat.Value format) { + return null; + } } diff --git a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/JSR310FormattedSerializerBase.java b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/JSR310FormattedSerializerBase.java index 631c7547..bd2f49f8 100644 --- a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/JSR310FormattedSerializerBase.java +++ b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/ser/JSR310FormattedSerializerBase.java @@ -144,18 +144,7 @@ public JsonSerializer createContextual(SerializerProvider prov, // If not, do we have a pattern? if (format.hasPattern()) { - final String pattern = format.getPattern(); - final Locale locale = format.hasLocale() ? format.getLocale() : prov.getLocale(); - if (locale == null) { - dtf = DateTimeFormatter.ofPattern(pattern); - } else { - dtf = DateTimeFormatter.ofPattern(pattern, locale); - } - //Issue #69: For instant serializers/deserializers we need to configure the formatter with - //a time zone picked up from JsonFormat annotation, otherwise serialization might not work - if (format.hasTimeZone()) { - dtf = dtf.withZone(format.getTimeZone().toZoneId()); - } + dtf = _useDateTimeFormatter(prov, format); } JSR310FormattedSerializerBase ser = this; if ((shape != _shape) || (useTimestamp != _useTimestamp) || (dtf != _formatter)) { @@ -211,7 +200,7 @@ protected JavaType _integerListType(SerializerProvider prov) { } return t; } - + /** * Overridable method that determines {@link SerializationFeature} that is used as * the global default in determining if date/time value serialized should use numeric @@ -265,4 +254,22 @@ protected boolean useNanoseconds(SerializerProvider provider) { return (provider != null) && provider.isEnabled(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS); } + + // modules-java8#189: to be overridden by other formatters using this as base class + protected DateTimeFormatter _useDateTimeFormatter(SerializerProvider prov, JsonFormat.Value format) { + DateTimeFormatter dtf; + final String pattern = format.getPattern(); + final Locale locale = format.hasLocale() ? format.getLocale() : prov.getLocale(); + if (locale == null) { + dtf = DateTimeFormatter.ofPattern(pattern); + } else { + dtf = DateTimeFormatter.ofPattern(pattern, locale); + } + //Issue #69: For instant serializers/deserializers we need to configure the formatter with + //a time zone picked up from JsonFormat annotation, otherwise serialization might not work + if (format.hasTimeZone()) { + dtf = dtf.withZone(format.getTimeZone().toZoneId()); + } + return dtf; + } } diff --git a/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/util/DurationUnitConverter.java b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/util/DurationUnitConverter.java new file mode 100644 index 00000000..9dac99b3 --- /dev/null +++ b/datetime/src/main/java/com/fasterxml/jackson/datatype/jsr310/util/DurationUnitConverter.java @@ -0,0 +1,82 @@ +package com.fasterxml.jackson.datatype.jsr310.util; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalUnit; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static com.fasterxml.jackson.datatype.jsr310.util.DurationUnitConverter.DurationSerialization.deserializer; + +/** + * Handles the conversion of the duration based on the API of {@link Duration} for a restricted set of {@link ChronoUnit}. + * Only the units considered as accurate are supported in this converter since are the only ones capable of handling + * deserialization in a precise manner (see {@link ChronoUnit#isDurationEstimated}). + * + * @since 2.12 + */ +public class DurationUnitConverter { + + protected static class DurationSerialization { + final Function serializer; + final Function deserializer; + + DurationSerialization( + Function serializer, + Function deserializer) { + this.serializer = serializer; + this.deserializer = deserializer; + } + + static Function deserializer(TemporalUnit unit) { + return v -> Duration.of(v, unit); + } + } + + private final static Map UNITS; + + static { + Map units = new LinkedHashMap<>(); + units.put(ChronoUnit.NANOS.name(), new DurationSerialization(Duration::toNanos, deserializer(ChronoUnit.NANOS))); + units.put(ChronoUnit.MICROS.name(), new DurationSerialization(d -> d.toNanos() / 1000, deserializer(ChronoUnit.MICROS))); + units.put(ChronoUnit.MILLIS.name(), new DurationSerialization(Duration::toMillis, deserializer(ChronoUnit.MILLIS))); + units.put(ChronoUnit.SECONDS.name(), new DurationSerialization(Duration::getSeconds, deserializer(ChronoUnit.SECONDS))); + units.put(ChronoUnit.MINUTES.name(), new DurationSerialization(Duration::toMinutes, deserializer(ChronoUnit.MINUTES))); + units.put(ChronoUnit.HOURS.name(), new DurationSerialization(Duration::toHours, deserializer(ChronoUnit.HOURS))); + units.put(ChronoUnit.HALF_DAYS.name(), new DurationSerialization(d -> d.toHours() / 12, deserializer(ChronoUnit.HALF_DAYS))); + units.put(ChronoUnit.DAYS.name(), new DurationSerialization(Duration::toDays, deserializer(ChronoUnit.DAYS))); + UNITS = units; + } + + + final DurationSerialization serialization; + + DurationUnitConverter(DurationSerialization serialization) { + this.serialization = serialization; + } + + public Duration convert(long value) { + return serialization.deserializer.apply(value); + } + + public long convert(Duration duration) { + return serialization.serializer.apply(duration); + } + + /** + * @return Description of all allowed valued as a sequence of + * double-quoted values separated by comma + */ + public static String descForAllowed() { + return "\"" + UNITS.keySet().stream() + .collect(Collectors.joining("\", \"")) + + "\""; + } + + public static DurationUnitConverter from(String unit) { + DurationSerialization def = UNITS.get(unit); + return (def == null) ? null : new DurationUnitConverter(def); + } +} diff --git a/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/ser/DurationSerTest.java b/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/ser/DurationSerTest.java index 52c5bc28..a418f70c 100644 --- a/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/ser/DurationSerTest.java +++ b/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/ser/DurationSerTest.java @@ -1,5 +1,6 @@ package com.fasterxml.jackson.datatype.jsr310.ser; +import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectWriter; import com.fasterxml.jackson.databind.SerializationFeature; @@ -161,4 +162,185 @@ public void testSerializationWithTypeInfo03() throws Exception assertEquals("The value is not correct.", "[\"" + Duration.class.getName() + "\",\"" + duration.toString() + "\"]", value); } + + /* + /********************************************************** + /* Tests for custom patterns (modules-java8#189) + /********************************************************** + */ + + @Test + public void shouldSerializeInNanos_whenSetAsPattern() throws Exception + { + ObjectMapper mapper = _mapperForPatternOverride("NANOS"); + + Duration duration = Duration.ofHours(1); + String value = mapper.writeValueAsString(duration); + + assertEquals("The value is not correct.", "3600000000000", value); + } + + @Test + public void shouldSerializeInMicros_whenSetAsPattern() throws Exception + { + ObjectMapper mapper = _mapperForPatternOverride("MICROS"); + + Duration duration = Duration.ofMillis(1); + String value = mapper.writeValueAsString(duration); + + assertEquals("The value is not correct.", "1000", value); + } + + @Test + public void shouldSerializeInMicrosDiscardingFractions_whenSetAsPattern() throws Exception + { + ObjectMapper mapper = _mapperForPatternOverride("MICROS"); + + Duration duration = Duration.ofNanos(1500); + String value = mapper.writeValueAsString(duration); + + assertEquals("The value is not correct.", "1", value); + } + + @Test + public void shouldSerializeInMillis_whenSetAsPattern() throws Exception + { + ObjectMapper mapper = _mapperForPatternOverride("MILLIS"); + + Duration duration = Duration.ofSeconds(1); + String value = mapper.writeValueAsString(duration); + + assertEquals("The value is not correct.", "1000", value); + } + + @Test + public void shouldSerializeInMillisDiscardingFractions_whenSetAsPattern() throws Exception + { + ObjectMapper mapper = _mapperForPatternOverride("MILLIS"); + + Duration duration = Duration.ofNanos(1500000); + String value = mapper.writeValueAsString(duration); + + assertEquals("The value is not correct.", "1", value); + } + + @Test + public void shouldSerializeInSeconds_whenSetAsPattern() throws Exception + { + ObjectMapper mapper = _mapperForPatternOverride("SECONDS"); + + Duration duration = Duration.ofMinutes(1); + String value = mapper.writeValueAsString(duration); + + assertEquals("The value is not correct.", "60", value); + } + + @Test + public void shouldSerializeInSecondsDiscardingFractions_whenSetAsPattern() throws Exception + { + ObjectMapper mapper = _mapperForPatternOverride("SECONDS"); + + Duration duration = Duration.ofMillis(1500); + String value = mapper.writeValueAsString(duration); + + assertEquals("The value is not correct.", "1", value); + } + + @Test + public void shouldSerializeInMinutes_whenSetAsPattern() throws Exception + { + ObjectMapper mapper = _mapperForPatternOverride("MINUTES"); + + Duration duration = Duration.ofHours(1); + String value = mapper.writeValueAsString(duration); + + assertEquals("The value is not correct.", "60", value); + } + + @Test + public void shouldSerializeInMinutesDiscardingFractions_whenSetAsPattern() throws Exception + { + ObjectMapper mapper = _mapperForPatternOverride("MINUTES"); + + Duration duration = Duration.ofSeconds(90); + String value = mapper.writeValueAsString(duration); + + assertEquals("The value is not correct.", "1", value); + } + + @Test + public void shouldSerializeInHours_whenSetAsPattern() throws Exception + { + ObjectMapper mapper = _mapperForPatternOverride("HOURS"); + + Duration duration = Duration.ofDays(1); + String value = mapper.writeValueAsString(duration); + + assertEquals("The value is not correct.", "24", value); + } + + @Test + public void shouldSerializeInHoursDiscardingFractions_whenSetAsPattern() throws Exception + { + ObjectMapper mapper = _mapperForPatternOverride("HOURS"); + + Duration duration = Duration.ofMinutes(90); + String value = mapper.writeValueAsString(duration); + + assertEquals("The value is not correct.", "1", value); + } + + @Test + public void shouldSerializeInHalfDays_whenSetAsPattern() throws Exception + { + ObjectMapper mapper = _mapperForPatternOverride("HALF_DAYS"); + + Duration duration = Duration.ofDays(1); + String value = mapper.writeValueAsString(duration); + + assertEquals("The value is not correct.", "2", value); + } + + @Test + public void shouldSerializeInHalfDaysDiscardingFractions_whenSetAsPattern() throws Exception + { + ObjectMapper mapper = _mapperForPatternOverride("DAYS"); + + Duration duration = Duration.ofHours(30); + String value = mapper.writeValueAsString(duration); + + assertEquals("The value is not correct.", "1", value); + } + + @Test + public void shouldSerializeInDays_whenSetAsPattern() throws Exception + { + ObjectMapper mapper = _mapperForPatternOverride("DAYS"); + + Duration duration = Duration.ofDays(1); + String value = mapper.writeValueAsString(duration); + + assertEquals("The value is not correct.", "1", value); + } + + @Test + public void shouldSerializeInDaysDiscardingFractions_whenSetAsPattern() throws Exception + { + ObjectMapper mapper = _mapperForPatternOverride("DAYS"); + + Duration duration = Duration.ofHours(36); + String value = mapper.writeValueAsString(duration); + + assertEquals("The value is not correct.", "1", value); + } + + protected ObjectMapper _mapperForPatternOverride(String pattern) { + ObjectMapper mapper = mapperBuilder() + .enable(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS) + .disable(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS) + .build(); + mapper.configOverride(Duration.class) + .setFormat(JsonFormat.Value.forPattern(pattern)); + return mapper; + } } diff --git a/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationUnitConverterTest.java b/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/util/DurationUnitConverterTest.java similarity index 86% rename from datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationUnitConverterTest.java rename to datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/util/DurationUnitConverterTest.java index 9e385814..bf75bba7 100644 --- a/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/deser/DurationUnitConverterTest.java +++ b/datetime/src/test/java/com/fasterxml/jackson/datatype/jsr310/util/DurationUnitConverterTest.java @@ -1,6 +1,5 @@ -package com.fasterxml.jackson.datatype.jsr310.deser; +package com.fasterxml.jackson.datatype.jsr310.util; -import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; @@ -9,7 +8,6 @@ import org.junit.Test; import com.fasterxml.jackson.datatype.jsr310.ModuleTestBase; -import com.fasterxml.jackson.datatype.jsr310.deser.DurationDeserializer.DurationUnitConverter; public class DurationUnitConverterTest extends ModuleTestBase @@ -28,7 +26,6 @@ public void shouldMapToTemporalUnit() { }) { DurationUnitConverter conv = DurationUnitConverter.from(inputUnit.name()); assertNotNull(conv); - assertEquals(inputUnit, conv.unit); // is case-sensitive: assertNull(DurationUnitConverter.from(inputUnit.name().toLowerCase())); }