DATAREDIS-543 - Allow customized type aliasing.

We now support customization of type aliasing for types using through Redis Repositories. Type aliasing can be customized by either annotating types with @TypeAlias or by providing a RedisTypeMapper to MappingRedisConverter.

Map<Class<?>, String> mapping = new HashMap<>();

mapping.put(Person.class, "person");

ConfigurableTypeInformationMapper mapper = new ConfigurableTypeInformationMapper(mapping);
RedisTypeMapper typeMapper = new DefaultRedisTypeMapper(DefaultRedisTypeMapper.DEFAULT_TYPE_KEY, Collections.singletonList(mapper))

alternatively:

@RedisHash
@TypeAlias("person")
class Person {
  // …
}

Original Pull Request: #299
This commit is contained in:
Mark Paluch
2017-12-20 14:28:01 +01:00
committed by Christoph Strobl
parent f3128bf0c0
commit 04153e07b0
9 changed files with 696 additions and 100 deletions

View File

@@ -0,0 +1,86 @@
/*
* Copyright 2017 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.core.convert;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.util.StringUtils;
/**
* Value object representing a path within a {@link Bucket}. Paths can be either top-level (if the {@code prefix} is
* {@literal null} or empty) or nested with a given {@code prefix}.
*
* @author Mark Paluch
* @since 2.1
*/
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public class BucketPropertyPath {
private final @NonNull Bucket bucket;
private final @Nullable String prefix;
/**
* Creates a top-level {@link BucketPropertyPath} given {@link Bucket}.
*
* @param bucket the bucket, must not be {@literal null}.
* @return {@link BucketPropertyPath} within the given {@link Bucket}.
*/
public static BucketPropertyPath from(Bucket bucket) {
return new BucketPropertyPath(bucket, null);
}
/**
* Creates a {@link BucketPropertyPath} given {@link Bucket} and {@code prefix}. The resulting path is top-level if
* {@code prefix} is empty or nested, if {@code prefix} is not empty.
*
* @param bucket the bucket, must not be {@literal null}.
* @param prefix the prefix. Property path is top-level if {@code prefix} is {@literal null} or empty.
* @return {@link BucketPropertyPath} within the given {@link Bucket} using {@code prefix}.
*/
public static BucketPropertyPath from(Bucket bucket, @Nullable String prefix) {
return new BucketPropertyPath(bucket, prefix);
}
/**
* Retrieve a value at {@code key} considering top-level/nesting.
*
* @param key must not be {@literal null} or empty.
* @return the resulting value, may be {@literal null}.
*/
@Nullable
public byte[] get(String key) {
return bucket.get(getPath(key));
}
/**
* Write a {@code value} at {@code key} considering top-level/nesting.
*
* @param key must not be {@literal null} or empty.
* @param value the value.
*/
public void put(String key, byte[] value) {
bucket.put(getPath(key), value);
}
private String getPath(String key) {
return StringUtils.hasText(prefix) ? prefix + "." + key : key;
}
}

View File

