Skip to content

Commit a3f876f

Browse files
mp911dechristophstrobl
authored andcommitted
DATAREDIS-605 - Query by Example for Redis Repositories.
We now support query by example via Redis repositories. We allow case-sensitive, exact matching of singular simple and nested properties, using any/all match modes, value transformation of the criteria value for indexed properties. We do not support findAll with sorting, case-insensitive properties or string matchers other than exact. interface PersonRepository extends QueryByExampleExecutor<Person> { } class PersonService { @Autowired PersonRepository personRepository; List<Person> findPeople(Person probe) { return personRepository.findAll(Example.of(probe)); } } Original Pull Request: #301
1 parent 2be6a3b commit a3f876f

15 files changed

+947
-20
lines changed

src/main/asciidoc/index.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Costin Leau, Jennifer Hickey, Christoph Strobl, Thomas Darimont, Mark Paluch
44
:revdate: {localdate}
55
:toc:
66
:toc-placement!:
7+
:spring-data-commons-include: ../../../../spring-data-commons/src/main/asciidoc
78
:spring-data-commons-docs: https://raw.githubusercontent.com/spring-projects/spring-data-commons/master/src/main/asciidoc
89

910
(C) 2011-2016 The original authors.

src/main/asciidoc/new-features.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ New and noteworthy in the latest releases.
88

99
* Unix domain socket connections using <<redis:connectors:lettuce,Lettuce>>.
1010
* <<redis:write-to-master-read-from-slave, Write to Master read from Slave>> support using Lettuce.
11+
* <<query-by-example,Query by Example>> integration
1112

1213
[[new-in-2.0.0]]
1314
== New in Spring Data Redis 2.0
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
[[query-by-example.execution]]
2+
== Executing an example
3+
4+
.Query by Example using a Repository
5+
====
6+
[source, java]
7+
----
8+
interface PersonRepository extends QueryByExampleExecutor<Person> {
9+
}
10+
11+
class PersonService {
12+
13+
@Autowired PersonRepository personRepository;
14+
15+
List<Person> findPeople(Person probe) {
16+
return personRepository.findAll(Example.of(probe));
17+
}
18+
}
19+
----
20+
====
21+
22+
Redis Repositories support with their secondary indexes a subset of Spring Data's Query by Example features.
23+
In particular, only exact, case-sensitive and non-null values are used to construct a query.
24+
25+
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.
26+
27+
Case-insensitive queries and unsupported ``StringMatcher``s are rejected at runtime.
28+
29+
*Supported Query by Example options*
30+
31+
* Case-sensitive, exact matching of simple and nested properties
32+
* Any/All match modes
33+
* Value transformation of the criteria value
34+
* Exclusion of `null` values from the criteria
35+
36+
*Not supported by Query by Example*
37+
38+
* Case-insensitive matching
39+
* Regex, prefix/contains/suffix String-matching
40+
* Querying of Associations, Collection, and Map-like properties
41+
* Inclusion of `null` values from the criteria
42+
* `findAll` with sorting

src/main/asciidoc/reference/redis-repositories.adoc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,8 @@ In the above example the lon/lat values are stored using `GEOADD` using the obje
468468

469469
NOTE: It is **not** possible to combine `near`/`within` with other criteria.
470470

471+
include::../{spring-data-commons-include}/query-by-example.adoc[leveloffset=+1]
472+
include::query-by-example.adoc[leveloffset=+1]
471473

472474
[[redis.repositories.expirations]]
473475
== Time To Live

