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:
committed by
Christoph Strobl
parent
1932a4ca27
commit
73b49862df
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user