From e7bb4a1258c93ccddd57a6d545f329fa95172d25 Mon Sep 17 00:00:00 2001 From: Michael Hunger Date: Mon, 31 Mar 2014 01:02:42 +0200 Subject: [PATCH] DATAGRAPH-454 Support for Common Geospatial Types and Derived spatial Queries --- .../Neo4jConversionServiceFactoryBean.java | 38 ++++++ .../repository/AbstractGraphRepository.java | 65 +++++---- .../data/neo4j/repository/GeoConverter.java | 121 +++++++++++++++++ .../data/neo4j/repository/GeoQueries.java | 125 ++++++++++++++++++ .../neo4j/repository/SpatialRepository.java | 16 +++ .../neo4j/repository/query/CypherQuery.java | 4 +- .../data/neo4j/repository/query/PartInfo.java | 4 + .../query/SpatialIndexStartClause.java | 83 ++++++++++++ .../repository/query/StartClauseFactory.java | 14 +- .../data/neo4j/model/Person.java | 9 +- .../neo4j/repositories/PersonRepository.java | 9 ++ .../data/neo4j/repository/GeoQueriesTest.java | 48 +++++++ .../data/neo4j/repository/SerialTesters.java | 2 +- .../SpatialGraphRepositoryTests.java | 47 ++++++- .../data/neo4j/repository/TestTeam.java | 2 +- spring-data-neo4j/template.mf | 1 + .../reference/programming-model/spatial.xml | 68 +++++++++- src/main/resources/changelog.txt | 9 +- 18 files changed, 612 insertions(+), 53 deletions(-) create mode 100644 spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/GeoConverter.java create mode 100644 spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/GeoQueries.java create mode 100644 spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/SpatialIndexStartClause.java create mode 100644 spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repository/GeoQueriesTest.java diff --git a/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/fieldaccess/Neo4jConversionServiceFactoryBean.java b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/fieldaccess/Neo4jConversionServiceFactoryBean.java index 192edd6ed..965dbdaf5 100644 --- a/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/fieldaccess/Neo4jConversionServiceFactoryBean.java +++ b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/fieldaccess/Neo4jConversionServiceFactoryBean.java @@ -23,6 +23,9 @@ import org.springframework.core.convert.converter.ConverterFactory; import org.springframework.core.convert.converter.ConverterRegistry; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.core.convert.support.GenericConversionService; +import org.springframework.data.geo.Point; +import org.springframework.data.geo.Shape; +import org.springframework.data.neo4j.repository.GeoConverter; import java.util.Date; @@ -44,6 +47,10 @@ public class Neo4jConversionServiceFactoryBean implements FactoryBean { + + @Override + public String convert(Shape source) { + return GeoConverter.toWellKnownText(source); + } + } + public static class PointToStringConverter implements Converter { + + @Override + public String convert(Point source) { + return GeoConverter.toWellKnownText(source); + } + } + + public static class StringToShapeConverter implements Converter { + + @Override + public Shape convert(String source) { + return GeoConverter.fromWellKnownText(source); + } + } + + public static class StringToPointConverter implements Converter { + + @Override + public Point convert(String source) { + return GeoConverter.pointFromWellKnownText(source); + } + } + public static class StringToEnumConverterFactory implements ConverterFactory { @SuppressWarnings("unchecked") diff --git a/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/AbstractGraphRepository.java b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/AbstractGraphRepository.java index fc67d1d2d..e7ef85961 100644 --- a/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/AbstractGraphRepository.java +++ b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/AbstractGraphRepository.java @@ -24,16 +24,19 @@ import org.neo4j.graphdb.index.ReadableIndex; import org.neo4j.helpers.collection.ClosableIterable; import org.springframework.dao.DataRetrievalFailureException; import org.springframework.data.domain.*; +import org.springframework.data.geo.Box; +import org.springframework.data.geo.Circle; +import org.springframework.data.geo.Shape; import org.springframework.data.neo4j.conversion.EndResult; import org.springframework.data.neo4j.mapping.Neo4jPersistentEntity; import org.springframework.data.neo4j.mapping.Neo4jPersistentProperty; import org.springframework.data.neo4j.repository.query.CypherQuery; import org.springframework.data.neo4j.support.Neo4jTemplate; import org.springframework.data.neo4j.support.query.CypherQueryEngine; -import org.springframework.data.neo4j.support.query.QueryEngine; import org.springframework.transaction.annotation.Transactional; import java.util.*; +import java.util.List; import static java.lang.String.format; import static org.neo4j.helpers.collection.MapUtil.map; @@ -49,31 +52,7 @@ import static org.neo4j.helpers.collection.MapUtil.map; public abstract class AbstractGraphRepository implements GraphRepository, NamedIndexRepository, SpatialRepository, CypherDslRepository { private final LegacyIndexSearcher legacyIndexSearcher; - - /* - index.query( LayerNodeIndex.WITHIN_WKT_GEOMETRY_QUERY, -                "withinWKTGeometry:POLYGON ((15 56, 15 57, 16 57, 16 56, 15 56))" ); - - hits = index.query( LayerNodeIndex.WITHIN_WKT_GEOMETRY_QUERY, -                "POLYGON ((15 56, 15 57, 16 57, 16 56, 15 56))" ); lon,lat -        assertTrue( hits.hasNext() ); - final String poly = String.format("POLYGON (())", lowerLeftLon, upperRightLon, lowerLeftLat, upperRightLat); - */ - - @Override - public EndResult findWithinWellKnownText( final String indexName, String wellKnownText) { - return legacyIndexSearcher.geoQuery(indexName, "withinWKTGeometry", wellKnownText); - } - @Override - public EndResult findWithinDistance( final String indexName, final double lat, double lon, double distanceKm) { - return legacyIndexSearcher.geoQuery(indexName, "withinDistance", map("point", new Double[] { lon, lat}, "distanceInKm", distanceKm)); - } - - @Override - public EndResult findWithinBoundingBox(final String indexName, final double lowerLeftLat, - final double lowerLeftLon, final double upperRightLat, final double upperRightLon) { - return legacyIndexSearcher.geoQuery(indexName, "bbox", format("[%s, %s, %s, %s]", lowerLeftLon, upperRightLon, lowerLeftLat, upperRightLat)); - } + private final GeoQueries geoQueries; interface Query { IndexHits query(ReadableIndex index); @@ -103,6 +82,7 @@ public abstract class AbstractGraphRepository im this.template = template; this.clazz = clazz; legacyIndexSearcher = new LegacyIndexSearcher<>(template,clazz); + geoQueries = new GeoQueries<>(legacyIndexSearcher); } @Override @@ -419,4 +399,37 @@ public abstract class AbstractGraphRepository im public EndResult query(Execute query, Map params) { return template.queryEngineFor().query(query.toString(), params).to(clazz); } + + // SpatialRepository + + @Override + public EndResult findWithinWellKnownText( final String indexName, String wellKnownText) { + return geoQueries.findWithinWellKnownText(indexName,wellKnownText); + } + @Override + public EndResult findWithinDistance( final String indexName, final double lat, double lon, double distanceKm) { + return geoQueries.findWithinDistance(indexName, lat, lon,distanceKm); + } + + @Override + public EndResult findWithinBoundingBox(final String indexName, final double lowerLeftLat, + final double lowerLeftLon, final double upperRightLat, final double upperRightLon) { + return geoQueries.findWithinBoundingBox(indexName, lowerLeftLat, lowerLeftLon, upperRightLat, upperRightLon); + } + + @Override + public EndResult findWithinBoundingBox(String indexName, Box box) { + return geoQueries.findWithinBoundingBox(indexName,box); + } + + @Override + public EndResult findWithinDistance(String indexName, Circle circle) { + return geoQueries.findWithinDistance(indexName, circle); + } + + @Override + public EndResult findWithinShape(String indexName, Shape shape) { + return geoQueries.findWithinShape(indexName,shape); + } } + diff --git a/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/GeoConverter.java b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/GeoConverter.java new file mode 100644 index 000000000..5409a4a25 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/GeoConverter.java @@ -0,0 +1,121 @@ +/** + * Copyright 2011 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.neo4j.repository; + +import org.springframework.core.convert.ConversionException; +import org.springframework.data.geo.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Conversion Utility between Well-Known-Text and geospatial types + */ +public class GeoConverter { + + public static final Pattern WKT_POINT = Pattern.compile("^POINT *\\( *([\\d.]+) *([\\d.]+) *\\) *$",Pattern.CASE_INSENSITIVE); + private static final String POINT = " *[\\d.]+ *[\\d.]+ *"; + public static final Pattern WKT_POLYGON = Pattern.compile("^POLYGON *\\( *\\(((?:" + POINT + ",)*" + POINT + ") *\\) *\\) *$", Pattern.CASE_INSENSITIVE); + + public static String toWktCoords(Point point) { + return String.format(Locale.ENGLISH,"%s %s",point.getX(),point.getY()); + } + + public static String toWkt(Polygon shape) { + StringBuilder wkt = new StringBuilder("POLYGON(("); + List points = shape.getPoints(); + if (!points.isEmpty()) { + for (Point point : points) { + wkt.append(toWktCoords(point)).append(","); + } + wkt.append(toWktCoords(points.get(0))); + } + wkt.append("))"); + return wkt.toString(); + } + + public static String toWellKnownText(Point point) throws ConversionException { + return "POINT(" + toWktCoords(point) + ")"; + } + + public static String toWellKnownText(Shape shape) throws ConversionException { + if (shape instanceof Point) return "POINT("+toWktCoords((Point)shape)+")"; + if (shape instanceof Polygon) return toWkt((Polygon) shape); + if (shape instanceof Circle) return toWkt(toPolygon((Circle) shape, 12)); + if (shape instanceof Box) return toWkt(toPolygon((Box)shape)); + throw new RuntimeException("Could not convert shape to WKT " +shape); + } + + // see: http://blog.fedecarg.com/2009/02/08/geo-proximity-search-the-haversine-equation/ + public static Polygon toPolygon(Circle circle, int segments) { + float angle = 0; + float delta = 2 * (float)Math.PI / segments; + Point center = circle.getCenter(); + double milesPerDegree = 69; + double verticalRadius = circle.getRadius().in(Metrics.MILES).getValue() / milesPerDegree; + // modifier for latitude, see haversin + double horizontalModifier = Math.abs(Math.cos(Math.toRadians(center.getY()))); + double horizontalRadius = verticalRadius / horizontalModifier; + + List points = new ArrayList<>(segments); + for (int i=0;i points = new ArrayList<>(pointStrings.length); + for (String pointString : pointStrings) { + String[] coords = pointString.trim().split(" +"); + Point point = new Point(Double.parseDouble(coords[0]), Double.parseDouble(coords[1])); + if (points.contains(point)) continue; + points.add(point); + } + return new Polygon(points); + } + throw new RuntimeException("Error parsing '"+wkt+"' as POINT(x y) well known text"); + } + + public static Point pointFromWellKnownText(String wkt) { + Matcher matcher = WKT_POINT.matcher(wkt); + if (matcher.matches()) { + return new Point(Double.parseDouble(matcher.group(1)),Double.parseDouble(matcher.group(2))); + } + throw new RuntimeException("Error parsing '"+wkt+"' as POINT(x y) well known text"); + } +} diff --git a/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/GeoQueries.java b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/GeoQueries.java new file mode 100644 index 000000000..fc70f1ed8 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/GeoQueries.java @@ -0,0 +1,125 @@ +/** + * Copyright 2011 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.neo4j.repository; + +import org.json.simple.JSONArray; +import org.neo4j.graphdb.PropertyContainer; +import org.neo4j.helpers.Pair; +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.geo.*; +import org.springframework.data.neo4j.conversion.EndResult; +import org.springframework.data.repository.query.parser.Part; +import org.springframework.util.Assert; + +import java.util.Map; + +import static java.util.Arrays.asList; +import static org.neo4j.helpers.collection.MapUtil.map; + +public class GeoQueries implements SpatialRepository { + public static final String WITHIN_WKT_GEOMETRY = "withinWKTGeometry"; + public static final String WITHIN_DISTANCE = "withinDistance"; + public static final String BBOX = "bbox"; + private final LegacyIndexSearcher legacyIndexSearcher; + + public GeoQueries(LegacyIndexSearcher legacyIndexSearcher) { + this.legacyIndexSearcher = legacyIndexSearcher; + } + @Override + public EndResult findWithinWellKnownText( final String indexName, String wellKnownText) { + return legacyIndexSearcher.geoQuery(indexName, WITHIN_WKT_GEOMETRY, wellKnownText); + } + + @Override + public EndResult findWithinShape(String indexName, Shape shape) { + Assert.notNull(indexName, "geo-index-name must not be null"); + Assert.notNull(shape,"shape must not be null"); + if (shape instanceof Circle) return findWithinDistance(indexName,(Circle)shape); + if (shape instanceof Box) return findWithinBoundingBox(indexName, (Box) shape); + if (shape instanceof Polygon) return findWithinWellKnownText(indexName, GeoConverter.toWkt((Polygon) shape)); + throw new InvalidDataAccessApiUsageException("Unknown shape "+shape.getClass().getSimpleName()+" "+shape); + } + + @Override + public EndResult findWithinDistance( final String indexName, final double lat, double lon, double distanceKm) { + return legacyIndexSearcher.geoQuery(indexName, WITHIN_DISTANCE, toWithinDistanceParams(lat, lon, distanceKm)); + } + + private static Map toWithinDistanceParams(double lat, double lon, double distanceKm) { + return map("point", new Double[] { lat, lon}, "distanceInKm", distanceKm); + } + + @Override + public EndResult findWithinDistance(String indexName, Circle circle) { + return legacyIndexSearcher.geoQuery(indexName, WITHIN_DISTANCE, toWithinDistanceParams(circle)); + } + + private static Map toWithinDistanceParams(Circle circle) { + double distance = circle.getRadius().in(Metrics.KILOMETERS).getValue(); + return toWithinDistanceParams(circle.getCenter().getY(),circle.getCenter().getX(),distance); + } + private static String toWithinDistanceParamsString(Circle circle) { + double distance = circle.getRadius().in(Metrics.KILOMETERS).getValue(); + return JSONArray.toJSONString(asList(circle.getCenter().getY(), circle.getCenter().getX(), distance)); + } + + @Override + public EndResult findWithinBoundingBox(final String indexName, final double lowerLeftLat, + final double lowerLeftLon, final double upperRightLat, final double upperRightLon) { + return legacyIndexSearcher.geoQuery(indexName, BBOX, toBoundingBoxParams(lowerLeftLat, lowerLeftLon, upperRightLat, upperRightLon)); + } + + private static String toBoundingBoxParams(double lowerLeftLat, double lowerLeftLon, double upperRightLat, double upperRightLon) { + return JSONArray.toJSONString(asList(lowerLeftLon, upperRightLon, lowerLeftLat, upperRightLat)); + } + + @Override + public EndResult findWithinBoundingBox(String indexName, Box box) { + return legacyIndexSearcher.geoQuery(indexName,BBOX,toBoundingBoxParams(box)); + } + + private static String toBoundingBoxParams(Box box) { + Point first = box.getFirst(); + Point second = box.getSecond(); + return toBoundingBoxParams( + Math.min(first.getY(),second.getY()), Math.min(first.getX(),second.getX()), + Math.max(first.getY(), second.getY()), Math.max(first.getX(), second.getX())); + } + + public static Pair toQueryParams(Part.Type type, Object value) { + if (value instanceof String && type == Part.Type.WITHIN) return Pair.of(WITHIN_WKT_GEOMETRY,(String)value); + if (value instanceof Circle && (type == Part.Type.NEAR || type == Part.Type.WITHIN)) return Pair.of(WITHIN_DISTANCE, toWithinDistanceParamsString((Circle) value)); + if (value instanceof Box && type == Part.Type.WITHIN) return Pair.of(BBOX, toBoundingBoxParams((Box)value)); + if (value instanceof Polygon && type == Part.Type.WITHIN) return Pair.of(WITHIN_WKT_GEOMETRY, GeoConverter.toWkt((Polygon) value)); + throw new IllegalArgumentException( + String.format("Must have a geospatial operator like equals, within or inside, but has %s and must have a geospatial value like circle, box, polygon, or WKT-String but has %s %s", + type, + value==null ? null : value.getClass(), + value)); + } + +} + +/* +index.query( LayerNodeIndex.WITHIN_WKT_GEOMETRY_QUERY, +                "withinWKTGeometry:POLYGON ((15 56, 15 57, 16 57, 16 56, 15 56))" ); + + hits = index.query( LayerNodeIndex.WITHIN_WKT_GEOMETRY_QUERY, +                "POLYGON ((15 56, 15 57, 16 57, 16 56, 15 56))" ); lon,lat +        assertTrue( hits.hasNext() ); + final String poly = String.format("POLYGON (())", lowerLeftLon, upperRightLon, lowerLeftLat, upperRightLat); + */ diff --git a/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/SpatialRepository.java b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/SpatialRepository.java index 4b8da6538..4a03ca639 100644 --- a/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/SpatialRepository.java +++ b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/SpatialRepository.java @@ -16,9 +16,13 @@ package org.springframework.data.neo4j.repository; +import org.springframework.data.geo.*; +import org.springframework.data.geo.Shape; import org.springframework.data.neo4j.conversion.EndResult; import org.springframework.transaction.annotation.Transactional; +import java.awt.*; + /** * Repository for spatial queries. * @@ -36,10 +40,22 @@ public interface SpatialRepository { double upperRightLat, double upperRightLon); + @Transactional + EndResult findWithinBoundingBox(String indexName, Box box); + @Transactional EndResult findWithinDistance( final String indexName, final double lat, double lon, double distanceKm); + @Transactional + EndResult findWithinDistance( final String indexName, Circle circle); + @Transactional EndResult findWithinWellKnownText( final String indexName, String wellKnownText); + + /** + * Converts the shape into a well-known text representation and executes the appropriate WKT query + */ + @Transactional + EndResult findWithinShape( final String indexName, Shape shape); } diff --git a/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/CypherQuery.java b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/CypherQuery.java index 7e988020d..78cb022ae 100644 --- a/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/CypherQuery.java +++ b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/CypherQuery.java @@ -128,10 +128,8 @@ public class CypherQuery implements CypherQueryDefinition { } private boolean addedStartClause(PartInfo partInfo) { - boolean invalidStartClauseScenario1 = !partInfo.isIndexed(); - boolean invalidStartClauseScenario2 = partInfo.isIndexed() && partInfo.isLabelIndexed(); - if (invalidStartClauseScenario1 || invalidStartClauseScenario2 ) return false; + if (!partInfo.isIndexed() || partInfo.isLabelIndexed()) return false; ListIterator it = startClauses.listIterator(); while (it.hasNext()) { diff --git a/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/PartInfo.java b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/PartInfo.java index b65074bc1..5f81673c7 100644 --- a/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/PartInfo.java +++ b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/PartInfo.java @@ -18,6 +18,7 @@ package org.springframework.data.neo4j.repository.query; import org.springframework.data.mapping.context.PersistentPropertyPath; import org.springframework.data.neo4j.mapping.IndexInfo; import org.springframework.data.neo4j.mapping.Neo4jPersistentProperty; +import org.springframework.data.neo4j.support.index.IndexType; import org.springframework.data.repository.query.parser.Part; import org.springframework.util.Assert; @@ -82,6 +83,9 @@ public class PartInfo { public boolean isFullText() { return isIndexed() && getIndexInfo().isFullText(); } + public boolean isSpatial() { + return isIndexed() && getIndexInfo().getIndexType() == IndexType.POINT; + } private IndexInfo getIndexInfo() { return getLeafProperty().getIndexInfo(); diff --git a/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/SpatialIndexStartClause.java b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/SpatialIndexStartClause.java new file mode 100644 index 000000000..668173802 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/SpatialIndexStartClause.java @@ -0,0 +1,83 @@ +/** + * Copyright 2013 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.neo4j.repository.query; + +import org.neo4j.helpers.Pair; +import org.neo4j.helpers.collection.IteratorUtil; +import org.springframework.data.neo4j.mapping.Neo4jPersistentProperty; +import org.springframework.data.neo4j.repository.GeoQueries; +import org.springframework.data.neo4j.support.Neo4jTemplate; +import org.springframework.data.repository.query.Parameter; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Represents a start clause which makes use of a spatial index + * based matching against one or more spatial shapes (box, circle, polygon). + * + * @author Nicki Watt + */ +public class SpatialIndexStartClause extends IndexBasedStartClause { + + public SpatialIndexStartClause(PartInfo partInfo) { + super(partInfo); + } + + @Override + public String toString() { + final PartInfo partInfo = getPartInfo(); + final String identifier = partInfo.getIdentifier(); + final String indexName = partInfo.getIndexName(); + final int parameterIndex = partInfo.getParameterIndex(); + return String.format(QueryTemplates.START_CLAUSE_INDEX_QUERY, identifier, indexName, parameterIndex); + } + + @Override + protected Object convertIfNecessary(Neo4jTemplate template, Object value, Neo4jPersistentProperty property) { + return value; + } + + @Override + public Map resolveParameters(Map parameters, Neo4jTemplate template) { + Map myParameters = findMyParameters(parameters.keySet()); + + Map result = new LinkedHashMap(parameters); + result.keySet().removeAll(myParameters.keySet()); + + final Map values = matchToPartsAndConvert(myParameters, parameters,template); + + // todo how to support multiple parameters ? e.g. intersection or union of polygons ? + if (values.size() > 1) throw new IllegalStateException("Can only support one spatial argument at this time, e.g. Circle, Box, Polygon or WKT-String"); + Parameter firstParam = IteratorUtil.first(myParameters.keySet()); + result.put(firstParam, renderQuery(values)); + return result; + } + + protected String renderQuery(Map values) { + StringBuilder sb=new StringBuilder(); + for (Map.Entry entry : values.entrySet()) { + Pair queryParams = GeoQueries.toQueryParams(entry.getKey().getType(), entry.getValue()); + sb.append(queryParams.first()).append(":").append(queryParams.other()); + } + return sb.toString(); + } + + + + +} diff --git a/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/StartClauseFactory.java b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/StartClauseFactory.java index 4ebb1e47e..76bb6cec3 100644 --- a/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/StartClauseFactory.java +++ b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/StartClauseFactory.java @@ -66,9 +66,14 @@ public class StartClauseFactory { */ public static StartClause create(PartInfo partInfo) { if (partInfo.isIndexed()) { - return (partInfo.isFullText() || isTextualSearchLikePart(partInfo)) - ? new FullTextIndexBasedStartClause(partInfo) - : new ExactIndexBasedStartClause(partInfo); + if (partInfo.isSpatial()) { + // todo check for parameter types + if (isSpatialSearchLikePart(partInfo)) return new SpatialIndexStartClause(partInfo); + } else { + if (partInfo.isFullText() || isTextualSearchLikePart(partInfo)) + return new FullTextIndexBasedStartClause(partInfo); + return new ExactIndexBasedStartClause(partInfo); + } } Neo4jPersistentProperty leafProperty = partInfo.getLeafProperty(); @@ -79,6 +84,9 @@ public class StartClauseFactory { throw new IllegalArgumentException("Cannot determine an appropriate Start Clause for partInfo=" + partInfo ); } + private static boolean isSpatialSearchLikePart(PartInfo partInfo) { + return EnumSet.of(Part.Type.NEAR,Part.Type.SIMPLE_PROPERTY,Part.Type.WITHIN).contains(partInfo.getType()); + } private static boolean isTextualSearchLikePart(PartInfo partInfo) { return EnumSet.of(Part.Type.LIKE,Part.Type.STARTING_WITH,Part.Type.CONTAINING,Part.Type.ENDING_WITH).contains(partInfo.getType()); } diff --git a/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/model/Person.java b/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/model/Person.java index e95181d56..1ec43f0d7 100644 --- a/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/model/Person.java +++ b/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/model/Person.java @@ -18,6 +18,7 @@ package org.springframework.data.neo4j.model; import org.neo4j.graphdb.*; import org.springframework.data.annotation.Transient; +import org.springframework.data.geo.Point; import org.springframework.data.neo4j.annotation.*; import org.springframework.data.neo4j.fieldaccess.DynamicProperties; import org.springframework.data.neo4j.support.index.IndexType; @@ -50,7 +51,7 @@ public class Person implements Being , Serializable { private String alias; @Indexed(indexType = IndexType.POINT, indexName="personLayer") - private String wkt; + private Point wkt; @Max(100) @Min(0) @@ -199,12 +200,8 @@ public class Person implements Being , Serializable { this.boss = boss; } - public void setLocation(String locationInWkt) { - this.wkt = locationInWkt; - } - public void setLocation(double lon, double lat) { - this.wkt = "POINT ( "+lon+" "+lat+" )"; + this.wkt = new Point(lon,lat); } @Override diff --git a/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repositories/PersonRepository.java b/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repositories/PersonRepository.java index 786ec6569..8fbfb4e93 100644 --- a/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repositories/PersonRepository.java +++ b/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repositories/PersonRepository.java @@ -20,12 +20,16 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.domain.Sort; +import org.springframework.data.geo.Box; +import org.springframework.data.geo.Circle; +import org.springframework.data.geo.Polygon; import org.springframework.data.neo4j.annotation.Query; import org.springframework.data.neo4j.annotation.QueryResult; import org.springframework.data.neo4j.annotation.ResultColumn; import org.springframework.data.neo4j.conversion.EndResult; import org.springframework.data.neo4j.model.Group; import org.springframework.data.neo4j.model.Person; +import org.springframework.data.neo4j.model.Personality; import org.springframework.data.neo4j.repository.*; import org.springframework.data.repository.query.Param; @@ -65,6 +69,11 @@ public interface PersonRepository extends GraphRepository, NamedIndexRep @Query("match (boss)-[:boss]->(person) where id(person) = {p_person} return boss") Person findBoss(@Param("p_person") Person person); + Collection findByWktNearAndName(Circle circle, String name); + Collection findByWktWithinAndAgeGreaterThan(Circle circle, int age); + Collection findByWktWithinAndPersonality(Polygon polygon, Personality personality); + Collection findByWktWithin(Box box); + @Query("match (boss)-[:boss]->(person) where id(person) = {p_person} return boss") Person findBoss(@Param("p_person") Long person); diff --git a/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repository/GeoQueriesTest.java b/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repository/GeoQueriesTest.java new file mode 100644 index 000000000..885793f68 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repository/GeoQueriesTest.java @@ -0,0 +1,48 @@ +package org.springframework.data.neo4j.repository; + +import org.junit.Test; +import org.springframework.data.geo.*; + +import java.util.List; + +import static java.util.Arrays.asList; +import static org.junit.Assert.assertEquals; + +/** + * @author mh + * @since 31.03.14 + */ +public class GeoQueriesTest { + @Test + public void testBoxToPolygon() throws Exception { + Polygon polygon = GeoConverter.toPolygon(new Box(new Point(0, 0), new Point(100, 100))); + assertEquals(asList(new Point(0, 0), new Point(100, 0), new Point(100, 100), new Point(0, 100)),polygon.getPoints()); + } + + @Test + public void testCircleToPolygon() throws Exception { + Polygon polygon = GeoConverter.toPolygon(new Circle(new Point(13, 52), new Distance(69, Metrics.MILES)), 4); + List expected = asList(new Point(14.624269, 52), new Point(13, 53), new Point(11.375731, 52.000000), new Point(13.000000, 51.000000)); + List points = polygon.getPoints(); + for (int i = 0; i < expected.size(); i++) { + assertEquals(expected.get(i).toString(),points.get(i).toString()); + } + } + + @Test + public void testPointToWkt() throws Exception { + assertEquals("POINT(100.0 30.0)", GeoConverter.toWellKnownText(new Point(100, 30))); + } + @Test + public void testBoxToWkt() throws Exception { + assertEquals("POLYGON((30.0 30.0,100.0 30.0,100.0 100.0,30.0 100.0,30.0 30.0))", GeoConverter.toWellKnownText(new Box(new Point(30, 30), new Point(100,100)))); + } + @Test + public void textPolygonFromWkt() throws Exception { + assertEquals(GeoConverter.toPolygon(new Box(new Point(30, 30), new Point(100,100))), GeoConverter.fromWellKnownText("POLYGON((30.0 30.0,100.0 30.0,100.0 100.0,30.0 100.0,30.0 30.0))")); + } + @Test + public void testWktToPoint() throws Exception { + assertEquals(new Point(100, 30.1), GeoConverter.pointFromWellKnownText("POINT (100 30.1 ) ")); + } +} diff --git a/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repository/SerialTesters.java b/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repository/SerialTesters.java index 360216181..980c77d33 100644 --- a/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repository/SerialTesters.java +++ b/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repository/SerialTesters.java @@ -58,7 +58,7 @@ public class SerialTesters { michael = new Person("Michael", 36); michael.setBoss(emil); michael.setPersonality(Personality.EXTROVERT); - michael.setLocation( "POINT(16 56)" ); + michael.setLocation( 16, 56); david = new Person("David", 25); david.setBoss(emil); diff --git a/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repository/SpatialGraphRepositoryTests.java b/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repository/SpatialGraphRepositoryTests.java index c3392a800..c0acea96f 100644 --- a/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repository/SpatialGraphRepositoryTests.java +++ b/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repository/SpatialGraphRepositoryTests.java @@ -23,7 +23,9 @@ import org.junit.runner.RunWith; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.geo.*; import org.springframework.data.neo4j.model.Person; +import org.springframework.data.neo4j.model.Personality; import org.springframework.data.neo4j.repositories.FriendshipRepository; import org.springframework.data.neo4j.repositories.GroupRepository; import org.springframework.data.neo4j.repositories.PersonRepository; @@ -91,6 +93,18 @@ public class SpatialGraphRepositoryTests { assertThat(asCollection(teamMembers), hasItems(testTeam.michael, testTeam.david)); } + @Test + public void testFindPeopleWithinBoundingBoxShape() { + Iterable teamMembers = personRepository.findWithinBoundingBox("personLayer", new Box(new Point(15, 55), new Point(17, 57))); + assertThat(asCollection(teamMembers), hasItems(testTeam.michael, testTeam.david)); + } + + @Test + public void testFindPeopleWithinBoundingBoxShapeDerived() { + Iterable teamMembers = personRepository.findByWktWithin(new Box(new Point(15, 55), new Point(17, 57))); + assertThat(asCollection(teamMembers), hasItems(testTeam.michael, testTeam.david)); + } + @Test public void testFindPeopleWithinPolygon() { Iterable teamMembers = personRepository.findWithinWellKnownText("personLayer", "POLYGON ((15 55, 15 57, 17 57, 17 55, 15 55))"); @@ -98,11 +112,40 @@ public class SpatialGraphRepositoryTests { } @Test - public void testFindPeopleWithinDistance() { - Iterable teamMembers = personRepository.findWithinDistance("personLayer", 16,56,70); + public void testFindPeopleWithinPolygonShape() { + Iterable teamMembers = personRepository.findWithinShape("personLayer", new Polygon(new Point(15,55),new Point(15,57), new Point(17,57),new Point(17,55))); assertThat(asCollection(teamMembers), hasItems(testTeam.michael, testTeam.david)); } + @Test + public void testFindPeopleWithinPolygonShapeDerived() { + Iterable teamMembers = personRepository.findByWktWithinAndPersonality(new Polygon(new Point(15, 55), new Point(15, 57), new Point(17, 57), new Point(17, 55)), Personality.EXTROVERT); + assertThat(asCollection(teamMembers), hasItems(testTeam.michael)); + } + + @Test + public void testFindPeopleWithinDistance() { + Iterable teamMembers = personRepository.findWithinDistance("personLayer", 56,16,70); + assertThat(asCollection(teamMembers), hasItems(testTeam.michael, testTeam.david)); + } + + @Test + public void testFindPeopleWithinCircle() { + Iterable teamMembers = personRepository.findWithinDistance("personLayer", new Circle(new Point(16,56),new Distance(70, Metrics.KILOMETERS))); + assertThat(asCollection(teamMembers), hasItems(testTeam.michael, testTeam.david)); + } + + @Test + public void testFindPeopleNearCircleDerived() { + Iterable teamMembers = personRepository.findByWktNearAndName(new Circle(new Point(16,56),new Distance(70, Metrics.KILOMETERS)),"David"); + assertThat(asCollection(teamMembers), contains(testTeam.david)); + } + @Test + public void testFindPeopleWithinCircleDerived() { + Iterable teamMembers = personRepository.findByWktWithinAndAgeGreaterThan(new Circle(new Point(16, 56), new Distance(70, Metrics.KILOMETERS)),30); + assertThat(asCollection(teamMembers), contains(testTeam.michael)); + } + @Test @Ignore public void testPerformance() throws Exception { diff --git a/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repository/TestTeam.java b/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repository/TestTeam.java index c232718d1..9dbb85b6b 100644 --- a/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repository/TestTeam.java +++ b/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repository/TestTeam.java @@ -44,7 +44,7 @@ public class TestTeam { michael = new Person("Michael", 36); michael.setBoss(emil); michael.setPersonality(Personality.EXTROVERT); - michael.setLocation( "POINT(16 56)" ); + michael.setLocation( 16, 56 ); michael.setAlias("michaelAlias"); david = new Person("David", 25); diff --git a/spring-data-neo4j/template.mf b/spring-data-neo4j/template.mf index ed9493272..b6db9cdec 100644 --- a/spring-data-neo4j/template.mf +++ b/spring-data-neo4j/template.mf @@ -25,6 +25,7 @@ Import-Template: org.objectweb.jotm.*;version="0";resolution:=optional, org.apache.lucene.*;version="0", org.slf4j.*;version="0", + org.json.simple.*;version="0", javax.lang.model.*;version="0";resolution:=optional, javax.tools.*;version="0";resolution:=optional, javax.validation.*;version="0";resolution:=optional, diff --git a/src/docbkx/reference/programming-model/spatial.xml b/src/docbkx/reference/programming-model/spatial.xml index 46c7907b6..2bc69177b 100644 --- a/src/docbkx/reference/programming-model/spatial.xml +++ b/src/docbkx/reference/programming-model/spatial.xml @@ -8,6 +8,10 @@ for GIS operations. So if you include the maven dependency in your pom.xml, Neo4j-Spatial and the required SPATIAL index provider is available. + + Spring Data Neo4j integrates with the common geo-spatial types in Spring Data Commons, like Circle, Box, Polygon and Point. You can use them as parameters + for repository finder methods and property values. We also provide converters between WKT and Shape/Point objects. + Neo4j-Spatial Dependencies @@ -15,7 +19,7 @@ org.neo4j neo4j-spatial - 0.7-SNAPSHOT + 0.13-neo4j-2.0.1 ]]> @@ -26,6 +30,10 @@ WKT is the Well Known Text Spatial Format eg. POINT( LON LAT ) or POLYGON (( LON1 LAT1 LON2 LAT2 LON3 LAT3 LON1 LAT1 )) + + Alternatively you can also use one of the geo-spatial primitives from Spring Data Commons which are automatically converted to WKT + when stored in the graph. + Fields of Well Known Text @@ -39,20 +47,39 @@ class Venue { } } -venue.setLocation(56,15); +venue.setLocation(15,56); +]]> + + + + + Field of Point + After adding the SpatialRepository to your repository you can use the - findWithinBoundingBox, findWithinDistance, findWithinWellKnownText. + findWithinBoundingBox, findWithinDistance, findWithinWellKnownText, findWithinShape methods. + We recommend to use the methods using the geospatial primitives as they are more typesafe and less error-prone in mixing the coordinate order. Spatial Queries - teamMembers = personRepository.findWithinBoundingBox("personLayer", 55, 15, 57, 17); + teamMembers = personRepository.findWithinBoundingBox("personLayer", 55, 15, 57, 17); +Iterable teamMembers = personRepository.findWithinBoundingBox("personLayer", new Box(new Point(15,55), new Point(17,57)); Iterable teamMembers = personRepository.findWithinWellKnownText("personLayer", "POLYGON ((15 55, 15 57, 17 57, 17 55, 15 55))"); +Iterable teamMembers = personRepository.findWithinShape("personLayer", new Polygon(new Point(15,55),new Point(15,57), new Point(17,57),new Point(17,55))); Iterable teamMembers = personRepository.findWithinDistance("personLayer", 16,56,70); +Iterable teamMembers = personRepository.findWithinDistance("personLayer", new Circle(new Point(16,56),new Distance(70, Metrics.KILOMETERS))); ]]> @@ -60,14 +87,41 @@ Iterable teamMembers = personRepository.findWithinDistance("personLayer" Methods of the Spatial Repository { - ClosableIterable findWithinBoundingBox(String indexName, double lowerLeftLat, + @Transactional + EndResult findWithinBoundingBox(String indexName, double lowerLeftLat, double lowerLeftLon, double upperRightLat, double upperRightLon); - ClosableIterable findWithinDistance( final String indexName, final double lat, double lon, double distanceKm); + @Transactional + EndResult findWithinBoundingBox(String indexName, Box box); - ClosableIterable findWithinWellKnownText( final String indexName, String wellKnownText); + @Transactional + EndResult findWithinDistance( final String indexName, final double lat, double lon, double distanceKm); + + @Transactional + EndResult findWithinDistance( final String indexName, Circle circle); + + @Transactional + EndResult findWithinWellKnownText( final String indexName, String wellKnownText); + + /** + * Converts the shape into a well-known text representation and executes the appropriate WKT query + */ + @Transactional + EndResult findWithinShape( final String indexName, Shape shape); +} +]]> + + + + + Derived Spatial Finder Methods + , SpatialRepository { + Collection findByWktNearAndName(Circle circle, String name); + Collection findByWktWithinAndAgeGreaterThan(Circle circle, int age); + Collection findByWktWithinAndPersonality(Polygon polygon, Personality personality); + Collection findByWktWithin(Box box); } ]]> diff --git a/src/main/resources/changelog.txt b/src/main/resources/changelog.txt index 9e812af3e..97968cbd2 100644 --- a/src/main/resources/changelog.txt +++ b/src/main/resources/changelog.txt @@ -3,11 +3,12 @@ Spring Data Neo4j Changelog Changes in version 3.1.0.M1 (2014-03-31) ---------------------------------------- -* DATAGRAPH-440 Add support for Slice API -* DATAGRAPH-451 RelationshipEntities will be validated when manipulated -* DATAGRAPH-450 Updated example code -* DATAGRAPH-449 Updated docs for 3.x +* DATAGRAPH-440 - Add support for Slice API +* DATAGRAPH-451 - RelationshipEntities will be validated when manipulated +* DATAGRAPH-450 - Updated example code +* DATAGRAPH-449 - Updated docs for 3.x * DATAGRAPH-452 - Adapted to changes in BeanWrapper generics. +* DATAGRAPH-454 - Support for Common Geospatial Types and Derived spatial Queries Changes in version 3.0.1.RELEASE (2014-03-13) ---------------------------------------------