DATACOUCH-165 - Add geospatial support

Add support for geo queries through the use of Spatial Views. This needs the query method to be annotated with @Dimensional (or a meta-annotation of it that is runtime retained).

See SpatialViewQueryCreator javadoc for supported part types and corresponding expected arguments (to be documented).
In a nutshell WITHIN Box (other shapes supported but approximated to bounding box), NEAR Point+Distance (approximated to bounding box), plus a few simple operators for dimensions beyond 2.

When approximating, a DEBUG log is made along the logging of the query.

Conversion methods from Shapes to bounding boxes are done in a GeoUtils class.

Unit and Integration test of the feature.

Added documentation for @Dimensional query derivation.
This commit is contained in:
Simon Baslé
2015-09-22 20:41:02 +02:00
parent 2914f57897
commit a2f26f4310
22 changed files with 1461 additions and 20 deletions

View File

@@ -16,6 +16,7 @@
package org.springframework.data.couchbase.repository;
import static org.hamcrest.CoreMatchers.instanceOf;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.*;
@@ -26,6 +27,7 @@ import java.util.List;
import com.couchbase.client.java.Bucket;
import com.couchbase.client.java.view.Stale;
import com.couchbase.client.java.view.ViewQuery;
import com.couchbase.client.java.view.ViewResult;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -75,9 +77,13 @@ public class CouchbaseRepositoryViewTests {
@Test
public void shouldCountWithCustomView() {
client.query(ViewQuery.from("userCustom", "customCountView").stale(Stale.FALSE));
ViewResult clientResult = client.query(ViewQuery.from("userCustom", "customCountView")
.reduce().stale(Stale.FALSE));
final Object clientRowValue = clientResult.allRows().get(0).value();
final long value = repository.count();
assertThat(value, is(100L));
assertThat(clientRowValue, instanceOf(Number.class));
assertThat(((Number) clientRowValue).longValue(), is(value));
}
@Test

View File

@@ -0,0 +1,57 @@
package org.springframework.data.couchbase.repository;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.List;
import com.couchbase.client.java.document.json.JsonArray;
import org.springframework.data.couchbase.core.view.Dimensional;
import org.springframework.data.geo.Box;
import org.springframework.data.geo.Circle;
import org.springframework.data.geo.Distance;
import org.springframework.data.geo.Point;
import org.springframework.data.geo.Polygon;
import org.springframework.data.repository.CrudRepository;
public interface DimensionalPartyRepository extends CrudRepository<Party, String> {
@Dimensional(designDocument = "partyGeo", spatialViewName = "byLocation")
List<Party> findByLocationNear(Point p, Distance d);
@Dimensional(designDocument = "partyGeo", spatialViewName = "byLocation")
List<Party> findByLocationWithin(Box boundingBox);
@Dimensional(designDocument = "partyGeo", spatialViewName = "byLocation")
List<Party> findByLocationWithin(Polygon zone);
@Dimensional(designDocument = "partyGeo", spatialViewName = "byLocation")
List<Party> findByLocationWithin(Point[] points);
@Dimensional(designDocument = "partyGeo", spatialViewName = "byLocation")
List<Party> findByLocationWithin(Point point);
@Dimensional(designDocument = "partyGeo", spatialViewName = "byLocation")
List<Party> findByLocationWithin(JsonArray range);
@Dimensional(designDocument = "partyGeo", spatialViewName = "byLocation")
List<Party> findByLocationWithin(Point lowerLeft, Point upperRight);
@Dimensional(designDocument = "partyGeo", spatialViewName = "byLocationAndAttendees", dimensions = 3)
List<Party> findByLocationWithin(Circle zone);
@Dimensional(designDocument = "partyGeo", spatialViewName = "byLocationAndAttendees", dimensions = 3)
List<Party> findByLocationWithinAndAttendeesGreaterThan(Polygon zone, double minAttendees);
@Dimensional(designDocument = "partyGeo", spatialViewName = "byLocationAndAttendees", dimensions = 3)
List<Party> findByLocationWithin(JsonArray startRange, JsonArray endRange);
//TODO more coverage of operators?
@IndexedByLocation
List<Party> findByLocationIsWithin(Point a, Point b);
@Dimensional(designDocument = "partyGeo", spatialViewName = "byLocation", dimensions = 2)
@Retention(RetentionPolicy.RUNTIME)
@interface IndexedByLocation { }
}

View File

@@ -0,0 +1,313 @@
package org.springframework.data.couchbase.repository;
import static org.junit.Assert.*;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import com.couchbase.client.java.document.json.JsonArray;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.couchbase.IntegrationTestApplicationConfig;
import org.springframework.data.couchbase.core.CouchbaseQueryExecutionException;
import org.springframework.data.couchbase.core.CouchbaseTemplate;
import org.springframework.data.couchbase.repository.support.CouchbaseRepositoryFactory;
import org.springframework.data.geo.Box;
import org.springframework.data.geo.Circle;
import org.springframework.data.geo.Distance;
import org.springframework.data.geo.Point;
import org.springframework.data.geo.Polygon;
import org.springframework.data.repository.core.support.RepositoryFactorySupport;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
/**
* @author Simon Baslé
*/
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = IntegrationTestApplicationConfig.class)
@TestExecutionListeners(PartyPopulatorListener.class)
public class DimensionalQueryTests {
@Autowired
private CouchbaseTemplate template;
private DimensionalPartyRepository repository;
@Before
public void setup() throws Exception {
RepositoryFactorySupport factory = new CouchbaseRepositoryFactory(template);
repository = factory.getRepository(DimensionalPartyRepository.class);
}
@Test
public void testFindWithingPolygon() {
Set<String> expectedKeys = new HashSet<String>();
expectedKeys.add("testparty-2");
expectedKeys.add("testparty-3");
expectedKeys.add("testparty-4");
expectedKeys.add("testparty-5");
//a zone that engulfs parties 2, 3, 4 and 5, in the shape of a downward arrow pointing right
Polygon zone = new Polygon(new Point(1, -2),
new Point(3, -1.5),
new Point(6, -4),
new Point(5.5, -5.5),
new Point(3, -5));
List<Party> parties = repository.findByLocationWithin(zone);
assertEquals(4, parties.size());
for (Party party : parties) {
assertTrue(expectedKeys.contains(party.getKey()));
}
}
@Test
public void testByLocationNear() {
Set<String> expectedKeys = new HashSet<String>();
expectedKeys.add("testparty-0");
expectedKeys.add("testparty-1");
List<Party> parties = repository.findByLocationNear(new Point(0, 0), new Distance(1.5));
assertEquals(2, parties.size());
for (Party party : parties) {
assertTrue(expectedKeys.contains(party.getKey()));
}
parties = repository.findByLocationNear(new Point(0, 0), new Distance(2.5));
expectedKeys.add("testparty-2");
assertEquals(3, parties.size());
for (Party party : parties) {
assertTrue(expectedKeys.contains(party.getKey()));
}
}
@Test
public void testByLocationAndSparseAttendees() {
Set<String> expectedKeys = new HashSet<String>();
expectedKeys.add("testparty-4");
expectedKeys.add("testparty-5");
//a zone that engulfs parties 2, 3, 4 and 5 (with resp. 120, 130, 140 and 150 attendees)
//in the shape of a downward arrow pointing right
Polygon zone = new Polygon(new Point(1, -2),
new Point(3, -1.5),
new Point(6, -4),
new Point(5.5, -5.5),
new Point(3, -5));
//first check the zone contains 4 parties
List<Party> allPartiesInZone = repository.findByLocationWithinAndAttendeesGreaterThan(zone, -1);
List<Party> allPartiesInZoneWithoutAttendeeCriteria = repository.findByLocationWithin(zone);
assertEquals(allPartiesInZone.toString(), 4, allPartiesInZone.size());
assertEquals(allPartiesInZoneWithoutAttendeeCriteria, allPartiesInZone);
//check parties are limited by the attendees
List<Party> parties = repository.findByLocationWithinAndAttendeesGreaterThan(zone, 140);
for (Party party : parties) {
System.out.println(party.getKey() + " : " + party.getLocation() + " " + party.getAttendees());
}
assertEquals(parties.toString(), 2, parties.size());
for (Party party : parties) {
assertTrue(party.getAttendees() >= 140);
assertTrue(expectedKeys.contains(party.getKey()));
}
}
@Test
public void testByLocationInCircle() {
Circle zone1 = new Circle(new Point(-5,5), new Distance(5.5));
Circle zone2 = new Circle(new Point(6, -6), new Distance(10));
Circle zoneEmpty = new Circle(new Point(6,6), new Distance(3));
List<Party> parties = repository.findByLocationWithin(zone1);
assertEquals(1, parties.size());
assertEquals("testparty-0", parties.get(0).getKey());
parties = repository.findByLocationWithin(zone2);
assertEquals(12, parties.size()); //all the parties except the special one at 100, 100
parties = repository.findByLocationWithin(zoneEmpty);
assertEquals(0, parties.size());
}
@Test
public void testByLocationInBox() {
Box zone1 = new Box(new Point(-10.5, -0.5), new Point(0.5, 10.5));
Box zone2 = new Box(new Point(-4, -16), new Point(16, 4));
Box zoneEmpty = new Box(new Point(3, 3), new Point(9, 9));
List<Party> parties = repository.findByLocationWithin(zone1);
assertEquals(1, parties.size());
assertEquals("testparty-0", parties.get(0).getKey());
parties = repository.findByLocationWithin(zone2);
assertEquals(12, parties.size()); //all the parties except the special one at 100, 100
parties = repository.findByLocationWithin(zoneEmpty);
assertEquals(0, parties.size());
}
@Test
public void testByLocationInPolygon() {
//bounding box of (-10.5, -0.5),(0.5, 10.5)
Polygon zone1 = new Polygon(
new Point(-10.5, -0.5),
new Point(-10.5, 10.5),
new Point(0.5, 10.5),
new Point(0.4, 1));
//bounding box of (-4, -16),(16,4)
Polygon zone2 = new Polygon(
new Point(-4, -10),
new Point(-3, 4),
new Point(14, 2),
new Point(16, -16));
//bounding box of (3,3)(9,9)
Polygon zoneEmpty = new Polygon(
new Point(3, 3),
new Point(3, 7),
new Point(6, 7),
new Point(6, 9),
new Point(9, 9),
new Point(9, 5),
new Point(6, 5),
new Point(6, 3));
List<Party> parties = repository.findByLocationWithin(zone1);
assertEquals(1, parties.size());
assertEquals("testparty-0", parties.get(0).getKey());
parties = repository.findByLocationWithin(zone2);
assertEquals(12, parties.size()); //all the parties except the special one at 100, 100
parties = repository.findByLocationWithin(zoneEmpty);
assertEquals(0, parties.size());
}
@Test
public void testByLocationWithinTwoPoints() {
Point zone1LowerLeft = new Point(-10.5, -0.5);
Point zone1UpperRight = new Point(0.5, 10.5);
Point zone2LowerLeft = new Point(-4, -16);
Point zone2UpperRight = new Point(16, 4);
Point zoneEmptyLowerLeft = new Point(3, 3);
Point zoneEmptyUpperRight = new Point(9, 9);
List<Party> parties = repository.findByLocationWithin(zone1LowerLeft, zone1UpperRight);
assertEquals(1, parties.size());
assertEquals("testparty-0", parties.get(0).getKey());
parties = repository.findByLocationWithin(zone2LowerLeft, zone2UpperRight);
assertEquals(12, parties.size()); //all the parties except the special one at 100, 100
parties = repository.findByLocationWithin(zoneEmptyLowerLeft, zoneEmptyUpperRight);
assertEquals(0, parties.size());
}
@Test
public void testPolygonAndArrayOfPointsProduceSameResult() {
Set<String> expectedKeys = new HashSet<String>();
expectedKeys.add("testparty-2");
expectedKeys.add("testparty-3");
expectedKeys.add("testparty-4");
expectedKeys.add("testparty-5");
//a zone that engulfs parties 2, 3, 4 and 5, in the shape of a downward arrow pointing right
Polygon zone = new Polygon(new Point(1, -2),
new Point(3, -1.5),
new Point(6, -4),
new Point(5.5, -5.5),
new Point(3, -5));
Point[] points = zone.getPoints().toArray(new Point[5]);
List<Party> fromZone = repository.findByLocationWithin(zone);
List<Party> fromPoints = repository.findByLocationWithin(points);
assertEquals(4, fromZone.size());
assertEquals(fromZone, fromPoints);
Set<String> keys = new HashSet<String>();
for (Party party : fromZone) {
keys.add(party.getKey());
}
assertEquals(expectedKeys, keys);
}
@Test
public void testProvidingOnePointIsRejected() {
try {
repository.findByLocationWithin(new Point(0, 0));
fail("Expected IllegalArgumentException");
} catch (IllegalArgumentException e) {
assertEquals("Cannot compute a bounding box for within, 2 Point needed, missing parameter", e.getMessage());
}
try {
repository.findByLocationWithin(new Point(0, 0), null);
fail("Expected IllegalArgumentException");
} catch (IllegalArgumentException e) {
assertEquals("Cannot compute a bounding box for within, 2 Point needed, got null", e.getMessage());
}
}
@Test
public void testProvidingOneJsonArrayIsRejected() {
try {
List<Party> parties = repository.findByLocationWithin(JsonArray.from(0,0));
fail();
} catch (IllegalArgumentException e) {
assertEquals("2 JsonArray required for within: startRange and endRange, missing parameter", e.getMessage());
}
}
@Test(expected = CouchbaseQueryExecutionException.class)
public void testJsonArrayWithNonNumericalValueProducesServerSideError() {
repository.findByLocationWithin(JsonArray.from("toto", -2), JsonArray.from(4, 1));
fail();
}
@Test
public void testWithinJsonArrayRangesFiltersLocationAndAttendees() {
List<Party> parties = repository.findByLocationWithin(JsonArray.from(0, -4, 115), JsonArray.from(4, 1, 132));
assertEquals(2, parties.size());
}
@Test
public void testDimensionalAnnotationCanBeUsedAsMeta() {
try {
//will trigger a specific message if parsed by SpatialViewQueryCreator
repository.findByLocationIsWithin(new Point(0,0), null);
fail("expected IllegalArgumentException from SpatialViewQueryCreator");
} catch (IllegalArgumentException e) {
assertEquals("Cannot compute a bounding box for within, 2 Point needed, got null", e.getMessage());
}
//when it is correctly formed, it actually returns data
assertEquals(1, repository.findByLocationIsWithin(new Point(-10.5, -0.5), new Point(0.5, 10.5)).size());
}
@Test
public void testFindWithinBoxCornerOrderDoesMatter() {
Point a = new Point(0, -4);
Point b = new Point(6, -2);
Box box1 = new Box(a, b);
Box box2 = new Box(b, a);
final List<Party> parties1 = repository.findByLocationWithin(box1);
final List<Party> parties2 = repository.findByLocationWithin(box2);
assertEquals(3, parties1.size());
assertNotEquals(parties1, parties2);
}
}