@@ -0,0 +1,170 @@
/*
* Copyright 2017 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.core.convert;
import java.util.Collections;
import java.util.List;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.support.GenericConversionService;
import org.springframework.data.convert.DefaultTypeMapper;
import org.springframework.data.convert.SimpleTypeInformationMapper;
import org.springframework.data.convert.TypeAliasAccessor;
import org.springframework.data.convert.TypeInformationMapper;
import org.springframework.data.mapping.Alias;
import org.springframework.data.mapping.PersistentEntity;
import org.springframework.data.mapping.context.MappingContext;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
* Default implementation of {@link RedisTypeMapper} allowing configuration of the key to lookup and store type
* information via {@link BucketPropertyPath} in buckets. The key defaults to {@link #DEFAULT_TYPE_KEY}. Actual
* type-to-{@code byte[]} conversion and back is done in {@link BucketTypeAliasAccessor}.
*
* @author Mark Paluch
* @since 2.1
*/
public class DefaultRedisTypeMapper extends DefaultTypeMapper<BucketPropertyPath> implements RedisTypeMapper {
public static final String DEFAULT_TYPE_KEY = "_class";
private final @Nullable String typeKey;
/**
* Create a new {@link DefaultRedisTypeMapper} using {@link #DEFAULT_TYPE_KEY} to exchange type hints.
*/
public DefaultRedisTypeMapper() {
this(DEFAULT_TYPE_KEY);
}
/**
* Create a new {@link DefaultRedisTypeMapper} given {@code typeKey} to exchange type hints. Does not consider type
* hints if {@code typeKey} is {@literal null}.
*
* @param typeKey the type key can be {@literal null} to skip type hinting.
*/
public DefaultRedisTypeMapper(@Nullable String typeKey) {
this(typeKey, Collections.singletonList(new SimpleTypeInformationMapper()));
}
/**
* Create a new {@link DefaultRedisTypeMapper} given {@code typeKey} to exchange type hints and
* {@link MappingContext}. Does not consider type hints if {@code typeKey} is {@literal null}. {@link MappingContext}
* is used to obtain entity-based aliases
*
* @param typeKey the type key can be {@literal null} to skip type hinting.
* @param mappingContext must not be {@literal null}.
* @see org.springframework.data.annotation.TypeAlias
*/
public DefaultRedisTypeMapper(@Nullable String typeKey,
MappingContext<? extends PersistentEntity<?, ?>, ?> mappingContext) {
this(typeKey, new BucketTypeAliasAccessor(typeKey, getConversionService()), mappingContext,
Collections.singletonList(new SimpleTypeInformationMapper()));
}
/**
* Create a new {@link DefaultRedisTypeMapper} given {@code typeKey} to exchange type hints and {@link List} of
* {@link TypeInformationMapper}. Does not consider type hints if {@code typeKey} is {@literal null}.
* {@link MappingContext} is used to obtain entity-based aliases
*
* @param typeKey the type key can be {@literal null} to skip type hinting.
* @param mappers must not be {@literal null}.
*/
public DefaultRedisTypeMapper(@Nullable String typeKey, List<? extends TypeInformationMapper> mappers) {
this(typeKey, new BucketTypeAliasAccessor(typeKey, getConversionService()), null, mappers);
}
private DefaultRedisTypeMapper(@Nullable String typeKey, TypeAliasAccessor<BucketPropertyPath> accessor,
@Nullable MappingContext<? extends PersistentEntity<?, ?>, ?> mappingContext,
List<? extends TypeInformationMapper> mappers) {
super(accessor, mappingContext, mappers);
this.typeKey = typeKey;
}
private static GenericConversionService getConversionService() {
GenericConversionService conversionService = new GenericConversionService();
new RedisCustomConversions().registerConvertersIn(conversionService);
return conversionService;
}
/*
* (non-Javadoc)
* @see org.springframework.data.redis.core.convert.RedisTypeMapper#isTypeKey(java.lang.String)
*/
public boolean isTypeKey(@Nullable String key) {
return key != null && typeKey != null && key.endsWith(typeKey);
}
/**
* {@link TypeAliasAccessor} to store aliases in a {@link Bucket}.
*
* @author Mark Paluch
*/
static final class BucketTypeAliasAccessor implements TypeAliasAccessor<BucketPropertyPath> {
private final @Nullable String typeKey;
private final ConversionService conversionService;
BucketTypeAliasAccessor(@Nullable String typeKey, ConversionService conversionService) {
Assert.notNull(conversionService, "ConversionService must not be null!");
this.typeKey = typeKey;
this.conversionService = conversionService;
}
/*
* (non-Javadoc)
* @see org.springframework.data.convert.TypeAliasAccessor#readAliasFrom(java.lang.Object)
*/
public Alias readAliasFrom(BucketPropertyPath source) {
if (typeKey == null || source instanceof List) {
return Alias.NONE;
}
byte[] bytes = source.get(typeKey);
if (bytes != null) {
return Alias.ofNullable(conversionService.convert(bytes, String.class));
}
return Alias.NONE;
}
/*
* (non-Javadoc)
* @see org.springframework.data.convert.TypeAliasAccessor#writeTypeTo(java.lang.Object, java.lang.Object)
*/
public void writeTypeTo(BucketPropertyPath sink, Object alias) {
if (typeKey != null) {
if (alias instanceof byte[]) {
sink.put(typeKey, (byte[]) alias);
} else {
sink.put(typeKey, conversionService.convert(alias, byte[].class));
}
}
}
}
}

View File

