diff --git a/src/main/asciidoc/repository.adoc b/src/main/asciidoc/repository.adoc index 8350b9f0..7c8182d2 100644 --- a/src/main/asciidoc/repository.adoc +++ b/src/main/asciidoc/repository.adoc @@ -341,10 +341,70 @@ The following query derivation keywords and parameters relative to geographical |`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. +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, but 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. +=== Removing False Positives +`Circle` and `Polygon` will be approximated to their surrounding bounding `Box`, but we provide `PointInShapeEvaluator` (`AwtPointInShapeEvaluator`) to be used on the results if you want to remove false positives. + +Here is an example of a repository using a spatial view with 3 dimensions (x, y and opening hours). +Second example demonstrates querying it within a `Polygon` then removing false positives in a second pass: + +.Example of dimensional repository +==== +[source,java] +---- +public interface StoreRepository extends PagingAndSortingRepository { + + @Dimensional(designDocument = "store", spatialViewName = "byLocationOpening", dimensions = 3) + public List findByLocationWithinAndOpenBefore(Polygon zone, Integer minOpeningHourMinute); + +} +---- +==== + +.Query a spatial view and eliminate false positives on the geo aspect +==== +[source,java] +---- +// Load the bean, or @Autowire it +StoreRepository repo = ctx.getBean(StoreRepository.class); + +// Prepare an arbitrary polygon (a triangle) +Polygon zone = new Polygon( + new Point(2.0, 0.0), + new Point(4.0, 0.0), + new Point(3.0, -5.5), + new Point(2.0, 0.0)); //we explicitly closed the polygon + +// Find all stores located inside the triangle. +// Use 3rd dimension to also query on opening hours, making sure one can go there at 8:00am +List stores = repo.findByLocationWithinAndOpenBefore(zone, 800); + +// The view will return all matching stores that are actually within the enclosing rectangle around the polygon +// So we remove false positives +PointInShapeEvaluator evaluator = new AwtPointInShapeEvaluator(); +List truePositives = new ArrayList(stores.size()); +for (Store store : stores) { + if (evaluator.pointInPolygon(store.getLocation(), zone)) { + truePositives.add(store); + } //else this is a false positive +} + +// Alternatively, use "removeFalsePositives" to get the list right away. +truePositives = evaluator.removeFalsePositives(stores, + //this method needs a Converter to know where to find the location + new Converter() { + @Override + public Point convert(Store source) { + return source.getLocation(); + } + }, + zone); +---- +==== + [[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/repository/query/support/AwtPointInShapeEvaluator.java b/src/main/java/org/springframework/data/couchbase/repository/query/support/AwtPointInShapeEvaluator.java new file mode 100644 index 00000000..3cb2d9f2 --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/repository/query/support/AwtPointInShapeEvaluator.java @@ -0,0 +1,89 @@ +/* + * 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 java.awt.geom.Path2D; +import java.awt.geom.Point2D; +import java.util.List; + +import org.springframework.data.geo.Circle; +import org.springframework.data.geo.Distance; +import org.springframework.data.geo.Point; +import org.springframework.data.geo.Polygon; + +/** + * A default {@link PointInShapeEvaluator} implementation based on the JDK's java.awt.geom classes. + * + * @author Simon Baslé + */ +public class AwtPointInShapeEvaluator extends PointInShapeEvaluator { + + @Override + public boolean pointInPolygon(Point p, Polygon polygon) { + final List points = polygon.getPoints(); + Path2D awtPolygon = new Path2D.Double(Path2D.WIND_EVEN_ODD, points.size()); + boolean first = true; + for (Point point : points) { + if (first) { + first = false; + awtPolygon.moveTo(point.getX(), point.getY()); + } else { + awtPolygon.lineTo(point.getX(), point.getY()); + } + } + awtPolygon.closePath(); + return pointInPolygon(p, awtPolygon); + } + + @Override + public boolean pointInPolygon(Point p, Point... points) { + if (points == null) throw new NullPointerException("Polygon must at least contain 3 points"); + if (points.length < 3) throw new IllegalArgumentException("Polygon must at least contain 3 points"); + Path2D awtPolygon = new Path2D.Double(Path2D.WIND_EVEN_ODD, points.length); + boolean first = true; + for (Point point : points) { + if (first) { + first = false; + awtPolygon.moveTo(point.getX(), point.getY()); + } else { + awtPolygon.lineTo(point.getX(), point.getY()); + } + } + awtPolygon.closePath(); + return pointInPolygon(p, awtPolygon); + } + + @Override + public boolean pointInCircle(Point p, Circle c) { + Point2D center = new Point2D.Double(c.getCenter().getX(), c.getCenter().getY()); + return pointNear(p, center, c.getRadius().getNormalizedValue()); + } + + @Override + public boolean pointInCircle(Point p, Point center, Distance radiusDistance) { + double radius = radiusDistance.getNormalizedValue(); + return pointNear(p, new Point2D.Double(center.getX(), center.getY()), radius); + } + + private boolean pointInPolygon(Point p, Path2D awtPolygon) { + return awtPolygon.contains(p.getX(), p.getY()); + } + + private boolean pointNear(Point p, Point2D center, double distance) { + return center.distance(p.getX(), p.getY()) <= distance; + } +} 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 index 48d35a65..b8a7210c 100644 --- 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 @@ -166,7 +166,8 @@ public class GeoUtils { private static void warnBoundingBoxApproximation(String kind) { if (LOGGER.isDebugEnabled()) { - LOGGER.debug("Spatial View queries " + kind + " are made using a bounding box approximation"); + LOGGER.debug("Spatial View queries " + kind + " are made using a bounding box approximation," + + "you probably want to remove false positives by applying a PointInShapeEvaluator"); } } } diff --git a/src/main/java/org/springframework/data/couchbase/repository/query/support/PointInShapeEvaluator.java b/src/main/java/org/springframework/data/couchbase/repository/query/support/PointInShapeEvaluator.java new file mode 100644 index 00000000..c843898c --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/repository/query/support/PointInShapeEvaluator.java @@ -0,0 +1,194 @@ +/* + * 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 java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.springframework.core.convert.converter.Converter; +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; + +/** + * PointInShapeEvaluator can be used to tell if a particular {@link Point} is contained by a + * {@link Polygon} or {@link Circle}. It is most useful to eliminate false positive when a + * geo query has been made using a {@link Box bounding box} approximation. + * + * For the purpose of this class, a point on the edge of a polygon isn't considered within it. + * On the contrary a point on the edge of a circle is considered in it (distance from center <= radius). + * + * To that end, {@link #removeFalsePositives(Collection, Converter, Circle) additional methods} + * that return a {@link List} of objects with false positives removed are also provided. However, + * these need a {@link Converter Converter<T, Point>} to extract the location attribute that + * should be tested against the polygon/circle. + * + * @author Simon Baslé + * @see AwtPointInShapeEvaluator for a simple implementation based on AWT standard library. + */ +public abstract class PointInShapeEvaluator { + + /** + * Determine if a {@link Point} is contained by a {@link Polygon}. + * + * @param p the point to test. + * @param polygon the polygon we want the point to be in. + * @return true if the polygon contains the point, false otherwise. + */ + public abstract boolean pointInPolygon(Point p, Polygon polygon); + + /** + * Determine if a {@link Point} is contained by a polygon represented + * as an array of {@link Point points}. + * + * The points are not required to form a closed shape, but can (by having the + * first and last points be the same). + * + * @param p the point to test. + * @param points the Point[] representation of the polygon we want the point to be in. + * @return true if the polygon contains the point, false otherwise. + */ + public abstract boolean pointInPolygon(Point p, Point... points); + + /** + * Determine if a {@link Point} is contained by a {@link Circle}. + * + * @param p the point to test. + * @param c the Circle we want the point to be in. + * @return true if the circle contains the point, false otherwise. + */ + public abstract boolean pointInCircle(Point p, Circle c); + + /** + * Determine if a {@link Point} is contained by a {@link Circle} represented + * by its {@link Point center Point} and {@link Distance Distance radius}. + * + * @param p the point to test. + * @param center the center Point of the Circle we want the point to be in. + * @param radius the Distance radius of the Circle we want the point to be in. + * @return true if the circle contains the point, false otherwise. + */ + public abstract boolean pointInCircle(Point p, Point center, Distance radius); + + /** + * Utility method to remove false positives from a collection of objects that have a + * notion of location, where we want to only include values that are located within a polygon. + * + * The collection should usually be already contained in the bounding box approximation of + * the polygon for maximum efficiency. + * + * @param boundingBoxResults the collections of located objects approximately inside the target polygon. + * @param locationExtractor a {@link Converter} to extract the location of the value objects. + * @param polygon the target polygon. + * @param the type of located value objects in the collection. + * @return a {@link List} of the value objects which location has been verified to actually be contained within the polygon. + */ + public List removeFalsePositives(Collection boundingBoxResults, + Converter locationExtractor, Polygon polygon) { + ArrayList result = new ArrayList(boundingBoxResults.size()); + for (T boxResult : boundingBoxResults) { + Point p = locationExtractor.convert(boxResult); + if (pointInPolygon(p, polygon)) { + result.add(boxResult); + } + } + result.trimToSize(); + return result; + } + + /** + * Utility method to remove false positives from a collection of objects that have a + * notion of location, where we want to only include values that are located within a circle. + * + * The collection should usually be already contained in the bounding box approximation of + * the circle for maximum efficiency. + * + * @param boundingBoxResults the collections of located objects approximately inside the target circle. + * @param locationExtractor a {@link Converter} to extract the location of the value objects. + * @param circle the target circle. + * @param the type of located value objects in the collection. + * @return a {@link List} of the value objects which location has been verified to actually be contained within the circle. + */ + public List removeFalsePositives(Collection boundingBoxResults, + Converter locationExtractor, Circle circle) { + ArrayList result = new ArrayList(boundingBoxResults.size()); + for (T boxResult : boundingBoxResults) { + Point p = locationExtractor.convert(boxResult); + if (pointInCircle(p, circle)) { + result.add(boxResult); + } + } + result.trimToSize(); + return result; + } + + /** + * Utility method to remove false positives from a collection of objects that have a + * notion of location, where we want to only include values that are located within a polygon. + * + * The collection should usually be already contained in the bounding box approximation of + * the polygon for maximum efficiency. + * + * @param boundingBoxResults the collections of located objects approximately inside the target polygon. + * @param locationExtractor a {@link Converter} to extract the location of the value objects. + * @param polygon the target polygon, as an array of {@link Point} (not necessarily closed). + * @param the type of located value objects in the collection. + * @return a {@link List} of the value objects which location has been verified to actually be contained within the polygon. + */ + public List removeFalsePositives(Collection boundingBoxResults, + Converter locationExtractor, Point... polygon) { + ArrayList result = new ArrayList(boundingBoxResults.size()); + for (T boxResult : boundingBoxResults) { + Point p = locationExtractor.convert(boxResult); + if (pointInPolygon(p, polygon)) { + result.add(boxResult); + } + } + result.trimToSize(); + return result; + } + + /** + * Utility method to remove false positives from a collection of objects that have a + * notion of location, where we want to only include values that are located within a circle. + * + * The collection should usually be already contained in the bounding box approximation of + * the circle for maximum efficiency. + * + * @param boundingBoxResults the collections of located objects approximately inside the target circle. + * @param locationExtractor a {@link Converter} to extract the location of the value objects. + * @param center the center of the target circle. + * @param radius the radius of the target circle. + * @param the type of located value objects in the collection. + * @return a {@link List} of the value objects which location has been verified to actually be contained within the circle. + */ + public List removeFalsePositives(Collection boundingBoxResults, + Converter locationExtractor, Point center, Distance radius) { + ArrayList result = new ArrayList(boundingBoxResults.size()); + for (T boxResult : boundingBoxResults) { + Point p = locationExtractor.convert(boxResult); + if (pointInCircle(p, center, radius)) { + result.add(boxResult); + } + } + result.trimToSize(); + return result; + } +} diff --git a/src/test/java/org/springframework/data/couchbase/repository/query/support/AbstractPointInShapeEvaluatorTest.java b/src/test/java/org/springframework/data/couchbase/repository/query/support/AbstractPointInShapeEvaluatorTest.java new file mode 100644 index 00000000..733027a4 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/repository/query/support/AbstractPointInShapeEvaluatorTest.java @@ -0,0 +1,232 @@ +package org.springframework.data.couchbase.repository.query.support; + +import static org.junit.Assert.*; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.geo.Circle; +import org.springframework.data.geo.Distance; +import org.springframework.data.geo.Point; +import org.springframework.data.geo.Polygon; + +/** + * An abstract base for testing {@link PointInShapeEvaluator} implementations. + * Each implementation can be commonly tested by extending this base and instantiating the evaluator in createEvaluator. + * + * @author Simon Baslé + */ +public abstract class AbstractPointInShapeEvaluatorTest { + + public abstract PointInShapeEvaluator createEvaluator(); + + private PointInShapeEvaluator evaluator; + + protected static final class LocatedValue { + public final Point location; + public final String name; + + public LocatedValue(Point location, String name) { + this.location = location; + this.name = name; + } + + @Override + public String toString() { + return name; + } + } + + protected static final Converter LOCATED_VALUE_POINT_CONVERTER = new Converter() { + @Override + public Point convert(LocatedValue source) { + return source.location; + } + }; + + @Before + public void init() { + evaluator = createEvaluator(); + } + + @Test + public void testPointInPolygon() throws Exception { + Polygon openTriangle = new Polygon( + new Point(1.0, 2.0), + new Point(2.0, 2.0), + new Point(1.5, 0.0) + ); + + Polygon closedTriangle = new Polygon( + new Point(1.0, 2.0), + new Point(2.0, 2.0), + new Point(1.5, 0.0), + new Point(1.0, 2.0) //explicitly closing the shape + ); + + Point inside = new Point(1.6, 1.8); + Point outside = new Point(1.1, 0.3); + Point edge = new Point(1.0, 2.0); + + assertTrue("point inside open polygon failed", evaluator.pointInPolygon(inside, openTriangle)); + assertFalse("point outside open polygon failed", evaluator.pointInPolygon(outside, openTriangle)); + assertFalse("point on edge of open polygon should not be considered within the polygon", + evaluator.pointInPolygon(edge, openTriangle)); + assertTrue("point inside closed polygon failed", evaluator.pointInPolygon(inside, closedTriangle)); + assertFalse("point outside closed polygon failed", evaluator.pointInPolygon(outside, closedTriangle)); + assertFalse("point on edge of closed polygon should not be considered within the polygon", + evaluator.pointInPolygon(edge, closedTriangle)); + } + + @Test + public void testPointInPolygonArray() throws Exception { + Point[] openTriangle = new Point[] { + new Point(1.0, 2.0), + new Point(2.0, 2.0), + new Point(1.5, 0.0) + }; + + Point[] closedTriangle = new Point[] { + new Point(1.0, 2.0), + new Point(2.0, 2.0), + new Point(1.5, 0.0), + new Point(1.0, 2.0) //explicitly closing the shape + }; + + Point inside = new Point(1.6, 1.8); + Point outside = new Point(1.1, 0.3); + Point edge = new Point(1.0, 2.0); + + assertTrue("point inside open polygon failed", evaluator.pointInPolygon(inside, openTriangle)); + assertFalse("point outside open polygon failed", evaluator.pointInPolygon(outside, openTriangle)); + assertFalse("point on edge of open polygon should not be considered within the polygon", + evaluator.pointInPolygon(edge, openTriangle)); + assertTrue("point inside closed polygon failed", evaluator.pointInPolygon(inside, closedTriangle)); + assertFalse("point outside closed polygon failed", evaluator.pointInPolygon(outside, closedTriangle)); + assertFalse("point on edge of closed polygon should not be considered within the polygon", + evaluator.pointInPolygon(edge, closedTriangle)); + } + + @Test + public void testPointInCircle() throws Exception { + Circle circle = new Circle(new Point(0, 0), new Distance(2)); + + Point inside = new Point(-0.3, 1.4); + Point outside = new Point(1.3, 2d); + Point onEdge = new Point(-2d, 0d); + + assertTrue("point inside failed", evaluator.pointInCircle(inside, circle)); + assertFalse("point outside failed", evaluator.pointInCircle(outside, circle)); + assertTrue("point on edge of circle should be considered within / near", evaluator.pointInCircle(onEdge, circle)); + } + + @Test + public void testPointInCircleCenterRadius() throws Exception { + Point center = new Point(0, 0); + Distance radius = new Distance(2); + + Point inside = new Point(-0.3, 1.8); + Point outside = new Point(1.3, 2d); + Point onEdge = new Point(-2d, 0d); + + assertTrue("point inside failed", evaluator.pointInCircle(inside, center, radius)); + assertFalse("point outside failed", evaluator.pointInCircle(outside, center, radius)); + assertTrue("point on edge of circle should be considered within / near", evaluator.pointInCircle(onEdge, center, radius)); + } + + @Test + public void testRemoveFalsePositivesPolygon() throws Exception { + Polygon openTriangle = new Polygon( + new Point(1.0, 2.0), + new Point(2.0, 2.0), + new Point(1.5, 0.0) + ); + + Polygon closedTriangle = new Polygon( + new Point(1.0, 2.0), + new Point(2.0, 2.0), + new Point(1.5, 0.0), + new Point(1.0, 2.0) //explicitly closing the shape + ); + + LocatedValue inside = new LocatedValue(new Point(1.6, 1.8), "inside"); + LocatedValue outside = new LocatedValue(new Point(1.1, 0.3), "outside"); + LocatedValue edge = new LocatedValue(new Point(1.5, 0d), "edge"); + + List expected = Collections.singletonList(inside); + List tested = Arrays.asList(inside, outside, edge); + + List filteredOpen = evaluator.removeFalsePositives(tested, LOCATED_VALUE_POINT_CONVERTER, openTriangle); + List filteredClosed = evaluator.removeFalsePositives(tested, LOCATED_VALUE_POINT_CONVERTER, closedTriangle); + + assertEquals(expected, filteredOpen); + assertEquals(expected, filteredClosed); + } + + @Test + public void testRemoveFalsePositivesPolygonArray() throws Exception { + Point[] openTriangle = new Point[] { + new Point(1.0, 2.0), + new Point(2.0, 2.0), + new Point(1.5, 0.0) + }; + + Point[] closedTriangle = new Point[] { + new Point(1.0, 2.0), + new Point(2.0, 2.0), + new Point(1.5, 0.0), + new Point(1.0, 2.0) //explicitly closing the shape + }; + + LocatedValue inside = new LocatedValue(new Point(1.6, 1.8), "inside"); + LocatedValue outside = new LocatedValue(new Point(1.1, 0.3), "outside"); + LocatedValue edge = new LocatedValue(new Point(1.5, 0d), "edge"); + + List expected = Collections.singletonList(inside); + List tested = Arrays.asList(inside, outside, edge); + + List filteredOpen = evaluator.removeFalsePositives(tested, LOCATED_VALUE_POINT_CONVERTER, openTriangle); + List filteredClosed = evaluator.removeFalsePositives(tested, LOCATED_VALUE_POINT_CONVERTER, closedTriangle); + + assertEquals(expected, filteredOpen); + assertEquals(expected, filteredClosed); + } + + @Test + public void testRemoveFalsePositivesCircle() throws Exception { + Circle circle = new Circle(new Point(0, 0), new Distance(2)); + + LocatedValue inside = new LocatedValue(new Point(-0.3, 1.4), "inside"); + LocatedValue outside = new LocatedValue(new Point(1.3, 2d), "outside"); + LocatedValue edge = new LocatedValue(new Point(-2d, 0d), "edge"); + + List expected = Arrays.asList(inside, edge); + List tested = Arrays.asList(inside, outside, edge); + + List filtered = evaluator.removeFalsePositives(tested, LOCATED_VALUE_POINT_CONVERTER, circle); + + assertEquals(expected, filtered); + } + + @Test + public void testRemoveFalsePositivesCircleCenterRadius() throws Exception { + Point center = new Point(0, 0); + Distance radius = new Distance(2); + + LocatedValue inside = new LocatedValue(new Point(-0.3, 1.4), "inside"); + LocatedValue outside = new LocatedValue(new Point(1.3, 2d), "outside"); + LocatedValue edge = new LocatedValue(new Point(-2d, 0d), "edge"); + + List expected = Arrays.asList(inside, edge); + List tested = Arrays.asList(inside, outside, edge); + + List filtered = evaluator.removeFalsePositives(tested, LOCATED_VALUE_POINT_CONVERTER, center, radius); + + assertEquals(expected, filtered); + } +} \ No newline at end of file diff --git a/src/test/java/org/springframework/data/couchbase/repository/query/support/AwtPointInShapeEvaluatorTest.java b/src/test/java/org/springframework/data/couchbase/repository/query/support/AwtPointInShapeEvaluatorTest.java new file mode 100644 index 00000000..7c40ec04 --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/repository/query/support/AwtPointInShapeEvaluatorTest.java @@ -0,0 +1,14 @@ +package org.springframework.data.couchbase.repository.query.support; + +/** + * Test case for the {@link AwtPointInShapeEvaluator}. + * + * @author Simon Baslé + */ +public class AwtPointInShapeEvaluatorTest extends AbstractPointInShapeEvaluatorTest { + + @Override + public PointInShapeEvaluator createEvaluator() { + return new AwtPointInShapeEvaluator(); + } +}