diff --git a/src/integration/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryViewTests.java b/src/integration/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryViewTests.java index f14ba8f1..6e5c5a16 100644 --- a/src/integration/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryViewTests.java +++ b/src/integration/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryViewTests.java @@ -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 diff --git a/src/integration/java/org/springframework/data/couchbase/repository/DimensionalPartyRepository.java b/src/integration/java/org/springframework/data/couchbase/repository/DimensionalPartyRepository.java new file mode 100644 index 00000000..1204189b --- /dev/null +++ b/src/integration/java/org/springframework/data/couchbase/repository/DimensionalPartyRepository.java @@ -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 { + + @Dimensional(designDocument = "partyGeo", spatialViewName = "byLocation") + List findByLocationNear(Point p, Distance d); + + @Dimensional(designDocument = "partyGeo", spatialViewName = "byLocation") + List findByLocationWithin(Box boundingBox); + + @Dimensional(designDocument = "partyGeo", spatialViewName = "byLocation") + List findByLocationWithin(Polygon zone); + + @Dimensional(designDocument = "partyGeo", spatialViewName = "byLocation") + List findByLocationWithin(Point[] points); + + @Dimensional(designDocument = "partyGeo", spatialViewName = "byLocation") + List findByLocationWithin(Point point); + + @Dimensional(designDocument = "partyGeo", spatialViewName = "byLocation") + List findByLocationWithin(JsonArray range); + + @Dimensional(designDocument = "partyGeo", spatialViewName = "byLocation") + List findByLocationWithin(Point lowerLeft, Point upperRight); + + @Dimensional(designDocument = "partyGeo", spatialViewName = "byLocationAndAttendees", dimensions = 3) + List findByLocationWithin(Circle zone); + + @Dimensional(designDocument = "partyGeo", spatialViewName = "byLocationAndAttendees", dimensions = 3) + List findByLocationWithinAndAttendeesGreaterThan(Polygon zone, double minAttendees); + + @Dimensional(designDocument = "partyGeo", spatialViewName = "byLocationAndAttendees", dimensions = 3) + List findByLocationWithin(JsonArray startRange, JsonArray endRange); + + //TODO more coverage of operators? + + @IndexedByLocation + List findByLocationIsWithin(Point a, Point b); + + @Dimensional(designDocument = "partyGeo", spatialViewName = "byLocation", dimensions = 2) + @Retention(RetentionPolicy.RUNTIME) + @interface IndexedByLocation { } +} diff --git a/src/integration/java/org/springframework/data/couchbase/repository/DimensionalQueryTests.java b/src/integration/java/org/springframework/data/couchbase/repository/DimensionalQueryTests.java new file mode 100644 index 00000000..3c03b340 --- /dev/null +++ b/src/integration/java/org/springframework/data/couchbase/repository/DimensionalQueryTests.java @@ -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 expectedKeys = new HashSet(); + 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 parties = repository.findByLocationWithin(zone); + + assertEquals(4, parties.size()); + for (Party party : parties) { + assertTrue(expectedKeys.contains(party.getKey())); + } + } + + @Test + public void testByLocationNear() { + Set expectedKeys = new HashSet(); + expectedKeys.add("testparty-0"); + expectedKeys.add("testparty-1"); + + List 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 expectedKeys = new HashSet(); + 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 allPartiesInZone = repository.findByLocationWithinAndAttendeesGreaterThan(zone, -1); + List allPartiesInZoneWithoutAttendeeCriteria = repository.findByLocationWithin(zone); + assertEquals(allPartiesInZone.toString(), 4, allPartiesInZone.size()); + assertEquals(allPartiesInZoneWithoutAttendeeCriteria, allPartiesInZone); + + //check parties are limited by the attendees + List 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 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 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 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 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 expectedKeys = new HashSet(); + 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 fromZone = repository.findByLocationWithin(zone); + List fromPoints = repository.findByLocationWithin(points); + + assertEquals(4, fromZone.size()); + assertEquals(fromZone, fromPoints); + Set keys = new HashSet(); + 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 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 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 parties1 = repository.findByLocationWithin(box1); + final List parties2 = repository.findByLocationWithin(box2); + + assertEquals(3, parties1.size()); + assertNotEquals(parties1, parties2); + } +} diff --git a/src/integration/java/org/springframework/data/couchbase/repository/N1qlCouchbaseRepositoryTests.java b/src/integration/java/org/springframework/data/couchbase/repository/N1qlCouchbaseRepositoryTests.java index d19d9bd8..6bb1caf9 100644 --- a/src/integration/java/org/springframework/data/couchbase/repository/N1qlCouchbaseRepositoryTests.java +++ b/src/integration/java/org/springframework/data/couchbase/repository/N1qlCouchbaseRepositoryTests.java @@ -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 diff --git a/src/integration/java/org/springframework/data/couchbase/repository/N1qlCrudRepositoryTests.java b/src/integration/java/org/springframework/data/couchbase/repository/N1qlCrudRepositoryTests.java index bf966c66..872255bd 100644 --- a/src/integration/java/org/springframework/data/couchbase/repository/N1qlCrudRepositoryTests.java +++ b/src/integration/java/org/springframework/data/couchbase/repository/N1qlCrudRepositoryTests.java @@ -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 parties = partyRepository.findAllByDescriptionNotNull(); diff --git a/src/integration/java/org/springframework/data/couchbase/repository/Party.java b/src/integration/java/org/springframework/data/couchbase/repository/Party.java index 4ce3dd47..fe5cac9c 100644 --- a/src/integration/java/org/springframework/data/couchbase/repository/Party.java +++ b/src/integration/java/org/springframework/data/couchbase/repository/Party.java @@ -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; diff --git a/src/integration/java/org/springframework/data/couchbase/repository/QueryDerivationConversionListener.java b/src/integration/java/org/springframework/data/couchbase/repository/PartyPopulatorListener.java similarity index 62% rename from src/integration/java/org/springframework/data/couchbase/repository/QueryDerivationConversionListener.java rename to src/integration/java/org/springframework/data/couchbase/repository/PartyPopulatorListener.java index b91bbc82..ad858836 100644 --- a/src/integration/java/org/springframework/data/couchbase/repository/QueryDerivationConversionListener.java +++ b/src/integration/java/org/springframework/data/couchbase/repository/PartyPopulatorListener.java @@ -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 views = Collections.singletonList(view); + //standard views + List views = new ArrayList(); + 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 geoViews = new ArrayList(); + 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); } } diff --git a/src/integration/java/org/springframework/data/couchbase/repository/QueryDerivationConversionTests.java b/src/integration/java/org/springframework/data/couchbase/repository/QueryDerivationConversionTests.java index a6e72d9f..21e3b118 100644 --- a/src/integration/java/org/springframework/data/couchbase/repository/QueryDerivationConversionTests.java +++ b/src/integration/java/org/springframework/data/couchbase/repository/QueryDerivationConversionTests.java @@ -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 diff --git a/src/main/asciidoc/repository.adoc b/src/main/asciidoc/repository.adoc index 7a30ce7e..8350b9f0 100644 --- a/src/main/asciidoc/repository.adoc +++ b/src/main/asciidoc/repository.adoc @@ -3,6 +3,14 @@ The goal of Spring Data repository abstraction is to significantly reduce the amount of boilerplate code required to implement data access layers for various persistence stores. +There are three backing mechanisms in Couchbase for repositories, described in the sections of this chapter: + + - <> + - <> + - <> + +Note that you can tune the consistency you want for your queries (see <>). + [[couchbase.repository.configuration]] == Configuration @@ -93,7 +101,7 @@ Now, let's imagine we `@Autowire` the `UserRepository` to a class that makes use Now that's awesome! Just by defining an interface we get full CRUD functionality on top of our managed entity. All methods suffixed with (*) in the table are backed by Views, which is explained later. -While the exposed methods provide you with a great variety of access patterns, very often you need to define custom ones. You can do this by adding method declarations to your interface, which will be automatically resolved to requests in the background, as we'll see in the next two sections. +While the exposed methods provide you with a great variety of access patterns, very often you need to define custom ones. You can do this by adding method declarations to your interface, which will be automatically resolved to requests in the background, as we'll see in the next sections. [[couchbase.repository.n1ql]] = N1QL based querying @@ -305,6 +313,38 @@ TIP: Note that the `reduce function` (not always a count) will be activated by p WARNING: Compound keys are not supported, and neither are Or composition, Ignore Case and Order By. You have to include a valid entity property in the naming of your method. + +Last method of querying in Couchbase (from Couchbase Server 4.0, like for N1QL) is querying for dimensional data through *Spatial Views*, as we'll see in the next section. + +[[couchbase.repository.spatial]] +== Spatial View based querying +Couchbase can accommodate multi-dimensional data and query it with the use of special views, the Spatial Views. Such views allows to perform multi-dimensional queries, not only limited to geographical data. + +Integration of these views in `Spring Data Couchbase` repositories is done through the `@Dimensional` annotation. Like `@View`, the annotation allows to indicate usage of a Spatial View as the backing mechanism for the annotated method. The annotation requires you to give the name of the `designDocument` and the `spatialViewName` to use. Additionally, you should specify the number of `dimensions` the view works with (unless it is the default classical 2). + +Multi-dimensionality concept is interesting, it means you can craft views that allows you to answer questions like "find all shops that are within Manhattan and open between 14:00 and 23:00" (the third dimension of the view being the opening hours). + +Couchbase's Spatial View support querying through ranges that represent "lowest" and "highest" values in each dimension, so for 2D it represents a bounding box, with the southwest-most point [x,y] as `startRange` and northeast-most point [x,y] as `endRange`. + +WARNING: Some forms of Geographical queries can only be approximated, for instance querying with a `Polygon` or a `Circle` can only be approximated to the containing bounding `Box` (in DEBUG log level this will be made explicit with each such query). + +The following query derivation keywords and parameters relative to geographical data in Spring Data are supported for Spatial Views: + +.Supported keywords inside @Dimensional method names +[options = "header, autowidth"] +|=============== +|Keyword|Sample|Remarks +|`Within,IsWithin`|`findByLocationWithin`| +|`Near,IsNear`|`findByLocationNear`|expects a `Point` and a `Distance`, will approximate to bounding box +|`Between`|`findByLocationWithinAndOpeningHoursBetween`|useful for dimensions beyond 2, adds two numerical values to the startRange and endRange respectively +|`GreaterThan,GreaterThanEqual,After`|`findByLocationWithinAndOpeningHoursAfter`|useful for dimensions beyond 2, adds a numerical value to the startRange +|`LessThan,LessThanEqual,Before`|`findByLocationWithinAndOpeningHoursBefore`|useful for dimensions beyond 2, adds a numerical value to the endRange +|=============== + +IMPORTANT: For "within" types of queries, the expected parameters map to geographical 2D data. Classes from the `org.springframework.data.geo` package are usually expected: `Circle` and `Polygon` will be approximated to their surrounding bounding `Box`. Polygon and Boxes can also be expressed as arrays of `Point`s. + +TIP: Further dimensions are supported through keywords other than Within and Near and require numerical input. + [[couchbase.repository.consistency]] == Querying with consistency One aspect that is often needed and doesn't have a direct equivalent in the Spring Data query derivation mechanism is diff --git a/src/main/java/org/springframework/data/couchbase/core/CouchbaseOperations.java b/src/main/java/org/springframework/data/couchbase/core/CouchbaseOperations.java index 70893c59..11afe0d9 100644 --- a/src/main/java/org/springframework/data/couchbase/core/CouchbaseOperations.java +++ b/src/main/java/org/springframework/data/couchbase/core/CouchbaseOperations.java @@ -28,6 +28,8 @@ import com.couchbase.client.java.query.N1qlQuery; import com.couchbase.client.java.query.N1qlQueryResult; import com.couchbase.client.java.query.N1qlParams; import com.couchbase.client.java.query.Statement; +import com.couchbase.client.java.view.SpatialViewQuery; +import com.couchbase.client.java.view.SpatialViewResult; import com.couchbase.client.java.view.ViewQuery; import com.couchbase.client.java.view.ViewResult; @@ -213,6 +215,28 @@ public interface CouchbaseOperations { */ ViewResult queryView(ViewQuery query); + /** + * Query a Spatial View for a list of documents of type T. + *

+ *

It is valid to pass in a empty constructed {@link SpatialViewQuery} object.

+ *

+ * + * @param query the SpatialViewQuery object (also specifying view design document and view name). + * @param entityClass the entity to map to. + * @return the converted collection + */ + List findBySpatialView(SpatialViewQuery query, Class entityClass); + + /** + * Query a Spatial View with direct access to the {@link SpatialViewResult}. + *

This method is available to ease the working with spatial views by still wrapping exceptions into the Spring + * infrastructure.

+ * + * @param query the SpatialViewQuery object (also specifying view design document and view name). + * @return SpatialViewResult containing the results of the query. + */ + SpatialViewResult querySpatialView(SpatialViewQuery query); + /** * Query the N1QL Service for JSON data of type T. Enough data to construct the full * entity is expected to be selected, including the metadata {@value #SELECT_ID} and @@ -349,5 +373,4 @@ public interface CouchbaseOperations { * @return the consistency to use for generated repository queries. */ Consistency getDefaultConsistency(); - -} +} \ No newline at end of file diff --git a/src/main/java/org/springframework/data/couchbase/core/CouchbaseTemplate.java b/src/main/java/org/springframework/data/couchbase/core/CouchbaseTemplate.java index 01432214..4360a044 100644 --- a/src/main/java/org/springframework/data/couchbase/core/CouchbaseTemplate.java +++ b/src/main/java/org/springframework/data/couchbase/core/CouchbaseTemplate.java @@ -41,6 +41,9 @@ import com.couchbase.client.java.query.N1qlQueryRow; import com.couchbase.client.java.query.N1qlParams; import com.couchbase.client.java.query.consistency.ScanConsistency; import com.couchbase.client.java.util.features.CouchbaseFeature; +import com.couchbase.client.java.view.SpatialViewQuery; +import com.couchbase.client.java.view.SpatialViewResult; +import com.couchbase.client.java.view.SpatialViewRow; import com.couchbase.client.java.view.ViewQuery; import com.couchbase.client.java.view.ViewResult; import com.couchbase.client.java.view.ViewRow; @@ -292,7 +295,7 @@ public class CouchbaseTemplate implements CouchbaseOperations, ApplicationEventP @Override public List findByView(ViewQuery query, Class entityClass) { - query.includeDocs(false); + query.includeDocs(false); //FIXME the doc says it is set to true... query.reduce(false); try { @@ -326,6 +329,41 @@ public class CouchbaseTemplate implements CouchbaseOperations, ApplicationEventP }); } + @Override + public List findBySpatialView(SpatialViewQuery query, Class entityClass) { + //note: don't make any assumption about includeDocs and let the user decide + + try { + final SpatialViewResult response = querySpatialView(query); + if (response.error() != null) { + throw new CouchbaseQueryExecutionException("Unable to execute spatial view query due to the following view error: " + + response.error().toString()); + } + + List allRows = response.allRows(); + + final List result = new ArrayList(allRows.size()); + for (final SpatialViewRow row : allRows) { + result.add(mapToEntity(row.id(), row.document(RawJsonDocument.class), entityClass)); + } + + return result; + } + catch (TranscodingException e) { + throw new CouchbaseQueryExecutionException("Unable to execute view query", e); + } + } + + @Override + public SpatialViewResult querySpatialView(final SpatialViewQuery query) { + return execute(new BucketCallback() { + @Override + public SpatialViewResult doInBucket() throws TimeoutException, ExecutionException, InterruptedException { + return client.query(query); + } + }); + } + @Override public List findByN1QL(N1qlQuery n1ql, Class entityClass) { checkN1ql(); diff --git a/src/main/java/org/springframework/data/couchbase/core/view/Dimensional.java b/src/main/java/org/springframework/data/couchbase/core/view/Dimensional.java new file mode 100644 index 00000000..f4c4e8ae --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/core/view/Dimensional.java @@ -0,0 +1,43 @@ +/* + * Copyright 2012-2015 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.couchbase.core.view; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.data.annotation.QueryAnnotation; + +/** + * An annotation to mark a repository method as querying using a Couchbase Spatial View. + * The attributes will be used to resolve the actual Spatial View to be used and to determine + * the expected number of dimensions in the Spatial View keys. It can also be used as meta-annotation. + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +@QueryAnnotation +public @interface Dimensional { + /** The name of the design document to be queried */ + String designDocument(); + /** The name of the spatial view to be queried */ + String spatialViewName(); + /** The number of dimensions in the spatial view, defaults to 2 */ + int dimensions() default 2; +} diff --git a/src/main/java/org/springframework/data/couchbase/core/view/Query.java b/src/main/java/org/springframework/data/couchbase/core/view/Query.java index e37a96e7..f4a022d3 100644 --- a/src/main/java/org/springframework/data/couchbase/core/view/Query.java +++ b/src/main/java/org/springframework/data/couchbase/core/view/Query.java @@ -49,6 +49,10 @@ import org.springframework.data.couchbase.repository.query.StringN1qlBasedQuery; * {@value StringN1qlBasedQuery#PLACEHOLDER_ENTITY} * (see {@link StringN1qlBasedQuery#PLACEHOLDER_ENTITY}) * + *
  • + * {@value StringN1qlBasedQuery#PLACEHOLDER_FILTER_TYPE} + * (see {@link StringN1qlBasedQuery#PLACEHOLDER_FILTER_TYPE}) + *
  • * * * @author Simon Baslé. diff --git a/src/main/java/org/springframework/data/couchbase/core/view/View.java b/src/main/java/org/springframework/data/couchbase/core/view/View.java index 87f12ea8..ad402b6d 100644 --- a/src/main/java/org/springframework/data/couchbase/core/view/View.java +++ b/src/main/java/org/springframework/data/couchbase/core/view/View.java @@ -22,6 +22,8 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import org.springframework.data.annotation.QueryAnnotation; + /** * Annotation to support the use of Views with Couchbase. * @@ -31,6 +33,7 @@ import java.lang.annotation.Target; @Documented @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) +//don't set @QueryAnnotation, as it causes problems with reduce and replacing count() method, the reduce detection needs to be improved public @interface View { /** diff --git a/src/main/java/org/springframework/data/couchbase/repository/query/CouchbaseQueryMethod.java b/src/main/java/org/springframework/data/couchbase/repository/query/CouchbaseQueryMethod.java index 0661e7d6..73325d81 100644 --- a/src/main/java/org/springframework/data/couchbase/repository/query/CouchbaseQueryMethod.java +++ b/src/main/java/org/springframework/data/couchbase/repository/query/CouchbaseQueryMethod.java @@ -21,6 +21,7 @@ import java.lang.reflect.Method; import org.springframework.core.annotation.AnnotationUtils; import org.springframework.data.couchbase.core.mapping.CouchbasePersistentEntity; import org.springframework.data.couchbase.core.mapping.CouchbasePersistentProperty; +import org.springframework.data.couchbase.core.view.Dimensional; import org.springframework.data.couchbase.core.view.Query; import org.springframework.data.couchbase.core.view.View; import org.springframework.data.mapping.context.MappingContext; @@ -101,6 +102,21 @@ public class CouchbaseQueryMethod extends QueryMethod { return method.getAnnotation(View.class); } + + /** + * @return true if the method has a @Dimensional annotation, false otherwise. + */ + public boolean hasDimensionalAnnotation() { + return getDimensionalAnnotation() != null; + } + + /** + * @return the @Dimensional annotation if set, null otherwise. + */ + public Dimensional getDimensionalAnnotation() { + return AnnotationUtils.findAnnotation(method, Dimensional.class); + } + /** * If the method has a @Query annotation. * diff --git a/src/main/java/org/springframework/data/couchbase/repository/query/N1qlQueryCreator.java b/src/main/java/org/springframework/data/couchbase/repository/query/N1qlQueryCreator.java index 885d1808..d0449ed2 100644 --- a/src/main/java/org/springframework/data/couchbase/repository/query/N1qlQueryCreator.java +++ b/src/main/java/org/springframework/data/couchbase/repository/query/N1qlQueryCreator.java @@ -35,6 +35,7 @@ import org.springframework.data.couchbase.core.mapping.CouchbasePersistentProper import org.springframework.data.couchbase.repository.query.support.N1qlUtils; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import org.springframework.data.geo.GeoResult; import org.springframework.data.mapping.context.PersistentPropertyPath; import org.springframework.data.repository.query.ParameterAccessor; import org.springframework.data.repository.query.parser.AbstractQueryCreator; diff --git a/src/main/java/org/springframework/data/couchbase/repository/query/SpatialViewBasedQuery.java b/src/main/java/org/springframework/data/couchbase/repository/query/SpatialViewBasedQuery.java new file mode 100644 index 00000000..a3ee4aa4 --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/repository/query/SpatialViewBasedQuery.java @@ -0,0 +1,84 @@ +/* + * Copyright 2013-2015 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.couchbase.repository.query; + +import com.couchbase.client.java.view.SpatialViewQuery; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.data.couchbase.core.CouchbaseOperations; +import org.springframework.data.couchbase.core.view.Dimensional; +import org.springframework.data.repository.query.ParametersParameterAccessor; +import org.springframework.data.repository.query.RepositoryQuery; +import org.springframework.data.repository.query.parser.PartTree; + +/** + * Execute a {@link Dimensional} repository query through the Spatial View mechanism. + * + * @author Simon Baslé + */ +public class SpatialViewBasedQuery implements RepositoryQuery { + + private static final Logger LOG = LoggerFactory.getLogger(SpatialViewBasedQuery.class); + + private final CouchbaseQueryMethod method; + private final CouchbaseOperations operations; + + public SpatialViewBasedQuery(CouchbaseQueryMethod method, CouchbaseOperations operations) { + this.method = method; + this.operations = operations; + } + + @Override + public Object execute(Object[] runtimeParams) { + String designDoc = method.getDimensionalAnnotation().designDocument(); + String viewName = method.getDimensionalAnnotation().spatialViewName(); + int dimensions = method.getDimensionalAnnotation().dimensions(); + + /* + here contrary to the classical view query we don't support not including an attribute of + the entity in the method name, those are mandatory and will result in a PropertyReferenceException + if not used... + */ + PartTree tree = new PartTree(method.getName(), method.getEntityInformation().getJavaType()); + + //prepare a spatial view query to be used as a base for the query creator + SpatialViewQuery baseSpatialQuery = SpatialViewQuery.from(designDoc, viewName) + .stale(operations.getDefaultConsistency().viewConsistency()); + + //use the SpatialViewQueryCreator to complete it + SpatialViewQueryCreator creator = new SpatialViewQueryCreator(dimensions, + tree, new ParametersParameterAccessor(method.getParameters(), runtimeParams), + baseSpatialQuery, operations.getConverter()); + SpatialViewQuery finalQuery = creator.createQuery(); + + //execute the spatial query + return execute(finalQuery); + } + + protected Object execute(SpatialViewQuery query) { + if (LOG.isDebugEnabled()) { + LOG.debug("Executing spatial view query: " + query.toString()); + } + return operations.findBySpatialView(query, method.getEntityInformation().getJavaType()); + } + + @Override + public CouchbaseQueryMethod getQueryMethod() { + return method; + } +} \ No newline at end of file diff --git a/src/main/java/org/springframework/data/couchbase/repository/query/SpatialViewQueryCreator.java b/src/main/java/org/springframework/data/couchbase/repository/query/SpatialViewQueryCreator.java new file mode 100644 index 00000000..b357b4e7 --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/repository/query/SpatialViewQueryCreator.java @@ -0,0 +1,240 @@ +/* + * Copyright 2012-2015 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.couchbase.repository.query; + +import java.util.Iterator; + +import com.couchbase.client.java.document.json.JsonArray; +import com.couchbase.client.java.view.SpatialViewQuery; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.data.couchbase.core.convert.CouchbaseConverter; +import org.springframework.data.couchbase.core.view.Dimensional; +import org.springframework.data.couchbase.repository.query.support.GeoUtils; +import org.springframework.data.domain.Sort; +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.geo.Shape; +import org.springframework.data.repository.query.ParameterAccessor; +import org.springframework.data.repository.query.parser.AbstractQueryCreator; +import org.springframework.data.repository.query.parser.Part; +import org.springframework.data.repository.query.parser.PartTree; + +/** + * A QueryCreator that will enrich a {@link SpatialViewQuery} using query derivation mechanisms + * and the parsed {@link PartTree}. + *

    + * Support for query derivation keywords is limited, it is triggered by having a {@link Dimensional} annotation + * on the query method. + *
    + * Here are the {@link Part.Type} supported: + *

      + *
    • WITHIN: finds elements contained in the provided {@link Shape}, {@link Point Point[] polygon}, + * pair of {@link Point}s bounding box (lower left+upper right) or pair of raw {@link JsonArray} (discouraged as it + * leaks Couchbase specific class in your method signature, needs to be numerical data)
    • + *
    • NEAR: finds elements near a provided {@link Point}, within the provided {@link Distance}
    • + *
    • GREATER_THAN, AFTER, GREATER_THAN_EQUALS: adds a numerical element to the startRange and null to the endRange + * (useful for non geographic additional dimensions)
    • + *
    • LESS_THAN, BEFORE, LESS_THAN_EQUALS: adds null to the startRange and a numerical element to the endRange + * (useful for non geographic additional dimensions)
    • + *
    • SIMPLE_PROPERTY (Is, Equals): adds a numerical element to both the startRange and the endRange + * (useful for non geographic additional dimensions)
    • + *
    • BETWEEN: adds a numerical element to the startRange and a second numerical element to the endRange + * (useful for non geographic additional dimensions)
    • + *
    + *

    + * Additionally, {@link PartTree#isLimiting()} will trigger usage of {@link SpatialViewQuery#limit(int) limit}. + */ +public class SpatialViewQueryCreator extends AbstractQueryCreator { + + private final SpatialViewQuery query; + private final PartTree tree; + private final CouchbaseConverter converter; + + private final int dimensions; + private final JsonArray startRange; + private final JsonArray endRange; + + public SpatialViewQueryCreator(int dimensions, PartTree tree, ParameterAccessor parameters, SpatialViewQuery query, + CouchbaseConverter converter) { + super(tree, parameters); + this.query = query; + this.tree = tree; + this.converter = converter; + this.dimensions = dimensions; + this.startRange = JsonArray.create(); + this.endRange = JsonArray.create(); + } + + @Override + protected SpatialViewQuery create(Part part, Iterator objectIterator) { + ConvertingIterator iterator = new ConvertingIterator(objectIterator, converter); + + switch (part.getType()) { + case WITHIN: + applyWithin(startRange, endRange, iterator); + break; + case NEAR: + applyNear(startRange, endRange, iterator); + break; + case GREATER_THAN: + case GREATER_THAN_EQUAL: + case AFTER: + startRange.add(checkedNext(iterator, Object.class, null)); + endRange.addNull(); + break; + case LESS_THAN: + case LESS_THAN_EQUAL: + case BEFORE: + startRange.addNull(); + endRange.add(checkedNext(iterator, Object.class, null)); + break; + case SIMPLE_PROPERTY: + Object equals = checkedNext(iterator, Object.class, null); + startRange.add(equals); + endRange.add(equals); + break; + case BETWEEN: + startRange.add(checkedNext(iterator, Object.class, null)); + endRange.add(checkedNext(iterator, Object.class, null)); + break; + default: + throw new IllegalArgumentException("Unsupported keyword in Spatial View query derivation: " + part.toString()); + } + + //will complete the ranges in complete step (if not enough elements for the ranges to match dimension count) + return query; + } + + private static void completeRangeIfNeeded(JsonArray range, int dimensions) { + for (int i = range.size(); i < dimensions; i++) { + range.addNull(); + } + } + + private static void applyWithin(JsonArray startRange, JsonArray endRange, Iterator iterator) { + if (!iterator.hasNext()) { + throw new IllegalArgumentException("Not enough parameters for within"); + } + + Object next = iterator.next(); + if (next instanceof Shape) { + GeoUtils.convertShapeTo2DRanges(startRange, endRange, (Shape) next); + } else if (next instanceof Point) { + //expect another point for the other corner of the bounding box + Point northwest = (Point) next; + Point southeast = checkedNext(iterator, Point.class, "Cannot compute a bounding box for within, 2 Point needed"); + GeoUtils.convertPointsTo2DRanges(startRange, endRange, true, northwest, southeast); + } else if (next instanceof Point[]) { + GeoUtils.convertPointsTo2DRanges(startRange, endRange, false, (Point[]) next); + } else if (next instanceof JsonArray) { //discouraged, leaks Couchbase classes into signatures + JsonArray first = (JsonArray) next; + for (Object o : first) { + startRange.add(o); + } + + JsonArray second = checkedNext(iterator, JsonArray.class, "2 JsonArray required for within: startRange and endRange"); + for (Object o : second) { + endRange.add(o); + } + } else { + //TODO Collection: same as polygon + //TODO Collection: one collection per range + //TODO Number[]: one array per range + throw new IllegalArgumentException("Unsupported parameter type for within: " + next.getClass()); + } + } + + private static void applyNear(JsonArray startRange, JsonArray endRange, Iterator iterator) { + if (!iterator.hasNext()) { + throw new IllegalArgumentException("Not enough parameters for near"); + } + + Point near = checkedNext(iterator, Point.class, "Near queries need a Point as first argument"); + Distance distance = checkedNext(iterator, Distance.class, "Near queries need a maximum Distance as second argument"); + + double[] boundingBox = GeoUtils.getBoundingBoxForNear(near, distance); + + startRange.add(boundingBox[0]).add(boundingBox[1]); + endRange.add(boundingBox[2]).add(boundingBox[3]); + } + + /** + * Retrieve next value in the iterator assuming it is of the specified type (otherwise throw + * an {@link IllegalArgumentException}. + * + * @param iterator the iterator to peek into. + * @param clazz the expected type of the next value in the iterator. + * @param errorMsg the error message prefix to use in the exception (will append a short message + * describing if the iterator has no value or if the type found was different than expected). + * @param the desired return type. + * @return the next value as a T. + * @throws IllegalArgumentException if there is no next value or it doesn't conform to the desired type. + */ + private static T checkedNext(Iterator iterator, Class clazz, String errorMsg) { + if (errorMsg == null) { + errorMsg = "Expected an additional parameter of type " + clazz.getName(); + } + + if (!iterator.hasNext()) { + throw new IllegalArgumentException(errorMsg + ", missing parameter"); + } + + Object next = iterator.next(); + if (clazz.isInstance(next)) { + return (T) next; + } else if (next == null) { + throw new IllegalArgumentException(errorMsg + ", got null"); + } else { + throw new IllegalArgumentException(errorMsg + ", got a " + next.getClass().getName()); + } + } + + @Override + protected SpatialViewQuery and(Part part, SpatialViewQuery base, Iterator iterator) { + return create(part, iterator);//and not really supported, all query derivation mutate the original ViewQuery + } + + @Override + protected SpatialViewQuery or(SpatialViewQuery base, SpatialViewQuery criteria) { + //this won't be called unless there's a Or keyword in the method + throw new UnsupportedOperationException("Or is not supported for View-based queries"); + } + + @Override + protected SpatialViewQuery complete(SpatialViewQuery criteria, Sort sort) { + if (sort != null) { + throw new IllegalArgumentException("Sort is not supported on Spatial View queries"); + } + + if (tree.isLimiting()) { + query.limit(tree.getMaxResults()); + } + + if (startRange.isEmpty() && endRange.isEmpty()) { + return query; + } + + completeRangeIfNeeded(startRange, dimensions); + completeRangeIfNeeded(endRange, dimensions); + return query.range(startRange, endRange); + } +} diff --git a/src/main/java/org/springframework/data/couchbase/repository/query/ViewBasedCouchbaseQuery.java b/src/main/java/org/springframework/data/couchbase/repository/query/ViewBasedCouchbaseQuery.java index e7a60c35..930298e2 100644 --- a/src/main/java/org/springframework/data/couchbase/repository/query/ViewBasedCouchbaseQuery.java +++ b/src/main/java/org/springframework/data/couchbase/repository/query/ViewBasedCouchbaseQuery.java @@ -19,6 +19,7 @@ package org.springframework.data.couchbase.repository.query; import java.util.List; import com.couchbase.client.java.document.json.JsonObject; +import com.couchbase.client.java.view.SpatialViewQuery; import com.couchbase.client.java.view.Stale; import com.couchbase.client.java.view.ViewQuery; import com.couchbase.client.java.view.ViewResult; @@ -28,6 +29,8 @@ import org.slf4j.LoggerFactory; import org.springframework.data.couchbase.core.CouchbaseOperations; import org.springframework.data.couchbase.core.CouchbaseQueryExecutionException; +import org.springframework.data.geo.Distance; +import org.springframework.data.geo.Shape; import org.springframework.data.mapping.PropertyReferenceException; import org.springframework.data.repository.query.ParametersParameterAccessor; import org.springframework.data.repository.query.QueryMethod; @@ -81,23 +84,33 @@ public class ViewBasedCouchbaseQuery implements RepositoryQuery { protected Object deriveAndExecute(Object[] runtimeParams) { String designDoc = designDocName(method); String viewName = method.getViewAnnotation().viewName(); + + //prepare a ViewQuery to be used as a base for the ViewQueryCreator ViewQuery baseQuery = ViewQuery.from(designDoc, viewName) .stale(operations.getDefaultConsistency().viewConsistency()); + try { PartTree tree = new PartTree(method.getName(), method.getEntityInformation().getJavaType()); - ViewQueryCreator creator = new ViewQueryCreator(tree, - new ParametersParameterAccessor(method.getParameters(), runtimeParams), + //use a ViewQueryCreator to complete the base query + ViewQueryCreator creator = new ViewQueryCreator(tree, new ParametersParameterAccessor(method.getParameters(), runtimeParams), baseQuery, operations.getConverter()); - ViewQuery query = creator.createQuery(); + //detect count prefix in the method name and consider it triggers a reduce if (tree.isCountProjection() == Boolean.TRUE) { return executeReduce(query, designDoc, viewName); } else { + //otherwise just execute the query return execute(query); } } catch (PropertyReferenceException e) { + /* + For views, not including an attribute name in the method will result in returning + the whole set of results from the view. + This is detected by looking for PropertyReferenceExceptions that seem to complain + about a missing property that corresponds to the method name + */ if (e.getPropertyName().equals(method.getName())) { return execute(baseQuery); } diff --git a/src/main/java/org/springframework/data/couchbase/repository/query/support/GeoUtils.java b/src/main/java/org/springframework/data/couchbase/repository/query/support/GeoUtils.java new file mode 100644 index 00000000..48d35a65 --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/repository/query/support/GeoUtils.java @@ -0,0 +1,172 @@ +/* + * Copyright 2012-2015 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.couchbase.repository.query.support; + +import com.couchbase.client.java.document.json.JsonArray; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +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.geo.Shape; + +/** + * Utility class to deal with geo/dimensional indexed data and queries. + * + * @author Simon Baslé + */ +public class GeoUtils { + + private static final Logger LOGGER = LoggerFactory.getLogger(GeoUtils.class); + + /** + * Computes the bounding box approximation for a "near" query (max distance from a point of origin). + * + * @param origin the point of origin, center for the query. + * @param distance the max distance to search within (negative distances will be multiplied by -1). + * @return the bounding box approximation for this query ([xMin, yMin, xMax, yMax]). + * @throws NullPointerException if any of the origin and distance are null + */ + public static double[] getBoundingBoxForNear(Point origin, Distance distance) { + if (origin == null || distance == null) throw new NullPointerException("Origin and distance required"); + warnBoundingBoxApproximation("near a Point (within Distance)"); + + //since maxDistance COULD be negative, we have to make sure we have correct min/max + double maxDistance = Math.abs(distance.getNormalizedValue()); + double xMin = origin.getX() - maxDistance; + double yMin = origin.getY() - maxDistance; + double xMax = origin.getX() + maxDistance; + double yMax = origin.getY() + maxDistance; + + return new double[] { xMin, yMin, xMax, yMax }; + } + + /** + * Convert a sequence of {@link Point Points} describing a polygon to a pair of + * {@link JsonArray} ranges corresponding to that polygon's bounding box, + * and inject the coordinates into startRange and endRange. + * If it is already equivalent to a Box (upper-left Point + lower-right Point), set + * isBoundingBox to true. + * Otherwise, this method will attempt to find the bounding box by finding the lowest + * and highest X and Y coordinates. + * + * @param startRange the startRange to populate with this shape's data. + * @param endRange the endRange to populate with this shape's data. + * @param isBoundingBox true to skip finding min/max X and Y coordinates and use 2 Points as a {@link Box}. + * @param points the sequence of Points. + * @throws IllegalArgumentException if no points are provided, or in the case of isBoundingBox true + * if more or less than 2 points are provided or the 2 points are not ordered (a.x <= b.x && a.y <= b.y). + */ + public static void convertPointsTo2DRanges(JsonArray startRange, JsonArray endRange, boolean isBoundingBox, Point... points) { + if (points == null || points.length == 0) { + throw new IllegalArgumentException("Needs points to convert"); + } + + if (isBoundingBox) { + if (points.length != 2) { + throw new IllegalArgumentException("Bounding box must be made of 2 points"); + } + if (points[0].getX() > points[1].getX() || points[0].getY() > points[1].getY()) { + throw new IllegalArgumentException("Bounding box must have point A on the lower left of point B"); + } + startRange.add(points[0].getX()).add(points[0].getY()); + endRange.add(points[1].getX()).add(points[1].getY()); + } else { + //this is like a polygon, find the bounding box + warnBoundingBoxApproximation("within a sequence of Points (polygon)"); + //find the lowest and highest X and Y to get the bounding box + double xMin = Double.POSITIVE_INFINITY; + double yMin = Double.POSITIVE_INFINITY; + double xMax = Double.NEGATIVE_INFINITY; + double yMax = Double.NEGATIVE_INFINITY; + for (Point point : points) { + xMin = point.getX() < xMin ? point.getX() : xMin; + xMax = point.getX() > xMax ? point.getX() : xMax; + + yMin = point.getY() < yMin ? point.getY() : yMin; + yMax = point.getY() > yMax ? point.getY() : yMax; + } + + //once we have the coordinates of lower left and upper right points, use them + startRange.add(xMin).add(yMin); + endRange.add(xMax).add(yMax); + } + } + + /** + * Convert a {@link Shape} to a pair of {@link JsonArray} ranges, injected into startRange and endRange. + * + * @param startRange the startRange to populate with this shape's data. + * @param endRange the endRange to populate with this shape's data. + * @param shape the shape to extract ranges from. + * @throws IllegalArgumentException if the {@link Shape} is unsupported. + */ + public static void convertShapeTo2DRanges(JsonArray startRange, JsonArray endRange, Shape shape) { + if (shape instanceof Box) { + Box box = (Box) shape; + startRange //add minimum coordinates for x and y + .add(box.getFirst().getX()) + .add(box.getFirst().getY()); + endRange //add maximum coordinates for x and y + .add(box.getSecond().getX()) + .add(box.getSecond().getY()); + } else if (shape instanceof Polygon) { + warnBoundingBoxApproximation("within a Polygon"); + //find the lowest and highest X and Y to get the bounding box + double xMin = Double.POSITIVE_INFINITY; + double yMin = Double.POSITIVE_INFINITY; + double xMax = Double.NEGATIVE_INFINITY; + double yMax = Double.NEGATIVE_INFINITY; + for (Point point : (Polygon) shape) { + xMin = point.getX() < xMin ? point.getX() : xMin; + xMax = point.getX() > xMax ? point.getX() : xMax; + + yMin = point.getY() < yMin ? point.getY() : yMin; + yMax = point.getY() > yMax ? point.getY() : yMax; + } + + //once we have the coordinates of lower left and upper right points, use them + startRange.add(xMin).add(yMin); + endRange.add(xMax).add(yMax); + } else if (shape instanceof Circle) { + warnBoundingBoxApproximation("within a Circle"); + //here the bounding box is the box that contains the circle + Circle circle = (Circle) shape; + Point center = circle.getCenter(); + double radius = circle.getRadius().getNormalizedValue(); + + double xMin = center.getX() - radius; + double xMax = center.getX() + radius; + double yMin = center.getY() - radius; + double yMax = center.getY() + radius; + + startRange.add(xMin).add(yMin); + endRange.add(xMax).add(yMax); + } else { + throw new IllegalArgumentException("Unsupported shape " + shape.getClass().getName()); + } + } + + private static void warnBoundingBoxApproximation(String kind) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Spatial View queries " + kind + " are made using a bounding box approximation"); + } + } +} diff --git a/src/main/java/org/springframework/data/couchbase/repository/support/CouchbaseRepositoryFactory.java b/src/main/java/org/springframework/data/couchbase/repository/support/CouchbaseRepositoryFactory.java index 6d25a7f2..d4a94a5b 100644 --- a/src/main/java/org/springframework/data/couchbase/repository/support/CouchbaseRepositoryFactory.java +++ b/src/main/java/org/springframework/data/couchbase/repository/support/CouchbaseRepositoryFactory.java @@ -32,6 +32,7 @@ import org.springframework.data.couchbase.repository.config.RepositoryOperations import org.springframework.data.couchbase.repository.query.CouchbaseEntityInformation; import org.springframework.data.couchbase.repository.query.CouchbaseQueryMethod; import org.springframework.data.couchbase.repository.query.PartTreeN1qlBasedQuery; +import org.springframework.data.couchbase.repository.query.SpatialViewBasedQuery; import org.springframework.data.couchbase.repository.query.StringN1qlBasedQuery; import org.springframework.data.couchbase.repository.query.ViewBasedCouchbaseQuery; import org.springframework.data.mapping.context.MappingContext; @@ -184,7 +185,9 @@ public class CouchbaseRepositoryFactory extends RepositoryFactorySupport { CouchbaseQueryMethod queryMethod = new CouchbaseQueryMethod(method, metadata, mappingContext); String namedQueryName = queryMethod.getNamedQueryName(); - if (queryMethod.hasViewAnnotation()) { + if (queryMethod.hasDimensionalAnnotation()) { + return new SpatialViewBasedQuery(queryMethod, couchbaseOperations); + } else if (queryMethod.hasViewAnnotation()) { return new ViewBasedCouchbaseQuery(queryMethod, couchbaseOperations); } else if (queryMethod.hasN1qlAnnotation()) { if (queryMethod.hasInlineN1qlQuery()) { diff --git a/src/test/java/org/springframework/data/couchbase/repository/query/support/GeoUtilsTest.java b/src/test/java/org/springframework/data/couchbase/repository/query/support/GeoUtilsTest.java new file mode 100644 index 00000000..7b7d6367 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/repository/query/support/GeoUtilsTest.java @@ -0,0 +1,353 @@ +package org.springframework.data.couchbase.repository.query.support; + +import static org.junit.Assert.assertEquals; + +import com.couchbase.client.java.document.json.JsonArray; +import org.junit.Test; + +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.geo.Shape; + +/** + * Unit tests for the {@link GeoUtils} utility class. + * @author Simon Baslé + */ +public class GeoUtilsTest { + + @Test + public void testGetBoundingBoxForNear() throws Exception { + final Distance distance = new Distance(120); + double[] bbox = GeoUtils.getBoundingBoxForNear(new Point(1, 1), distance); + assertEquals(-119d, bbox[0], 0d); //xMin + assertEquals(121d, bbox[2], 0d); //xMax + assertEquals(-119d, bbox[1], 0d); //yMin + assertEquals(121d, bbox[3], 0d); //yMax + + bbox = GeoUtils.getBoundingBoxForNear(new Point(-3,-5), distance); + assertEquals(-123d, bbox[0], 0d); //xMin + assertEquals(117d, bbox[2], 0d); //xMax + assertEquals(-125d, bbox[1], 0d); //yMin + assertEquals(115d, bbox[3], 0d); //yMax + } + + @Test + public void testGetBoundingBoxForNearNegativeDistance() throws Exception { + final Distance distance = new Distance(-120); + double[] bbox = GeoUtils.getBoundingBoxForNear(new Point(1, 1), distance); + assertEquals(-119d, bbox[0], 0d); //xMin + assertEquals(121d, bbox[2], 0d); //xMax + assertEquals(-119d, bbox[1], 0d); //yMin + assertEquals(121d, bbox[3], 0d); //yMax + + bbox = GeoUtils.getBoundingBoxForNear(new Point(-3,-5), distance); + assertEquals(-123d, bbox[0], 0d); //xMin + assertEquals(117d, bbox[2], 0d); //xMax + assertEquals(-125d, bbox[1], 0d); //yMin + assertEquals(115d, bbox[3], 0d); //yMax + } + + @Test(expected = NullPointerException.class) + public void testGetBoundingBoxForNearNullOrigin() { + GeoUtils.getBoundingBoxForNear(null, new Distance(100)); + } + + @Test(expected = NullPointerException.class) + public void testGetBoundingBoxForNearNullDistance() { + GeoUtils.getBoundingBoxForNear(new Point(1, 1), null); + } + + @Test(expected = NullPointerException.class) + public void testGetBoundingBoxForNearNullOriginAndDistance() { + GeoUtils.getBoundingBoxForNear(null, null); + } + + @Test(expected = IllegalArgumentException.class) + public void testConvertPointsTo2DRangesIsBoundingBoxFailsIfLessThan2Points() { + JsonArray startRange = JsonArray.create(); + JsonArray endRange = JsonArray.create(); + Point p1 = new Point(2,3); + + GeoUtils.convertPointsTo2DRanges(startRange, endRange, true, p1); + } + + @Test(expected = IllegalArgumentException.class) + public void testConvertPointsTo2DRangesIsBoundingBoxFailsIfMoreThan2Points() { + JsonArray startRange = JsonArray.create(); + JsonArray endRange = JsonArray.create(); + Point p1 = new Point(2,3); + Point p2 = new Point(4,5); + Point p3 = new Point(6,7); + + GeoUtils.convertPointsTo2DRanges(startRange, endRange, true, p1, p2, p3); + } + + @Test(expected = IllegalArgumentException.class) + public void testConvertPointsTo2DRangesIsBoundingBoxFailsIfNotOrderedPoints() { + JsonArray startRange = JsonArray.create(); + JsonArray endRange = JsonArray.create(); + Point p1 = new Point(2,3); + Point p2 = new Point(4,5); + + GeoUtils.convertPointsTo2DRanges(startRange, endRange, true, p2, p1); + } + + @Test + public void testConvertPointsTo2DRanges2PointsBoundingBox() throws Exception { + JsonArray startRange = JsonArray.create(); + JsonArray endRange = JsonArray.create(); + Point p1 = new Point(2,3); + Point p2 = new Point(4,5); + + GeoUtils.convertPointsTo2DRanges(startRange, endRange, true, p1, p2); + + assertEquals(2, startRange.size()); + assertEquals(2, endRange.size()); + assertEquals(2d, startRange.getDouble(0), 0d); + assertEquals(3d, startRange.getDouble(1), 0d); + assertEquals(4d, endRange.getDouble(0), 0d); + assertEquals(5d, endRange.getDouble(1), 0d); + } + + @Test + public void testConvertPointsTo2DRanges2PointsPoly() throws Exception { + JsonArray startRange = JsonArray.create(); + JsonArray endRange = JsonArray.create(); + Point p1 = new Point(2,3); + Point p2 = new Point(4,5); + + GeoUtils.convertPointsTo2DRanges(startRange, endRange, false, p1, p2); + + assertEquals(2, startRange.size()); + assertEquals(2, endRange.size()); + assertEquals(2d, startRange.getDouble(0), 0d); + assertEquals(3d, startRange.getDouble(1), 0d); + assertEquals(4d, endRange.getDouble(0), 0d); + assertEquals(5d, endRange.getDouble(1), 0d); + } + + @Test + public void testConvertPointsTo2DRanges3PointsPoly() throws Exception { + JsonArray startRange = JsonArray.create(); + JsonArray endRange = JsonArray.create(); + Point p1 = new Point(2,3); + Point p2 = new Point(-4, 3); + Point p3 = new Point(6, -12); + + GeoUtils.convertPointsTo2DRanges(startRange, endRange, false, p1, p2, p3); + + assertEquals(2, startRange.size()); + assertEquals(2, endRange.size()); + assertEquals(-4d, startRange.getDouble(0), 0d); + assertEquals(-12d, startRange.getDouble(1), 0d); + assertEquals(6d, endRange.getDouble(0), 0d); + assertEquals(3d, endRange.getDouble(1), 0d); + } + + @Test + public void testConvertPointsTo2DRanges8Points() throws Exception { + JsonArray startRange = JsonArray.create(); + JsonArray endRange = JsonArray.create(); + //bounding box of (3,3)(9,9) + GeoUtils.convertPointsTo2DRanges(startRange, endRange, false, + 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)); + + assertEquals(2, startRange.size()); + assertEquals(2, endRange.size()); + assertEquals(3d, startRange.getDouble(0), 0d); + assertEquals(3d, startRange.getDouble(1), 0d); + assertEquals(9d, endRange.getDouble(0), 0d); + assertEquals(9d, endRange.getDouble(1), 0d); + } + + @Test(expected = IllegalArgumentException.class) + public void testConvertPointsTo2DRangesNullPoints() throws Exception { + GeoUtils.convertPointsTo2DRanges(JsonArray.empty(), JsonArray.empty(), false, null); + } + + @Test(expected = IllegalArgumentException.class) + public void testConvertPointsTo2DRangesEmptyPoints() throws Exception { + GeoUtils.convertPointsTo2DRanges(JsonArray.empty(), JsonArray.empty(), false, new Point[0]); + } + + @Test + public void testConvertShapeTo2DRangesBox() throws Exception { + JsonArray startRange = JsonArray.create(); + JsonArray endRange = JsonArray.create(); + Box box = new Box(new Point(0, 5), new Point(10, 30)); + + GeoUtils.convertShapeTo2DRanges(startRange, endRange, box); + + assertEquals(2, startRange.size()); + assertEquals(2, endRange.size()); + assertEquals(0d, startRange.getDouble(0), 0d); + assertEquals(5d, startRange.getDouble(1), 0d); + assertEquals(10d, endRange.getDouble(0), 0d); + assertEquals(30, endRange.getDouble(1), 0d); + } + + @Test + public void testConvertShapeTo2DRangesBoxIsNotReordered() throws Exception { + JsonArray startRange = JsonArray.create(); + JsonArray endRange = JsonArray.create(); + Box box = new Box(new Point(0, 5), new Point(10, -3)); + + GeoUtils.convertShapeTo2DRanges(startRange, endRange, box); + + assertEquals(2, startRange.size()); + assertEquals(2, endRange.size()); + assertEquals(0d, startRange.getDouble(0), 0d); + assertEquals(5d, startRange.getDouble(1), 0d); + assertEquals(10d, endRange.getDouble(0), 0d); + assertEquals(-3d, endRange.getDouble(1), 0d); + } + + @Test + public void testConvertPolygonBoxTo2DRangesIsReordered() throws Exception { + JsonArray startRange = JsonArray.create(); + JsonArray endRange = JsonArray.create(); + Polygon box = new Polygon(new Point(0, 5), new Point(10, -3), new Point(0, 5)); + + GeoUtils.convertShapeTo2DRanges(startRange, endRange, box); + + assertEquals(2, startRange.size()); + assertEquals(2, endRange.size()); + assertEquals(0d, startRange.getDouble(0), 0d); + assertEquals(-3d, startRange.getDouble(1), 0d); + assertEquals(10d, endRange.getDouble(0), 0d); + assertEquals(5d, endRange.getDouble(1), 0d); + } + + @Test + public void testConvertShapeTo2DRangesPolygon() throws Exception { + JsonArray startRange = JsonArray.create(); + JsonArray endRange = JsonArray.create(); + Polygon polygon = 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) + ); + + GeoUtils.convertShapeTo2DRanges(startRange, endRange, polygon); + + assertEquals(2, startRange.size()); + assertEquals(2, endRange.size()); + assertEquals(3d, startRange.getDouble(0), 0d); + assertEquals(3d, startRange.getDouble(1), 0d); + assertEquals(9d, endRange.getDouble(0), 0d); + assertEquals(9d, endRange.getDouble(1), 0d); + } + + @Test + public void testConvertShapeTo2DRangesPolygonIsSameAsMultiplePointsTo2DRanges() throws Exception { + JsonArray startRangePoints = JsonArray.create(); + JsonArray endRangePoints = JsonArray.create(); + JsonArray startRangePolygon = JsonArray.create(); + JsonArray endRangePolygon = JsonArray.create(); + + GeoUtils.convertPointsTo2DRanges(startRangePoints, endRangePoints, false, + 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)); + + Polygon polygon = 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)); + + GeoUtils.convertShapeTo2DRanges(startRangePolygon, endRangePolygon, polygon); + + assertEquals(2, startRangePoints.size()); + assertEquals(2, endRangePoints.size()); + assertEquals(3d, startRangePoints.getDouble(0), 0d); + assertEquals(3d, startRangePoints.getDouble(1), 0d); + assertEquals(9d, endRangePoints.getDouble(0), 0d); + assertEquals(9d, endRangePoints.getDouble(1), 0d); + assertEquals(endRangePoints, endRangePolygon); + assertEquals(startRangePoints, startRangePolygon); + } + + @Test + public void testConvertShapeTo2DRangesCircle() throws Exception { + JsonArray startRange = JsonArray.create(); + JsonArray endRange = JsonArray.create(); + Circle circle = new Circle(new Point(0, 0), new Distance(3)); + + GeoUtils.convertShapeTo2DRanges(startRange, endRange, circle); + + assertEquals(2, startRange.size()); + assertEquals(2, endRange.size()); + assertEquals(-3d, startRange.getDouble(0), 0d); + assertEquals(-3d, startRange.getDouble(1), 0d); + assertEquals(3d, endRange.getDouble(0), 0d); + assertEquals(3d, endRange.getDouble(1), 0d); + } + + @Test + public void testCircleApproximationIsSameAsNearApproximation() throws Exception { + JsonArray startRangeCircle = JsonArray.create(); + JsonArray endRangeCircle = JsonArray.create(); + Point origin = new Point(0, 0); + Distance distance = new Distance(3); + Circle circle = new Circle(origin, distance); + + GeoUtils.convertShapeTo2DRanges(startRangeCircle, endRangeCircle, circle); + double[] bbox = GeoUtils.getBoundingBoxForNear(origin, distance); + + assertEquals(2, startRangeCircle.size()); + assertEquals(2, endRangeCircle.size()); + + assertEquals(-3d, bbox[0], 0d); + assertEquals(-3d, bbox[1], 0d); + assertEquals(3d, bbox[2], 0d); + assertEquals(3d, bbox[3], 0d); + + assertEquals(bbox[0], startRangeCircle.getDouble(0), 0d); + assertEquals(bbox[1], startRangeCircle.getDouble(1), 0d); + assertEquals(bbox[2], endRangeCircle.getDouble(0), 0d); + assertEquals(bbox[3], endRangeCircle.getDouble(1), 0d); + } + + @Test(expected = IllegalArgumentException.class) + public void testConvertShapeTo2DRangesOtherShape() throws Exception { + JsonArray startRange = JsonArray.create(); + JsonArray endRange = JsonArray.create(); + + Shape customShape = new Shape() {}; + + GeoUtils.convertShapeTo2DRanges(startRange, endRange, customShape); + } + + @Test(expected = NullPointerException.class) + public void testConvertShapeTo2DRangesNullShape() throws Exception { + JsonArray startRange = JsonArray.create(); + JsonArray endRange = JsonArray.create(); + + GeoUtils.convertShapeTo2DRanges(startRange, endRange, null); + } +} \ No newline at end of file