src/main/java/org/springframework/data/redis/core/RedisKeyValueTemplate.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
*/
3636
public class RedisKeyValueTemplate extends KeyValueTemplate {
3737

38+
private final RedisKeyValueAdapter adapter;
39+
3840
/**
3941
* Create new {@link RedisKeyValueTemplate}.
4042
*
@@ -43,6 +45,15 @@ public class RedisKeyValueTemplate extends KeyValueTemplate {
4345
*/
4446
public RedisKeyValueTemplate(RedisKeyValueAdapter adapter, RedisMappingContext mappingContext) {
4547
super(adapter, mappingContext);
48+
this.adapter = adapter;
49+
}
50+
51+
/**
52+
* @return the {@link RedisKeyValueAdapter}.
53+
* @since 2.1
54+
*/
55+
public RedisKeyValueAdapter getAdapter() {
56+
return adapter;
4657
}
4758

4859
/*
@@ -54,6 +65,7 @@ public RedisMappingContext getMappingContext() {
5465
return (RedisMappingContext) super.getMappingContext();
5566
}
5667

68+
5769
/**
5870
* Retrieve entities by resolving their {@literal id}s and converting them into required type. <br />
5971
* The callback provides either a single {@literal id} or an {@link Iterable} of {@literal id}s, used for retrieving

src/main/java/org/springframework/data/redis/core/RedisQueryEngine.java

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -159,20 +159,23 @@ public Collection<?> execute(RedisOperationChain criteria, Comparator<?> sort, l
159159
@Override
160160
public long count(RedisOperationChain criteria, String keyspace) {
161161

162-
if (criteria == null) {
162+
if (criteria == null || criteria.isEmpty()) {
163163
return this.getAdapter().count(keyspace);
164164
}
165165

166166
return this.getAdapter().execute(connection -> {
167167

168-
String key = keyspace + ":";
169-
byte[][] keys = new byte[criteria.getSismember().size()][];
170-
int i = 0;
171-
for (Object o : criteria.getSismember()) {
172-
keys[i] = getAdapter().getConverter().getConversionService().convert(key + o, byte[].class);
168+
long result = 0;
169+
170+
if (!criteria.getOrSismember().isEmpty()) {
171+
result += connection.sUnion(keys(keyspace + ":", criteria.getOrSismember())).size();
172+
}
173+
174+
if (!criteria.getSismember().isEmpty()) {
175+
result += connection.sInter(keys(keyspace + ":", criteria.getSismember())).size();
173176
}
174177

175-
return (long) connection.sInter(keys).size();
178+
return result;
176179
});
177180
}
178181

src/main/java/org/springframework/data/redis/core/convert/MappingRedisConverter.java

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,8 @@
1818
import lombok.RequiredArgsConstructor;
1919

2020
import java.lang.reflect.Array;
21-
import java.util.ArrayList;
22-
import java.util.Collection;
23-
import java.util.Collections;
24-
import java.util.Comparator;
25-
import java.util.HashMap;
26-
import java.util.Iterator;
27-
import java.util.List;
28-
import java.util.Map;
21+
import java.util.*;
2922
import java.util.Map.Entry;
30-
import java.util.Optional;
31-
import java.util.Set;
3223
import java.util.regex.Matcher;
3324
import java.util.regex.Pattern;
3425

@@ -990,6 +981,16 @@ public RedisMappingContext getMappingContext() {
990981
return this.mappingContext;
991982
}
992983

984+
/*
985+
* (non-Javadoc)
986+
* @see org.springframework.data.redis.core.convert.RedisConverter#getIndexResolver()
987+
*/
988+
@Nullable
989+
@Override
990+
public IndexResolver getIndexResolver() {
991+
return this.indexResolver;
992+
}
993+
993994
/*
994995
* (non-Javadoc)
995996
* @see org.springframework.data.convert.EntityConverter#getConversionService()

src/main/java/org/springframework/data/redis/core/convert/RedisConverter.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@
1919
import org.springframework.data.redis.core.mapping.RedisMappingContext;
2020
import org.springframework.data.redis.core.mapping.RedisPersistentEntity;
2121
import org.springframework.data.redis.core.mapping.RedisPersistentProperty;
22+
import org.springframework.lang.Nullable;
2223

2324
/**
2425
* Redis specific {@link EntityConverter}.
2526
*
2627
* @author Christoph Strobl
28+
* @author Mark Paluch
2729
* @since 1.7
2830
*/
2931
public interface RedisConverter
@@ -35,4 +37,11 @@ public interface RedisConverter
3537
*/
3638
@Override
3739
RedisMappingContext getMappingContext();
40+
41+
/**
42+
* @return the configured {@link IndexResolver}, may be {@literal null}.
43+
* @since 2.1
44+
*/
45+
@Nullable
46+
IndexResolver getIndexResolver();
3847
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/*
2+
* Copyright 2018 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.data.redis.repository.query;
17+
18+
import java.util.Collections;
19+
import java.util.EnumSet;
20+
import java.util.Optional;
21+
import java.util.Set;
22+
import java.util.function.Predicate;
23+
import java.util.stream.Collectors;
24+
25+
import org.springframework.dao.InvalidDataAccessApiUsageException;
26+
import org.springframework.data.domain.Example;
27+
import org.springframework.data.domain.ExampleMatcher.MatchMode;
28+
import org.springframework.data.domain.ExampleMatcher.PropertyValueTransformer;
29+
import org.springframework.data.domain.ExampleMatcher.StringMatcher;
30+
import org.springframework.data.mapping.PersistentPropertyAccessor;
31+
import org.springframework.data.mapping.context.MappingContext;
32+
import org.springframework.data.redis.core.convert.IndexResolver;
33+
import org.springframework.data.redis.core.convert.IndexedData;
34+
import org.springframework.data.redis.core.mapping.RedisPersistentEntity;
35+
import org.springframework.data.redis.core.mapping.RedisPersistentProperty;
36+
import org.springframework.data.support.ExampleMatcherAccessor;
37+
import org.springframework.lang.Nullable;
38+
import org.springframework.util.Assert;
39+
import org.springframework.util.StringUtils;
40+
41+
/**
42+
* Mapper for Query-by-Example examples to an actual query.
43+
* <p/>
44+
* This mapper creates a {@link RedisOperationChain} for a given {@link Example} considering exact matches,
45+
* {@link PropertyValueTransformer value transformations} and {@link MatchMode} for indexed simple and nested type
46+
* properties. {@link java.util.Map} and {@link java.util.Collection} properties are not considered.
47+
* <p/>
48+
* Example matching is limited to case-sensitive and exact matches only.
49+
*
50+
* @author Mark Paluch
51+
* @since 2.1
52+
*/
53+
public class ExampleQueryMapper {
54+
55+
private final Set<StringMatcher> SUPPORTED_MATCHERS = EnumSet.of(StringMatcher.DEFAULT, StringMatcher.EXACT);
56+
57+
private final MappingContext<RedisPersistentEntity<?>, RedisPersistentProperty> mappingContext;
58+
private final IndexResolver indexResolver;
59+
60+
/**
61+
* Creates a new {@link ExampleQueryMapper} given {@link MappingContext} and {@link IndexResolver}.
62+
*
63+
* @param mappingContext must not be {@literal null}.
64+
* @param indexResolver must not be {@literal null}.
65+
*/
66+
public ExampleQueryMapper(MappingContext<RedisPersistentEntity<?>, RedisPersistentProperty> mappingContext,
67+
IndexResolver indexResolver) {
68+
69+
Assert.notNull(mappingContext, "MappingContext must not be null!");
70+
Assert.notNull(indexResolver, "IndexResolver must not be null!");
71+
72+
this.mappingContext = mappingContext;
73+
this.indexResolver = indexResolver;
74+
}
75+
76+
/**
77+
* Retrieve a mapped {@link RedisOperationChain} to query secondary indexes given {@link Example}.
78+
*
79+
* @param example must not be {@literal null}.
80+
* @return the mapped {@link RedisOperationChain}.
81+
*/
82+
public RedisOperationChain getMappedExample(Example<?> example) {
83+
84+
RedisOperationChain chain = new RedisOperationChain();
85+
86+
ExampleMatcherAccessor matcherAccessor = new ExampleMatcherAccessor(example.getMatcher());
87+
88+
applyPropertySpecs("", example.getProbe(), mappingContext.getRequiredPersistentEntity(example.getProbeType()),
89+
matcherAccessor, example.getMatcher().getMatchMode(), chain);
90+
91+
return chain;
92+
}
93+
94+
private void applyPropertySpecs(String path, @Nullable Object probe, RedisPersistentEntity<?> persistentEntity,
95+
ExampleMatcherAccessor exampleSpecAccessor, MatchMode matchMode, RedisOperationChain chain) {
96+
97+
if (probe == null) {
98+
return;
99+
}
100+
101+
PersistentPropertyAccessor propertyAccessor = persistentEntity.getPropertyAccessor(probe);
102+
103+
Set<IndexedData> indexedData = getIndexedData(path, probe, persistentEntity);
104+
Set<String> indexNames = indexedData.stream().map(IndexedData::getIndexName).distinct().collect(Collectors.toSet());
105+
106+
persistentEntity.forEach(property -> {
107+
108+
if (property.isIdProperty()) {
109+
return;
110+
}
111+
112+
String propertyPath = StringUtils.hasText(path) ? path + "." + property.getName() : property.getName();
113+
114+
if (exampleSpecAccessor.isIgnoredPath(propertyPath) || property.isCollectionLike() || property.isMap()) {
115+
return;
116+
}
117+
118+
applyPropertySpec(propertyPath, indexNames::contains, exampleSpecAccessor, propertyAccessor, property, matchMode,
119+
chain);
120+
});
121+
}
122+
123+
private void applyPropertySpec(String path, Predicate<String> hasIndex, ExampleMatcherAccessor exampleSpecAccessor,
124+
PersistentPropertyAccessor propertyAccessor, RedisPersistentProperty property, MatchMode matchMode,
125+
RedisOperationChain chain) {
126+
127+
StringMatcher stringMatcher = exampleSpecAccessor.getDefaultStringMatcher();
128+
boolean ignoreCase = exampleSpecAccessor.isIgnoreCaseEnabled();
129+
Object value = propertyAccessor.getProperty(property);
130+
131+
if (exampleSpecAccessor.hasPropertySpecifiers()) {
132+
stringMatcher = exampleSpecAccessor.getStringMatcherForPath(path);
133+
ignoreCase = exampleSpecAccessor.isIgnoreCaseForPath(path);
134+
}
135+
136+
if (ignoreCase) {
137+
throw new InvalidDataAccessApiUsageException("Redis Query-by-Example supports only case-sensitive matching.");
138+
}
139+
140+
if (!SUPPORTED_MATCHERS.contains(stringMatcher)) {
141+
throw new InvalidDataAccessApiUsageException(
142+
String.format("Redis Query-by-Example does not support string matcher %s. Supported matchers are: %s.",
143+
stringMatcher, SUPPORTED_MATCHERS));
144+
}
145+
146+
if (exampleSpecAccessor.hasPropertySpecifier(path)) {
147+
148+
PropertyValueTransformer valueTransformer = exampleSpecAccessor.getValueTransformerForPath(path);
149+
value = valueTransformer.apply(Optional.ofNullable(value)).orElse(null);
150+
}
151+
152+
if (value == null) {
153+
return;
154+
}
155+
156+
if (property.isEntity()) {
157+
applyPropertySpecs(path, value, mappingContext.getRequiredPersistentEntity(property), exampleSpecAccessor,
158+
matchMode, chain);
159+
} else {
160+
161+
if (matchMode == MatchMode.ALL) {
162+
if (hasIndex.test(path)) {
163+
chain.sismember(path, value);
164+
}
165+
} else {
166+
chain.orSismember(path, value);
167+
}
168+
}
169+
}
170+
171+
private Set<IndexedData> getIndexedData(String path, Object probe, RedisPersistentEntity<?> persistentEntity) {
172+
173+
String keySpace = persistentEntity.getKeySpace();
174+
return keySpace == null ? Collections.emptySet()
175+
: indexResolver.resolveIndexesFor(persistentEntity.getKeySpace(), path, persistentEntity.getTypeInformation(),
176+
probe);
177+
}
178+
}

src/main/java/org/springframework/data/redis/repository/query/RedisOperationChain.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
* Simple set of operations required to run queries against Redis.
3434
*
3535
* @author Christoph Strobl
36+
* @author Mark Paluch
3637
* @since 1.7
3738
*/
3839
public class RedisOperationChain {
@@ -42,6 +43,10 @@ public class RedisOperationChain {
4243

4344
private @Nullable NearPath near;
4445

46+
public boolean isEmpty() {
47+
return near == null && sismember.isEmpty() && orSismember.isEmpty();
48+
}
49+
4550
public void sismember(String path, Object value) {
4651
sismember(new PathAndValue(path, value));
4752
}

0 commit comments

Comments
 (0)