View File

@@ -43,7 +43,7 @@ import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
*/
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = IntegrationTestApplicationConfig.class)
@TestExecutionListeners(QueryDerivationConversionListener.class)
@TestExecutionListeners(PartyPopulatorListener.class)
public class N1qlCouchbaseRepositoryTests {
@Autowired

View File

@@ -33,6 +33,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.couchbase.IntegrationTestApplicationConfig;
import org.springframework.data.couchbase.repository.config.RepositoryOperationsMapping;
import org.springframework.data.couchbase.repository.support.CouchbaseRepositoryFactory;
import org.springframework.data.geo.Point;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
@@ -53,7 +54,7 @@ public class N1qlCrudRepositoryTests {
private ItemRepository itemRepository;
private static final Item item = new Item("itemNotParty", "short description");
private static final Party party = new Party("partyNotItem", "partyName", "short description", new Date(), 120);
private static final Party party = new Party("partyNotItem", "partyName", "short description", new Date(), 120, new Point(500, 500));
@Before
public void setup() throws Exception {
@@ -84,7 +85,7 @@ public class N1qlCrudRepositoryTests {
@Test
public void shouldSaveObjectWithN1qlKeywordField() {
Party party = new Party("partyHasKeyword", "party", "desc is a N1QL keyword", new Date(), 40);
Party party = new Party("partyHasKeyword", "party", "desc is a N1QL keyword", new Date(), 40, new Point(500, 500));
partyRepository.save(party);
List<Object> parties = partyRepository.findAllByDescriptionNotNull();

View File

@@ -3,6 +3,8 @@ package org.springframework.data.couchbase.repository;
import java.util.Date;
import org.springframework.data.annotation.Id;
import org.springframework.data.geo.Point;
import com.couchbase.client.java.repository.annotation.Field;
/**
@@ -24,12 +26,15 @@ public class Party {
private final long attendees;
public Party(String key, String name, String description, Date eventDate, long attendees) {
private final Point location;
public Party(String key, String name, String description, Date eventDate, long attendees, Point location) {
this.key = key;
this.name = name;
this.description = description;
this.eventDate = eventDate;
this.attendees = attendees;
this.location = location;
}
public String getKey() {
@@ -52,6 +57,10 @@ public class Party {
return attendees;
}
public Point getLocation() {
return location;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;

View File

@@ -1,5 +1,6 @@
package org.springframework.data.couchbase.repository;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.List;
@@ -10,16 +11,18 @@ import com.couchbase.client.java.ReplicateTo;
import com.couchbase.client.java.cluster.ClusterInfo;
import com.couchbase.client.java.view.DefaultView;
import com.couchbase.client.java.view.DesignDocument;
import com.couchbase.client.java.view.SpatialView;
import com.couchbase.client.java.view.View;
import org.springframework.data.couchbase.core.CouchbaseTemplate;
import org.springframework.data.geo.Point;
import org.springframework.test.context.TestContext;
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener;
/**
* @author Simon Baslé
*/
public class QueryDerivationConversionListener extends DependencyInjectionTestExecutionListener {
public class PartyPopulatorListener extends DependencyInjectionTestExecutionListener {
@Override
public void beforeTestClass(final TestContext testContext) throws Exception {
@@ -40,7 +43,8 @@ public class QueryDerivationConversionListener extends DependencyInjectionTestEx
for (int i = 0; i < 12; i++) {
Party p = new Party("testparty-" + i, "party like it's 199" + i,
"An awesome party, 90's themed, every 10 of the month",
cal.getTime(), 100 + i * 10);
cal.getTime(), 100 + i * 10,
new Point(i, -i));
template.save(p, PersistTo.MASTER, ReplicateTo.NONE);
cal.roll(Calendar.MONTH, true);
}
@@ -49,14 +53,32 @@ public class QueryDerivationConversionListener extends DependencyInjectionTestEx
cal.set(Calendar.YEAR, 1990);
cal.set(Calendar.MONTH, Calendar.JANUARY);
cal.set(Calendar.DAY_OF_MONTH, 01);
template.save(new Party("aTestParty", "New Year's Eve 90", "Happy New Year", cal.getTime(), 1230000));
template.save(new Party("aTestParty", "New Year's Eve 90", "Happy New Year", cal.getTime(), 1230000, new Point(100, 100)));
}
private void createAndWaitForDesignDocs(Bucket client) {
String mapFunction = "function (doc, meta) { if(doc._class == \"" + Party.class.getName() + "\") { emit(doc.eventDate, null); } }";
View view = DefaultView.create("byDate", mapFunction, "_count");
List<View> views = Collections.singletonList(view);
//standard views
List<View> views = new ArrayList<View>();
String mapFunction = "function (doc, meta) { if(doc._class == \"" + Party.class.getName() + "\") " +
"{ emit(doc.eventDate, null); } }";
views.add(DefaultView.create("byDate", mapFunction, "_count"));
//create the view design document
DesignDocument designDoc = DesignDocument.create("party", views);
client.bucketManager().upsertDesignDocument(designDoc);
//geo views
List<View> geoViews = new ArrayList<View>();
mapFunction = "function (doc, meta) { if(doc._class == \"" + Party.class.getName() + "\") " +
"{ emit([doc.location.x, doc.location.y], null); } }";
geoViews.add(SpatialView.create("byLocation", mapFunction));
mapFunction = "function (doc, meta) { if(doc._class == \"" + Party.class.getName() + "\") " +
"{ emit([doc.location.x, doc.location.y, doc.attendees], null); } }";
geoViews.add(SpatialView.create("byLocationAndAttendees", mapFunction));
//create the geo views design document
DesignDocument geoDesignDoc = DesignDocument.create("partyGeo", geoViews);
client.bucketManager().upsertDesignDocument(geoDesignDoc);
}
}

View File

@@ -27,7 +27,7 @@ import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
*/
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = IntegrationTestApplicationConfig.class)
@TestExecutionListeners(QueryDerivationConversionListener.class)
@TestExecutionListeners(PartyPopulatorListener.class)
public class QueryDerivationConversionTests {
@Autowired