diff --git a/pom.xml b/pom.xml index ae60a0701..d6fe30b30 100644 --- a/pom.xml +++ b/pom.xml @@ -118,6 +118,13 @@ true + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + ${jackson} + true + + com.fasterxml.jackson.core jackson-databind diff --git a/src/main/asciidoc/reference/redis.adoc b/src/main/asciidoc/reference/redis.adoc index 3d57c271e..305d400f9 100644 --- a/src/main/asciidoc/reference/redis.adoc +++ b/src/main/asciidoc/reference/redis.adoc @@ -430,6 +430,8 @@ public class Person { String firstname; String lastname; Address address; + Date date; + LocalDateTime localDateTime; } public class Address { @@ -454,6 +456,12 @@ The following table shows how the data in the preceding class would appear in no |address |`{ "city" : "Castle Black", "country" : "The North" }` + +|date +|`1561543964015` + +|localDateTime +|`2018-01-02T12:13:14` |==== The following table shows how the data in the preceding class would appear in flat mapping: @@ -475,10 +483,18 @@ The following table shows how the data in the preceding class would appear in fl |address.country |`The North` + +|date +|`1561543964015` + +|localDateTime +|`2018-01-02T12:13:14` |==== NOTE: Flattening requires all property names to not interfere with the JSON path. Using dots or brackets in map keys or as property names is not supported when you use flattening. The resulting hash cannot be mapped back into an Object. +NOTE: `java.util.Date` and `java.util.Calendar` are represented with milliseconds. JSR-310 Date/Time types are serialized to their `toString` form if `jackson-datatype-jsr310` is on the class path. + :leveloffset: 2 include::{referenceDir}/redis-messaging.adoc[] diff --git a/src/main/java/org/springframework/data/redis/hash/Jackson2HashMapper.java b/src/main/java/org/springframework/data/redis/hash/Jackson2HashMapper.java index 84ea4414b..5634df9c7 100644 --- a/src/main/java/org/springframework/data/redis/hash/Jackson2HashMapper.java +++ b/src/main/java/org/springframework/data/redis/hash/Jackson2HashMapper.java @@ -18,6 +18,7 @@ package org.springframework.data.redis.hash; import java.io.IOException; import java.text.ParseException; import java.util.ArrayList; +import java.util.Calendar; import java.util.Date; import java.util.HashMap; import java.util.Iterator; @@ -36,99 +37,105 @@ import org.springframework.util.StringUtils; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonTypeInfo.As; +import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.BeanDescription; -import com.fasterxml.jackson.databind.DeserializationConfig; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.JsonDeserializer; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper.DefaultTyping; import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.databind.deser.BeanDeserializerModifier; -import com.fasterxml.jackson.databind.deser.DeserializationProblemHandler; +import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.deser.std.UntypedObjectDeserializer; -import com.fasterxml.jackson.databind.jsontype.TypeIdResolver; +import com.fasterxml.jackson.databind.jsontype.TypeDeserializer; +import com.fasterxml.jackson.databind.jsontype.TypeSerializer; import com.fasterxml.jackson.databind.module.SimpleModule; -import com.fasterxml.jackson.databind.type.TypeFactory; +import com.fasterxml.jackson.databind.ser.std.CalendarSerializer; +import com.fasterxml.jackson.databind.ser.std.DateSerializer; /** * {@link ObjectMapper} based {@link HashMapper} implementation that allows flattening. Given an entity {@code Person} * with an {@code Address} like below the flattening will create individual hash entries for all nested properties and * resolve complex types into simple types, as far as possible. - *

+ *

* Flattening requires all property names to not interfere with JSON paths. Using dots or brackets in map keys or as * property names is not supported using flattening. The resulting hash cannot be mapped back into an Object. - * Example + *

+ *

Example

* - *
- * 
+ * 
  * class Person {
- *   String firstname;
- *   String lastname;
- *   Address address;
+ * 	String firstname;
+ * 	String lastname;
+ * 	Address address;
+ * 	Date date;
+ * 	LocalDateTime localDateTime;
  * }
  *
  * class Address {
- *   String city;
- *   String country;
+ * 	String city;
+ * 	String country;
  * }
- * 
  * 
* - * Normal + *

Normal

* * * - * * * * - * * * * - * * * * - * + * + * + * + * + * + * + * + * * *
Hash fieldValue - * + * Value
firstnameJon - * + * Jon
lastnameSnow - * + * Snow
address{ "city" : "Castle Black", "country" : "The North" } - * + * { "city" : "Castle Black", "country" : "The North" }
date1561543964015
localDateTime2018-01-02T12:13:14
- *
- * Flat: + *

Flat

* * * - * * - *   * * - * * * * - * * * * - * * * * - * + * + * + * + * + * + * + * + * * *
Hash fieldValue - * + * Value
firstnameJon - * + * Jon
lastnameSnow - * + * Snow
address.cityCastle Black - * + * Castle Black
address.countryThe North - * + * The North
date1561543964015
localDateTime2018-01-02T12:13:14
* @@ -138,6 +145,8 @@ import com.fasterxml.jackson.databind.type.TypeFactory; */ public class Jackson2HashMapper implements HashMapper { + private final HashMapperModule HASH_MAPPER_MODULE = new HashMapperModule(); + private final ObjectMapper typingMapper; private final ObjectMapper untypedMapper; private final boolean flatten; @@ -149,56 +158,16 @@ public class Jackson2HashMapper implements HashMapper { */ public Jackson2HashMapper(boolean flatten) { - this(new ObjectMapper(), flatten); + this(new ObjectMapper().findAndRegisterModules(), flatten); typingMapper.enableDefaultTyping(DefaultTyping.NON_FINAL, As.PROPERTY); typingMapper.configure(SerializationFeature.WRITE_NULL_MAP_VALUES, false); + + // Prevent splitting time types into arrays. E + typingMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); typingMapper.setSerializationInclusion(Include.NON_NULL); typingMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - - typingMapper.addHandler(new DeserializationProblemHandler() { - @Override - public JavaType handleMissingTypeId(DeserializationContext ctxt, JavaType baseType, TypeIdResolver idResolver, - String failureMsg) { - return TypeFactory.defaultInstance().constructSimpleType(java.util.Date.class, new JavaType[] {}); - } - }); - - SimpleModule module = new SimpleModule(); - module.setDeserializerModifier(new BeanDeserializerModifier() { - @Override - public JsonDeserializer modifyDeserializer(DeserializationConfig config, BeanDescription beanDesc, - JsonDeserializer deserializer) { - - if (beanDesc.getBeanClass().equals(java.util.Date.class)) { - return new JsonDeserializer() { - - JsonDeserializer delegate = new UntypedObjectDeserializer(null, null); - - @Override - public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { - - Object val = delegate.deserialize(p, ctxt); - - if (val instanceof Date) { - return val; - } - - try { - return ctxt.getConfig().getDateFormat().parse(val.toString()); - } catch (ParseException e) { - e.printStackTrace(); - return new Date(NumberUtils.parseNumber(val.toString(), Long.class)); - } - } - }; - } - - return deserializer; - } - }); - - typingMapper.registerModule(module); + typingMapper.registerModule(HASH_MAPPER_MODULE); } /** @@ -210,11 +179,11 @@ public class Jackson2HashMapper implements HashMapper { public Jackson2HashMapper(ObjectMapper mapper, boolean flatten) { Assert.notNull(mapper, "Mapper must not be null!"); - this.typingMapper = mapper; this.flatten = flatten; this.untypedMapper = new ObjectMapper(); + untypedMapper.findAndRegisterModules(); this.untypedMapper.configure(SerializationFeature.WRITE_NULL_MAP_VALUES, false); this.untypedMapper.setSerializationInclusion(Include.NON_NULL); } @@ -377,7 +346,7 @@ public class Jackson2HashMapper implements HashMapper { @SuppressWarnings("unchecked") private void appendValueToTypedList(String key, Object value, List destination) { - int index = Integer.valueOf(key.substring(key.indexOf('[') + 1, key.length() - 1)); + int index = Integer.parseInt(key.substring(key.indexOf('[') + 1, key.length() - 1)); List resultList = ((List) destination.get(1)); if (resultList.size() < index) { resultList.add(value); @@ -395,4 +364,121 @@ public class Jackson2HashMapper implements HashMapper { listWithTypeHint.add(values); return listWithTypeHint; } + + private static class HashMapperModule extends SimpleModule { + + HashMapperModule() { + + addSerializer(java.util.Date.class, new UntypedSerializer<>(new DateToTimestampSerializer())); + addSerializer(java.util.Calendar.class, new UntypedSerializer<>(new CalendarToTimestampSerializer())); + + addDeserializer(java.util.Date.class, new UntypedDateDeserializer()); + addDeserializer(java.util.Calendar.class, new UntypedCalendarDeserializer()); + } + } + + /** + * {@link JsonDeserializer} for {@link Date} objects without considering type hints. + */ + private static class UntypedDateDeserializer extends JsonDeserializer { + + private final JsonDeserializer delegate = new UntypedObjectDeserializer(null, null); + + @Override + public Object deserializeWithType(JsonParser p, DeserializationContext ctxt, TypeDeserializer typeDeserializer) + throws IOException { + return deserialize(p, ctxt); + } + + @Override + public Date deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + + Object val = delegate.deserialize(p, ctxt); + + if (val instanceof Date) { + return (Date) val; + } + + try { + return ctxt.getConfig().getDateFormat().parse(val.toString()); + } catch (ParseException e) { + return new Date(NumberUtils.parseNumber(val.toString(), Long.class)); + } + } + } + + /** + * {@link JsonDeserializer} for {@link Calendar} objects without considering type hints. + */ + private static class UntypedCalendarDeserializer extends JsonDeserializer { + + private final UntypedDateDeserializer dateDeserializer = new UntypedDateDeserializer(); + + @Override + public Object deserializeWithType(JsonParser p, DeserializationContext ctxt, TypeDeserializer typeDeserializer) + throws IOException { + return deserialize(p, ctxt); + } + + @Override + public Calendar deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + + Date date = dateDeserializer.deserialize(p, ctxt); + + if (date == null) { + return null; + } + + Calendar calendar = Calendar.getInstance(); + calendar.setTime(date); + return calendar; + } + } + + /** + * Untyped {@link JsonSerializer} to serialize plain values without writing JSON type hints. + * + * @param + */ + private static class UntypedSerializer extends JsonSerializer { + + private final JsonSerializer delegate; + + UntypedSerializer(JsonSerializer delegate) { + this.delegate = delegate; + } + + @Override + public void serializeWithType(T value, JsonGenerator gen, SerializerProvider serializers, TypeSerializer typeSer) + throws IOException { + serialize(value, gen, serializers); + } + + @Override + public void serialize(T value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + if (value == null) { + serializers.defaultSerializeNull(gen); + } else { + delegate.serialize(value, gen, serializers); + } + } + } + + private static class DateToTimestampSerializer extends DateSerializer { + + // Prevent splitting to array. + @Override + protected boolean _asTimestamp(SerializerProvider serializers) { + return true; + } + } + + private static class CalendarToTimestampSerializer extends CalendarSerializer { + + // Prevent splitting to array. + @Override + protected boolean _asTimestamp(SerializerProvider serializers) { + return true; + } + } } diff --git a/src/test/java/org/springframework/data/redis/mapping/AbstractHashMapperTest.java b/src/test/java/org/springframework/data/redis/mapping/AbstractHashMapperTest.java index ac337c32d..ce7f2cc9a 100644 --- a/src/test/java/org/springframework/data/redis/mapping/AbstractHashMapperTest.java +++ b/src/test/java/org/springframework/data/redis/mapping/AbstractHashMapperTest.java @@ -15,6 +15,7 @@ */ package org.springframework.data.redis.mapping; +import static org.hamcrest.core.IsEqual.*; import static org.junit.Assert.*; import java.util.Map; @@ -42,7 +43,7 @@ public abstract class AbstractHashMapperTest { HashMapper mapper = mapperFor(o.getClass()); Map hash = mapper.toHash(o); - assertThat(mapper.fromHash(hash), IsEqual.equalTo(o)); + assertThat(mapper.fromHash(hash), equalTo(o)); } @Test diff --git a/src/test/java/org/springframework/data/redis/mapping/Jackson2HashMapperUnitTests.java b/src/test/java/org/springframework/data/redis/mapping/Jackson2HashMapperUnitTests.java index 6c7da8772..48e65c698 100644 --- a/src/test/java/org/springframework/data/redis/mapping/Jackson2HashMapperUnitTests.java +++ b/src/test/java/org/springframework/data/redis/mapping/Jackson2HashMapperUnitTests.java @@ -17,8 +17,11 @@ package org.springframework.data.redis.mapping; import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Arrays; +import java.util.Calendar; import java.util.Collection; import java.util.Date; import java.util.LinkedHashMap; @@ -38,6 +41,7 @@ import org.springframework.data.redis.hash.Jackson2HashMapper; * Unit tests for {@link Jackson2HashMapper}. * * @author Christoph Strobl + * @author Mark Paluch */ @RunWith(Parameterized.class) public class Jackson2HashMapperUnitTests extends AbstractHashMapperTest { @@ -165,9 +169,12 @@ public class Jackson2HashMapperUnitTests extends AbstractHashMapperTest { @Test // DATAREDIS-1001 public void dateValueShouldBeTreatedCorrectly() { - WithDate source = new WithDate(); + WithDates source = new WithDates(); source.string = "id-1"; source.date = new Date(1561543964015L); + source.calendar = Calendar.getInstance(); + source.localDate = LocalDate.parse("2018-01-02"); + source.localDateTime = LocalDateTime.parse("2018-01-02T12:13:14"); assertBackAndForwardMapping(source); } @@ -187,9 +194,12 @@ public class Jackson2HashMapperUnitTests extends AbstractHashMapperTest { } @Data - static class WithDate { + static class WithDates { private String string; private Date date; + private Calendar calendar; + private LocalDate localDate; + private LocalDateTime localDateTime; } }