@@ -42,13 +42,9 @@ import org.springframework.core.convert.ConverterNotFoundException;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.core.convert.support.GenericConversionService;
import org.springframework.data.convert.CustomConversions;
import org.springframework.data.convert.DefaultTypeMapper;
import org.springframework.data.convert.EntityInstantiator;
import org.springframework.data.convert.EntityInstantiators;
import org.springframework.data.convert.TypeAliasAccessor;
import org.springframework.data.convert.TypeMapper;
import org.springframework.data.keyvalue.core.mapping.KeySpaceResolver;
import org.springframework.data.mapping.Alias;
import org.springframework.data.mapping.AssociationHandler;
import org.springframework.data.mapping.MappingException;
import org.springframework.data.mapping.PersistentPropertyAccessor;
@@ -121,13 +117,12 @@ import org.springframework.util.comparator.NullSafeComparator;
*/
public class MappingRedisConverter implements RedisConverter, InitializingBean {
private static final String TYPE_HINT_ALIAS = "_class";
private static final String INVALID_TYPE_ASSIGNMENT = "Value of type %s cannot be assigned to property %s of type %s.";
private final RedisMappingContext mappingContext;
private final GenericConversionService conversionService;
private final EntityInstantiators entityInstantiators;
private final TypeMapper<RedisData> typeMapper;
private final RedisTypeMapper typeMapper;
private final Comparator<String> listKeyComparator = new NullSafeComparator<>(NaturalOrderingKeyComparator.INSTANCE,
true);
@@ -141,7 +136,7 @@ public class MappingRedisConverter implements RedisConverter, InitializingBean {
* @param context can be {@literal null}.
*/
MappingRedisConverter(RedisMappingContext context) {
this(context, null, null);
this(context, null, null, null);
}
/**
@@ -149,17 +144,32 @@ public class MappingRedisConverter implements RedisConverter, InitializingBean {
*
* @param mappingContext can be {@literal null}.
* @param indexResolver can be {@literal null}.
* @param referenceResolver must not be {@literal null}.
* @param referenceResolver can be not be {@literal null}.
*/
public MappingRedisConverter(@Nullable RedisMappingContext mappingContext, @Nullable IndexResolver indexResolver,
@Nullable ReferenceResolver referenceResolver) {
this(mappingContext, indexResolver, referenceResolver, null);
}
/**
* Creates new {@link MappingRedisConverter} and defaults {@link RedisMappingContext} when {@literal null}.
*
* @param mappingContext can be {@literal null}.
* @param indexResolver can be {@literal null}.
* @param referenceResolver can be {@literal null}.
* @param typeMapper can be {@literal null}.
* @since 2.1
*/
public MappingRedisConverter(@Nullable RedisMappingContext mappingContext, @Nullable IndexResolver indexResolver,
@Nullable ReferenceResolver referenceResolver, @Nullable RedisTypeMapper typeMapper) {
this.mappingContext = mappingContext != null ? mappingContext : new RedisMappingContext();
this.entityInstantiators = new EntityInstantiators();
this.conversionService = new DefaultConversionService();
this.customConversions = new RedisCustomConversions();
this.typeMapper = new DefaultTypeMapper<>(new RedisTypeAliasAccessor(this.conversionService));
this.typeMapper = typeMapper != null ? typeMapper
: new DefaultRedisTypeMapper(DefaultRedisTypeMapper.DEFAULT_TYPE_KEY, this.mappingContext);
this.indexResolver = indexResolver != null ? indexResolver : new PathIndexResolver(this.mappingContext);
this.referenceResolver = referenceResolver;
@@ -182,10 +192,8 @@ public class MappingRedisConverter implements RedisConverter, InitializingBean {
return null;
}
TypeInformation<?> readType = typeMapper.readType(source);
if (readType == null) {
readType = ClassTypeInformation.from(type);
}
TypeInformation<?> readType = typeMapper.readType(BucketPropertyPath.from(source.getBucket()),
ClassTypeInformation.from(type));
RedisPersistentEntity<?> entity = mappingContext.getPersistentEntity(readType);
@@ -267,16 +275,13 @@ public class MappingRedisConverter implements RedisConverter, InitializingBean {
} else if (persistentProperty.isEntity() && !conversionService.canConvert(byte[].class,
persistentProperty.getTypeInformation().getActualType().getType())) {
Class<R> targetType = (Class<R>) persistentProperty.getTypeInformation().getActualType().getType();
Bucket bucket = source.getBucket().extract(currentPath + ".");
RedisData newBucket = new RedisData(bucket);
TypeInformation<?> typeInformation = typeMapper.readType(BucketPropertyPath.from(bucket, currentPath),
persistentProperty.getTypeInformation().getActualType());
byte[] type1 = bucket.get(currentPath + "." + TYPE_HINT_ALIAS);
if (type1 != null && type1.length > 0) {
newBucket.getBucket().put(TYPE_HINT_ALIAS, type1);
}
Class<R> targetType = (Class<R>) typeInformation.getType();
R val = readInternal(currentPath, targetType, newBucket);
@@ -381,12 +386,12 @@ public class MappingRedisConverter implements RedisConverter, InitializingBean {
RedisPersistentEntity<?> entity = mappingContext.getPersistentEntity(source.getClass());
if (!customConversions.hasCustomWriteTarget(source.getClass())) {
typeMapper.writeType(ClassUtils.getUserClass(source), sink);
typeMapper.writeType(ClassUtils.getUserClass(source), BucketPropertyPath.from(sink.getBucket()));
}
if (entity == null) {
typeMapper.writeType(ClassUtils.getUserClass(source), sink);
typeMapper.writeType(ClassUtils.getUserClass(source), BucketPropertyPath.from(sink.getBucket()));
sink.getBucket().put("_raw", conversionService.convert(source, byte[].class));
return;
}
@@ -416,8 +421,12 @@ public class MappingRedisConverter implements RedisConverter, InitializingBean {
RedisPersistentEntity<?> entity = mappingContext.getRequiredPersistentEntity(update.getTarget());
write(update.getValue(), sink);
if (sink.getBucket().keySet().contains(TYPE_HINT_ALIAS)) {
sink.getBucket().remove(TYPE_HINT_ALIAS); // overwrite stuff in here
for (String key : sink.getBucket().keySet()) {
if (typeMapper.isTypeKey(key)) {
sink.getBucket().remove(key);
break;
}
}
if (update.isRefreshTtl() && !update.getPropertyUpdates().isEmpty()) {
@@ -574,8 +583,7 @@ public class MappingRedisConverter implements RedisConverter, InitializingBean {
}
if (value.getClass() != typeHint.getType()) {
sink.getBucket().put((!path.isEmpty() ? path + "." + TYPE_HINT_ALIAS : TYPE_HINT_ALIAS),
toBytes(value.getClass().getName()));
typeMapper.writeType(value.getClass(), BucketPropertyPath.from(sink.getBucket(), path));
}
RedisPersistentEntity<?> entity = mappingContext.getRequiredPersistentEntity(value.getClass());
@@ -735,8 +743,7 @@ public class MappingRedisConverter implements RedisConverter, InitializingBean {
if (!targetType.filter(it -> ClassUtils.isAssignable(Map.class, it)).isPresent()
&& customConversions.isSimpleType(value.getClass()) && value.getClass() != propertyType) {
sink.getBucket().put((!path.isEmpty() ? path + "." + TYPE_HINT_ALIAS : TYPE_HINT_ALIAS),
toBytes(value.getClass().getName()));
typeMapper.writeType(value.getClass(), BucketPropertyPath.from(sink.getBucket(), path));
}
if (targetType.filter(it -> ClassUtils.isAssignable(Map.class, it)).isPresent()) {
@@ -753,7 +760,6 @@ public class MappingRedisConverter implements RedisConverter, InitializingBean {
String.format("Cannot convert value '%s' of type %s to bytes.", value, value.getClass()));
}
}
}
private Object readCollectionOrArray(String path, Class<?> collectionType, Class<?> valueType, Bucket bucket) {
@@ -767,22 +773,20 @@ public class MappingRedisConverter implements RedisConverter, InitializingBean {
for (String key : keys) {
if (key.endsWith(TYPE_HINT_ALIAS)) {
if (typeMapper.isTypeKey(key)) {
continue;
}
Bucket elementData = bucket.extract(key);
byte[] typeInfo = elementData.get(key + "." + TYPE_HINT_ALIAS);
if (typeInfo != null && typeInfo.length > 0) {
elementData.put(TYPE_HINT_ALIAS, typeInfo);
}
TypeInformation<?> typeInformation = typeMapper.readType(BucketPropertyPath.from(elementData, key),
ClassTypeInformation.from(valueType));
Class<?> typeToUse = getTypeHint(key, elementData, valueType);
Class<?> typeToUse = typeInformation.getType();
if (conversionService.canConvert(byte[].class, typeToUse)) {
target.add(fromBytes(elementData.get(key), typeToUse));
} else {
target.add(readInternal(key, valueType, new RedisData(elementData)));
target.add(readInternal(key, typeToUse, new RedisData(elementData)));
}
}
@@ -850,7 +854,7 @@ public class MappingRedisConverter implements RedisConverter, InitializingBean {
for (Entry<String, byte[]> entry : partial.entrySet()) {
if (entry.getKey().endsWith(TYPE_HINT_ALIAS)) {
if (typeMapper.isTypeKey(entry.getKey())) {
continue;
}
@@ -881,16 +885,14 @@ public class MappingRedisConverter implements RedisConverter, InitializingBean {
for (String key : keys) {
Bucket partial = source.getBucket().extract(key);
byte[] typeInfo = partial.get(key + "." + TYPE_HINT_ALIAS);
if (typeInfo != null && typeInfo.length > 0) {
partial.put(TYPE_HINT_ALIAS, typeInfo);
}
Object value = readInternal(key, valueType, new RedisData(partial));
Object mapKey = extractMapKeyForPath(path, key, keyType);
target.put(mapKey, value);
TypeInformation<?> typeInformation = typeMapper.readType(BucketPropertyPath.from(source.getBucket(), key),
ClassTypeInformation.from(valueType));
Object o = readInternal(key, typeInformation.getType(), new RedisData(partial));
target.put(mapKey, o);
}
return target.isEmpty() ? null : target;
@@ -918,18 +920,9 @@ public class MappingRedisConverter implements RedisConverter, InitializingBean {
private Class<?> getTypeHint(String path, Bucket bucket, Class<?> fallback) {
byte[] typeInfo = bucket.get(path + "." + TYPE_HINT_ALIAS);
if (typeInfo == null || typeInfo.length < 1) {
return fallback;
}
String typeName = fromBytes(typeInfo, String.class);
try {
return ClassUtils.forName(typeName, this.getClass().getClassLoader());
} catch (ClassNotFoundException | LinkageError e) {
throw new MappingException(String.format("Cannot find class for type %s. ", typeName), e);
}
TypeInformation<?> typeInformation = typeMapper.readType(BucketPropertyPath.from(bucket, path),
ClassTypeInformation.from(fallback));
return typeInformation.getType();
}
/**
@@ -1062,36 +1055,6 @@ public class MappingRedisConverter implements RedisConverter, InitializingBean {
}
}
/**
* @author Christoph Strobl
*/
private static class RedisTypeAliasAccessor implements TypeAliasAccessor<RedisData> {
private final String typeKey;
private final ConversionService conversionService;
RedisTypeAliasAccessor(ConversionService conversionService) {
this(conversionService, TYPE_HINT_ALIAS);
}
RedisTypeAliasAccessor(ConversionService conversionService, String typeKey) {
this.conversionService = conversionService;
this.typeKey = typeKey;
}
@Override
public Alias readAliasFrom(RedisData source) {
return Alias.ofNullable(conversionService.convert(source.getBucket().get(typeKey), String.class));
}
@Override
public void writeTypeTo(RedisData sink, Object alias) {
sink.getBucket().put(typeKey, conversionService.convert(alias, byte[].class));
}
}
enum ClassNameKeySpaceResolver implements KeySpaceResolver {
INSTANCE;

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2017 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.core.convert;
import org.springframework.data.convert.TypeMapper;
/**
* Redis-specific {@link TypeMapper} exposing that {@link BucketPropertyPath}s might contain a type key.
*
* @author Mark Paluch
* @since 2.1
* @see BucketPropertyPath
*/
public interface RedisTypeMapper extends TypeMapper<BucketPropertyPath> {
/**
* Returns whether the given {@code key} is the type key.
*
* @return {@literal true} if the given {@code key} is the type key.
*/
boolean isTypeKey(String key);
}