Retain target type hint when deserializing Stream records.

We now retain the target type when obtaining a HashMapper through StreamObjectMapper. To achieve this, we introduced the HashObjectReader interface accepting a target type.

Resolves: #2198
Related: #1566
Original Pull Request: #2253
This commit is contained in:
Mark Paluch
2022-02-02 15:38:37 +01:00
committed by Christoph Strobl
parent 1932a4ca27
commit 73b49862df
9 changed files with 254 additions and 37 deletions

View File

@@ -17,9 +17,9 @@ package org.springframework.data.redis.core;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.support.DefaultConversionService;
@@ -29,6 +29,7 @@ import org.springframework.data.redis.connection.stream.Record;
import org.springframework.data.redis.connection.stream.StreamRecords;
import org.springframework.data.redis.core.convert.RedisCustomConversions;
import org.springframework.data.redis.hash.HashMapper;
import org.springframework.data.redis.hash.HashObjectReader;
import org.springframework.data.redis.hash.ObjectHashMapper;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
@@ -72,25 +73,7 @@ class StreamObjectMapper {
this.mapper = (HashMapper) mapper;
if (mapper instanceof ObjectHashMapper) {
ObjectHashMapper ohm = (ObjectHashMapper) mapper;
this.objectHashMapper = new HashMapper<Object, Object, Object>() {
@Override
public Map<Object, Object> toHash(Object object) {
return (Map) ohm.toHash(object);
}
@Override
public Object fromHash(Map<Object, Object> hash) {
Map<byte[], byte[]> map = hash.entrySet().stream()
.collect(Collectors.toMap(e -> conversionService.convert(e.getKey(), byte[].class),
e -> conversionService.convert(e.getValue(), byte[].class)));
return ohm.fromHash(map);
}
};
this.objectHashMapper = new BinaryObjectHashMapperAdapter((ObjectHashMapper) mapper);
} else {
this.objectHashMapper = null;
}
@@ -174,9 +157,27 @@ class StreamObjectMapper {
return transformed;
}
@SuppressWarnings("unchecked")
@SuppressWarnings({ "unchecked", "rawtypes" })
final <V, HK, HV> HashMapper<V, HK, HV> getHashMapper(Class<V> targetType) {
return (HashMapper) doGetHashMapper(conversionService, targetType);
HashMapper hashMapper = doGetHashMapper(conversionService, targetType);
if (hashMapper instanceof HashObjectReader) {
return new HashMapper<V, HK, HV>() {
@Override
public Map<HK, HV> toHash(V object) {
return hashMapper.toHash(object);
}
@Override
public V fromHash(Map<HK, HV> hash) {
return ((HashObjectReader<HK, HV>) hashMapper).fromHash(targetType, hash);
}
};
}
return hashMapper;
}
/**
@@ -208,4 +209,46 @@ class StreamObjectMapper {
ConversionService getConversionService() {
return conversionService;
}
private static class BinaryObjectHashMapperAdapter
implements HashMapper<Object, Object, Object>, HashObjectReader<Object, Object> {
private final ObjectHashMapper ohm;
public BinaryObjectHashMapperAdapter(ObjectHashMapper ohm) {
this.ohm = ohm;
}
@Override
@SuppressWarnings({ "unchecked", "rawtypes" })
public Map<Object, Object> toHash(Object object) {
return (Map) ohm.toHash(object);
}
@Override
public Object fromHash(Map<Object, Object> hash) {
return ohm.fromHash(toMap(hash));
}
@Override
public <R> R fromHash(Class<R> type, Map<Object, Object> hash) {
return ohm.fromHash(type, toMap(hash));
}
private static Map<byte[], byte[]> toMap(Map<Object, Object> hash) {
Map<byte[], byte[]> target = new LinkedHashMap<>(hash.size());
for (Map.Entry<Object, Object> entry : hash.entrySet()) {
target.put(toBytes(entry.getKey()), toBytes(entry.getValue()));
}
return target;
}
@Nullable
private static byte[] toBytes(Object value) {
return value instanceof byte[] ? (byte[]) value : conversionService.convert(value, byte[].class);
}
}
}

View File

@@ -21,6 +21,8 @@ import java.util.Map.Entry;
import org.apache.commons.beanutils.BeanUtils;
import org.springframework.util.Assert;
/**
* HashMapper based on Apache Commons BeanUtils project. Does NOT supports nested properties.
*
@@ -28,7 +30,7 @@ import org.apache.commons.beanutils.BeanUtils;
* @author Christoph Strobl
* @author Mark Paluch
*/
public class BeanUtilsHashMapper<T> implements HashMapper<T, String, String> {
public class BeanUtilsHashMapper<T> implements HashMapper<T, String, String>, HashObjectReader<String, String> {
private final Class<T> type;
@@ -47,8 +49,20 @@ public class BeanUtilsHashMapper<T> implements HashMapper<T, String, String> {
*/
@Override
public T fromHash(Map<String, String> hash) {
return fromHash(type, hash);
}
T instance = org.springframework.beans.BeanUtils.instantiateClass(type);
/*
* (non-Javadoc)
* @see org.springframework.data.redis.hash.HashMapper#fromHash(java.lang.Class, java.util.Map)
*/
@Override
public <R> R fromHash(Class<R> type, Map<String, String> hash) {
Assert.notNull(type, "Type must not be null");
Assert.notNull(hash, "Hash must not be null");
R instance = org.springframework.beans.BeanUtils.instantiateClass(type);
try {

View File

@@ -26,6 +26,7 @@ import java.util.Map;
* @param <V> Redis Hash value type
* @author Costin Leau
* @author Mark Paluch
* @see HashObjectReader
*/
public interface HashMapper<T, K, V> {

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2022 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
*
* https://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.util.Map;
/**
* Core mapping contract to materialize an object using particular Java class from a Redis Hash.
*
* @param <K> Redis Hash field type
* @param <V> Redis Hash value type
* @author Mark Paluch
* @since 2.7
* @see HashMapper
*/
public interface HashObjectReader<K, V> {
/**
* Materialize an object of the {@link Class type} from a {@code hash}.
*
* @param hash must not be {@literal null}.
* @return the materialized object from the given {@code hash}.
*/
<R> R fromHash(Class<R> type, Map<K, V> hash);
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2016-2021 the original author or authors.
* Copyright 2016-2022 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.
@@ -48,6 +48,7 @@ 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.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectMapper.DefaultTyping;
import com.fasterxml.jackson.databind.SerializationFeature;
@@ -148,7 +149,7 @@ import com.fasterxml.jackson.databind.ser.std.DateSerializer;
* @author Mark Paluch
* @since 1.8
*/
public class Jackson2HashMapper implements HashMapper<Object, String, Object> {
public class Jackson2HashMapper implements HashMapper<Object, String, Object>, HashObjectReader<String, Object> {
private final HashMapperModule HASH_MAPPER_MODULE = new HashMapperModule();
@@ -176,6 +177,12 @@ public class Jackson2HashMapper implements HashMapper<Object, String, Object> {
}
if (EVERYTHING.equals(_appliesFor)) {
// yuck! Isn't there a better way to distinguish whether there's a registered serializer so that we don't
// use type builders?
if (t.getRawClass().getPackage().getName().startsWith("java.time")) {
return false;
}
return !TreeNode.class.isAssignableFrom(t.getRawClass());
}
@@ -188,12 +195,14 @@ public class Jackson2HashMapper implements HashMapper<Object, String, Object> {
typingMapper.activateDefaultTyping(typingMapper.getPolymorphicTypeValidator(), DefaultTyping.EVERYTHING,
As.PROPERTY);
typingMapper.configure(SerializationFeature.WRITE_NULL_MAP_VALUES, false);
typingMapper.configure(MapperFeature.USE_BASE_TYPE_AS_DEFAULT_IMPL, true);
// 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.registerModule(HASH_MAPPER_MODULE);
typingMapper.findAndRegisterModules();
}
/**
@@ -209,7 +218,7 @@ public class Jackson2HashMapper implements HashMapper<Object, String, Object> {
this.flatten = flatten;
this.untypedMapper = new ObjectMapper();
untypedMapper.findAndRegisterModules();
this.untypedMapper.findAndRegisterModules();
this.untypedMapper.configure(SerializationFeature.WRITE_NULL_MAP_VALUES, false);
this.untypedMapper.setSerializationInclusion(Include.NON_NULL);
}
@@ -232,16 +241,25 @@ public class Jackson2HashMapper implements HashMapper<Object, String, Object> {
*/
@Override
public Object fromHash(Map<String, Object> hash) {
return fromHash(Object.class, hash);
}
/*
* (non-Javadoc)
* @see org.springframework.data.redis.hash.HashMapper#fromHash(Class, java.util.Map)
*/
@Override
public <R> R fromHash(Class<R> type, Map<String, Object> hash) {
try {
if (flatten) {
return typingMapper.reader().forType(Object.class)
return typingMapper.reader().forType(type)
.readValue(untypedMapper.writeValueAsBytes(doUnflatten(hash)));
}
return typingMapper.treeToValue(untypedMapper.valueToTree(hash), Object.class);
return typingMapper.treeToValue(untypedMapper.valueToTree(hash), type);
} catch (IOException e) {
throw new MappingException(e.getMessage(), e);

View File

@@ -69,7 +69,7 @@ import org.springframework.util.Assert;
* @author Mark Paluch
* @since 1.8
*/
public class ObjectHashMapper implements HashMapper<Object, byte[], byte[]> {
public class ObjectHashMapper implements HashMapper<Object, byte[], byte[]>, HashObjectReader<byte[], byte[]> {
@Nullable private volatile static ObjectHashMapper sharedInstance;
@@ -169,12 +169,20 @@ public class ObjectHashMapper implements HashMapper<Object, byte[], byte[]> {
*/
@Override
public Object fromHash(Map<byte[], byte[]> hash) {
return fromHash(Object.class, hash);
}
if (hash == null || hash.isEmpty()) {
return null;
}
/*
* (non-Javadoc)
* @see org.springframework.data.redis.hash.HashMapper#fromHash(java.lang.Class, java.util.Map)
*/
@Override
public <R> R fromHash(Class<R> type, Map<byte[], byte[]> hash) {
return converter.read(Object.class, new RedisData(hash));
Assert.notNull(type, "Type must not be null");
Assert.notNull(hash, "Hash must not be null");
return converter.read(type, new RedisData(hash));
}
/**

View File

@@ -0,0 +1,62 @@
/*
* Copyright 2022 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
*
* https://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.core;
import static org.assertj.core.api.Assertions.*;
import lombok.Data;
import java.util.Collections;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.hash.Jackson2HashMapper;
import org.springframework.data.redis.hash.ObjectHashMapper;
/**
* Unit tests for {@link StreamObjectMapper}.
*
* @author Mark Paluch
*/
class StreamObjectMapperUnitTests {
@Test // GH-2198
void shouldRetainTypeHintUsingObjectHashMapper() {
StreamObjectMapper mapper = new StreamObjectMapper(ObjectHashMapper.getSharedInstance());
MyType result = mapper.getHashMapper(MyType.class)
.fromHash(Collections.singletonMap("value".getBytes(), "hello".getBytes()));
assertThat(result.value).isEqualTo("hello");
}
@Test // GH-2198
void shouldRetainTypeHintUsingJackson() {
StreamObjectMapper mapper = new StreamObjectMapper(new Jackson2HashMapper(true));
MyType result = mapper.getHashMapper(MyType.class).fromHash(Collections.singletonMap("value", "hello"));
assertThat(result.value).isEqualTo("hello");
}
@Data
static class MyType {
String value;
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2016-2021 the original author or authors.
* Copyright 2016-2022 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.
@@ -15,6 +15,8 @@
*/
package org.springframework.data.redis.mapping;
import static org.assertj.core.api.Assertions.*;
import lombok.Data;
import java.time.LocalDate;
@@ -48,13 +50,13 @@ public abstract class Jackson2HashMapperUnitTests extends AbstractHashMapperTest
this.mapper = mapper;
}
static class FlatteningJackson2HashMapperUnitTests extends Jackson2HashMapperUnitTests {
public static class FlatteningJackson2HashMapperUnitTests extends Jackson2HashMapperUnitTests {
FlatteningJackson2HashMapperUnitTests() {
super(new Jackson2HashMapper(true));
}
}
static class NonFlatteningJackson2HashMapperUnitTests extends Jackson2HashMapperUnitTests {
public static class NonFlatteningJackson2HashMapperUnitTests extends Jackson2HashMapperUnitTests {
NonFlatteningJackson2HashMapperUnitTests() {
super(new Jackson2HashMapper(false));
@@ -183,6 +185,24 @@ public abstract class Jackson2HashMapperUnitTests extends AbstractHashMapperTest
assertBackAndForwardMapping(source);
}
@Test // GH-2198
void shouldDeserializeObjectWithoutClassHint() {
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");
Map<String, Object> map = mapper.toHash(source);
// ensure that we remove the correct type hint
assertThat(map.remove("@class")).isNotNull();
assertThat(mapper.fromHash(WithDates.class, map)).isEqualTo(source);
}
@Test // GH-1566
void mapFinalClass() {

View File

@@ -20,6 +20,7 @@ import static org.assertj.core.api.Assertions.*;
import lombok.Data;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
@@ -83,6 +84,18 @@ class ObjectHashMapperTests extends AbstractHashMapperTests {
assertThat(objectHashMapper.fromHash(hash)).isEqualTo(source);
}
@Test // GH-2198
void readHashConsidersTypeHint() {
Map<byte[], byte[]> hash = new LinkedHashMap<>();
hash.put("value".getBytes(), "hello".getBytes());
ObjectHashMapper objectHashMapper = ObjectHashMapper.getSharedInstance();
WithTypeAlias withTypeAlias = objectHashMapper.fromHash(WithTypeAlias.class, hash);
assertThat(withTypeAlias.value).isEqualTo("hello");
}
@TypeAlias("_42_")
@Data
static class WithTypeAlias {