DATAREDIS-533 - Add support for geo indexes.

We now allow usage of @GeoIndexed to mark GeoLocation or Point properties as candidates for secondary index creation. Non null values will be included in GEOADD command as follows:

    GEOADD keyspace:property-path point.x point.y entity-id

@GeoIndexed can be used on top level as well as on nested properties.

class Person {

	@Id String id;
	String firstname, lastname;
	Address hometown;
}

class Address {
	String city, street, housenumber;
	@GeoIndexed Point location;
}

The above allows us to derive geospatial queries from a given method using NEAR and WITHIN keywords like:

interface PersonRepository extends CrudRepository<Person, String> {

	List<Person> findByAddressLocationNear(Point point, Distance distance);

	List<Person> findByAddressLocationWithin(Circle circle);
}

Partial updates on the Point itself also trigger an index refresh operation. So it is possible to alter existing entities via:

template.save(new PartialUpdate<Person>("1", Person.class).set("address.location", new Point(17, 18));

Original pull request: #215.
This commit is contained in:
Christoph Strobl
2016-07-14 12:14:32 +02:00
committed by Mark Paluch
parent 5d09272876
commit 07d0b82100
21 changed files with 930 additions and 43 deletions

View File

@@ -35,6 +35,7 @@ import org.springframework.core.convert.converter.Converter;
import org.springframework.core.convert.support.GenericConversionService;
import org.springframework.dao.InvalidDataAccessApiUsageException;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.convert.GeoIndexedPropertyValue;
import org.springframework.data.redis.core.convert.IndexedData;
import org.springframework.data.redis.core.convert.MappingRedisConverter;
import org.springframework.data.redis.core.convert.PathIndexResolver;
@@ -215,6 +216,21 @@ public class IndexWriterUnitTests {
verify(connectionMock, times(1)).sRem(any(byte[].class), eq(KEY_BIN));
}
/**
* @see DATAREDIS-533
*/
@Test
public void removeGeoIndexShouldCallGeoRemove() {
byte[] indexKey1 = "persons:location".getBytes(CHARSET);
when(connectionMock.keys(any(byte[].class))).thenReturn(new LinkedHashSet<byte[]>(Arrays.asList(indexKey1)));
writer.removeKeyFromExistingIndexes(KEY_BIN, new GeoIndexedPropertyValue(KEYSPACE, "address.city", null));
verify(connectionMock).geoRemove(indexKey1, KEY_BIN);
}
static class StubIndxedData implements IndexedData {
@Override

View File

@@ -36,6 +36,7 @@ import org.springframework.beans.factory.InitializingBean;
import org.springframework.dao.DataAccessException;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Reference;
import org.springframework.data.geo.Point;
import org.springframework.data.keyvalue.annotation.KeySpace;
import org.springframework.data.redis.ConnectionFactoryTracker;
import org.springframework.data.redis.connection.RedisConnection;
@@ -45,6 +46,7 @@ import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactor
import org.springframework.data.redis.core.convert.Bucket;
import org.springframework.data.redis.core.convert.KeyspaceConfiguration;
import org.springframework.data.redis.core.convert.MappingConfiguration;
import org.springframework.data.redis.core.index.GeoIndexed;
import org.springframework.data.redis.core.index.IndexConfiguration;
import org.springframework.data.redis.core.index.Indexed;
import org.springframework.data.redis.core.mapping.RedisMappingContext;
@@ -510,6 +512,90 @@ public class RedisKeyValueAdapterTests {
assertThat(template.opsForHash().hasKey("persons:1", "relatives.[stepfather].firstname"), is(false));
}
/**
* @see DATAREDIS-533
*/
@Test
public void putShouldCreateGeoIndexCorrectly() {
Person tam = new Person();
tam.id = "1";
tam.firstname = "tam";
tam.address = new Address();
tam.address.location = new Point(10, 20);
adapter.put("1", tam, "persons");
assertThat(template.opsForZSet().score("persons:address:location", "1"), is(notNullValue()));
}
/**
* @see DATAREDIS-533
*/
@Test
public void deleteShouldRemoveGeoIndexCorrectly() {
Person tam = new Person();
tam.id = "1";
tam.firstname = "tam";
tam.address = new Address();
tam.address.location = new Point(10, 20);
adapter.put("1", tam, "persons");
adapter.delete("1", "persons", Person.class);
assertThat(template.opsForZSet().score("persons:address:location", "1"), is(nullValue()));
}
/**
* @see DATAREDIS-533
*/
@Test
public void updateShouldAlterGeoIndexCorrectlyOnDelete() {
Person tam = new Person();
tam.id = "1";
tam.firstname = "tam";
tam.address = new Address();
tam.address.location = new Point(10, 20);
adapter.put("1", tam, "persons");
PartialUpdate<Person> update = new PartialUpdate<Person>("1", Person.class) //
.del("address.location");
adapter.update(update);
assertThat(template.opsForZSet().score("persons:address:location", "1"), is(nullValue()));
}
/**
* @see DATAREDIS-533
*/
@Test
public void updateShouldAlterGeoIndexCorrectlyOnUpdate() {
Person tam = new Person();
tam.id = "1";
tam.firstname = "tam";
tam.address = new Address();
tam.address.location = new Point(10, 20);
adapter.put("1", tam, "persons");
PartialUpdate<Person> update = new PartialUpdate<Person>("1", Person.class) //
.set("address.location", new Point(17, 18));
adapter.update(update);
assertThat(template.opsForZSet().score("persons:address:location", "1"), is(notNullValue()));
Point updatedLocation = template.opsForGeo().geoPos("persons:address:location", "1").iterator().next();
assertThat(updatedLocation.getX(), is(closeTo(17D, 0.005)));
assertThat(updatedLocation.getY(), is(closeTo(18D, 0.005)));
}
@KeySpace("persons")
static class Person {
@@ -536,6 +622,8 @@ public class RedisKeyValueAdapterTests {
String city;
@Indexed String country;
@GeoIndexed Point location;
}
static class AddressWithId extends Address {

View File

@@ -34,10 +34,13 @@ import java.util.Set;
import org.hamcrest.core.IsCollectionContaining;
import org.hamcrest.core.IsInstanceOf;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.runners.MockitoJUnitRunner;
import org.springframework.data.geo.Point;
import org.springframework.data.mapping.PersistentProperty;
import org.springframework.data.redis.core.convert.ConversionTestEntities.Address;
import org.springframework.data.redis.core.convert.ConversionTestEntities.AddressWithId;
@@ -48,6 +51,7 @@ import org.springframework.data.redis.core.convert.ConversionTestEntities.Person
import org.springframework.data.redis.core.convert.ConversionTestEntities.Size;
import org.springframework.data.redis.core.convert.ConversionTestEntities.TaVeren;
import org.springframework.data.redis.core.convert.ConversionTestEntities.TheWheelOfTime;
import org.springframework.data.redis.core.index.GeoIndexed;
import org.springframework.data.redis.core.index.IndexConfiguration;
import org.springframework.data.redis.core.index.Indexed;
import org.springframework.data.redis.core.index.SimpleIndexDefinition;
@@ -61,6 +65,8 @@ import org.springframework.data.util.ClassTypeInformation;
@RunWith(MockitoJUnitRunner.class)
public class PathIndexResolverUnitTests {
public @Rule ExpectedException exception = ExpectedException.none();
IndexConfiguration indexConfig;
PathIndexResolver indexResolver;
@@ -514,6 +520,68 @@ public class PathIndexResolverUnitTests {
new SimpleIndexedPropertyValue(IndexedOnPrimitiveArrayField.class.getName(), "values", 3)));
}
/**
* @see DATAREDIS-533
*/
@Test
public void resolveGeoIndexShouldMapNameCorrectly() {
when(propertyMock.isMap()).thenReturn(true);
when(propertyMock.isAnnotationPresent(eq(GeoIndexed.class))).thenReturn(true);
when(propertyMock.findAnnotation(eq(GeoIndexed.class))).thenReturn(createGeoIndexedInstance());
IndexedData index = resolve("location", new Point(1D, 2D));
assertThat(index.getIndexName(), is("location"));
}
/**
* @see DATAREDIS-533
*/
@Test
public void resolveGeoIndexShouldMapNameForNestedPropertyCorrectly() {
when(propertyMock.isMap()).thenReturn(true);
when(propertyMock.isAnnotationPresent(eq(GeoIndexed.class))).thenReturn(true);
when(propertyMock.findAnnotation(eq(GeoIndexed.class))).thenReturn(createGeoIndexedInstance());
IndexedData index = resolve("property.location", new Point(1D, 2D));
assertThat(index.getIndexName(), is("property:location"));
}
/**
* @see DATAREDIS-533
*/
@Test
public void resolveGeoIndexOnPointField() {
GeoIndexedOnPoint source = new GeoIndexedOnPoint();
source.location = new Point(1D, 2D);
Set<IndexedData> indexes = indexResolver.resolveIndexesFor(ClassTypeInformation.from(GeoIndexedOnPoint.class),
source);
assertThat(indexes.size(), is(1));
assertThat(indexes, IsCollectionContaining.<IndexedData> hasItems(
new GeoIndexedPropertyValue(GeoIndexedOnPoint.class.getName(), "location", source.location)));
}
/**
* @see DATAREDIS-533
*/
@Test
public void resolveGeoIndexOnArrayFieldThrowsError() {
exception.expect(IllegalArgumentException.class);
exception.expectMessage("GeoIndexed property needs to be of type Point or GeoLocation");
GeoIndexedOnArray source = new GeoIndexedOnArray();
source.location = new double[] { 10D, 20D };
indexResolver.resolveIndexesFor(ClassTypeInformation.from(GeoIndexedOnArray.class), source);
}
private IndexedData resolve(String path, Object value) {
Set<IndexedData> data = indexResolver.resolveIndex(KEYSPACE_PERSON, path, propertyMock, value);
@@ -534,7 +602,16 @@ public class PathIndexResolverUnitTests {
public Class<? extends Annotation> annotationType() {
return Indexed.class;
}
};
}
private GeoIndexed createGeoIndexedInstance() {
return new GeoIndexed() {
@Override
public Class<? extends Annotation> annotationType() {
return GeoIndexed.class;
}
};
}
@@ -553,4 +630,12 @@ public class PathIndexResolverUnitTests {
@Indexed Map<String, String> values;
}
static class GeoIndexedOnPoint {
@GeoIndexed Point location;
}
static class GeoIndexedOnArray {
@GeoIndexed double[] location;
}
}

View File

@@ -51,8 +51,8 @@ public class RedisRepositoryClusterIntegrationTests extends RedisRepositoryInteg
@Configuration
@EnableRedisRepositories(considerNestedRepositories = true, indexConfiguration = MyIndexConfiguration.class,
keyspaceConfiguration = MyKeyspaceConfiguration.class,
includeFilters = { @ComponentScan.Filter(type = FilterType.REGEX, pattern = ".*PersonRepository") })
keyspaceConfiguration = MyKeyspaceConfiguration.class, includeFilters = {
@ComponentScan.Filter(type = FilterType.REGEX, pattern = ".*PersonRepository|.*CityRepository") })
static class Config {
@Bean

View File

@@ -15,9 +15,7 @@
*/
package org.springframework.data.redis.repository;
import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.collection.IsCollectionWithSize.*;
import static org.hamcrest.collection.IsIterableContainingInAnyOrder.*;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.*;
import java.io.Serializable;
@@ -34,13 +32,18 @@ import org.springframework.data.annotation.Reference;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.geo.Distance;
import org.springframework.data.geo.Metrics;
import org.springframework.data.geo.Point;
import org.springframework.data.keyvalue.core.KeyValueTemplate;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.convert.KeyspaceConfiguration;
import org.springframework.data.redis.core.index.GeoIndexed;
import org.springframework.data.redis.core.index.IndexConfiguration;
import org.springframework.data.redis.core.index.IndexDefinition;
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;
/**
@@ -52,6 +55,7 @@ import org.springframework.data.repository.PagingAndSortingRepository;
public abstract class RedisRepositoryIntegrationTestBase {
@Autowired PersonRepository repo;
@Autowired CityRepository cityRepo;
@Autowired KeyValueTemplate kvTemplate;
@Before
@@ -318,6 +322,74 @@ public abstract class RedisRepositoryIntegrationTestBase {
}
}
/**
* @see DATAREDIS-533
*/
@Test
public void nearQueryShouldReturnResultsCorrectly() {
City palermo = new City();
palermo.location = new Point(13.361389D, 38.115556D);
City catania = new City();
catania.location = new Point(15.087269D, 37.502669D);
cityRepo.save(Arrays.asList(palermo, catania));
List<City> result = cityRepo.findByLocationNear(new Point(15D, 37D), new Distance(200, Metrics.KILOMETERS));
assertThat(result, hasItems(palermo, catania));
result = cityRepo.findByLocationNear(new Point(15D, 37D), new Distance(100, Metrics.KILOMETERS));
assertThat(result, hasItems(catania));
assertThat(result, not(hasItems(palermo)));
}
/**
* @see DATAREDIS-533
*/
@Test
public void nearQueryShouldFindNothingIfOutOfRange() {
City palermo = new City();
palermo.location = new Point(13.361389D, 38.115556D);
City catania = new City();
catania.location = new Point(15.087269D, 37.502669D);
cityRepo.save(Arrays.asList(palermo, catania));
List<City> result = cityRepo.findByLocationNear(new Point(15D, 37D), new Distance(10, Metrics.KILOMETERS));
assertThat(result, is(empty()));
}
/**
* @see DATAREDIS-533
*/
@Test
public void nearQueryShouldReturnResultsCorrectlyOnNestedProperty() {
City palermo = new City();
palermo.location = new Point(13.361389D, 38.115556D);
City catania = new City();
catania.location = new Point(15.087269D, 37.502669D);
Person p1 = new Person("foo", "bar");
p1.hometown = palermo;
Person p2 = new Person("two", "two");
p2.hometown = catania;
repo.save(Arrays.asList(p1, p2));
List<Person> result = repo.findByHometownLocationNear(new Point(15D, 37D), new Distance(200, Metrics.KILOMETERS));
assertThat(result, hasItems(p1, p2));
result = repo.findByHometownLocationNear(new Point(15D, 37D), new Distance(100, Metrics.KILOMETERS));
assertThat(result, hasItems(p2));
assertThat(result, not(hasItems(p1)));
}
public static interface PersonRepository extends PagingAndSortingRepository<Person, String> {
List<Person> findByFirstname(String firstname);
@@ -337,6 +409,13 @@ public abstract class RedisRepositoryIntegrationTestBase {
List<Person> findTop2ByLastname(String lastname);
Page<Person> findBy(Pageable page);
List<Person> findByHometownLocationNear(Point point, Distance distance);
}
public static interface CityRepository extends CrudRepository<City, String> {
List<City> findByLocationNear(Point point, Distance distance);
}
/**
@@ -373,6 +452,7 @@ public abstract class RedisRepositoryIntegrationTestBase {
@Indexed String firstname;
String lastname;
@Reference City city;
City hometown;
public Person() {}
@@ -460,9 +540,12 @@ public abstract class RedisRepositoryIntegrationTestBase {
}
public static class City {
@Id String id;
String name;
@GeoIndexed Point location;
@Override
public int hashCode() {
final int prime = 31;

View File

@@ -35,8 +35,8 @@ public class RedisRepositoryIntegrationTests extends RedisRepositoryIntegrationT
@Configuration
@EnableRedisRepositories(considerNestedRepositories = true, indexConfiguration = MyIndexConfiguration.class,
keyspaceConfiguration = MyKeyspaceConfiguration.class,
includeFilters = { @ComponentScan.Filter(type = FilterType.REGEX, pattern = ".*PersonRepository") })
keyspaceConfiguration = MyKeyspaceConfiguration.class, includeFilters = {
@ComponentScan.Filter(type = FilterType.REGEX, pattern = ".*PersonRepository|.*CityRepository") })
static class Config {
@Bean

View File

@@ -16,15 +16,26 @@
package org.springframework.data.redis.repository.query;
import static org.hamcrest.collection.IsCollectionWithSize.*;
import static org.hamcrest.core.Is.*;
import static org.hamcrest.core.IsCollectionContaining.*;
import static org.hamcrest.core.IsNull.*;
import static org.junit.Assert.*;
import java.lang.reflect.Method;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.mockito.Mock;
import org.springframework.dao.InvalidDataAccessApiUsageException;
import org.springframework.data.geo.Box;
import org.springframework.data.geo.Circle;
import org.springframework.data.geo.Distance;
import org.springframework.data.geo.Metrics;
import org.springframework.data.geo.Point;
import org.springframework.data.geo.Shape;
import org.springframework.data.keyvalue.core.query.KeyValueQuery;
import org.springframework.data.redis.core.convert.ConversionTestEntities;
import org.springframework.data.redis.core.convert.ConversionTestEntities.Person;
import org.springframework.data.redis.repository.query.RedisOperationChain.PathAndValue;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.core.RepositoryMetadata;
@@ -37,6 +48,8 @@ import org.springframework.data.repository.query.parser.PartTree;
*/
public class RedisQueryCreatorUnitTests {
public @Rule ExpectedException exception = ExpectedException.none();
private @Mock RepositoryMetadata metadataMock;
/**
@@ -61,8 +74,8 @@ public class RedisQueryCreatorUnitTests {
public void findByMultipleSimpleProperties() throws SecurityException, NoSuchMethodException {
RedisQueryCreator creator = createQueryCreatorForMethodWithArgs(
SampleRepository.class.getMethod("findByFirstnameAndAge", String.class, Integer.class), new Object[] {
"eddard", 43 });
SampleRepository.class.getMethod("findByFirstnameAndAge", String.class, Integer.class),
new Object[] { "eddard", 43 });
KeyValueQuery<RedisOperationChain> query = creator.createQuery();
@@ -78,8 +91,8 @@ public class RedisQueryCreatorUnitTests {
public void findByMultipleSimplePropertiesUsingOr() throws SecurityException, NoSuchMethodException {
RedisQueryCreator creator = createQueryCreatorForMethodWithArgs(
SampleRepository.class.getMethod("findByAgeOrFirstname", Integer.class, String.class), new Object[] { 43,
"eddard" });
SampleRepository.class.getMethod("findByAgeOrFirstname", Integer.class, String.class),
new Object[] { 43, "eddard" });
KeyValueQuery<RedisOperationChain> query = creator.createQuery();
@@ -88,21 +101,127 @@ public class RedisQueryCreatorUnitTests {
assertThat(query.getCritieria().getOrSismember(), hasItem(new PathAndValue("firstname", "eddard")));
}
/**
* @see DATAREDIS-533
*/
@Test
public void findWithinCircle() throws SecurityException, NoSuchMethodException {
RedisQueryCreator creator = createQueryCreatorForMethodWithArgs(
SampleRepository.class.getMethod("findByLocationWithin", Circle.class),
new Object[] { new Circle(new Point(1, 2), new Distance(200, Metrics.KILOMETERS)) });
KeyValueQuery<RedisOperationChain> query = creator.createQuery();
assertThat(query.getCritieria().getNear(), is(notNullValue()));
assertThat(query.getCritieria().getNear().getPoint(), is(new Point(1, 2)));
assertThat(query.getCritieria().getNear().getDistance(), is(new Distance(200, Metrics.KILOMETERS)));
}
/**
* @see DATAREDIS-533
*/
@Test
public void findNearWithPointAndDistance() throws SecurityException, NoSuchMethodException {
RedisQueryCreator creator = createQueryCreatorForMethodWithArgs(
SampleRepository.class.getMethod("findByLocationNear", Point.class, Distance.class),
new Object[] { new Point(1, 2), new Distance(200, Metrics.KILOMETERS) });
KeyValueQuery<RedisOperationChain> query = creator.createQuery();
assertThat(query.getCritieria().getNear(), is(notNullValue()));
assertThat(query.getCritieria().getNear().getPoint(), is(new Point(1, 2)));
assertThat(query.getCritieria().getNear().getDistance(), is(new Distance(200, Metrics.KILOMETERS)));
}
/**
* @see DATAREDIS-533
*/
@Test
public void findNearWithPointAndNumericValueDefaultsToKilometers() throws SecurityException, NoSuchMethodException {
RedisQueryCreator creator = createQueryCreatorForMethodWithArgs(
SampleRepository.class.getMethod("findByLocationNear", Shape.class, Object.class),
new Object[] { new Point(1, 2), 200F });
KeyValueQuery<RedisOperationChain> query = creator.createQuery();
assertThat(query.getCritieria().getNear(), is(notNullValue()));
assertThat(query.getCritieria().getNear().getPoint(), is(new Point(1, 2)));
assertThat(query.getCritieria().getNear().getDistance(), is(new Distance(200, Metrics.KILOMETERS)));
}
/**
* @see DATAREDIS-533
*/
@Test
public void findNearWithInvalidShapeParameter() throws SecurityException, NoSuchMethodException {
exception.expect(InvalidDataAccessApiUsageException.class);
exception.expectMessage("Expected to find a Circle or Point/Distance");
RedisQueryCreator creator = createQueryCreatorForMethodWithArgs(
SampleRepository.class.getMethod("findByLocationNear", Shape.class, Object.class),
new Object[] { new Box(new Point(0, 0), new Point(1, 1)), 200F });
creator.createQuery();
}
/**
* @see DATAREDIS-533
*/
@Test
public void findNearWithInvalidDistanceParameter() throws SecurityException, NoSuchMethodException {
exception.expect(InvalidDataAccessApiUsageException.class);
exception.expectMessage("Expected to find Distance or Numeric value");
RedisQueryCreator creator = createQueryCreatorForMethodWithArgs(
SampleRepository.class.getMethod("findByLocationNear", Shape.class, Object.class),
new Object[] { new Point(0, 0), "200" });
creator.createQuery();
}
/**
* @see DATAREDIS-533
*/
@Test
public void findNearWithMissingDistanceParameter() throws SecurityException, NoSuchMethodException {
exception.expect(InvalidDataAccessApiUsageException.class);
exception.expectMessage("Are you missing a parameter?");
RedisQueryCreator creator = createQueryCreatorForMethodWithArgs(
SampleRepository.class.getMethod("findByLocationNear", Shape.class), new Object[] { new Point(0, 0) });
creator.createQuery();
}
private RedisQueryCreator createQueryCreatorForMethodWithArgs(Method method, Object[] args) {
PartTree partTree = new PartTree(method.getName(), method.getReturnType());
RedisQueryCreator creator = new RedisQueryCreator(partTree, new ParametersParameterAccessor(new DefaultParameters(
method), args));
RedisQueryCreator creator = new RedisQueryCreator(partTree,
new ParametersParameterAccessor(new DefaultParameters(method), args));
return creator;
}
private interface SampleRepository extends Repository<ConversionTestEntities.Person, String> {
private interface SampleRepository extends Repository<Person, String> {
ConversionTestEntities.Person findByFirstname(String firstname);
Person findByFirstname(String firstname);
ConversionTestEntities.Person findByFirstnameAndAge(String firstname, Integer age);
Person findByFirstnameAndAge(String firstname, Integer age);
ConversionTestEntities.Person findByAgeOrFirstname(Integer age, String firstname);
Person findByAgeOrFirstname(Integer age, String firstname);
Person findByLocationWithin(Circle circle);
Person findByLocationNear(Point point, Distance distance);
Person findByLocationNear(Shape point, Object distance);
Person findByLocationNear(Shape point);
}
}