From 568e41f8dd9febc658f5d920ef7df9d59ec9fe04 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Tue, 3 May 2016 12:01:29 +0200 Subject: [PATCH] DATAREDIS-423 - Add support for Jackson2 based HashMapper. Added FasterXML Jackson `ObjectMapper` based `HashMapper` implementation that allows flattening. ```java class Person { String firstname; String lastname; Address address; } class Address { String city; String country; } ``` ```bash firstname:Jon lastname:Snow address:{ city : Castle Black, country : The North } firstname:Jon lastname:Snow address.city:Castle Black address.country:The North ``` Original pull request: #197. --- src/main/asciidoc/reference/redis.adoc | 1 + .../data/redis/hash/Jackson2HashMapper.java | 295 ++++++++++++++++++ .../mapping/Jackson2HashMapperTests.java | 99 ++++++ .../mapping/Jackson2HashMapperUnitTests.java | 204 ++++++++++++ 4 files changed, 599 insertions(+) create mode 100644 src/main/java/org/springframework/data/redis/hash/Jackson2HashMapper.java create mode 100644 src/test/java/org/springframework/data/redis/mapping/Jackson2HashMapperTests.java create mode 100644 src/test/java/org/springframework/data/redis/mapping/Jackson2HashMapperUnitTests.java diff --git a/src/main/asciidoc/reference/redis.adoc b/src/main/asciidoc/reference/redis.adoc index 52453487e..769f5b398 100644 --- a/src/main/asciidoc/reference/redis.adoc +++ b/src/main/asciidoc/reference/redis.adoc @@ -365,6 +365,7 @@ Multiple implementations are available out of the box: 1. `BeanUtilsHashMapper` using Spring's http://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/beans/BeanUtils.html[BeanUtils] 2. `ObjectHashMapper` using <> +3. `Jackson2HashMapper` using https://github.com/FasterXML/jackson[FasterXML Jackson]. [source,java] ---- diff --git a/src/main/java/org/springframework/data/redis/hash/Jackson2HashMapper.java b/src/main/java/org/springframework/data/redis/hash/Jackson2HashMapper.java new file mode 100644 index 000000000..052373b6b --- /dev/null +++ b/src/main/java/org/springframework/data/redis/hash/Jackson2HashMapper.java @@ -0,0 +1,295 @@ +/* + * Copyright 2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.hash; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +import org.springframework.data.mapping.model.MappingException; +import org.springframework.data.util.DirectFieldAccessFallbackBeanWrapper; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectMapper.DefaultTyping; +import com.fasterxml.jackson.databind.SerializationFeature; + +/** + * {@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. + * + *
+ * 
+ * class Person {
+ *   String firstname;
+ *   String lastname;
+ *   Address address;
+ * }
+ *
+ * class Address {
+ *   String city;
+ *   String country;
+ * }
+ * 
+ * 
+ * + *
+ * Normal
+ * + * + * + * + *
firstnameJon
lastnameSnow
address{ city : Castle Black, country : The North }
+ * + * Flat:
+ * + *   + * + * + * + *
firstnameJon
lastnameSnow
address.cityCastle Black
address.countryThe North
+ *
+ * + * @author Christoph Strobl + * @since 1.8 + */ +public class Jackson2HashMapper implements HashMapper { + + private final ObjectMapper typingMapper; + private final ObjectMapper untypedMapper; + private final boolean flatten; + + /** + * Creates new {@link Jackson2HashMapper} with default {@link ObjectMapper}. + * + * @param flatten + */ + public Jackson2HashMapper(boolean flatten) { + + this(new ObjectMapper(), flatten); + + typingMapper.enableDefaultTyping(DefaultTyping.NON_FINAL, As.PROPERTY); + typingMapper.configure(SerializationFeature.WRITE_NULL_MAP_VALUES, false); + typingMapper.setSerializationInclusion(Include.NON_NULL); + typingMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } + + /** + * Creates new {@link Jackson2HashMapper}. + * + * @param mapper must not be {@literal null}. + * @param flatten + */ + public Jackson2HashMapper(ObjectMapper mapper, boolean flatten) { + + Assert.notNull(mapper, "Mapper must not be null!"); + + this.typingMapper = mapper; + this.flatten = flatten; + + this.untypedMapper = new ObjectMapper(); + this.untypedMapper.configure(SerializationFeature.WRITE_NULL_MAP_VALUES, false); + this.untypedMapper.setSerializationInclusion(Include.NON_NULL); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.redis.hash.HashMapper#toHash(java.lang.Object) + */ + @Override + @SuppressWarnings("unchecked") + public Map toHash(Object source) { + + JsonNode tree = typingMapper.valueToTree(source); + return flatten ? flattenMap(tree.fields()) : untypedMapper.convertValue(tree, Map.class); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.redis.hash.HashMapper#fromHash(java.util.Map) + */ + @Override + public Object fromHash(Map hash) { + + try { + + if (flatten) { + + return typingMapper.reader().forType(Object.class) + .readValue(untypedMapper.writeValueAsBytes(doUnflatten(hash))); + } + + return typingMapper.treeToValue(untypedMapper.valueToTree(hash), Object.class); + + } catch (JsonParseException e) { + throw new MappingException(e.getMessage(), e); + } catch (JsonMappingException e) { + throw new MappingException(e.getMessage(), e); + } catch (IOException e) { + throw new MappingException(e.getMessage(), e); + } + } + + @SuppressWarnings("unchecked") + private Map doUnflatten(Map source) { + + Map result = new LinkedHashMap(); + Set treatSeperate = new LinkedHashSet(); + for (Entry entry : source.entrySet()) { + + String key = entry.getKey(); + String[] args = key.split("\\."); + + if (args.length == 1 && !args[0].contains("[")) { + result.put(entry.getKey(), entry.getValue()); + continue; + } + + if (args.length == 1 && args[0].contains("[")) { + + String prunedKey = args[0].substring(0, args[0].indexOf('[')); + if (result.containsKey(prunedKey)) { + appendValueToTypedList(args[0], entry.getValue(), (List) result.get(prunedKey)); + } else { + result.put(prunedKey, createTypedListWithValue(entry.getValue())); + } + } else { + treatSeperate.add(key.substring(0, key.indexOf('.'))); + } + } + + for (String partial : treatSeperate) { + + Map newSource = new LinkedHashMap(); + + for (Entry entry : source.entrySet()) { + if (entry.getKey().startsWith(partial)) { + newSource.put(entry.getKey().substring(partial.length() + 1), entry.getValue()); + } + } + + if (partial.endsWith("]")) { + + String prunedKey = partial.substring(0, partial.indexOf('[')); + + if (result.containsKey(prunedKey)) { + appendValueToTypedList(partial, doUnflatten(newSource), (List) result.get(prunedKey)); + } else { + result.put(prunedKey, createTypedListWithValue(doUnflatten(newSource))); + } + } else { + result.put(partial, doUnflatten(newSource)); + } + } + + return result; + } + + private Map flattenMap(Iterator> source) { + + Map resultMap = new HashMap(); + this.doFlatten("", source, resultMap); + return resultMap; + } + + private void doFlatten(String propertyPrefix, Iterator> inputMap, + Map resultMap) { + + if (StringUtils.hasText(propertyPrefix)) { + propertyPrefix = propertyPrefix + "."; + } + + while (inputMap.hasNext()) { + + Entry entry = inputMap.next(); + flattenElement(propertyPrefix + entry.getKey(), entry.getValue(), resultMap); + } + } + + private void flattenElement(String propertyPrefix, Object source, Map resultMap) { + + if (!(source instanceof JsonNode)) { + + resultMap.put(propertyPrefix, source); + return; + } + + JsonNode element = (JsonNode) source; + if (element.isArray()) { + + Iterator nodes = element.elements(); + + while (nodes.hasNext()) { + + JsonNode cur = nodes.next(); + if (cur.isArray()) { + this.falttenCollection(propertyPrefix, cur.elements(), resultMap); + } + } + + } else if (element.isContainerNode()) { + this.doFlatten(propertyPrefix, element.fields(), resultMap); + } else { + resultMap.put(propertyPrefix, new DirectFieldAccessFallbackBeanWrapper(element).getPropertyValue("_value")); + } + } + + private void falttenCollection(String propertyPrefix, Iterator list, Map resultMap) { + + int counter = 0; + while (list.hasNext()) { + JsonNode element = list.next(); + flattenElement(propertyPrefix + "[" + counter + "]", element, resultMap); + counter++; + } + } + + @SuppressWarnings("unchecked") + private void appendValueToTypedList(String key, Object value, List destination) { + + int index = Integer.valueOf(key.substring(key.indexOf('[') + 1, key.length() - 1)).intValue(); + List resultList = ((List) destination.get(1)); + if (resultList.size() < index) { + resultList.add(value); + } else { + resultList.add(index, value); + } + } + + private List createTypedListWithValue(Object value) { + + List listWithTypeHint = new ArrayList(); + listWithTypeHint.add(ArrayList.class.getName()); // why jackson? why? + List values = new ArrayList(); + values.add(value); + listWithTypeHint.add(values); + return listWithTypeHint; + } +} diff --git a/src/test/java/org/springframework/data/redis/mapping/Jackson2HashMapperTests.java b/src/test/java/org/springframework/data/redis/mapping/Jackson2HashMapperTests.java new file mode 100644 index 000000000..28678ab7b --- /dev/null +++ b/src/test/java/org/springframework/data/redis/mapping/Jackson2HashMapperTests.java @@ -0,0 +1,99 @@ +/* + * Copyright 2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.mapping; + +import java.util.Arrays; +import java.util.Collection; + +import org.hamcrest.core.Is; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.data.redis.Address; +import org.springframework.data.redis.ConnectionFactoryTracker; +import org.springframework.data.redis.Person; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.jedis.JedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.hash.Jackson2HashMapper; + +/** + * Integration tests for {@link Jackson2HashMapper}. + * + * @author Christoph Strobl + */ +@RunWith(Parameterized.class) +public class Jackson2HashMapperTests { + + RedisTemplate template; + RedisConnectionFactory factory; + Jackson2HashMapper mapper; + + public Jackson2HashMapperTests(RedisConnectionFactory factory) throws Exception { + + this.factory = factory; + if (factory instanceof InitializingBean) { + ((InitializingBean) factory).afterPropertiesSet(); + } + + ConnectionFactoryTracker.add(factory); + } + + @Parameters + public static Collection params() { + return Arrays. asList(new JedisConnectionFactory(), new LettuceConnectionFactory()); + } + + @AfterClass + public static void cleanUp() { + ConnectionFactoryTracker.cleanUp(); + } + + @Before + public void setUp() { + + this.template = new RedisTemplate(); + this.template.setConnectionFactory(factory); + template.afterPropertiesSet(); + + this.mapper = new Jackson2HashMapper(true); + } + + /** + * @see DATAREDIS-423 + */ + @Test + public void shouldWriteReadHashCorrtectly() { + + Person jon = new Person("jon", "snow", 19); + Address adr = new Address(); + adr.setStreet("the wall"); + adr.setNumber(100); + jon.setAddress(adr); + + template.opsForHash().putAll("JON-SNOW", mapper.toHash(jon)); + + Person result = (Person) mapper.fromHash(template. opsForHash().entries("JON-SNOW")); + Assert.assertThat(result, Is.is(jon)); + } + +} diff --git a/src/test/java/org/springframework/data/redis/mapping/Jackson2HashMapperUnitTests.java b/src/test/java/org/springframework/data/redis/mapping/Jackson2HashMapperUnitTests.java new file mode 100644 index 000000000..f54583c99 --- /dev/null +++ b/src/test/java/org/springframework/data/redis/mapping/Jackson2HashMapperUnitTests.java @@ -0,0 +1,204 @@ +/* + * Copyright 2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.mapping; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; +import org.springframework.data.redis.Address; +import org.springframework.data.redis.Person; +import org.springframework.data.redis.hash.HashMapper; +import org.springframework.data.redis.hash.Jackson2HashMapper; + +import lombok.Data; + +/** + * Unit tests for {@link Jackson2HashMapper}. + * + * @author Christoph Strobl + */ +@RunWith(Parameterized.class) +public class Jackson2HashMapperUnitTests extends AbstractHashMapperTest { + + private final Jackson2HashMapper mapper; + + public Jackson2HashMapperUnitTests(Jackson2HashMapper mapper) { + this.mapper = mapper; + } + + @Parameters + public static Collection params() { + return Arrays.asList(new Jackson2HashMapper(true), new Jackson2HashMapper(false)); + } + + @Override + @SuppressWarnings("rawtypes") + protected HashMapper mapperFor(Class t) { + return this.mapper; + } + + /** + * @see DATAREDIS-423 + */ + @Test + public void shouldMapTypedListOfSimpleType() { + + WithList source = new WithList(); + source.strings = Arrays.asList("spring", "data", "redis"); + assertBackAndForwardMapping(source); + } + + /** + * @see DATAREDIS-423 + */ + @Test + public void shouldMapTypledListOfComplexType() { + + WithList source = new WithList(); + + source.persons = Arrays.asList(new Person("jon", "snow", 19), new Person("tyrion", "lannister", 27)); + assertBackAndForwardMapping(source); + } + + /** + * @see DATAREDIS-423 + */ + @Test + public void shouldMapTypedListOfComplexObjectWihtNestedElements() { + + WithList source = new WithList(); + + Person jon = new Person("jon", "snow", 19); + Address adr = new Address(); + adr.setStreet("the wall"); + adr.setNumber(100); + jon.setAddress(adr); + + source.persons = Arrays.asList(jon, new Person("tyrion", "lannister", 27)); + assertBackAndForwardMapping(source); + } + + /** + * @see DATAREDIS-423 + */ + @Test + public void shouldMapNestedObject() { + + Person jon = new Person("jon", "snow", 19); + Address adr = new Address(); + adr.setStreet("the wall"); + adr.setNumber(100); + jon.setAddress(adr); + + assertBackAndForwardMapping(jon); + } + + /** + * @see DATAREDIS-423 + */ + @Test + public void shouldMapUntypedList() { + + WithList source = new WithList(); + source.objects = Arrays. asList(Integer.valueOf(100), "foo", new Person("jon", "snow", 19)); + assertBackAndForwardMapping(source); + } + + /** + * @see DATAREDIS-423 + */ + @Test + public void shouldMapTypedMapOfSimpleTypes() { + + WithMap source = new WithMap(); + source.strings = new LinkedHashMap(); + source.strings.put("1", "spring"); + source.strings.put("2", "data"); + source.strings.put("3", "redis"); + assertBackAndForwardMapping(source); + } + + /** + * @see DATAREDIS-423 + */ + @Test + public void shouldMapTypedMapOfComplexTypes() { + + WithMap source = new WithMap(); + source.persons = new LinkedHashMap(); + source.persons.put("1", new Person("jon", "snow", 19)); + source.persons.put("2", new Person("tyrion", "lannister", 19)); + assertBackAndForwardMapping(source); + } + + /** + * @see DATAREDIS-423 + */ + @Test + public void shouldMapUntypedMap() { + + WithMap source = new WithMap(); + source.objects = new LinkedHashMap(); + source.objects.put("1", "spring"); + source.objects.put("2", Integer.valueOf(100)); + source.objects.put("3", "redis"); + assertBackAndForwardMapping(source); + } + + /** + * @see DATAREDIS-423 + */ + @Test + public void nestedStuff() { + + WithList nestedList = new WithList(); + nestedList.objects = new ArrayList(); + + WithMap deepNestedMap = new WithMap(); + deepNestedMap.persons = new LinkedHashMap(); + deepNestedMap.persons.put("jon", new Person("jon", "snow", 24)); + + nestedList.objects.add(deepNestedMap); + + WithMap outer = new WithMap(); + outer.objects = new LinkedHashMap(); + outer.objects.put("1", nestedList); + + assertBackAndForwardMapping(outer); + } + + @Data + public static class WithList { + List strings; + List objects; + List persons; + } + + @Data + public static class WithMap { + Map strings; + Map objects; + Map persons; + } +}