diff --git a/src/main/asciidoc/index.adoc b/src/main/asciidoc/index.adoc index fc9475590..ad28f7c27 100644 --- a/src/main/asciidoc/index.adoc +++ b/src/main/asciidoc/index.adoc @@ -4,6 +4,7 @@ Costin Leau, Jennifer Hickey, Christoph Strobl, Thomas Darimont, Mark Paluch :revdate: {localdate} :toc: :toc-placement!: +:spring-data-commons-include: ../../../../spring-data-commons/src/main/asciidoc :spring-data-commons-docs: https://raw.githubusercontent.com/spring-projects/spring-data-commons/master/src/main/asciidoc (C) 2011-2016 The original authors. diff --git a/src/main/asciidoc/new-features.adoc b/src/main/asciidoc/new-features.adoc index be60e018f..ff354c94b 100644 --- a/src/main/asciidoc/new-features.adoc +++ b/src/main/asciidoc/new-features.adoc @@ -8,6 +8,7 @@ New and noteworthy in the latest releases. * Unix domain socket connections using <>. * <> support using Lettuce. +* <> integration [[new-in-2.0.0]] == New in Spring Data Redis 2.0 diff --git a/src/main/asciidoc/reference/query-by-example.adoc b/src/main/asciidoc/reference/query-by-example.adoc new file mode 100644 index 000000000..4ed60697b --- /dev/null +++ b/src/main/asciidoc/reference/query-by-example.adoc @@ -0,0 +1,42 @@ +[[query-by-example.execution]] +== Executing an example + +.Query by Example using a Repository +==== +[source, java] +---- +interface PersonRepository extends QueryByExampleExecutor { +} + +class PersonService { + + @Autowired PersonRepository personRepository; + + List findPeople(Person probe) { + return personRepository.findAll(Example.of(probe)); + } +} +---- +==== + +Redis Repositories support with their secondary indexes a subset of Spring Data's Query by Example features. +In particular, only exact, case-sensitive and non-null values are used to construct a query. + +Secondary indexes use set-based operations (Set intersection, Set union) to determine matching keys. Adding a property to the query that is not indexed returns no result as no index exists. Query by Example support inspects indexing configuration to only include properties in the query that are covered by an index. This is to prevent accidental inclusion of not indexed properties. + +Case-insensitive queries and unsupported ``StringMatcher``s are rejected at runtime. + +*Supported Query by Example options* + +* Case-sensitive, exact matching of simple and nested properties +* Any/All match modes +* Value transformation of the criteria value +* Exclusion of `null` values from the criteria + +*Not supported by Query by Example* + +* Case-insensitive matching +* Regex, prefix/contains/suffix String-matching +* Querying of Associations, Collection, and Map-like properties +* Inclusion of `null` values from the criteria +* `findAll` with sorting diff --git a/src/main/asciidoc/reference/redis-repositories.adoc b/src/main/asciidoc/reference/redis-repositories.adoc index 9957ed98d..f07816a2f 100644 --- a/src/main/asciidoc/reference/redis-repositories.adoc +++ b/src/main/asciidoc/reference/redis-repositories.adoc @@ -468,6 +468,8 @@ In the above example the lon/lat values are stored using `GEOADD` using the obje NOTE: It is **not** possible to combine `near`/`within` with other criteria. +include::../{spring-data-commons-include}/query-by-example.adoc[leveloffset=+1] +include::query-by-example.adoc[leveloffset=+1] [[redis.repositories.expirations]] == Time To Live diff --git a/src/main/java/org/springframework/data/redis/core/RedisKeyValueTemplate.java b/src/main/java/org/springframework/data/redis/core/RedisKeyValueTemplate.java index a6c0ca425..9ad49a868 100644 --- a/src/main/java/org/springframework/data/redis/core/RedisKeyValueTemplate.java +++ b/src/main/java/org/springframework/data/redis/core/RedisKeyValueTemplate.java @@ -35,6 +35,8 @@ import org.springframework.util.ClassUtils; */ public class RedisKeyValueTemplate extends KeyValueTemplate { + private final RedisKeyValueAdapter adapter; + /** * Create new {@link RedisKeyValueTemplate}. * @@ -43,6 +45,15 @@ public class RedisKeyValueTemplate extends KeyValueTemplate { */ public RedisKeyValueTemplate(RedisKeyValueAdapter adapter, RedisMappingContext mappingContext) { super(adapter, mappingContext); + this.adapter = adapter; + } + + /** + * @return the {@link RedisKeyValueAdapter}. + * @since 2.1 + */ + public RedisKeyValueAdapter getAdapter() { + return adapter; } /* @@ -54,6 +65,7 @@ public class RedisKeyValueTemplate extends KeyValueTemplate { return (RedisMappingContext) super.getMappingContext(); } + /** * Retrieve entities by resolving their {@literal id}s and converting them into required type.
* The callback provides either a single {@literal id} or an {@link Iterable} of {@literal id}s, used for retrieving diff --git a/src/main/java/org/springframework/data/redis/core/RedisQueryEngine.java b/src/main/java/org/springframework/data/redis/core/RedisQueryEngine.java index 664769919..9177f5398 100644 --- a/src/main/java/org/springframework/data/redis/core/RedisQueryEngine.java +++ b/src/main/java/org/springframework/data/redis/core/RedisQueryEngine.java @@ -159,20 +159,23 @@ class RedisQueryEngine extends QueryEngine { - String key = keyspace + ":"; - byte[][] keys = new byte[criteria.getSismember().size()][]; - int i = 0; - for (Object o : criteria.getSismember()) { - keys[i] = getAdapter().getConverter().getConversionService().convert(key + o, byte[].class); + long result = 0; + + if (!criteria.getOrSismember().isEmpty()) { + result += connection.sUnion(keys(keyspace + ":", criteria.getOrSismember())).size(); } - return (long) connection.sInter(keys).size(); + if (!criteria.getSismember().isEmpty()) { + result += connection.sInter(keys(keyspace + ":", criteria.getSismember())).size(); + } + + return result; }); } diff --git a/src/main/java/org/springframework/data/redis/core/convert/MappingRedisConverter.java b/src/main/java/org/springframework/data/redis/core/convert/MappingRedisConverter.java index 97ae4aef8..1d4452cab 100644 --- a/src/main/java/org/springframework/data/redis/core/convert/MappingRedisConverter.java +++ b/src/main/java/org/springframework/data/redis/core/convert/MappingRedisConverter.java @@ -18,17 +18,8 @@ package org.springframework.data.redis.core.convert; import lombok.RequiredArgsConstructor; import java.lang.reflect.Array; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.Map.Entry; -import java.util.Optional; -import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -990,6 +981,16 @@ public class MappingRedisConverter implements RedisConverter, InitializingBean { return this.mappingContext; } + /* + * (non-Javadoc) + * @see org.springframework.data.redis.core.convert.RedisConverter#getIndexResolver() + */ + @Nullable + @Override + public IndexResolver getIndexResolver() { + return this.indexResolver; + } + /* * (non-Javadoc) * @see org.springframework.data.convert.EntityConverter#getConversionService() diff --git a/src/main/java/org/springframework/data/redis/core/convert/RedisConverter.java b/src/main/java/org/springframework/data/redis/core/convert/RedisConverter.java index 0417b8c96..60584df56 100644 --- a/src/main/java/org/springframework/data/redis/core/convert/RedisConverter.java +++ b/src/main/java/org/springframework/data/redis/core/convert/RedisConverter.java @@ -19,11 +19,13 @@ import org.springframework.data.convert.EntityConverter; import org.springframework.data.redis.core.mapping.RedisMappingContext; import org.springframework.data.redis.core.mapping.RedisPersistentEntity; import org.springframework.data.redis.core.mapping.RedisPersistentProperty; +import org.springframework.lang.Nullable; /** * Redis specific {@link EntityConverter}. * * @author Christoph Strobl + * @author Mark Paluch * @since 1.7 */ public interface RedisConverter @@ -35,4 +37,11 @@ public interface RedisConverter */ @Override RedisMappingContext getMappingContext(); + + /** + * @return the configured {@link IndexResolver}, may be {@literal null}. + * @since 2.1 + */ + @Nullable + IndexResolver getIndexResolver(); } diff --git a/src/main/java/org/springframework/data/redis/repository/query/ExampleQueryMapper.java b/src/main/java/org/springframework/data/redis/repository/query/ExampleQueryMapper.java new file mode 100644 index 000000000..79a722e27 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/repository/query/ExampleQueryMapper.java @@ -0,0 +1,178 @@ +/* + * Copyright 2018 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.repository.query; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.domain.Example; +import org.springframework.data.domain.ExampleMatcher.MatchMode; +import org.springframework.data.domain.ExampleMatcher.PropertyValueTransformer; +import org.springframework.data.domain.ExampleMatcher.StringMatcher; +import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.redis.core.convert.IndexResolver; +import org.springframework.data.redis.core.convert.IndexedData; +import org.springframework.data.redis.core.mapping.RedisPersistentEntity; +import org.springframework.data.redis.core.mapping.RedisPersistentProperty; +import org.springframework.data.support.ExampleMatcherAccessor; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Mapper for Query-by-Example examples to an actual query. + *

+ * This mapper creates a {@link RedisOperationChain} for a given {@link Example} considering exact matches, + * {@link PropertyValueTransformer value transformations} and {@link MatchMode} for indexed simple and nested type + * properties. {@link java.util.Map} and {@link java.util.Collection} properties are not considered. + *

+ * Example matching is limited to case-sensitive and exact matches only. + * + * @author Mark Paluch + * @since 2.1 + */ +public class ExampleQueryMapper { + + private final Set SUPPORTED_MATCHERS = EnumSet.of(StringMatcher.DEFAULT, StringMatcher.EXACT); + + private final MappingContext, RedisPersistentProperty> mappingContext; + private final IndexResolver indexResolver; + + /** + * Creates a new {@link ExampleQueryMapper} given {@link MappingContext} and {@link IndexResolver}. + * + * @param mappingContext must not be {@literal null}. + * @param indexResolver must not be {@literal null}. + */ + public ExampleQueryMapper(MappingContext, RedisPersistentProperty> mappingContext, + IndexResolver indexResolver) { + + Assert.notNull(mappingContext, "MappingContext must not be null!"); + Assert.notNull(indexResolver, "IndexResolver must not be null!"); + + this.mappingContext = mappingContext; + this.indexResolver = indexResolver; + } + + /** + * Retrieve a mapped {@link RedisOperationChain} to query secondary indexes given {@link Example}. + * + * @param example must not be {@literal null}. + * @return the mapped {@link RedisOperationChain}. + */ + public RedisOperationChain getMappedExample(Example example) { + + RedisOperationChain chain = new RedisOperationChain(); + + ExampleMatcherAccessor matcherAccessor = new ExampleMatcherAccessor(example.getMatcher()); + + applyPropertySpecs("", example.getProbe(), mappingContext.getRequiredPersistentEntity(example.getProbeType()), + matcherAccessor, example.getMatcher().getMatchMode(), chain); + + return chain; + } + + private void applyPropertySpecs(String path, @Nullable Object probe, RedisPersistentEntity persistentEntity, + ExampleMatcherAccessor exampleSpecAccessor, MatchMode matchMode, RedisOperationChain chain) { + + if (probe == null) { + return; + } + + PersistentPropertyAccessor propertyAccessor = persistentEntity.getPropertyAccessor(probe); + + Set indexedData = getIndexedData(path, probe, persistentEntity); + Set indexNames = indexedData.stream().map(IndexedData::getIndexName).distinct().collect(Collectors.toSet()); + + persistentEntity.forEach(property -> { + + if (property.isIdProperty()) { + return; + } + + String propertyPath = StringUtils.hasText(path) ? path + "." + property.getName() : property.getName(); + + if (exampleSpecAccessor.isIgnoredPath(propertyPath) || property.isCollectionLike() || property.isMap()) { + return; + } + + applyPropertySpec(propertyPath, indexNames::contains, exampleSpecAccessor, propertyAccessor, property, matchMode, + chain); + }); + } + + private void applyPropertySpec(String path, Predicate hasIndex, ExampleMatcherAccessor exampleSpecAccessor, + PersistentPropertyAccessor propertyAccessor, RedisPersistentProperty property, MatchMode matchMode, + RedisOperationChain chain) { + + StringMatcher stringMatcher = exampleSpecAccessor.getDefaultStringMatcher(); + boolean ignoreCase = exampleSpecAccessor.isIgnoreCaseEnabled(); + Object value = propertyAccessor.getProperty(property); + + if (exampleSpecAccessor.hasPropertySpecifiers()) { + stringMatcher = exampleSpecAccessor.getStringMatcherForPath(path); + ignoreCase = exampleSpecAccessor.isIgnoreCaseForPath(path); + } + + if (ignoreCase) { + throw new InvalidDataAccessApiUsageException("Redis Query-by-Example supports only case-sensitive matching."); + } + + if (!SUPPORTED_MATCHERS.contains(stringMatcher)) { + throw new InvalidDataAccessApiUsageException( + String.format("Redis Query-by-Example does not support string matcher %s. Supported matchers are: %s.", + stringMatcher, SUPPORTED_MATCHERS)); + } + + if (exampleSpecAccessor.hasPropertySpecifier(path)) { + + PropertyValueTransformer valueTransformer = exampleSpecAccessor.getValueTransformerForPath(path); + value = valueTransformer.apply(Optional.ofNullable(value)).orElse(null); + } + + if (value == null) { + return; + } + + if (property.isEntity()) { + applyPropertySpecs(path, value, mappingContext.getRequiredPersistentEntity(property), exampleSpecAccessor, + matchMode, chain); + } else { + + if (matchMode == MatchMode.ALL) { + if (hasIndex.test(path)) { + chain.sismember(path, value); + } + } else { + chain.orSismember(path, value); + } + } + } + + private Set getIndexedData(String path, Object probe, RedisPersistentEntity persistentEntity) { + + String keySpace = persistentEntity.getKeySpace(); + return keySpace == null ? Collections.emptySet() + : indexResolver.resolveIndexesFor(persistentEntity.getKeySpace(), path, persistentEntity.getTypeInformation(), + probe); + } +} diff --git a/src/main/java/org/springframework/data/redis/repository/query/RedisOperationChain.java b/src/main/java/org/springframework/data/redis/repository/query/RedisOperationChain.java index e7d00c40b..f92ffb57b 100644 --- a/src/main/java/org/springframework/data/redis/repository/query/RedisOperationChain.java +++ b/src/main/java/org/springframework/data/redis/repository/query/RedisOperationChain.java @@ -33,6 +33,7 @@ import org.springframework.util.Assert; * Simple set of operations required to run queries against Redis. * * @author Christoph Strobl + * @author Mark Paluch * @since 1.7 */ public class RedisOperationChain { @@ -42,6 +43,10 @@ public class RedisOperationChain { private @Nullable NearPath near; + public boolean isEmpty() { + return near == null && sismember.isEmpty() && orSismember.isEmpty(); + } + public void sismember(String path, Object value) { sismember(new PathAndValue(path, value)); } diff --git a/src/main/java/org/springframework/data/redis/repository/support/QueryByExampleRedisExecutor.java b/src/main/java/org/springframework/data/redis/repository/support/QueryByExampleRedisExecutor.java new file mode 100644 index 000000000..b42f2fd7e --- /dev/null +++ b/src/main/java/org/springframework/data/redis/repository/support/QueryByExampleRedisExecutor.java @@ -0,0 +1,189 @@ +/* + * Copyright 2018 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.repository.support; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Example; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.keyvalue.core.query.KeyValueQuery; +import org.springframework.data.redis.core.RedisKeyValueTemplate; +import org.springframework.data.redis.core.convert.IndexResolver; +import org.springframework.data.redis.core.convert.PathIndexResolver; +import org.springframework.data.redis.core.convert.RedisConverter; +import org.springframework.data.redis.repository.query.ExampleQueryMapper; +import org.springframework.data.redis.repository.query.RedisOperationChain; +import org.springframework.data.repository.core.EntityInformation; +import org.springframework.data.repository.query.QueryByExampleExecutor; +import org.springframework.util.Assert; + +/** + * Repository fragment implementing Redis {@link QueryByExampleExecutor Query-by-Example} operations. + *

+ * This executor uses {@link ExampleQueryMapper} to map {@link Example}s into {@link KeyValueQuery} to execute its query + * methods. + * + * @author Mark Paluch + * @since 2.1 + */ +@SuppressWarnings("unchecked") +public class QueryByExampleRedisExecutor implements QueryByExampleExecutor { + + private final EntityInformation entityInformation; + private final RedisKeyValueTemplate keyValueTemplate; + private final ExampleQueryMapper mapper; + + /** + * Create a new {@link QueryByExampleRedisExecutor} given {@link EntityInformation} and {@link RedisKeyValueTemplate}. + * This constructor uses the configured {@link IndexResolver} from the converter. + * + * @param entityInformation must not be {@literal null}. + * @param keyValueTemplate must not be {@literal null}. + */ + public QueryByExampleRedisExecutor(EntityInformation entityInformation, + RedisKeyValueTemplate keyValueTemplate) { + + Assert.notNull(entityInformation, "EntityInformation must not be null!"); + Assert.notNull(keyValueTemplate, "RedisKeyValueTemplate must not be null!"); + + this.entityInformation = entityInformation; + this.keyValueTemplate = keyValueTemplate; + + RedisConverter converter = keyValueTemplate.getAdapter().getConverter(); + IndexResolver indexResolver = converter.getIndexResolver(); + + this.mapper = new ExampleQueryMapper(keyValueTemplate.getMappingContext(), + indexResolver != null ? indexResolver : new PathIndexResolver(converter.getMappingContext())); + } + + /** + * Create a new {@link QueryByExampleRedisExecutor} given {@link EntityInformation} and {@link RedisKeyValueTemplate}. + * + * @param entityInformation must not be {@literal null}. + * @param keyValueTemplate must not be {@literal null}. + */ + public QueryByExampleRedisExecutor(EntityInformation entityInformation, RedisKeyValueTemplate keyValueTemplate, + IndexResolver indexResolver) { + + Assert.notNull(entityInformation, "EntityInformation must not be null!"); + Assert.notNull(keyValueTemplate, "RedisKeyValueTemplate must not be null!"); + Assert.notNull(indexResolver, "IndexResolver must not be null!"); + + this.entityInformation = entityInformation; + this.keyValueTemplate = keyValueTemplate; + + this.mapper = new ExampleQueryMapper(keyValueTemplate.getMappingContext(), + new PathIndexResolver(keyValueTemplate.getMappingContext())); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.query.QueryByExampleExecutor#findOne(org.springframework.data.domain.Example) + */ + @Override + public Optional findOne(Example example) { + + RedisOperationChain operationChain = getQuery(example); + + KeyValueQuery query = new KeyValueQuery<>(operationChain); + Iterable result = keyValueTemplate.find(query.limit(1), entityInformation.getJavaType()); + Iterator iterator = result.iterator(); + + return iterator.hasNext() ? Optional.of((S) iterator.next()) : Optional.empty(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.query.QueryByExampleExecutor#findAll(org.springframework.data.domain.Example) + */ + @Override + public Iterable findAll(Example example) { + + RedisOperationChain operationChain = getQuery(example); + + return (Iterable) keyValueTemplate.find(new KeyValueQuery<>(operationChain), entityInformation.getJavaType()); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.query.QueryByExampleExecutor#findAll(org.springframework.data.domain.Example, org.springframework.data.domain.Sort) + */ + @Override + public Iterable findAll(Example example, Sort sort) { + throw new UnsupportedOperationException("Ordering is not supported"); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.query.QueryByExampleExecutor#findAll(org.springframework.data.domain.Example, org.springframework.data.domain.Pageable) + */ + @Override + public Page findAll(Example example, Pageable pageable) { + + Assert.notNull(pageable, "Pageable must not be null!"); + + RedisOperationChain operationChain = getQuery(example); + + KeyValueQuery query = new KeyValueQuery<>(operationChain); + Iterable result = keyValueTemplate.find( + query.orderBy(pageable.getSort()).skip(pageable.getOffset()).limit(pageable.getPageSize()), + entityInformation.getJavaType()); + + long count = operationChain.isEmpty() ? keyValueTemplate.count(entityInformation.getJavaType()) + : keyValueTemplate.count(query, entityInformation.getJavaType()); + + List list = new ArrayList<>(); + for (T t : result) { + list.add((S) t); + } + + return new PageImpl<>(list, pageable, count); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.query.QueryByExampleExecutor#count(org.springframework.data.domain.Example) + */ + @Override + public long count(Example example) { + + RedisOperationChain operationChain = getQuery(example); + + return keyValueTemplate.count(new KeyValueQuery<>(operationChain), entityInformation.getJavaType()); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.query.QueryByExampleExecutor#exists(org.springframework.data.domain.Example) + */ + @Override + public boolean exists(Example example) { + return count(example) > 0; + } + + private RedisOperationChain getQuery(Example example) { + + Assert.notNull(example, "Example must not be null!"); + + return mapper.getMappedExample(example); + } +} diff --git a/src/main/java/org/springframework/data/redis/repository/support/RedisRepositoryFactory.java b/src/main/java/org/springframework/data/redis/repository/support/RedisRepositoryFactory.java index ba2510c13..239fe3e87 100644 --- a/src/main/java/org/springframework/data/redis/repository/support/RedisRepositoryFactory.java +++ b/src/main/java/org/springframework/data/redis/repository/support/RedisRepositoryFactory.java @@ -18,11 +18,16 @@ package org.springframework.data.redis.repository.support; import org.springframework.data.keyvalue.core.KeyValueOperations; import org.springframework.data.keyvalue.repository.query.KeyValuePartTreeQuery; import org.springframework.data.keyvalue.repository.support.KeyValueRepositoryFactory; +import org.springframework.data.redis.core.mapping.RedisMappingContext; import org.springframework.data.redis.core.mapping.RedisPersistentEntity; import org.springframework.data.redis.repository.core.MappingRedisEntityInformation; import org.springframework.data.redis.repository.query.RedisQueryCreator; import org.springframework.data.repository.core.EntityInformation; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.RepositoryComposition.RepositoryFragments; import org.springframework.data.repository.core.support.RepositoryFactorySupport; +import org.springframework.data.repository.core.support.RepositoryFragment; +import org.springframework.data.repository.query.QueryByExampleExecutor; import org.springframework.data.repository.query.RepositoryQuery; import org.springframework.data.repository.query.parser.AbstractQueryCreator; @@ -70,6 +75,28 @@ public class RedisRepositoryFactory extends KeyValueRepositoryFactory { this.operations = keyValueOperations; } + /* + * (non-Javadoc) + * @see org.springframework.data.repository.core.support.RepositoryFactorySupport#getRepositoryFragments(org.springframework.data.repository.core.RepositoryMetadata) + */ + @Override + protected RepositoryFragments getRepositoryFragments(RepositoryMetadata metadata) { + + RepositoryFragments fragments = RepositoryFragments.empty(); + + if (QueryByExampleExecutor.class.isAssignableFrom(metadata.getRepositoryInterface())) { + + RedisMappingContext mappingContext = (RedisMappingContext) this.operations.getMappingContext(); + RedisPersistentEntity persistentEntity = mappingContext.getRequiredPersistentEntity(metadata.getDomainType()); + MappingRedisEntityInformation entityInformation = new MappingRedisEntityInformation<>(persistentEntity); + + fragments = fragments.append(RepositoryFragment.implemented(QueryByExampleExecutor.class, + getTargetRepositoryViaReflection(QueryByExampleRedisExecutor.class, entityInformation, operations))); + } + + return fragments; + } + /* * (non-Javadoc) * @see org.springframework.data.keyvalue.repository.support.KeyValueRepositoryFactory#getEntityInformation(java.lang.Class) diff --git a/src/test/java/org/springframework/data/redis/repository/RedisRepositoryIntegrationTestBase.java b/src/test/java/org/springframework/data/redis/repository/RedisRepositoryIntegrationTestBase.java index b08e46d9d..8d526b5fa 100644 --- a/src/test/java/org/springframework/data/redis/repository/RedisRepositoryIntegrationTestBase.java +++ b/src/test/java/org/springframework/data/redis/repository/RedisRepositoryIntegrationTestBase.java @@ -18,19 +18,20 @@ package org.springframework.data.redis.repository; import static org.hamcrest.Matchers.*; import static org.junit.Assert.*; -import java.io.Serializable; +import lombok.Data; + import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Optional; -import lombok.Data; import org.hamcrest.core.IsNull; import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.annotation.Id; import org.springframework.data.annotation.Reference; +import org.springframework.data.domain.Example; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -47,6 +48,7 @@ import org.springframework.data.redis.core.index.Indexed; import org.springframework.data.redis.core.index.SimpleIndexDefinition; import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.data.repository.query.QueryByExampleExecutor; /** * Base for testing Redis repository support in different configurations. @@ -288,6 +290,22 @@ public abstract class RedisRepositoryIntegrationTestBase { } } + @Test // DATAREDIS-605 + public void shouldFindByExample() { + + Person eddard = new Person("eddard", "stark"); + Person tyrion = new Person("tyrion", "lannister"); + Person robb = new Person("robb", "stark"); + Person jon = new Person("jon", "snow"); + Person arya = new Person("arya", "stark"); + + repo.saveAll(Arrays.asList(eddard, tyrion, robb, jon, arya)); + + List result = repo.findAll(Example.of(new Person(null, "stark"))); + + assertThat(result, hasSize(3)); + } + @Test // DATAREDIS-533 public void nearQueryShouldReturnResultsCorrectly() { @@ -347,7 +365,8 @@ public abstract class RedisRepositoryIntegrationTestBase { assertThat(result, not(hasItems(p1))); } - public static interface PersonRepository extends PagingAndSortingRepository { + public static interface PersonRepository + extends PagingAndSortingRepository, QueryByExampleExecutor { List findByFirstname(String firstname); @@ -368,6 +387,9 @@ public abstract class RedisRepositoryIntegrationTestBase { Page findBy(Pageable page); List findByHometownLocationNear(Point point, Distance distance); + + @Override + List findAll(Example example); } public static interface CityRepository extends CrudRepository { diff --git a/src/test/java/org/springframework/data/redis/repository/query/ExampleQueryMapperUnitTests.java b/src/test/java/org/springframework/data/redis/repository/query/ExampleQueryMapperUnitTests.java new file mode 100644 index 000000000..5cdb0f315 --- /dev/null +++ b/src/test/java/org/springframework/data/redis/repository/query/ExampleQueryMapperUnitTests.java @@ -0,0 +1,221 @@ +/* + * Copyright 2018 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.repository.query; + +import static org.assertj.core.api.Assertions.*; + +import lombok.Data; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.junit.Test; +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.Reference; +import org.springframework.data.domain.Example; +import org.springframework.data.domain.ExampleMatcher; +import org.springframework.data.domain.ExampleMatcher.StringMatcher; +import org.springframework.data.redis.core.convert.PathIndexResolver; +import org.springframework.data.redis.core.index.Indexed; +import org.springframework.data.redis.core.mapping.RedisMappingContext; +import org.springframework.data.redis.repository.query.RedisOperationChain.PathAndValue; + +/** + * Unit tests for {@link ExampleQueryMapper}. + * + * @author Mark Paluch + */ +public class ExampleQueryMapperUnitTests { + + RedisMappingContext mappingContext = new RedisMappingContext(); + ExampleQueryMapper mapper = new ExampleQueryMapper(mappingContext, new PathIndexResolver(mappingContext)); + + @Test // DATAREDIS-605 + public void shouldRejectCaseInsensitiveMatching() { + + assertThatThrownBy(() -> { + mapper.getMappedExample(Example.of(new Person(), ExampleMatcher.matching().withIgnoreCase())); + }).isInstanceOf(InvalidDataAccessApiUsageException.class); + } + + @Test // DATAREDIS-605 + public void shouldRejectUnsupportedStringMatchers() { + + List unsupported = Arrays.asList(StringMatcher.STARTING, StringMatcher.REGEX, + StringMatcher.CONTAINING, StringMatcher.ENDING); + + for (StringMatcher stringMatcher : unsupported) { + + assertThatThrownBy(() -> { + mapper.getMappedExample( + Example.of(new Person(), ExampleMatcher.matching().withStringMatcher(StringMatcher.STARTING))); + }) // + .hasMessageContaining("does not support") // + .describedAs("Unsupported matcher " + stringMatcher) // + .isInstanceOf(InvalidDataAccessApiUsageException.class); + } + } + + @Test // DATAREDIS-605 + public void shouldMapSimpleExample() { + + Person person = new Person(); + person.setFirstname("Walter"); + person.setGender(Gender.MALE); + person.setAge(50); + + RedisOperationChain operationChain = mapper.getMappedExample(Example.of(person)); + + assertThat(operationChain.getOrSismember()).isEmpty(); + assertThat(operationChain.getSismember()).contains(new PathAndValue("firstname", "Walter"), + new PathAndValue("gender", Gender.MALE), new PathAndValue("age", 50)); + } + + @Test // DATAREDIS-605 + public void shouldIgnoreFieldsWithoutIndexWithAllMatch() { + + Person person = new Person(); + person.setLastname("Foo"); + + RedisOperationChain operationChain = mapper.getMappedExample(Example.of(person)); + + assertThat(operationChain.getOrSismember()).isEmpty(); + assertThat(operationChain.getSismember()).isEmpty(); + } + + @Test // DATAREDIS-605 + public void shouldIncludeFieldsWithoutIndexWithAnyMatch() { + + Person person = new Person(); + person.setLastname("Foo"); + + RedisOperationChain operationChain = mapper.getMappedExample(Example.of(person, ExampleMatcher.matchingAny())); + + assertThat(operationChain.getOrSismember()).containsOnly(new PathAndValue("lastname", "Foo")); + assertThat(operationChain.getSismember()).isEmpty(); + } + + @Test // DATAREDIS-605 + public void shouldIgnorePaths() { + + Person person = new Person(); + person.setFirstname("Walter"); + person.setGender(Gender.MALE); + person.setAge(50); + + RedisOperationChain operationChain = mapper + .getMappedExample(Example.of(person, ExampleMatcher.matching().withIgnorePaths("gender", "age"))); + + assertThat(operationChain.getOrSismember()).isEmpty(); + assertThat(operationChain.getSismember()).containsOnly(new PathAndValue("firstname", "Walter")); + } + + @Test // DATAREDIS-605 + public void shouldMapNestedExample() { + + Person person = new Person(); + + Species species = new Species(); + species.name = "Homo Coquus Caeruleus Methiticus"; + + person.setSpecies(species); + + RedisOperationChain operationChain = mapper.getMappedExample(Example.of(person)); + + assertThat(operationChain.getOrSismember()).isEmpty(); + assertThat(operationChain.getSismember()) + .containsOnly(new PathAndValue("species.name", "Homo Coquus Caeruleus Methiticus")); + } + + @Test // DATAREDIS-605 + public void shouldIgnoreMapsAndCollections() { + + Person person = new Person(); + person.setNicknames(Arrays.asList("Heisenberg")); + person.setPhysicalAttributes(Collections.singletonMap("healthy", "no")); + + RedisOperationChain operationChain = mapper.getMappedExample(Example.of(person)); + + assertThat(operationChain.getOrSismember()).isEmpty(); + assertThat(operationChain.getSismember()).isEmpty(); + } + + @Test // DATAREDIS-605 + public void shouldMapMatchingAny() { + + Person person = new Person(); + person.setFirstname("Walter"); + person.setGender(Gender.MALE); + person.setAge(50); + + RedisOperationChain operationChain = mapper.getMappedExample(Example.of(person, ExampleMatcher.matchingAny())); + + assertThat(operationChain.getSismember()).isEmpty(); + assertThat(operationChain.getOrSismember()).contains(new PathAndValue("firstname", "Walter"), + new PathAndValue("gender", Gender.MALE), new PathAndValue("age", 50)); + } + + @Test // DATAREDIS-605 + public void shouldApplyPropertyTransformation() { + + Person person = new Person(); + person.setFirstname("Walter"); + + Example example = Example.of(person, + ExampleMatcher.matching().withTransformer("firstname", v -> v.map(s -> s.toString().toUpperCase()))); + + RedisOperationChain operationChain = mapper.getMappedExample(example); + + assertThat(operationChain.getSismember()).contains(new PathAndValue("firstname", "WALTER")); + } + + @Data + public static class Person { + + @Id String id; + + @Indexed String firstname; + String lastname; + @Indexed Gender gender; + + List nicknames; + @Indexed Integer age; + + Map physicalAttributes; + + @Reference Person relative; + + Species species; + } + + public enum Gender { + MALE, FEMALE { + + @Override + public String toString() { + return "Superwoman"; + } + } + } + + public static class Species { + + @Indexed String name; + } +} diff --git a/src/test/java/org/springframework/data/redis/repository/support/QueryByExampleRedisExecutorTests.java b/src/test/java/org/springframework/data/redis/repository/support/QueryByExampleRedisExecutorTests.java new file mode 100644 index 000000000..0a806c4fc --- /dev/null +++ b/src/test/java/org/springframework/data/redis/repository/support/QueryByExampleRedisExecutorTests.java @@ -0,0 +1,214 @@ +/* + * Copyright 2018 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.repository.support; + +import static org.assertj.core.api.Assertions.*; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Arrays; +import java.util.Optional; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.data.annotation.Id; +import org.springframework.data.domain.Example; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.keyvalue.core.KeyValueTemplate; +import org.springframework.data.keyvalue.repository.support.SimpleKeyValueRepository; +import org.springframework.data.redis.connection.jedis.JedisConnectionFactory; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.RedisKeyValueAdapter; +import org.springframework.data.redis.core.RedisKeyValueTemplate; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.index.Indexed; +import org.springframework.data.redis.core.mapping.RedisMappingContext; +import org.springframework.data.redis.core.mapping.RedisPersistentEntity; +import org.springframework.data.redis.repository.core.MappingRedisEntityInformation; + +/** + * Integration tests for {@link QueryByExampleRedisExecutor}. + * + * @author Mark Paluch + */ +public class QueryByExampleRedisExecutorTests { + + JedisConnectionFactory connectionFactory; + RedisMappingContext mappingContext = new RedisMappingContext(); + RedisKeyValueTemplate kvTemplate; + + Person walt, hank, gus; + + @Before + public void before() { + + connectionFactory = new JedisConnectionFactory(); + connectionFactory.afterPropertiesSet(); + + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.afterPropertiesSet(); + + kvTemplate = new RedisKeyValueTemplate(new RedisKeyValueAdapter(template, mappingContext), mappingContext); + + SimpleKeyValueRepository repository = new SimpleKeyValueRepository<>( + getEntityInformation(Person.class), new KeyValueTemplate(new RedisKeyValueAdapter(template))); + repository.deleteAll(); + + walt = new Person("Walter", "White"); + walt.setHometown(new City("Albuquerqe")); + + hank = new Person("Hank", "Schrader"); + hank.setHometown(new City("Albuquerqe")); + + gus = new Person("Gus", "Fring"); + gus.setHometown(new City("Albuquerqe")); + + repository.saveAll(Arrays.asList(walt, hank, gus)); + } + + @After + public void tearDown() { + connectionFactory.destroy(); + } + + @Test // DATAREDIS-605 + public void shouldFindOneByExample() { + + QueryByExampleRedisExecutor executor = new QueryByExampleRedisExecutor<>(getEntityInformation(Person.class), + kvTemplate); + + Optional result = executor.findOne(Example.of(walt)); + + assertThat(result).contains(walt); + } + + @Test // DATAREDIS-605 + public void shouldNotFindOneByExample() { + + QueryByExampleRedisExecutor executor = new QueryByExampleRedisExecutor<>(getEntityInformation(Person.class), + kvTemplate); + + Optional result = executor.findOne(Example.of(new Person("Skyler", "White"))); + assertThat(result).isEmpty(); + } + + @Test // DATAREDIS-605 + public void shouldFindAllByExample() { + + QueryByExampleRedisExecutor executor = new QueryByExampleRedisExecutor<>(getEntityInformation(Person.class), + kvTemplate); + + Person person = new Person(); + person.setHometown(walt.getHometown()); + + Iterable result = executor.findAll(Example.of(person)); + assertThat(result).contains(walt, gus, hank); + } + + @Test // DATAREDIS-605 + public void shouldNotSupportFindAllOrdered() { + + QueryByExampleRedisExecutor executor = new QueryByExampleRedisExecutor<>(getEntityInformation(Person.class), + kvTemplate); + + Person person = new Person(); + person.setHometown(walt.getHometown()); + + assertThatThrownBy(() -> executor.findAll(Example.of(person), Sort.by("foo"))) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test // DATAREDIS-605 + public void shouldFindAllPagedByExample() { + + QueryByExampleRedisExecutor executor = new QueryByExampleRedisExecutor<>(getEntityInformation(Person.class), + kvTemplate); + + Person person = new Person(); + person.setHometown(walt.getHometown()); + + Page result = executor.findAll(Example.of(person), PageRequest.of(0, 2)); + assertThat(result).hasSize(2); + assertThat(result.getTotalElements()).isEqualTo(3); + } + + @Test // DATAREDIS-605 + public void shouldCountCorrectly() { + + QueryByExampleRedisExecutor executor = new QueryByExampleRedisExecutor<>(getEntityInformation(Person.class), + kvTemplate); + + Person person = new Person(); + person.setHometown(walt.getHometown()); + + assertThat(executor.count(Example.of(person))).isEqualTo(3); + assertThat(executor.count(Example.of(walt))).isEqualTo(1); + assertThat(executor.count(Example.of(new Person()))).isEqualTo(3); + assertThat(executor.count(Example.of(new Person("Foo", "Bar")))).isZero(); + } + + @Test // DATAREDIS-605 + public void shouldReportExistenceCorrectly() { + + QueryByExampleRedisExecutor executor = new QueryByExampleRedisExecutor<>(getEntityInformation(Person.class), + kvTemplate); + + Person person = new Person(); + person.setHometown(walt.getHometown()); + + assertThat(executor.exists(Example.of(person))).isTrue(); + assertThat(executor.exists(Example.of(walt))).isTrue(); + assertThat(executor.exists(Example.of(new Person()))).isTrue(); + assertThat(executor.exists(Example.of(new Person("Foo", "Bar")))).isFalse(); + } + + @SuppressWarnings("unchecked") + private MappingRedisEntityInformation getEntityInformation(Class entityClass) { + return new MappingRedisEntityInformation<>( + (RedisPersistentEntity) mappingContext.getRequiredPersistentEntity(entityClass)); + } + + @RedisHash("persons") + @Data + static class Person { + + @Id String id; + @Indexed String firstname; + String lastname; + City hometown; + + public Person() {} + + public Person(String firstname, String lastname) { + + this.firstname = firstname; + this.lastname = lastname; + } + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + static class City { + @Indexed String name; + } +}