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
This commit is contained in:
Mark Paluch
2018-01-09 16:30:27 +01:00
committed by Christoph Strobl
parent 2be6a3b3f7
commit a3f876f926
15 changed files with 947 additions and 20 deletions

View File

@@ -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<Person> 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<Person, String> {
public static interface PersonRepository
extends PagingAndSortingRepository<Person, String>, QueryByExampleExecutor<Person> {
List<Person> findByFirstname(String firstname);
@@ -368,6 +387,9 @@ public abstract class RedisRepositoryIntegrationTestBase {
Page<Person> findBy(Pageable page);
List<Person> findByHometownLocationNear(Point point, Distance distance);
@Override
<S extends Person> List<S> findAll(Example<S> example);
}
public static interface CityRepository extends CrudRepository<City, String> {

View File

@@ -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<StringMatcher> 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<Person> 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<String> nicknames;
@Indexed Integer age;
Map<String, String> physicalAttributes;
@Reference Person relative;
Species species;
}
public enum Gender {
MALE, FEMALE {
@Override
public String toString() {
return "Superwoman";
}
}
}
public static class Species {
@Indexed String name;
}
}

View File

@@ -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<byte[], byte[]> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.afterPropertiesSet();
kvTemplate = new RedisKeyValueTemplate(new RedisKeyValueAdapter(template, mappingContext), mappingContext);
SimpleKeyValueRepository<Person, String> 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<Person> executor = new QueryByExampleRedisExecutor<>(getEntityInformation(Person.class),
kvTemplate);
Optional<Person> result = executor.findOne(Example.of(walt));
assertThat(result).contains(walt);
}
@Test // DATAREDIS-605
public void shouldNotFindOneByExample() {
QueryByExampleRedisExecutor<Person> executor = new QueryByExampleRedisExecutor<>(getEntityInformation(Person.class),
kvTemplate);
Optional<Person> result = executor.findOne(Example.of(new Person("Skyler", "White")));
assertThat(result).isEmpty();
}
@Test // DATAREDIS-605
public void shouldFindAllByExample() {
QueryByExampleRedisExecutor<Person> executor = new QueryByExampleRedisExecutor<>(getEntityInformation(Person.class),
kvTemplate);
Person person = new Person();
person.setHometown(walt.getHometown());
Iterable<Person> result = executor.findAll(Example.of(person));
assertThat(result).contains(walt, gus, hank);
}
@Test // DATAREDIS-605
public void shouldNotSupportFindAllOrdered() {
QueryByExampleRedisExecutor<Person> 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<Person> executor = new QueryByExampleRedisExecutor<>(getEntityInformation(Person.class),
kvTemplate);
Person person = new Person();
person.setHometown(walt.getHometown());
Page<Person> 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<Person> 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<Person> 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 <T> MappingRedisEntityInformation<T, String> getEntityInformation(Class<T> 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;
}
}